Claude Skill

paper-glossary

Use when building reusable Obsidian glossary notes from an existing paper source manifest, optionally with a raw-sections override, especially when a reader needs a reviewed shortlist before glossary notes and article links are changed.

LLM Mart · 0 points · 16 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download 917dhj-deeppapernote-skills_paper-glossary-9d97d33.zip · 99 KB
Part of 917dhj/deeppapernote — 2 skills

Install

skills CLI npx skills add https://github.com/917Dhj/DeepPaperNote/tree/main/skills/paper-glossary
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install 917dhj-deeppapernote@llmmart
Git git clone https://github.com/917Dhj/DeepPaperNote.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole 917dhj/deeppapernote collection as a plugin from our marketplace. Git is the plain clone.

README

Paper Glossary

paper-glossary builds reusable Obsidian glossary notes from *_source_manifest.json and *_raw_sections.jsonl. It remains self-contained and does not call a paper-reading workflow.

One-Time Device Setup

Each person configures one term directory once per device. The directory must be inside an Obsidian vault: its nearest vault ancestor must contain .obsidian. The local configuration is ~/.paper-glossary/config.json; it stores an absolute resolved vault root and the terms subdirectory, not a repository setting. Relative and Windows drive-relative vault roots are rejected. Later runs reuse it.

Setup may create ~/.paper-glossary/config.json; before selection, no glossary notes or article Markdown are written. Run commands from the repository root and replace each quoted <vault> placeholder with the real vault path.

Windows PowerShell

py -3.12 skills\paper-glossary\scripts\configure_glossary.py --terms-dir '<vault>\Glossary'
py -3.12 skills\paper-glossary\scripts\configure_glossary.py --show
py -3.12 skills\paper-glossary\scripts\configure_glossary.py --reset
py -3.12 skills\paper-glossary\scripts\configure_glossary.py --validate-article '<vault>\Papers\example.md'
py -3.12 -m pytest -q skills\paper-glossary\tests --basetemp .pytest-tmp-paper-glossary

macOS / Linux Bash

python3 skills/paper-glossary/scripts/configure_glossary.py --terms-dir '<vault>/Glossary'
python3 skills/paper-glossary/scripts/configure_glossary.py --show
python3 skills/paper-glossary/scripts/configure_glossary.py --reset
python3 skills/paper-glossary/scripts/configure_glossary.py --validate-article '<vault>/Papers/example.md'
python3 -m pytest -q skills/paper-glossary/tests --basetemp .pytest-tmp-paper-glossary

When article links are requested, provide the article Markdown path explicitly. It must be a .md file in the same Obsidian vault as the configured term directory; the skill never guesses an article from the paper source artifacts.

--source-manifest is required for proposal, review recording, and triage. --raw-sections is an optional override for the manifest's raw_sections_path, not a standalone replacement. The artifact workflow is:

  1. Run plan_glossary.py --propose ... --output proposal.json.
  2. Perform one semantic review, then record its exact ordered subset with --review-proposal proposal.json --reviewed-terms ... --output reviewed.json. If this produces an empty reviewed shortlist, report no_candidates and stop without displaying an empty selector.
  3. Display the complete reviewed shortlist as numbers and wait. Resolve number responses or 全部写入 to exact names outside the CLI.
  4. Run triage with --reviewed-shortlist reviewed.json --terms ...; --terms alone is rejected.
  5. Pass triage to inventory, inventory to the writer at the configured glossary directory (use --config-path only to select that device-local configuration), and successful writer results to the linker when an article was requested.

For inventory, writer, and linker, always pass the current --source-manifest, optional --raw-sections, and saved --reviewed-shortlist. Inventory reads the saved --triage as its input; writer and linker also require that same saved --triage and enforce exact ordered term and surface_forms equality with it. These post-triage CLIs recompute the current proposal/review chain before writing. When the writer receives --article, paper_link is derived from the resolved article Markdown stem. A glossary-only writer invocation omits --article and derives paper_link from the validated manifest paper_id; do not run the linker for that branch because it rejects a writer artifact with no bound article path.

Proposal candidates carry ordered paper-grounded surface_forms plus SHA-256 provenance and the bounded candidate/summary material needed to recompute it. Inventory and writer both fail closed unless proposal, review, and selection digests recompute from one matching paper/source chain and the exact ordered forms. Review, triage, inventory, and writer results preserve those forms; model-authored note aliases are metadata and are not article-match forms. Writer output also preserves triage_sha256, validated provenance, and context and binds the triage identity plus exact ordered mappings with mappings_sha256; the linker recomputes that digest and validates every note path, stem, and grounded form. See SKILL.md and references/file-contract.md for exact article and glossary-only invocations and JSON contracts.

Skill manifest

Paper Glossary

Build shared glossary notes from *_source_manifest.json. --source-manifest is always required; --raw-sections only overrides its raw_sections_path with an explicit *_raw_sections.jsonl. This skill never runs or modifies a paper-reading workflow; the manifest and raw-sections file are its only paper-content boundary. See references/file-contract.md for JSON and CLI contracts.

Preview And Wait (Default)

  1. Show saved configuration on first use per person/device. If absent, ask for a term directory inside an Obsidian vault and configure it; reuse valid configuration later. Setup may create ~/.paper-glossary/config.json.
  2. Require an explicit article Markdown path when article links are requested. Validate it and the configured term directory are in the same Obsidian vault; never infer an article path.
  3. Run deterministic proposal from the effective body.
  4. Perform exactly one grounded host semantic review, bounded by shortlist_limit. It may only drop or reorder candidates and must preserve each retained proposal candidate's exact term string.
  5. Record the reviewed shortlist with plan_glossary.py --review-proposal PROPOSAL --reviewed-terms NAMES; this validates the saved proposal against the current paper source and preserves each full candidate, ordered surface_forms, and provenance. If review produces an empty reviewed shortlist, report no_candidates and stop without presenting a selector.
  6. Present every retained term from that reviewed artifact as a numbered Markdown list. This is the terminal, Codex, and Claude Code interaction. Show the resolved term directory, the article Markdown path (or that none was requested), and that selection authorizes glossary writes/enrichment plus first-safe-occurrence article links when an article was supplied.
  7. State: No glossary notes or article Markdown have been written before selection. End the response and wait. Do not triage, inventory, generate, link, or lint during preview.

A broad request, manifest, raw sections path, or article path is not selection approval. A host-native selector is allowed only when it displays the complete same list in one interaction.

After Selection

Accept only numbers, exact term names, or 全部写入 from the immediately preceding numbered list. Resolve them to the exact displayed term strings before invoking a script; 全部写入 applies only to that list. Invalid selections receive the valid range and another wait. Triage requires both --reviewed-shortlist REVIEW and the resolved exact names in --terms; never use --terms alone or add alias syntax.

Pass the current --source-manifest, optional --raw-sections, and saved --reviewed-shortlist to inventory, writer, and linker. Inventory consumes the saved --triage artifact as its selected-term input; writer and linker must also receive that same saved --triage. They require exact ordered term and surface_forms equality with this independent authorization before any glossary or article write.

  1. Triage the selection; its selection identity binds each exact selected name and ordered paper-grounded forms.
  2. Run library inventory from that triage artifact; it recomputes the complete proposal/review/selection provenance chain and fails closed on mismatched paper, source, shortlist, or forms.
  3. Then generate one action-aware batch.
  4. Run one writer invocation against the configured glossary directory; it revalidates the same provenance chain and requires its ordered inventory results to match the authorized triage exactly, then performs whole-batch preflight followed by the create/enrich/reuse commit. The writer resolves device-local configuration (or an explicit --config-path for that device) and does not accept a standalone write destination. With --article, its backlink is derived from the resolved article Markdown stem. Without an article, the glossary-only backlink comes from the validated manifest paper_id. The successful artifact preserves triage_sha256, provenance, article context, ordered mappings, and their deterministic mappings_sha256; the mapping digest binds the triage identity.
  5. Link each successful writer result at its first safe occurrence only if an article Markdown was supplied/requested. The linker authenticates the writer provenance, context, digest, note paths, stems, and forms against the same current source/review, and rejects an artifact with no bound article path. For a glossary-only request, skip link_glossary_terms.py.
  6. Lint writer-returned changed glossary note files whose action is created, enriched, or updated in one lint_glossary.py invocation with repeated --input PATH arguments. Do not pass article Markdown to lint_glossary.py.
  7. Report observable wall-clock timing and statuses. Time host-only phases separately. Each CLI emits top-level elapsed_ms for its own complete invocation. The single writer invocation includes whole-batch preflight and commit. Do not report separate preflight and commit timings.

new entries are created; existing_thin entries receive only missing structured fields and a missing occurrence; existing_complete entries may receive only a missing occurrence. Do not overwrite existing note content. See references/file-contract.md for existing_thin, entry operations, writer forms, and link statuses.

Grounding

  • anchor_only: concise paper use plus a thin general explanation.
  • needs_explanation: labeled background explanation with confidence and paper occurrence.
  • Exclude reference-only occurrences as evidence. Keep paper facts in occurrence and outside knowledge labeled.
Files (deeppapernote)
  • agents
    • openai.yaml 475 B
      interface:
        display_name: "Paper Glossary"
        short_description: "Build reusable glossary notes from paper raw sections."
        default_prompt: "Use $paper-glossary in preview-and-wait mode: propose candidates from this paper manifest and raw sections, perform semantic review, record the reviewed artifact, present its complete numbered shortlist, then stop and wait for my explicit selection before writing glossary notes or article links; triage starts only after selection."
      
  • references
    • file-contract.md 12.6 KB
      # Paper Glossary File Contract
      
      ## Boundary And Configuration
      
      `--source-manifest` is always required as a JSON object or path. `--raw-sections` only overrides the manifest's `raw_sections_path`; it cannot replace the manifest. Together, `*_source_manifest.json` and the resolved `*_raw_sections.jsonl` are the complete shared-file collaboration boundary. `plan_glossary.py` reads records whose `record_type` is absent or `section`; `kind: references` is excluded from evidence. No DeepPaperNote import or workflow call is part of this contract.
      
      `configure_glossary.py --terms-dir PATH` writes device-local `~/.paper-glossary/config.json`:
      
      ```json
      {"vault_root":"/vault","terms_subdir":"Glossary"}
      ```
      
      `vault_root` must be absolute; relative and Windows drive-relative values are invalid. Loading returns the resolved absolute vault root so write authority does not depend on the process working directory.
      
      `--show` returns that configuration plus resolved `terms_dir`, or `{"workflow_state":"needs_configuration"}`. `--reset` returns `{"workflow_state":"reset"}`. `--validate-article PATH` requires an existing `.md` file in the configured vault and returns:
      
      ```json
      {"terms_dir":"/vault/Glossary","article_path":"/vault/Papers/example.md","article_sha256":"sha256"}
      ```
      
      ## Proposal, Review, And Triage
      
      `plan_glossary.py --propose` emits a deterministic candidate pool for one host semantic review. Each candidate contains ordered paper-grounded `surface_forms`. `provenance.source_sha256` binds the current `paper_id`, manifest, and source records; `provenance.proposal_sha256` also binds the exact candidate payload and summary. The host may **only drop or reorder candidates**, must preserve each retained candidate's **exact `term` string** and full object, and must display no more than `summary.shortlist_limit` terms before waiting for selection.
      
      ```json
      {
        "status": "ok",
        "script": "plan_glossary.py",
        "mode": "propose",
        "workflow_state": "awaiting_semantic_review",
        "next_action": "record_reviewed_shortlist_then_present_and_wait",
        "paper_id": "paper-id",
        "candidates": [{"term": "MoE", "surface_forms": ["MoE", "MoE"], "category": "acronym-or-model", "occurrences": 2, "section_id": "sec:method", "page_start": 3, "snippet": "..."}],
        "summary": {"effective_body_characters": 9000, "shortlist_limit": 10, "pool_candidates": 1},
        "provenance": {
          "paper_id": "paper-id",
          "source_sha256": "sha256",
          "candidates": [{"term": "MoE", "surface_forms": ["MoE", "MoE"], "category": "acronym-or-model", "occurrences": 2, "section_id": "sec:method", "page_start": 3, "snippet": "..."}],
          "summary": {"effective_body_characters": 9000, "shortlist_limit": 10, "pool_candidates": 1},
          "proposal_sha256": "sha256"
        }
      }
      ```
      
      The dynamic `shortlist_limit` is 10 below 10,000 effective body characters, 18 below 30,000, 25 below 60,000, and 35 otherwise. The pool can contain up to ten review alternatives beyond that limit. With no proposal candidates, or when semantic review records an **empty reviewed shortlist**, the state is `no_candidates` and `next_action` is `"report_no_candidates"`; the host reports that terminal state without displaying an empty selector.
      
      Record the semantic review before display:
      
      ```text
      plan_glossary.py --review-proposal proposal.json --reviewed-terms '["MoE"]' --source-manifest paper_source_manifest.json --output reviewed.json
      ```
      
      The reviewed artifact contains `mode: "review"`, `reviewed_shortlist` with the selected full candidate objects unchanged, `proposal_provenance`, and provenance that repeats the exact proposal paper/source/candidate/summary fields, adds the full ordered `reviewed_shortlist`, and binds them with `"review_sha256"`. Review recording rejects unknown or duplicate names, alias syntax, more than `shortlist_limit`, a changed source, and any proposal payload or identity that does not match the current source.
      
      After the complete reviewed list is displayed, resolve shortlist numbers and `全部写入` to exact names outside the CLI. Triage requires the reviewed artifact; the old `--terms`-only path fails:
      
      ```text
      plan_glossary.py --reviewed-shortlist reviewed.json --terms '["MoE"]' --source-manifest paper_source_manifest.json --output triage.json
      ```
      
      Every selected name must exactly match `reviewed_shortlist`. Triage revalidates the review against the current source and proposal, copies candidate `surface_forms` unchanged, uses only those forms for occurrences and anchors, and returns proposal, review, and selection provenance. `selection_sha256` binds the review identity plus each selected exact `term` and ordered `surface_forms` list.
      
      ## Inventory And Entries
      
      Pass triage to `inspect_glossary_library.py` with the current `--source-manifest`, optional `--raw-sections`, and saved `--reviewed-shortlist`:
      
      ```text
      inspect_glossary_library.py --terms triage.json --terms-dir /vault/Glossary --source-manifest paper_source_manifest.json --raw-sections paper_raw_sections.jsonl --reviewed-shortlist reviewed.json --output inventory.json
      ```
      
      The CLI recomputes the current deterministic proposal and validates the saved review before comparing the proposal, review, and selection digests from the triage artifact. This saved `--triage` is the independent authorization artifact passed unchanged to writer and linker. Both require exact ordered `term` and `surface_forms` equality with it. Inventory requires matching top-level/proposal/review paper and source fields and preserves that provenance plus each exact ordered `surface_forms` list. Its output includes the validated top-level `paper_id`. Missing, empty, non-string, whitespace-altered, duplicate, term-omitting, shortlist-mismatched, or identity-mismatched form data fails instead of being repaired. Every result contains `term`, `surface_forms`, `state`, `file`, `link_stem`, and `missing_fields`. State is `"new"`, `"existing_thin"`, or `"existing_complete"`.
      
      The one generated glossary batch has an `entries` array. Every entry has `name`, `aliases`, `operation`, and `occurrence`; `operation` is one of `create`, `enrich`, or `reuse`.
      
      ```json
      {
        "entries": [{
          "name": "MoE",
          "aliases": ["Mixture of Experts"],
          "operation": "enrich",
          "elaboration": "Only supplied when inventory marks it missing.",
          "occurrence": "Paper-specific evidence."
        }]
      }
      ```
      
      `create` requires a definition and valid confidence. `enrich` may add only inventory `missing_fields`. `reuse` cannot replace concept fields. Every operation requires an occurrence. The writer resolves the **configured glossary directory** from device-local configuration (or `--config-path`), requires it to remain a valid Obsidian-vault directory, and has no standalone destination override. It requires the inventory's full provenance, reruns the same chain validation against the ordered inventory result terms/forms, then rechecks current library state and preflights the complete batch before changing a note.
      
      ## Writer And Linker Results
      
      The article-bound writer invocation is:
      
      ```text
      write_glossary_terms.py --glossary entries.json --inventory inventory.json --config-path config.json --source-manifest paper_source_manifest.json --raw-sections paper_raw_sections.jsonl --reviewed-shortlist reviewed.json --triage triage.json --article /vault/Papers/example.md --output writer.json
      ```
      
      For a glossary-only invocation, omit `--article`:
      
      ```text
      write_glossary_terms.py --glossary entries.json --inventory inventory.json --config-path config.json --source-manifest paper_source_manifest.json --raw-sections paper_raw_sections.jsonl --reviewed-shortlist reviewed.json --triage triage.json --output writer.json
      ```
      
      Both modes revalidate the current source and saved review before loading generated entries. With an article, `paper_link` is **derived from the resolved article Markdown stem** and `article_path` is its resolved absolute path. In glossary-only mode, `paper_link` is the wiki-link-safe **validated manifest `paper_id`** and `article_path` is empty. `write_glossary_terms.py` returns successful results with the actual note target and only the exact reviewed/inventory `forms`:
      
      ```json
      {
        "status": "ok",
        "script": "write_glossary_terms.py",
        "paper_id": "paper-id",
        "triage_sha256": "sha256",
        "provenance": {
          "proposal": {
            "paper_id": "paper-id",
            "source_sha256": "sha256",
            "candidates": [{"term": "MoE", "surface_forms": ["MoE", "MoE"]}],
            "summary": {"shortlist_limit": 10},
            "proposal_sha256": "sha256"
          },
          "review": {
            "paper_id": "paper-id",
            "source_sha256": "sha256",
            "candidates": [{"term": "MoE", "surface_forms": ["MoE", "MoE"]}],
            "summary": {"shortlist_limit": 10},
            "proposal_sha256": "sha256",
            "reviewed_shortlist": [{"term": "MoE", "surface_forms": ["MoE", "MoE"]}],
            "review_sha256": "sha256"
          },
          "selection_sha256": "sha256"
        },
        "context": {"paper_id": "paper-id", "paper_link": "example", "article_path": "/vault/Papers/example.md"},
        "mappings_sha256": "sha256",
        "results": [{
          "name": "MoE",
          "forms": ["MoE", "MoE"],
          "file": "/vault/Glossary/MoE.md",
          "action": "enriched",
          "link_stem": "MoE",
          "fields_added": ["elaboration"],
          "occurrence_added": true
        }]
      }
      ```
      
      `action` is `created`, `enriched`, `updated`, or `unchanged`. Entry `aliases` may be written to note metadata and participate in logical collision checks, but they are never added to writer `forms`. Generated names and aliases are checked against the full library's NFKC/casefold alias index before any write; an existing form is allowed only for the same validated target. Grounded inventory forms need not be repeated in entry `aliases`. Existing notes are never replaced, only missing fields and an absent occurrence may be added. `triage_sha256` deterministically identifies the authorized triage's paper, provenance, and terms. `mappings_sha256` binds that triage identity, validated provenance, derived context, and exact ordered `name`, `forms`, `file`, and `link_stem` mappings.
      
      Before filesystem-invalid-character cleanup, the writer translates Obsidian wikilink syntax characters `# ^ [ ] | : %` in generated filename stems to their fullwidth equivalents. When the allocated `link_stem` differs from the exact term, the note keeps that exact term as an alias and visible H1, so article links use forms such as `[[C#|C#]]`. Inventory rejects a selected existing note whose filename still contains those unsafe ASCII characters and asks the user to replace its filename; it never changes user-owned note paths automatically.
      
      Run the linker **only if an article Markdown was supplied/requested**:
      
      ```text
      link_glossary_terms.py --input /vault/Papers/example.md --write-result writer.json --expected-sha256 sha256 --config-path config.json --source-manifest paper_source_manifest.json --raw-sections paper_raw_sections.jsonl --reviewed-shortlist reviewed.json --triage triage.json --output linker.json
      ```
      
      The linker revalidates the successful writer provenance against the current source, saved review, and same saved triage; requires its mappings to equal the triage's exact authorized selection; verifies `triage_sha256`; recomputes `mappings_sha256`; and checks context, order, files, stems, and full-library form resolution before touching the article. It rejects a writer artifact with **no bound article path**. Supply the preview's `article_sha256` as `--expected-sha256`. **For a glossary-only request, skip `link_glossary_terms.py`**. When run, it returns the validated `article_path`, per-note `results`, and counts:
      
      ```json
      {
        "status": "ok",
        "script": "link_glossary_terms.py",
        "article_path": "/vault/Papers/example.md",
        "results": [{"link_stem": "MoE", "status": "already_linked"}],
        "summary": {"linked": 0, "already_linked": 1, "not_found": 0}
      }
      ```
      
      Link statuses are `linked`, `already_linked`, or `not_found`. A stale article hash stops linking before article modification.
      
      ## Lint And Timing Boundaries
      
      Lint only **writer-returned changed glossary note files** whose **`action` is `created`, `enriched`, or `updated`**, using **one `lint_glossary.py` invocation with repeated `--input PATH`** arguments. Explicit inputs are resolved and deduplicated, and may be combined with `--terms-dir` for an intentional full-library audit. **Do not pass article Markdown to `lint_glossary.py`**; it validates the term-note structure, not ordinary articles.
      
      **Time host-only phases separately**. **Each CLI emits top-level `elapsed_ms`** as a non-negative integer for its own complete invocation:
      
      ```json
      {
        "elapsed_ms": 12
      }
      ```
      
      The **single writer invocation includes whole-batch preflight and commit**. **Do not report separate preflight and commit timings**. Host semantic review and model generation remain separately timed host-only phases and are not fabricated by Python.
      
    • optimization-design.md 9.6 KB
      # Paper Glossary Optimization Design
      
      Date: 2026-07-14
      
      Status: approved for implementation
      
      ## Goals
      
      - Keep `paper-glossary` independent from the DeepPaperNote workflow.
      - Use only `*_source_manifest.json` and `*_raw_sections.jsonl` as the paper-content contract.
      - Let each user configure one Obsidian term-library directory on first use per device.
      - Present a high-quality, length-aware candidate list before any write.
      - After selection, write or enrich term notes and add safe outbound links to the explicit article Markdown.
      - Reduce model latency without making term notes too thin.
      
      ## Non-goals
      
      - Do not modify `skills/deeppapernote/` or call its workflow.
      - Do not change plugin manifests.
      - Do not add a vector database or third-party dependency.
      - Do not guess which Markdown article to modify.
      - Do not replace or rewrite user-authored term-note content.
      
      ## Inputs and Boundaries
      
      Term evidence comes only from a source manifest and its `raw_sections_path`, or from an explicit raw-sections JSONL file. The Markdown article path is a separate, explicit write target. It is never inferred from a PDF title, `full_text_md_path`, or a same-named file search.
      
      If the user provides only a PDF, manifest, or raw-sections path, preview may run, but article linking must wait until the user supplies the Markdown note path.
      
      ## First-use Configuration
      
      On the first write-capable run, ask the user to choose a term-library directory inside an Obsidian vault. Find the nearest ancestor containing `.obsidian` and reject a directory without one.
      
      Store device-local configuration in `~/.paper-glossary/config.json`:
      
      ```json
      {
        "vault_root": "F:\\我的笔记\\123",
        "terms_subdir": "book\\术语"
      }
      ```
      
      The config is not written into the plugin repository or Obsidian vault. Later runs reuse it without asking, and the preview still displays the resolved term-library path. Writer destination authority comes only from validated device config. `--config-path` selects a config file, not a direct destination. To use a different location, reset and configure the device again.
      
      Before any write:
      
      - verify that `vault_root/.obsidian` exists;
      - resolve paths before checking containment;
      - verify that the term directory remains inside the configured vault;
      - verify that the target article Markdown is inside the same vault.
      
      If any check fails, stop before changing files and request configuration again.
      
      ## Candidate Workflow
      
      ### Deterministic prefilter
      
      Build a grounded candidate pool from prose, headings, emphasis, acronym expansions, named methods, models, datasets, tools, and important components. Exclude:
      
      - fenced and inline code;
      - URLs and existing links;
      - references-only records or reference sections;
      - environment-variable fragments and code-identifier fragments;
      - example-only brands or entities without reusable conceptual value;
      - case-only duplicates and known aliases.
      
      Prefer longer and more specific forms before short forms. Keep evidence anchors and surface forms for every candidate.
      
      ### Length-aware limit
      
      Measure effective body characters after excluded material is removed. The final shortlist limit is:
      
      | Effective body length | Maximum candidates |
      | --- | ---: |
      | `< 10,000` | 10 |
      | `10,000-29,999` | 18 |
      | `30,000-59,999` | 25 |
      | `>= 60,000` | 35 |
      
      These are upper limits, not quotas. Never add noise to fill a tier. The deterministic pool may contain up to ten more items than the final limit so the semantic review has alternatives.
      
      ### Semantic review
      
      Use one compact host-model review to rank the grounded pool, remove residual noise, and retain core concepts that the deterministic ranking would otherwise underrank. The review may only drop or reorder exact proposal candidates: every retained candidate preserves its exact `term` and ordered `surface_forms`. It may not merge aliases, author forms, or introduce a term without paper evidence in the pool.
      
      Present the complete final shortlist as a numbered Markdown list. Also display:
      
      - the resolved term-library directory;
      - the article Markdown path, when available;
      - a clear statement that selection will write term notes and add one safe article outlink per selected term.
      
      Then stop and wait. A broad request or paper path alone is not write approval.
      
      ## Selection and Generation
      
      Accept numbers, names, or `全部写入` only from the immediately preceding shortlist. Resolve control input to canonical names before invoking triage.
      
      Classify selected terms as:
      
      - `new`: no matching name or alias in the central library;
      - `existing_complete`: an existing note has a definition, confidence, and at least two substantive explanatory fields;
      - `existing_thin`: an existing note is missing required fields or has fewer than two substantive explanatory fields.
      
      Use one batch generation step for all `new` entries and all missing fields in `existing_thin` entries:
      
      - `anchor_only`: concise definition, paper usage/occurrence, confidence, and only necessary supplementary context;
      - `needs_explanation`: definition, elaboration, intuition, distinction, occurrence, and confidence.
      
      For every existing note, append the new paper occurrence when absent. For `existing_thin`, add only missing fields. Never remove, replace, reorder, or rephrase existing user content. `existing_complete` receives the occurrence only.
      
      Validate the entire generated batch before writing any entry. One structured-output repair attempt is allowed. If the repaired payload is still invalid, stop without writing generated content.
      
      ## Article Outlinks
      
      Selection authorizes both glossary writes and article linking because the preview states both effects in advance.
      
      Use the actual `link_stem` returned by the glossary writer. In the explicit article Markdown, link the first safe prose occurrence:
      
      ```md
      [[ReAct]]
      [[ReAct|REACT]]
      ```
      
      Skip matches inside:
      
      - YAML frontmatter;
      - headings;
      - fenced or inline code;
      - URLs, Markdown links, HTML, and existing wiki links;
      - reference sections;
      - a larger identifier or word.
      
      Match longer terms before shorter ones. Preserve the original surface text through an alias link. Add at most one outlink per term. A repeated run must not add another link.
      
      Record the article hash at preview. Recheck it immediately before link application. If it changed, stop article writing and report the conflict. Build the edited text in memory, write a temporary sibling file, and atomically replace the article only after validation. If no safe occurrence exists, keep the valid glossary result and report the missing outlink without forcing a fallback insertion.
      
      ## Performance
      
      - Use at most one semantic-review phase before selection.
      - Use one batch generation phase after selection; never call the model once per term.
      - Generate content only for new entries and missing fields in thin entries.
      - Lint only files changed by the current run; keep a separate full-library audit command.
      - Report elapsed time for configuration, extraction, semantic review, generation, glossary write, article linking, and lint.
      - Treat under-two-second local script work on the chapter-four regression fixture as an observed, environment-dependent target. Repeated benchmark runs report a median and range, or exact overruns. Overall model time is a visible target of roughly one to two minutes, not a platform-independent guarantee.
      
      ## Result and Failure Reporting
      
      The final result reports separate counts and paths for:
      
      - created;
      - enriched;
      - reused;
      - occurrences appended;
      - article outlinks added;
      - selected terms without a safe outlink;
      - failures.
      
      Configuration errors, cross-vault paths, invalid selections, stale article hashes, and invalid generated payloads must fail before the corresponding write. A failed enrichment preserves the original note. A failed article link does not corrupt or delete a valid term note.
      
      ## Implementation Surface
      
      Keep changes inside `skills/paper-glossary/`:
      
      - update `SKILL.md` for first-use setup, hybrid preview, selection effects, and adaptive generation;
      - add device-local config resolution under `scripts/`;
      - improve `plan_glossary.py` prefiltering, alias normalization, evidence, and dynamic limits;
      - extend `write_glossary_terms.py` with preflight validation and non-destructive thin-note enrichment;
      - add a focused article-linking script with hash and atomic-write protection;
      - update `references/file-contract.md` and `README.md`;
      - add focused unit and integration tests.
      
      ## Verification
      
      Tests must cover:
      
      - first-use configuration, reuse, reset, deleted paths, non-vault paths, and cross-vault rejection;
      - all four candidate-size tiers without quota filling;
      - code, URL, references, environment-variable, example-entity, case, and alias filtering;
      - preservation of core chapter concepts such as `Reflection`;
      - concise and full entry schemas;
      - new, complete, and thin existing-note behavior;
      - preservation of frontmatter, custom sections, comments, and user text;
      - idempotent occurrence enrichment;
      - direct and alias outlinks, protected Markdown regions, longer-term priority, stale hashes, and idempotency;
      - the complete preview, selection, write, link, and lint flow using repository fixtures.
      
      Run the focused paper-glossary tests and the repository's normal full test suite. No commit or push occurs until implementation and verification are complete and the user explicitly approves it.
      
      ## Current Demo Cleanup
      
      The earlier demo wrote notes to `F:\我的笔记\123\学习\hello_agent\术语`. Do not migrate those notes blindly. After implementation passes tests, rerun preview for the chapter-four article with the improved filter, let the user select from the corrected shortlist, merge approved content into the configured central library, add article outlinks, and only then remove assistant-created demo files after confirming they contain no later user edits.
      
    • optimization-implementation-plan.md 44 KB
      # Paper Glossary Optimization Implementation Plan
      
      > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
      
      **Goal:** Build a fast, first-use-configured paper-glossary workflow that proposes length-aware terms, non-destructively enriches a shared Obsidian term library, and adds safe first-occurrence links to the explicit article Markdown.
      
      **Architecture:** Keep paper evidence intake limited to `*_source_manifest.json` and `*_raw_sections.jsonl`. Add a device-local configuration layer, deterministic candidate prefilter followed by one host semantic review, deterministic library inventory, one batched model payload for new/thin entries, and a separate atomic Markdown linker. DeepPaperNote remains untouched.
      
      **Tech Stack:** Python 3.10+, standard library only, Markdown/JSON/JSONL, pytest 8+, existing repository scripts and contracts.
      
      ## Global Constraints
      
      - Modify only `skills/paper-glossary/`; do not modify `skills/deeppapernote/` or root plugin manifests.
      - Keep `*_source_manifest.json` and `*_raw_sections.jsonl` as the only paper-content collaboration contract.
      - Do not add dependencies.
      - Do not guess an article Markdown path from a title, PDF, or `full_text_md_path`.
      - Require the configured term directory and article Markdown to belong to the same nearest Obsidian vault.
      - Preserve all user-authored term-note content; enrichment may only add missing structured fields and a missing occurrence.
      - Use TDD: run every named failing test before implementation and rerun it after the minimal change.
      - Preserve the current uncommitted selection-gate work and build on it rather than reverting it.
      - Do not commit or push during implementation. Each task ends with a diff/test checkpoint; commit only after the user explicitly approves the completed implementation.
      - Before editing skill behavior, invoke `superpowers:writing-skills`; before each behavior change, invoke `superpowers:test-driven-development`.
      
      ## File Map
      
      | File | Responsibility |
      | --- | --- |
      | `scripts/glossary_config.py` | Device-local config, nearest-vault validation, same-vault article validation, article hash |
      | `scripts/configure_glossary.py` | Configure/show/reset/validate CLI for first-use interaction |
      | `scripts/plan_glossary.py` | Effective-prose cleanup, dynamic limits, grounded candidate pool, selected-term triage |
      | `scripts/glossary_library.py` | Alias index, existing-note inspection, completeness classification, additive field insertion |
      | `scripts/inspect_glossary_library.py` | CLI inventory of selected terms before model generation |
      | `scripts/write_glossary_terms.py` | Preflight operation validation and create/enrich/reuse writes |
      | `scripts/link_glossary_terms.py` | Protected-region-aware, hash-guarded, atomic article linking |
      | `scripts/lint_glossary.py` | Lint one or more changed notes without scanning the full library |
      | `scripts/glossary_common.py` | Shared JSON emission and elapsed-time helper |
      | `SKILL.md` and `agents/openai.yaml` | Cross-host first-use, preview, selection, write, and link instructions |
      | `README.md` and `references/file-contract.md` | User setup and artifact contracts |
      | `tests/` | Focused unit, contract, and end-to-end coverage |
      
      ## Spec Coverage
      
      | Design section | Implemented by |
      | --- | --- |
      | Goals, non-goals, inputs, and boundaries | Global constraints; Tasks 1 and 5 |
      | First-use configuration | Task 1 |
      | Candidate workflow and dynamic limits | Task 2 |
      | Selection, adaptive generation, and old-note enrichment | Tasks 3 and 5 |
      | Article outlinks and write safety | Task 4 |
      | Performance and stage timing | Task 6 |
      | Result and failure reporting | Tasks 1, 3, 4, and 6 |
      | Implementation surface and verification | Tasks 1-6 |
      | Current demo cleanup and real acceptance | Task 7 |
      
      ---
      
      ### Task 1: Device-local Obsidian configuration
      
      **Files:**
      - Create: `skills/paper-glossary/scripts/glossary_config.py`
      - Create: `skills/paper-glossary/scripts/configure_glossary.py`
      - Create: `skills/paper-glossary/tests/test_glossary_config.py`
      
      **Interfaces:**
      - Produces: `find_vault_root(path: Path) -> Path | None`
      - Produces: `configure_terms_dir(terms_dir: Path, config_path: Path | None = None) -> dict[str, str]`
      - Produces: `load_config(config_path: Path | None = None) -> dict[str, str] | None`
      - Produces: `resolve_terms_dir(config: dict[str, str]) -> Path`
      - Produces: `validate_article(article: Path, config: dict[str, str]) -> dict[str, str]`
      - Produces CLI modes: `--terms-dir`, `--show`, `--reset`, `--validate-article`, plus `--config-path` and `--output`
      
      - [ ] **Step 1: Write failing configuration and vault-boundary tests**
      
      Add tests that exercise an existing term folder, a not-yet-created child folder, nearest nested vault selection, a non-vault path, same-vault article validation, cross-vault rejection, config reuse, reset, and missing configured paths. Core tests must include:
      
      ```python
      from __future__ import annotations
      
      import json
      from pathlib import Path
      
      import pytest
      
      from glossary_config import (
          configure_terms_dir,
          load_config,
          resolve_terms_dir,
          validate_article,
      )
      
      
      def test_configure_terms_dir_persists_nearest_vault_and_relative_subdir(
          tmp_path: Path,
      ) -> None:
          outer = tmp_path / "outer"
          inner = outer / "research"
          (outer / ".obsidian").mkdir(parents=True)
          (inner / ".obsidian").mkdir()
          terms_dir = inner / "book" / "术语"
          config_path = tmp_path / "device" / "config.json"
      
          payload = configure_terms_dir(terms_dir, config_path)
      
          assert payload == {
              "vault_root": str(inner.resolve()),
              "terms_subdir": str(Path("book") / "术语"),
          }
          assert terms_dir.is_dir()
          assert load_config(config_path) == payload
          assert resolve_terms_dir(payload) == terms_dir.resolve()
      
      
      def test_validate_article_rejects_other_vault(tmp_path: Path) -> None:
          first = tmp_path / "first"
          second = tmp_path / "second"
          (first / ".obsidian").mkdir(parents=True)
          (second / ".obsidian").mkdir(parents=True)
          article = second / "Paper.md"
          article.write_text("# Paper\n", encoding="utf-8")
          config = configure_terms_dir(first / "术语", tmp_path / "config.json")
      
          with pytest.raises(SystemExit, match="same Obsidian vault"):
              validate_article(article, config)
      
      
      def test_load_config_returns_none_before_first_use(tmp_path: Path) -> None:
          assert load_config(tmp_path / "missing.json") is None
      ```
      
      - [ ] **Step 2: Run the focused test and verify RED**
      
      Run:
      
      ```powershell
      py -3.12 -m pytest -q skills/paper-glossary/tests/test_glossary_config.py --basetemp .pytest-tmp-paper-glossary-config
      ```
      
      Expected: collection fails because `glossary_config` does not exist.
      
      - [ ] **Step 3: Implement config resolution and validation**
      
      Use `Path.resolve(strict=False)` before containment checks and walk from the selected path or its nearest existing parent. The core implementation must follow this shape:
      
      ```python
      from __future__ import annotations
      
      import hashlib
      import json
      from pathlib import Path
      
      CONFIG_DIRNAME = ".paper-glossary"
      CONFIG_FILENAME = "config.json"
      
      
      def default_config_path() -> Path:
          return Path.home() / CONFIG_DIRNAME / CONFIG_FILENAME
      
      
      def find_vault_root(path: Path) -> Path | None:
          current = path.expanduser().resolve(strict=False)
          while not current.exists() and current != current.parent:
              current = current.parent
          if current.is_file():
              current = current.parent
          for candidate in (current, *current.parents):
              if (candidate / ".obsidian").is_dir():
                  return candidate.resolve()
          return None
      
      
      def _is_within(path: Path, parent: Path) -> bool:
          try:
              path.resolve(strict=False).relative_to(parent.resolve())
          except ValueError:
              return False
          return True
      
      
      def configure_terms_dir(
          terms_dir: Path, config_path: Path | None = None
      ) -> dict[str, str]:
          resolved = terms_dir.expanduser().resolve(strict=False)
          vault = find_vault_root(resolved)
          if vault is None or not _is_within(resolved, vault):
              raise SystemExit("Term directory must be inside an Obsidian vault containing .obsidian.")
          resolved.mkdir(parents=True, exist_ok=True)
          payload = {
              "vault_root": str(vault),
              "terms_subdir": str(resolved.relative_to(vault)),
          }
          target = (config_path or default_config_path()).expanduser()
          target.parent.mkdir(parents=True, exist_ok=True)
          target.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
          return payload
      
      
      def load_config(config_path: Path | None = None) -> dict[str, str] | None:
          target = (config_path or default_config_path()).expanduser()
          if not target.is_file():
              return None
          payload = json.loads(target.read_text(encoding="utf-8-sig"))
          if not isinstance(payload, dict):
              raise SystemExit(f"Invalid paper-glossary config object: {target}")
          return {"vault_root": str(payload.get("vault_root", "")), "terms_subdir": str(payload.get("terms_subdir", ""))}
      
      
      def resolve_terms_dir(config: dict[str, str]) -> Path:
          vault = Path(config.get("vault_root", "")).expanduser().resolve(strict=False)
          terms_dir = (vault / config.get("terms_subdir", "")).resolve(strict=False)
          if not (vault / ".obsidian").is_dir() or not terms_dir.is_dir() or not _is_within(terms_dir, vault):
              raise SystemExit("Configured Obsidian term directory is missing or invalid; configure it again.")
          return terms_dir
      
      
      def validate_article(article: Path, config: dict[str, str]) -> dict[str, str]:
          resolved = article.expanduser().resolve()
          if resolved.suffix.lower() != ".md" or not resolved.is_file():
              raise SystemExit(f"Article Markdown not found: {resolved}")
          article_vault = find_vault_root(resolved)
          configured_vault = Path(config["vault_root"]).expanduser().resolve()
          if article_vault != configured_vault:
              raise SystemExit("Article and term directory must be inside the same Obsidian vault.")
          digest = hashlib.sha256(resolved.read_bytes()).hexdigest()
          return {"article_path": str(resolved), "article_sha256": digest}
      ```
      
      Keep JSON formatting readable and use `SystemExit` messages that the host can relay directly.
      
      - [ ] **Step 4: Add the small configuration CLI**
      
      Implement mutually exclusive configure/show/reset/validate modes. `--show` must return `workflow_state: "needs_configuration"` when no config exists instead of failing. `--validate-article` must include the resolved `terms_dir`, `article_path`, and `article_sha256` in its JSON output. `--reset` removes only the config file and never removes a vault or term directory.
      
      - [ ] **Step 5: Run Task 1 tests and verify GREEN**
      
      Run the command from Step 2. Expected: all tests in `test_glossary_config.py` pass.
      
      - [ ] **Step 6: Task 1 checkpoint without commit**
      
      Run:
      
      ```powershell
      git diff --check -- skills/paper-glossary/scripts/glossary_config.py skills/paper-glossary/scripts/configure_glossary.py skills/paper-glossary/tests/test_glossary_config.py
      ```
      
      Expected: exit code 0. Record `git status --short`; do not stage or commit.
      
      ---
      
      ### Task 2: Length-aware deterministic candidate pool
      
      **Files:**
      - Modify: `skills/paper-glossary/scripts/plan_glossary.py:14-390`
      - Modify: `skills/paper-glossary/tests/test_plan_glossary.py`
      
      **Interfaces:**
      - Produces: `effective_prose_text(records: list[dict[str, Any]]) -> str`
      - Produces: `shortlist_limit(effective_characters: int) -> int`
      - Changes: `propose_candidates(records)` returns a grounded pool capped at `shortlist_limit + 10`
      - Changes proposal JSON: `workflow_state`, `next_action`, `summary.effective_body_characters`, `summary.shortlist_limit`, `summary.pool_candidates`
      - Preserves: selected-term triage and unresolved-selection rejection
      
      - [ ] **Step 1: Replace the fixed-limit test with failing tier and noise tests**
      
      Add parameterized limit tests and a chapter-style regression:
      
      ```python
      @pytest.mark.parametrize(
          ("characters", "expected"),
          [(9_999, 10), (10_000, 18), (29_999, 18), (30_000, 25), (59_999, 25), (60_000, 35)],
      )
      def test_shortlist_limit_uses_effective_body_length(characters: int, expected: int) -> None:
          assert shortlist_limit(characters) == expected
      
      
      def test_propose_filters_code_noise_and_keeps_emphasized_core_terms() -> None:
          records = [
              {
                  "record_type": "section",
                  "section_id": "sec:chapter",
                  "kind": "chapter",
                  "title": "智能体经典范式",
                  "text": (
                      "**ReAct**、**Plan-and-Solve** 与 **Reflection** 是三种经典范式。\n"
                      "<strong>SerpApi</strong> 为智能体提供搜索能力。\n"
                      "```python\nLLM_API_KEY = 'KEY'\nREACT_PROMPT_TEMPLATE = 'MODEL'\n"
                      "print('HUAWEI phone result')\n```\n"
                  ),
              },
              {
                  "record_type": "section",
                  "section_id": "sec:refs",
                  "kind": "references",
                  "title": "References",
                  "text": "SERPAPI TEMPLATE PLANNER",
              },
          ]
      
          terms = {item["term"] for item in propose_candidates(records)}
      
          assert {"ReAct", "Plan-and-Solve", "Reflection", "SerpApi"} <= terms
          assert terms.isdisjoint({"KEY", "MODEL", "TEMPLATE", "HUAWEI"})
          assert not ({"ReAct", "REACT"} <= terms)
      
      
      def test_proposal_reports_semantic_review_and_dynamic_limit(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          payload = _run_proposal(tmp_path, monkeypatch, _records())
          assert payload["workflow_state"] == "awaiting_semantic_review"
          assert payload["next_action"] == "review_candidate_pool_then_present_and_wait"
          assert payload["summary"]["shortlist_limit"] == 10
          assert payload["summary"]["pool_candidates"] == len(payload["candidates"])
      ```
      
      Update imports to include `shortlist_limit`. Keep the existing no-candidate terminal-state test.
      
      - [ ] **Step 2: Run candidate tests and verify RED**
      
      Run:
      
      ```powershell
      py -3.12 -m pytest -q skills/paper-glossary/tests/test_plan_glossary.py --basetemp .pytest-tmp-paper-glossary-plan
      ```
      
      Expected: failures for the missing `shortlist_limit`, code-noise candidates, missing `Reflection`, and old workflow-state fields.
      
      - [ ] **Step 3: Implement effective-prose cleanup and limits**
      
      Add standard-library regexes and helpers. Preserve link labels while removing destinations, remove reference tails inside monolithic Markdown records, and count non-whitespace characters:
      
      ```python
      FENCED_CODE_RE = re.compile(r"(?ms)^(?:`{3,}|~{3,})[^\n]*\n.*?^(?:`{3,}|~{3,})\s*$")
      INLINE_CODE_RE = re.compile(r"`+[^`\n]+`+")
      MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\([^\)]+\)")
      URL_RE = re.compile(r"https?://\S+")
      REFERENCE_TAIL_RE = re.compile(r"(?ims)^#{1,6}\s*(?:references|参考文献|引用)\s*$.*\Z")
      EMPHASIS_RE = re.compile(r"(?:\*\*|__)([^\n*_]{2,80})(?:\*\*|__)")
      STRONG_RE = re.compile(r"(?is)<strong>([^<]{2,80})</strong>")
      
      
      def _candidate_prose(text: str) -> str:
          cleaned = FENCED_CODE_RE.sub("\n", text)
          cleaned = INLINE_CODE_RE.sub(" ", cleaned)
          cleaned = MARKDOWN_LINK_RE.sub(r"\1", cleaned)
          cleaned = URL_RE.sub(" ", cleaned)
          return REFERENCE_TAIL_RE.sub("", cleaned)
      
      
      def effective_prose_text(records: list[dict[str, Any]]) -> str:
          return "\n".join(
              _candidate_prose(str(record.get("text", "")))
              for record in _evidence_records(records)
          )
      
      
      def shortlist_limit(effective_characters: int) -> int:
          if effective_characters < 10_000:
              return 10
          if effective_characters < 30_000:
              return 18
          if effective_characters < 60_000:
              return 25
          return 35
      ```
      
      Use cleaned record copies only for candidate extraction. Continue using original evidence records for occurrences and snippets.
      
      - [ ] **Step 4: Add grounded emphasis extraction and casefold deduplication**
      
      Change `add()` to use `unicodedata.normalize("NFKC", term).casefold()` as the candidate key. Prefer the first prose form and never emit a second case-only form. Feed bold and `<strong>` terms through conservative length/shape checks so `Reflection` and `SerpApi` survive without admitting sentences. Cap the deterministic pool at `shortlist_limit + 10`; the host semantic pass may present fewer than the final limit and may not invent terms outside this pool.
      
      - [ ] **Step 5: Emit the semantic-review contract**
      
      For a nonempty pool emit:
      
      ```python
      {
          "workflow_state": "awaiting_semantic_review",
          "next_action": "review_candidate_pool_then_present_and_wait",
          "summary": {
              "effective_body_characters": effective_characters,
              "shortlist_limit": display_limit,
              "pool_candidates": len(candidates),
          },
      }
      ```
      
      For an empty pool preserve `workflow_state: "no_candidates"` and `next_action: "report_no_candidates"`.
      
      - [ ] **Step 6: Run Task 2 tests and verify GREEN**
      
      Run the command from Step 2. Expected: all `test_plan_glossary.py` tests pass.
      
      - [ ] **Step 7: Task 2 checkpoint without commit**
      
      Run `git diff --check` on the two Task 2 files and record the diff. Do not stage or commit.
      
      ---
      
      ### Task 3: Existing-library inventory and non-destructive enrichment
      
      **Files:**
      - Create: `skills/paper-glossary/scripts/glossary_library.py`
      - Create: `skills/paper-glossary/scripts/inspect_glossary_library.py`
      - Create: `skills/paper-glossary/tests/test_glossary_library.py`
      - Modify: `skills/paper-glossary/scripts/write_glossary_terms.py:64-231`
      - Modify: `skills/paper-glossary/tests/test_write_and_lint_glossary.py`
      
      **Interfaces:**
      - Produces: `inspect_selected_terms(selected: list[dict[str, Any]], terms_dir: Path) -> list[dict[str, Any]]`
      - Inventory CLI requires `--source-manifest`, optional `--raw-sections`, and saved `--reviewed-shortlist`; it consumes the saved triage artifact as `--terms triage.json`.
      - Produces inventory states: `new`, `existing_thin`, `existing_complete`
      - Produces: `missing_concept_fields(text: str) -> list[str]`
      - Produces: `add_missing_concept_fields(text: str, patch: dict[str, Any]) -> tuple[str, list[str]]`
      - Produces: `write_glossary_entries(glossary: dict[str, Any], inventory: dict[str, Any], terms_dir: Path, paper_link: str) -> dict[str, Any]`
      - Changes glossary-entry contract: each entry has `operation` equal to `create`, `enrich`, or `reuse`
      - Changes writer CLI: require `--inventory` for the optimized workflow and validate the entire batch before writes
      - Changes writer result: include `forms`, `fields_added`, `occurrence_added`, and exact `link_stem`
      
      - [ ] **Step 1: Write failing inventory and preservation tests**
      
      Tests must prove classification by aliases and additive behavior:
      
      ```python
      def test_inventory_classifies_new_thin_and_complete_notes(tmp_path: Path) -> None:
          terms_dir = tmp_path / "术语"
          terms_dir.mkdir()
          (terms_dir / "LLM.md").write_text(
              "---\naliases:\n  - \"大语言模型\"\n---\n\n# LLM\n\n"
              "## 概念解释\n- 定义:语言模型。\n- 置信度:中\n\n"
              "## 在论文中的出现\n- [[OldPaper]]:旧出处\n",
              encoding="utf-8",
          )
          (terms_dir / "ReAct.md").write_text(
              "# ReAct\n\n## 概念解释\n"
              "- 定义:交替进行推理与行动的智能体范式。\n"
              "- 详解:模型在工具观察后继续推理。\n"
              "- 直觉:边想边做。\n"
              "- 置信度:高\n\n"
              "## 在论文中的出现\n- [[OldPaper]]:旧出处\n",
              encoding="utf-8",
          )
          selected = [
              {"term": "大语言模型", "surface_forms": ["LLM", "大语言模型"]},
              {"term": "ReAct", "surface_forms": ["ReAct"]},
              {"term": "Reflection", "surface_forms": ["Reflection"]},
          ]
      
          items = inspect_selected_terms(selected, terms_dir)
          states = {item["term"]: item["state"] for item in items}
      
          assert states == {
              "大语言模型": "existing_thin",
              "ReAct": "existing_complete",
              "Reflection": "new",
          }
      
      
      def test_enrichment_adds_only_missing_fields_and_preserves_custom_text() -> None:
          original = (
              "# LLM\n\n用户自己的说明。\n\n## 概念解释\n"
              "- 定义:语言模型。\n- 置信度:中\n\n## 自定义\n不要改动。\n"
          )
          updated, fields = add_missing_concept_fields(
              original,
              {"elaboration": "用于智能体推理。", "intuition": "智能体的大脑。"},
          )
      
          assert fields == ["elaboration", "intuition"]
          assert "用户自己的说明。" in updated
          assert "## 自定义\n不要改动。" in updated
          assert "- 定义:语言模型。" in updated
          assert updated.count("- 详解:用于智能体推理。") == 1
          assert updated.count("- 直觉:智能体的大脑。") == 1
      ```
      
      Add writer tests that reject an invalid operation before creating any file, enrich a thin note without replacing existing fields, append only a missing occurrence, and remain unchanged on a second identical run.
      
      - [ ] **Step 2: Run Task 3 tests and verify RED**
      
      Run:
      
      ```powershell
      py -3.12 -m pytest -q skills/paper-glossary/tests/test_glossary_library.py skills/paper-glossary/tests/test_write_and_lint_glossary.py --basetemp .pytest-tmp-paper-glossary-library
      ```
      
      Expected: missing module/functions and old writer behavior fail.
      
      - [ ] **Step 3: Implement deterministic library inspection**
      
      Move frontmatter alias, heading-name, and alias-index parsing into `glossary_library.py`; re-import those names from `write_glossary_terms.py` so existing imports remain compatible. Define completeness as:
      
      ```python
      CONCEPT_FIELDS = {
          "definition": "定义:",
          "elaboration": "详解:",
          "intuition": "直觉:",
          "distinction": "与相邻概念的区别:",
          "confidence": "置信度:",
      }
      
      
      def note_state(text: str) -> tuple[str, list[str]]:
          missing = missing_concept_fields(text)
          optional_present = sum(
              field not in missing for field in ("elaboration", "intuition", "distinction")
          )
          required_missing = any(field in missing for field in ("definition", "confidence"))
          state = "existing_thin" if required_missing or optional_present < 2 else "existing_complete"
          return state, missing
      ```
      
      Inventory output must include `term`, `surface_forms`, `state`, `file`, `link_stem`, and `missing_fields`. A missing alias match is `new` with an empty file and link stem based on the canonical term.
      
      - [ ] **Step 4: Implement additive field insertion**
      
      Insert only labels named in `missing_fields`. Place definition first, optional explanatory fields before confidence, and confidence last. If the concept section is absent, append a complete concept section without changing existing custom sections. Never replace a line whose label already exists. Return the exact list of fields added so repeat runs can prove idempotency.
      
      - [ ] **Step 5: Add inventory CLI**
      
      `inspect_glossary_library.py` accepts `--terms` pointing to triage JSON, `--terms-dir`, `--source-manifest`, optional `--raw-sections`, `--reviewed-shortlist`, and `--output`. It emits:
      
      ```json
      {
        "status": "ok",
        "script": "inspect_glossary_library.py",
        "results": [
          {
            "term": "LLM",
            "surface_forms": ["LLM", "大语言模型"],
            "state": "existing_thin",
            "file": "C:\\Notes\\Vault\\book\\术语\\LLM.md",
            "link_stem": "LLM",
            "missing_fields": ["elaboration", "intuition", "distinction"]
          }
        ]
      }
      ```
      
      - [ ] **Step 6: Extend writer preflight and operations**
      
      Validate every entry against inventory before writing any file:
      
      - `create`: inventory state must be `new`; require name, definition, confidence, occurrence.
      - `enrich`: state must be `existing_thin`; allow only fields listed in `missing_fields`; require occurrence and a valid confidence when confidence is missing.
      - `reuse`: state must be `existing_complete`; require occurrence; reject concept-field replacements.
      
      Apply writes only after the full batch validates. Enrich missing fields, append the paper occurrence once, and report `created`, `enriched`, `updated`, or `unchanged`. Include all canonical and surface forms in each result for article matching.
      
      - [ ] **Step 7: Run Task 3 tests and verify GREEN**
      
      Run the command from Step 2. Expected: all inventory, writer, and existing lint tests pass.
      
      - [ ] **Step 8: Task 3 checkpoint without commit**
      
      Run `git diff --check` on Task 3 files and record status. Do not stage or commit.
      
      ---
      
      ### Task 4: Safe first-occurrence article linker
      
      **Files:**
      - Create: `skills/paper-glossary/scripts/link_glossary_terms.py`
      - Create: `skills/paper-glossary/tests/test_link_glossary_terms.py`
      
      **Interfaces:**
      - Consumes: writer JSON `results[].link_stem` and `results[].forms`
      - Consumes: configured vault and preview-time article SHA-256
      - Produces: `link_article_terms(article: Path, mappings: list[dict[str, Any]], expected_sha256: str) -> dict[str, Any]`
      - Produces result records: `linked`, `already_linked`, `not_found`
      
      - [ ] **Step 1: Write failing protected-region, alias, hash, and idempotency tests**
      
      Use a single fixture containing frontmatter, heading, ordinary prose, inline code, fenced code, URL, Markdown link, wiki link, and a references heading:
      
      ```python
      def test_linker_links_first_safe_alias_and_skips_protected_regions(tmp_path: Path) -> None:
          article = tmp_path / "Paper.md"
          article.write_text(
              "---\ntitle: ReAct\n---\n"
              "# ReAct heading\n\n"
              "`ReAct` and [ReAct](https://example.test) are protected.\n\n"
              "REACT combines reasoning and acting. ReAct appears again.\n\n"
              "```python\nReAct = 'code'\n```\n\n"
              "## References\nReAct paper\n",
              encoding="utf-8",
          )
          before = article.read_bytes()
          digest = hashlib.sha256(before).hexdigest()
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct", "REACT"]}],
              digest,
          )
      
          text = article.read_text(encoding="utf-8")
          assert "[[ReAct|REACT]] combines reasoning" in text
          assert "# ReAct heading" in text
          assert "`ReAct`" in text
          assert "```python\nReAct = 'code'\n```" in text
          assert text.count("[[ReAct|REACT]]") == 1
          assert result["summary"] == {"linked": 1, "already_linked": 0, "not_found": 0}
      
      
      def test_linker_rejects_stale_article_hash(tmp_path: Path) -> None:
          article = tmp_path / "Paper.md"
          article.write_text("ReAct text\n", encoding="utf-8")
      
          with pytest.raises(SystemExit, match="changed since preview"):
              link_article_terms(article, [{"link_stem": "ReAct", "forms": ["ReAct"]}], "0" * 64)
      
      
      def test_linker_is_idempotent(tmp_path: Path) -> None:
          article = tmp_path / "Paper.md"
          article.write_text("ReAct text\n", encoding="utf-8")
          first_hash = hashlib.sha256(article.read_bytes()).hexdigest()
          link_article_terms(article, [{"link_stem": "ReAct", "forms": ["ReAct"]}], first_hash)
          linked_bytes = article.read_bytes()
          second_hash = hashlib.sha256(linked_bytes).hexdigest()
          result = link_article_terms(article, [{"link_stem": "ReAct", "forms": ["ReAct"]}], second_hash)
      
          assert article.read_bytes() == linked_bytes
          assert result["summary"]["already_linked"] == 1
      ```
      
      Add tests for longer-term priority (`SerpApi` before `API`), no substring match, existing wiki links, UTF-8 BOM preservation, CRLF preservation, no safe occurrence, and same-vault validation through configured paths.
      
      - [ ] **Step 2: Run linker tests and verify RED**
      
      Run:
      
      ```powershell
      py -3.12 -m pytest -q skills/paper-glossary/tests/test_link_glossary_terms.py --basetemp .pytest-tmp-paper-glossary-link
      ```
      
      Expected: collection fails because `link_glossary_terms` does not exist.
      
      - [ ] **Step 3: Implement protected-range discovery**
      
      Create spans for frontmatter at file start, heading lines, fenced blocks, inline code, Markdown links, wiki links, HTML tags, URLs, and the references tail. Merge overlapping spans before matching. The linker must test candidate match ranges against these spans and against previously planned edits.
      
      Use ASCII-aware boundaries for ASCII forms:
      
      ```python
      def term_pattern(form: str) -> re.Pattern[str]:
          escaped = re.escape(form)
          if form.isascii():
              return re.compile(rf"(?<![A-Za-z0-9_]){escaped}(?![A-Za-z0-9_])", re.IGNORECASE)
          return re.compile(escaped, re.IGNORECASE)
      ```
      
      Sort forms by descending length. Plan all edits against the original text, then apply them from the highest offset to the lowest so offsets remain stable.
      
      - [ ] **Step 4: Implement canonical and alias wiki-link rendering**
      
      Render exact canonical display as `[[link_stem]]`; otherwise preserve the matched source text as `[[link_stem|surface]]`. Before planning a new link, detect whether any existing wiki link already targets `link_stem` and report `already_linked`.
      
      - [ ] **Step 5: Implement hash guard and atomic write**
      
      Read bytes, detect UTF-8 BOM and the dominant newline style, verify SHA-256, edit decoded text, restore newline/BOM, write a temporary sibling, then use `os.replace`. Delete the temporary file in a `finally` block if replacement did not occur. If no edits exist, do not rewrite the article.
      
      - [ ] **Step 6: Add CLI**
      
      Accept `--input`, `--write-result`, `--expected-sha256`, `--config-path`, and `--output`. Load device config, reject a cross-vault article before hashing, use only successful writer results, and emit per-term status plus summary counts.
      
      - [ ] **Step 7: Run Task 4 tests and verify GREEN**
      
      Run the command from Step 2. Expected: all linker tests pass.
      
      - [ ] **Step 8: Task 4 checkpoint without commit**
      
      Run `git diff --check` on Task 4 files and record status. Do not stage or commit.
      
      ---
      
      ### Task 5: Cross-host skill workflow and public contracts
      
      **Files:**
      - Modify: `skills/paper-glossary/SKILL.md`
      - Modify: `skills/paper-glossary/agents/openai.yaml`
      - Modify: `skills/paper-glossary/README.md`
      - Modify: `skills/paper-glossary/references/file-contract.md`
      - Modify: `skills/paper-glossary/tests/test_skill_boundary.py`
      
      **Interfaces:**
      - Consumes Task 1 configure/show/validate operations
      - Consumes Task 2 proposal pool and dynamic shortlist limit
      - Consumes Task 3 inventory and action-aware glossary payload
      - Consumes Task 4 article-link result
      - Produces the same numbered Markdown interaction in terminals, Codex, and Claude Code
      
      - [ ] **Step 1: Write failing workflow-contract tests**
      
      Replace fixed-15 assertions and require the approved behavior:
      
      ```python
      def test_skill_documents_first_use_dynamic_preview_and_link_consent() -> None:
          text = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
          for requirement in (
              "first use",
              ".paper-glossary",
              "same Obsidian vault",
              "semantic review",
              "effective body",
              "全部写入",
              "End the response and wait",
              "article Markdown",
              "first safe occurrence",
              "existing_thin",
              "Do not overwrite",
          ):
              assert requirement in text
      
      
      def test_readme_explains_per_device_one_time_configuration() -> None:
          text = (SKILL_DIR / "README.md").read_text(encoding="utf-8")
          assert "~/.paper-glossary/config.json" in text
          assert "Windows" in text
          assert "macOS" in text
          assert "Linux" in text
          assert ".obsidian" in text
      
      
      def test_contract_documents_dynamic_proposal_inventory_and_link_results() -> None:
          text = (SKILL_DIR / "references" / "file-contract.md").read_text(encoding="utf-8")
          for field in (
              '"shortlist_limit"',
              '"awaiting_semantic_review"',
              '"existing_thin"',
              '"operation": "enrich"',
              '"article_sha256"',
              '"already_linked"',
          ):
              assert field in text
      ```
      
      Keep the existing self-contained boundary test that rejects imports from DeepPaperNote.
      
      - [ ] **Step 2: Run skill-boundary tests and verify RED**
      
      Run:
      
      ```powershell
      py -3.12 -m pytest -q skills/paper-glossary/tests/test_skill_boundary.py --basetemp .pytest-tmp-paper-glossary-skill
      ```
      
      Expected: failures for missing first-use, dynamic, inventory, and linker documentation.
      
      - [ ] **Step 3: Rewrite `SKILL.md` around the approved interaction**
      
      Keep it concise and route details to references. The operational order must be explicit:
      
      1. Show existing config or ask for an Obsidian term directory on first use.
      2. Validate an explicit article Markdown when links are requested.
      3. Run deterministic proposal.
      4. Perform one grounded semantic review bounded by `shortlist_limit`.
      5. Display every retained term as a numbered Markdown list, the term directory, article path, and both write effects.
      6. State that no files have been written, end the response, and wait.
      7. Resolve numbers/names/`全部写入` from the immediately preceding list.
      8. Triage, inventory, generate one action-aware batch, preflight, write/enrich/reuse, link article, lint changed files, and report timing/status.
      
      The default prompt in `agents/openai.yaml` must trigger preview and waiting, not immediate writing.
      
      - [ ] **Step 4: Document first-use setup and contracts**
      
      `README.md` must explain that each person configures a directory once per device, that the directory must be inside an Obsidian vault, where local config lives, how to show/reset it, and that the article must be in the same vault. Include Windows, macOS, and Linux command examples without assuming a fixed drive.
      
      `file-contract.md` must document proposal metrics/state, inventory state, operation-aware entry schema, writer forms, article hash, and linker results. Preserve the manifest/raw-sections collaboration boundary.
      
      - [ ] **Step 5: Run Task 5 tests and verify GREEN**
      
      Run the command from Step 2. Expected: all boundary tests pass.
      
      - [ ] **Step 6: Validate the skill metadata**
      
      Run:
      
      ```powershell
      python -X utf8 C:\Users\lenovo\.codex\skills\.system\skill-creator\scripts\quick_validate.py skills\paper-glossary
      ```
      
      Expected: `Skill is valid!`
      
      - [ ] **Step 7: Task 5 checkpoint without commit**
      
      Run `git diff --check` on Task 5 files and record status. Do not stage or commit.
      
      ---
      
      ### Task 6: Changed-file linting, timing, and end-to-end verification
      
      **Files:**
      - Modify: `skills/paper-glossary/scripts/glossary_common.py`
      - Modify: `skills/paper-glossary/scripts/lint_glossary.py`
      - Modify: all paper-glossary CLIs created or changed in Tasks 1-4 to emit elapsed time
      - Create: `skills/paper-glossary/tests/test_workflow_integration.py`
      - Modify: `skills/paper-glossary/tests/test_write_and_lint_glossary.py`
      
      **Interfaces:**
      - Produces: `elapsed_ms(started: float) -> int`
      - Changes lint CLI: repeatable `--input` values while preserving `--terms-dir`
      - Produces end-to-end status across config, proposal, review recording, triage, inventory, write, link, and lint
      
      - [ ] **Step 1: Write failing timing, multi-input lint, and integration tests**
      
      The integration fixture must exercise the authenticated artifact chain in this exact order: proposal -> recorded review -> explicit selection -> triage -> authenticated inventory -> config-backed writer -> authenticated linker when an article is supplied -> changed-note lint. Its inventory invocation passes `--source-manifest`, optional `--raw-sections`, and `--reviewed-shortlist`; its writer and linker also pass the same saved `--triage`. Use CLI helpers that construct those authenticated commands rather than direct production helpers.
      
      Add a helper test and a complete fixture workflow. The integration test must use temporary files and deterministic model-authored entry JSON:
      
      ```python
      def test_complete_selected_flow_creates_enriches_links_and_lints(
          tmp_path: Path,
      ) -> None:
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          article = vault / "Chapter.md"
          article.write_text(
              "# 智能体范式\n\nReAct 与 Reflection 是两种智能体范式。\n",
              encoding="utf-8",
          )
          terms_dir = vault / "book" / "术语"
          config = configure_terms_dir(terms_dir, tmp_path / "config.json")
          article_info = validate_article(article, config)
      
          entries = {
              "entries": [
                  {
                      "name": "ReAct",
                      "aliases": ["Reasoning and Acting"],
                      "operation": "create",
                      "definition": "交替进行推理与行动的智能体范式。",
                      "confidence": "高",
                      "occurrence": "章节正文首次介绍 ReAct。",
                  },
                  {
                      "name": "Reflection",
                      "aliases": ["反思"],
                      "operation": "create",
                      "definition": "利用反馈检查并改进先前结果的智能体范式。",
                      "confidence": "高",
                      "occurrence": "章节正文将 Reflection 列为经典范式。",
                  },
              ]
          }
      
          inventory = {
              "results": _run_authenticated_inventory_cli(
                  [
                      {"term": "ReAct", "surface_forms": ["ReAct"]},
                      {"term": "Reflection", "surface_forms": ["Reflection", "反思"]},
                  ],
                  terms_dir,
              )
          }
          write_result = _run_authenticated_writer_cli(entries, inventory, terms_dir, "Chapter")
          link_result = _run_authenticated_linker_cli(
              article,
              write_result["results"],
              article_info["article_sha256"],
          )
      
          assert link_result["summary"]["linked"] == 2
          assert "[[ReAct]]" in article.read_text(encoding="utf-8")
          assert "[[Reflection]]" in article.read_text(encoding="utf-8")
          for path in terms_dir.glob("*.md"):
              assert lint_term_file_text(path.read_text(encoding="utf-8"))["passes"] is True
      ```
      
      Keep the CLI helper assertions focused on emitted artifacts and article/note effects; do not duplicate writer or linker logic in the test.
      
      Add a CLI test proving `lint_glossary.py --input A.md --input B.md` checks exactly two files.
      
      - [ ] **Step 2: Run integration tests and verify RED**
      
      Run:
      
      ```powershell
      py -3.12 -m pytest -q skills/paper-glossary/tests/test_workflow_integration.py skills/paper-glossary/tests/test_write_and_lint_glossary.py --basetemp .pytest-tmp-paper-glossary-integration
      ```
      
      Expected: failures for missing helper integration points, repeatable lint input, and elapsed-time fields.
      
      - [ ] **Step 3: Add shared elapsed-time reporting**
      
      In `glossary_common.py` add:
      
      ```python
      from time import perf_counter
      
      
      def elapsed_ms(started: float) -> int:
          return max(0, round((perf_counter() - started) * 1000))
      ```
      
      Each CLI captures `started = perf_counter()` immediately inside `main()` and adds `elapsed_ms` to its emitted top-level JSON. The host separately reports semantic-review and model-generation time because those phases do not run inside Python.
      
      - [ ] **Step 4: Make lint input repeatable**
      
      Change `--input` to `action="append", default=[]`, collect each resolved path once, and retain `--terms-dir` for explicit full-library audits. Reject an empty final file list as before.
      
      - [ ] **Step 5: Complete the integration path and run GREEN**
      
      Expose a writer function beneath the CLI so integration tests call production logic. Run the command from Step 2. Expected: all integration and writer/lint tests pass.
      
      - [ ] **Step 6: Run the full focused suite**
      
      Run:
      
      ```powershell
      py -3.12 -m pytest -q skills/paper-glossary/tests --basetemp .pytest-tmp-paper-glossary
      ```
      
      Expected: zero failures.
      
      - [ ] **Step 7: Run syntax and style checks available in the environment**
      
      Run:
      
      ```powershell
      python -m compileall -q skills\paper-glossary\scripts
      python -m ruff check skills\paper-glossary
      git diff --check -- skills/paper-glossary
      ```
      
      Expected: all installed checks exit 0. If `ruff` is unavailable, report the exact `No module named ruff` result and do not install a dependency solely for this task.
      
      - [ ] **Step 8: Run the repository test suite**
      
      Run:
      
      ```powershell
      py -3.12 -m pytest -q --basetemp .pytest-tmp-full
      ```
      
      Expected: zero failures; existing third-party deprecation warnings may remain and must be reported separately.
      
      - [ ] **Step 9: Measure local chapter-fixture stages**
      
      Use PowerShell `Measure-Command` for proposal, review recording, triage, inventory, writer, linker on a temporary copy, and changed-file lint. The review command must consume the saved proposal and exact proposal names; the triage command must consume that saved reviewed-shortlist artifact and the resolved exact user selection. Record each stage and total. The under-two-second local-script number for the chapter-four fixture is an observed, environment-dependent target: repeated runs must report a median and range, or exact overruns. It is not a flaky pytest threshold.
      
      - [ ] **Step 10: Final implementation checkpoint without commit**
      
      Review `git diff --stat`, `git diff --check`, and `git status --short`. Confirm that no file outside `skills/paper-glossary/` changed and that the six pre-existing modified files remain incorporated. Do not stage, commit, push, or open a PR.
      
      ---
      
      ### Task 7: Real chapter-four acceptance preview
      
      **Files:**
      - Runtime config: `~/.paper-glossary/config.json`
      - Input article: `F:\我的笔记\123\学习\hello_agent\docs\chapter4\第四章 智能体经典范式构建.md`
      - Central library: `F:\我的笔记\123\book\术语`
      - Existing demo directory retained for now: `F:\我的笔记\123\学习\hello_agent\术语`
      
      **Interfaces:**
      - Consumes the completed Tasks 1-6 implementation
      - Produces the normal numbered selection interaction and stops before write
      
      - [ ] **Step 1: Configure this device once**
      
      Run:
      
      ```powershell
      python -X utf8 skills\paper-glossary\scripts\configure_glossary.py --terms-dir 'F:\我的笔记\123\book\术语' --output tmp\paper-glossary-chapter4-demo\device_config_result.json
      ```
      
      Expected: config points to nearest vault `F:\我的笔记\123` and relative directory `book\术语`.
      
      - [ ] **Step 2: Validate the explicit article and capture its preview hash**
      
      Run:
      
      ```powershell
      python -X utf8 skills\paper-glossary\scripts\configure_glossary.py --validate-article 'F:\我的笔记\123\学习\hello_agent\docs\chapter4\第四章 智能体经典范式构建.md' --output tmp\paper-glossary-chapter4-demo\article_validation.json
      ```
      
      Expected: article and term library resolve to the same vault and output contains `article_sha256`.
      
      - [ ] **Step 3: Run the improved proposal**
      
      Run:
      
      ```powershell
      python -X utf8 skills\paper-glossary\scripts\plan_glossary.py --propose --source-manifest tmp\paper-glossary-chapter4-demo\chapter4_source_manifest.json --output tmp\paper-glossary-chapter4-demo\glossary_candidate_pool.json
      ```
      
      Expected: a dynamic limit derived from effective prose, `Reflection` is present in the grounded pool, case-only duplicates are absent, and code/example noise such as `KEY`, `MODEL`, `TEMPLATE`, and `HUAWEI` is absent.
      
      - [ ] **Step 4: Perform one semantic review, record it, and display the actual selection UI**
      
      Retain only grounded, reusable concepts up to the returned dynamic limit and save their exact ordered proposal `term` strings to `tmp\paper-glossary-chapter4-demo\reviewed_terms.json`. Record that host-reviewed subset before displaying it:
      
      ```powershell
      python -X utf8 skills\paper-glossary\scripts\plan_glossary.py --review-proposal tmp\paper-glossary-chapter4-demo\glossary_candidate_pool.json --reviewed-terms tmp\paper-glossary-chapter4-demo\reviewed_terms.json --source-manifest tmp\paper-glossary-chapter4-demo\chapter4_source_manifest.json --output tmp\paper-glossary-chapter4-demo\glossary_reviewed_shortlist.json
      ```
      
      Expected: `glossary_reviewed_shortlist.json` contains the exact retained full candidate objects, ordered forms, and proposal/review provenance. Display every retained item from this reviewed artifact as a numbered Markdown list together with:
      
      - `F:\我的笔记\123\book\术语` as the write destination;
      - the exact chapter Markdown path as the link target;
      - the statement that selecting terms will create/enrich notes and add first safe article links;
      - the statement that no files have yet been written.
      
      End the response and wait for the user's numbers, names, or `全部写入`. This task intentionally stops at the approved selection gate.
      
      - [ ] **Step 5: Preserve old demo output until the corrected flow is approved**
      
      Do not move or delete `F:\我的笔记\123\学习\hello_agent\术语` during preview. After a later explicit selection completes the new write/link/lint flow, compare those assistant-created demo files with their recorded content and ask before removing any file that may contain later user edits.
      
      ---
      
      ## Execution Completion Criteria
      
      - Tasks 1-6 pass focused and full tests with no unreported failures.
      - The skill validator passes.
      - Local script timings are reported and the chapter fixture is benchmarked.
      - No DeepPaperNote workflow, plugin manifest, dependency file, or unrelated path changes.
      - No commit or push occurs.
      - Task 7 presents the corrected real shortlist and stops for explicit user selection.
      
  • scripts
    • configure_glossary.py 2.7 KB
      #!/usr/bin/env python3
      """Configure and inspect the device-local paper-glossary vault."""
      
      from __future__ import annotations
      
      import argparse
      import json
      from pathlib import Path
      from time import perf_counter
      from typing import Any
      
      from glossary_common import elapsed_ms
      from glossary_config import (
          configure_terms_dir,
          default_config_path,
          load_config,
          resolve_terms_dir,
          validate_article,
      )
      
      
      def parser() -> argparse.ArgumentParser:
          command = argparse.ArgumentParser(
              description=__doc__ or "configure paper-glossary"
          )
          modes = command.add_mutually_exclusive_group(required=True)
          modes.add_argument("--terms-dir", metavar="PATH", help="Term-library directory.")
          modes.add_argument("--show", action="store_true", help="Show saved configuration.")
          modes.add_argument("--reset", action="store_true", help="Remove saved configuration.")
          modes.add_argument(
              "--validate-article", metavar="PATH", help="Validate an article Markdown path."
          )
          command.add_argument("--config-path", metavar="PATH", help="Config JSON path.")
          command.add_argument("--output", default="", metavar="PATH", help="Output JSON path.")
          return command
      
      
      def _emit(payload: dict[str, Any], output: str) -> None:
          text = json.dumps(payload, ensure_ascii=False, indent=2) + "\n"
          if output:
              target = Path(output).expanduser()
              target.parent.mkdir(parents=True, exist_ok=True)
              target.write_text(text, encoding="utf-8")
          else:
              print(text, end="")
      
      
      def main() -> None:
          started = perf_counter()
          args = parser().parse_args()
          config_path = Path(args.config_path).expanduser() if args.config_path else None
      
          if args.terms_dir:
              payload = configure_terms_dir(Path(args.terms_dir), config_path)
          elif args.show:
              config = load_config(config_path)
              if config is None:
                  payload = {"workflow_state": "needs_configuration"}
              else:
                  payload = {**config, "terms_dir": str(resolve_terms_dir(config))}
          elif args.reset:
              target = config_path or default_config_path()
              if target.expanduser().is_file():
                  target.expanduser().unlink()
              payload = {"workflow_state": "reset"}
          else:
              config = load_config(config_path)
              if config is None:
                  raise SystemExit(
                      "Paper-glossary is not configured; run with --terms-dir first."
                  )
              terms_dir = resolve_terms_dir(config)
              payload = {"terms_dir": str(terms_dir), **validate_article(Path(args.validate_article), config)}
      
          _emit({**payload, "elapsed_ms": elapsed_ms(started)}, args.output)
      
      
      if __name__ == "__main__":
          main()
      
    • glossary_common.py 11.6 KB
      from __future__ import annotations
      
      import hashlib
      import json
      import re
      from pathlib import Path
      from time import perf_counter
      from typing import Any
      
      
      def elapsed_ms(started: float) -> int:
          return max(0, round((perf_counter() - started) * 1000))
      
      
      def normalize_whitespace(value: str) -> str:
          return re.sub(r"\s+", " ", value).strip()
      
      
      def canonical_sha256(value: Any) -> str:
          encoded = json.dumps(
              value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
          ).encode("utf-8")
          return hashlib.sha256(encoded).hexdigest()
      
      
      def proposal_sha256(
          paper_id: str,
          source_sha256: str,
          candidates: list[dict[str, Any]],
          summary: dict[str, Any],
      ) -> str:
          return canonical_sha256(
              {
                  "paper_id": paper_id,
                  "source_sha256": source_sha256,
                  "candidates": candidates,
                  "summary": summary,
              }
          )
      
      
      def review_sha256(
          proposal_provenance: dict[str, Any], reviewed_shortlist: list[dict[str, Any]]
      ) -> str:
          return canonical_sha256(
              {
                  "paper_id": proposal_provenance.get("paper_id"),
                  "proposal_provenance": proposal_provenance,
                  "reviewed_shortlist": reviewed_shortlist,
              }
          )
      
      
      def selection_sha256(review_sha256: str, items: list[dict[str, Any]]) -> str:
          selected_terms = [
              {"term": item.get("term"), "surface_forms": item.get("surface_forms")}
              for item in items
          ]
          return canonical_sha256(
              {"review_sha256": review_sha256, "selected_terms": selected_terms}
          )
      
      
      def writer_mappings_sha256(
          provenance: dict[str, Any],
          results: list[dict[str, Any]],
          context: dict[str, Any],
          triage_sha256: str,
      ) -> str:
          mappings: list[dict[str, Any]] = []
          for item in results:
              if not isinstance(item, dict):
                  raise SystemExit("Writer results must contain mapping objects.")
              try:
                  mappings.append(
                      {
                          field: item[field]
                          for field in ("name", "forms", "file", "link_stem")
                      }
                  )
              except KeyError as exc:
                  raise SystemExit(
                      f"Writer mapping is missing required field: {exc.args[0]}"
                  ) from None
          if not _valid_digest(triage_sha256):
              raise SystemExit("Writer mappings require a valid triage artifact digest.")
          return canonical_sha256(
              {
                  "provenance": provenance,
                  "mappings": mappings,
                  "context": context,
                  "triage_sha256": triage_sha256,
              }
          )
      
      
      def _valid_digest(value: Any) -> bool:
          return isinstance(value, str) and re.fullmatch(r"[0-9a-f]{64}", value) is not None
      
      
      def _validated_term_sequence(value: Any, label: str) -> list[dict[str, Any]]:
          if not isinstance(value, list) or any(not isinstance(item, dict) for item in value):
              raise SystemExit(f"Invalid {label} provenance structure.")
          terms: set[str] = set()
          for item in value:
              term = item.get("term")
              forms = item.get("surface_forms")
              if (
                  not isinstance(term, str)
                  or not term
                  or normalize_whitespace(term) != term
                  or term in terms
                  or not isinstance(forms, list)
                  or not forms
              ):
                  raise SystemExit(f"Invalid {label} term or surface_forms provenance.")
              exact_forms: set[str] = set()
              for form in forms:
                  if (
                      not isinstance(form, str)
                      or not form
                      or normalize_whitespace(form) != form
                      or form in exact_forms
                  ):
                      raise SystemExit(f"Invalid or duplicate {label} surface_forms provenance.")
                  exact_forms.add(form)
              if term not in exact_forms:
                  raise SystemExit(f"Canonical {label} term must be present in surface_forms.")
              terms.add(term)
          return value
      
      
      def validate_provenance_chain(
          payload: dict[str, Any], selected_items: Any
      ) -> dict[str, Any]:
          paper_id = payload.get("paper_id")
          provenance = payload.get("provenance")
          proposal = provenance.get("proposal") if isinstance(provenance, dict) else None
          review = provenance.get("review") if isinstance(provenance, dict) else None
          selection_digest = (
              provenance.get("selection_sha256") if isinstance(provenance, dict) else None
          )
          if (
              not isinstance(paper_id, str)
              or not paper_id
              or not isinstance(proposal, dict)
              or not isinstance(review, dict)
              or not _valid_digest(selection_digest)
          ):
              raise SystemExit("Artifact must contain valid paper and selection provenance.")
      
          source_digest = proposal.get("source_sha256")
          proposal_digest = proposal.get("proposal_sha256")
          candidates = _validated_term_sequence(
              proposal.get("candidates"), "proposal candidates"
          )
          summary = proposal.get("summary")
          if (
              proposal.get("paper_id") != paper_id
              or not _valid_digest(source_digest)
              or not _valid_digest(proposal_digest)
              or not isinstance(summary, dict)
          ):
              raise SystemExit("Invalid proposal provenance or top-level paper binding.")
          expected_proposal_digest = proposal_sha256(
              paper_id, source_digest, candidates, summary
          )
          if proposal_digest != expected_proposal_digest:
              raise SystemExit("Proposal digest does not match embedded provenance material.")
      
          proposal_fields = (
              "paper_id",
              "source_sha256",
              "candidates",
              "summary",
              "proposal_sha256",
          )
          if any(review.get(field) != proposal[field] for field in proposal_fields):
              raise SystemExit("Proposal and review provenance fields do not match.")
          reviewed_shortlist = _validated_term_sequence(
              review.get("reviewed_shortlist"), "reviewed shortlist"
          )
          reviewed_terms: set[str] = set()
          candidates_by_term = {candidate["term"]: candidate for candidate in candidates}
          for candidate in reviewed_shortlist:
              term = candidate["term"]
              if candidate != candidates_by_term.get(term) or term in reviewed_terms:
                  raise SystemExit("Reviewed shortlist is not an exact proposal subset.")
              reviewed_terms.add(term)
          limit = summary.get("shortlist_limit")
          if not isinstance(limit, int) or isinstance(limit, bool) or limit < 0:
              raise SystemExit("Invalid proposal shortlist limit provenance.")
          if len(reviewed_shortlist) > limit:
              raise SystemExit("Reviewed shortlist exceeds proposal shortlist limit.")
      
          review_digest = review.get("review_sha256")
          if not _valid_digest(review_digest):
              raise SystemExit("Invalid review digest provenance.")
          proposal_material = {field: proposal[field] for field in proposal_fields}
          if review_digest != review_sha256(proposal_material, reviewed_shortlist):
              raise SystemExit("Review digest does not match embedded provenance material.")
      
          selected = _validated_term_sequence(selected_items, "selected")
          reviewed_by_term = {candidate["term"]: candidate for candidate in reviewed_shortlist}
          for item in selected:
              reviewed = reviewed_by_term.get(item["term"])
              if reviewed is None or item["surface_forms"] != reviewed["surface_forms"]:
                  raise SystemExit(
                      "Selected term and surface_forms must exactly match the reviewed shortlist."
                  )
          if selection_digest != selection_sha256(review_digest, selected):
              raise SystemExit("Selection digest does not match selected term provenance.")
          return provenance
      
      
      def validate_current_provenance_chain(
          payload: dict[str, Any],
          selected_items: Any,
          source_manifest: str,
          raw_sections: str,
          reviewed_shortlist: str,
      ) -> dict[str, Any]:
          # Imported lazily because plan_glossary itself uses the digest helpers above.
          from plan_glossary import _validated_review, load_manifest_and_sections
      
          manifest, records = load_manifest_and_sections(source_manifest, raw_sections)
          review_payload = load_json_file(
              Path(reviewed_shortlist).expanduser().resolve()
          )
          current_review, current_proposal = _validated_review(
              review_payload, manifest, records
          )
          provenance = validate_provenance_chain(payload, selected_items)
          if (
              payload.get("paper_id") != current_proposal["paper_id"]
              or provenance.get("proposal") != current_proposal["provenance"]
              or provenance.get("review") != current_review["provenance"]
          ):
              raise SystemExit(
                  "Artifact provenance does not match the current source and reviewed shortlist."
              )
          return {
              "paper_id": current_proposal["paper_id"],
              "manifest": manifest,
              "provenance": provenance,
          }
      
      
      def _selected_term_forms(
          value: Any,
          label: str,
          term_field: str = "term",
          forms_field: str = "surface_forms",
      ) -> list[dict[str, Any]]:
          if not isinstance(value, list) or any(not isinstance(item, dict) for item in value):
              raise SystemExit(f"Invalid {label} authorization structure.")
          selected = [
              {"term": item.get(term_field), "surface_forms": item.get(forms_field)}
              for item in value
          ]
          return _validated_term_sequence(selected, label)
      
      
      def triage_artifact_sha256(triage: dict[str, Any]) -> str:
          return canonical_sha256(
              {
                  "paper_id": triage["paper_id"],
                  "provenance": triage["provenance"],
                  "terms": triage["terms"],
              }
          )
      
      
      def validate_authorized_selection(
          payload: dict[str, Any],
          selected_items: Any,
          triage_artifact: str,
          source_manifest: str,
          raw_sections: str,
          reviewed_shortlist: str,
          *,
          term_field: str = "term",
          forms_field: str = "surface_forms",
      ) -> dict[str, Any]:
          triage = load_json_file(Path(triage_artifact).expanduser().resolve())
          if (
              triage.get("status") != "ok"
              or triage.get("script") != "plan_glossary.py"
              or triage.get("mode") != "triage"
          ):
              raise SystemExit("Authorized selection must be a saved triage artifact.")
          authorized = _selected_term_forms(triage.get("terms"), "authorized triage")
          current = validate_current_provenance_chain(
              triage,
              authorized,
              source_manifest,
              raw_sections,
              reviewed_shortlist,
          )
          selected = _selected_term_forms(
              selected_items,
              "downstream selected",
              term_field,
              forms_field,
          )
          provenance = validate_provenance_chain(payload, selected)
          if (
              selected != authorized
              or payload.get("paper_id") != current["paper_id"]
              or provenance != current["provenance"]
          ):
              raise SystemExit(
                  "Downstream selection does not exactly match the authorized triage artifact."
              )
          return {
              **current,
              "authorized_selection": authorized,
              "triage_sha256": triage_artifact_sha256(triage),
          }
      
      
      def load_json_file(path: Path) -> dict[str, Any]:
          data = json.loads(path.read_text(encoding="utf-8-sig"))
          if not isinstance(data, dict):
              raise SystemExit(f"Expected JSON object in {path}.")
          return data
      
      
      def maybe_load_json_record(value: str) -> dict[str, Any] | None:
          raw = value.strip()
          if raw.startswith("{"):
              data = json.loads(raw)
              if not isinstance(data, dict):
                  raise SystemExit("Expected JSON object.")
              return data
          path = Path(raw).expanduser()
          if path.is_file():
              return load_json_file(path)
          return None
      
      
      def emit(payload: dict[str, Any], output: str = "") -> None:
          text = json.dumps(payload, ensure_ascii=False, indent=2)
          if output:
              Path(output).expanduser().write_text(text + "\n", encoding="utf-8")
          else:
              print(text)
      
    • glossary_config.py 4.8 KB
      #!/usr/bin/env python3
      """Resolve and validate device-local paper-glossary configuration."""
      
      from __future__ import annotations
      
      import hashlib
      import json
      from pathlib import Path
      
      CONFIG_DIRNAME = ".paper-glossary"
      CONFIG_FILENAME = "config.json"
      
      
      def default_config_path() -> Path:
          return Path.home() / CONFIG_DIRNAME / CONFIG_FILENAME
      
      
      def find_vault_root(path: Path) -> Path | None:
          current = path.expanduser().resolve(strict=False)
          while not current.exists() and current != current.parent:
              current = current.parent
          if current.is_file():
              current = current.parent
          for candidate in (current, *current.parents):
              if (candidate / ".obsidian").is_dir():
                  return candidate.resolve()
          return None
      
      
      def _is_within(path: Path, parent: Path) -> bool:
          try:
              path.resolve(strict=False).relative_to(parent.resolve())
          except ValueError:
              return False
          return True
      
      
      def _validated_config_payload(
          payload: object,
          error: str,
          source_terms_dir: Path | None = None,
      ) -> dict[str, str]:
          if not isinstance(payload, dict):
              raise SystemExit(error)
          for field in ("vault_root", "terms_subdir"):
              value = payload.get(field)
              if not isinstance(value, str) or not value.strip():
                  raise SystemExit(error)
          try:
              subdir = Path(payload["terms_subdir"])
              vault_path = Path(payload["vault_root"])
              if not vault_path.is_absolute():
                  raise SystemExit(error)
              paths = (subdir,) if source_terms_dir is None else (subdir, source_terms_dir)
              if any(
                  not part.strip()
                  for path in paths
                  for part in path.parts
                  if part != path.anchor
              ):
                  raise SystemExit(error)
              vault = vault_path.resolve(strict=False)
              terms_dir = (vault / subdir).resolve(strict=False)
          except (OSError, RuntimeError, ValueError) as exc:
              raise SystemExit(error) from exc
          if subdir.anchor or terms_dir == vault:
              raise SystemExit(error)
          return {
              "vault_root": str(vault),
              "terms_subdir": payload["terms_subdir"],
          }
      
      
      def configure_terms_dir(
          terms_dir: Path, config_path: Path | None = None
      ) -> dict[str, str]:
          resolved = terms_dir.expanduser().resolve(strict=False)
          vault = find_vault_root(resolved)
          if vault is None or not _is_within(resolved, vault):
              raise SystemExit(
                  "Term directory must be inside an Obsidian vault containing .obsidian."
              )
          payload = _validated_config_payload(
              {
                  "vault_root": str(vault),
                  "terms_subdir": str(resolved.relative_to(vault)),
              },
              "Term directory must be a real child inside an Obsidian vault.",
              terms_dir.expanduser(),
          )
          resolved.mkdir(parents=True, exist_ok=True)
          target = (config_path or default_config_path()).expanduser()
          target.parent.mkdir(parents=True, exist_ok=True)
          target.write_text(
              json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
          )
          return payload
      
      
      def load_config(config_path: Path | None = None) -> dict[str, str] | None:
          target = (config_path or default_config_path()).expanduser()
          if not target.is_file():
              return None
          try:
              payload = json.loads(target.read_text(encoding="utf-8-sig"))
          except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
              raise SystemExit(f"Invalid paper-glossary config: {target}") from exc
          return _validated_config_payload(
              payload, f"Invalid paper-glossary config object: {target}"
          )
      
      
      def resolve_terms_dir(config: dict[str, str]) -> Path:
          vault = Path(config.get("vault_root", "")).expanduser().resolve(strict=False)
          terms_dir = (vault / config.get("terms_subdir", "")).resolve(strict=False)
          if (
              not (vault / ".obsidian").is_dir()
              or not terms_dir.is_dir()
              or not _is_within(terms_dir, vault)
              or find_vault_root(terms_dir) != vault
          ):
              raise SystemExit(
                  "Configured Obsidian term directory is missing or invalid; configure it again."
              )
          return terms_dir
      
      
      def validate_article(article: Path, config: dict[str, str]) -> dict[str, str]:
          resolved = article.expanduser().resolve()
          if resolved.suffix.lower() != ".md" or not resolved.is_file():
              raise SystemExit(f"Article Markdown not found: {resolved}")
          article_vault = find_vault_root(resolved)
          configured_vault = Path(config["vault_root"]).expanduser().resolve()
          if article_vault != configured_vault:
              raise SystemExit(
                  "Article and term directory must be inside the same Obsidian vault."
              )
          digest = hashlib.sha256(resolved.read_bytes()).hexdigest()
          return {"article_path": str(resolved), "article_sha256": digest}
      
    • glossary_contracts.py 518 B
      from __future__ import annotations
      
      GLOSSARY_TERMS_DIRNAME = "术语"
      GLOSSARY_TERM_TAG = "term"
      GLOSSARY_DISCLAIMER = (
          "> [!warning] 概念解释为背景知识,可能由模型据通用知识生成、非某篇论文的权威内容,可能有误,请以权威资料为准。"
      )
      
      GLOSSARY_CONCEPT_HEADING = "概念解释"
      GLOSSARY_OCCURRENCE_HEADING = "在论文中的出现"
      GLOSSARY_CONFIDENCE_VALUES = ("高", "中", "低")
      GLOSSARY_LABEL_DEFINITION = "定义:"
      GLOSSARY_LABEL_CONFIDENCE = "置信度:"
      
    • glossary_library.py 13.5 KB
      from __future__ import annotations
      
      import json
      import re
      import unicodedata
      from pathlib import Path
      from typing import Any
      
      from glossary_common import normalize_whitespace
      from glossary_contracts import (
          GLOSSARY_CONCEPT_HEADING,
          GLOSSARY_LABEL_CONFIDENCE,
          GLOSSARY_LABEL_DEFINITION,
      )
      
      CONCEPT_FIELDS = {
          "definition": GLOSSARY_LABEL_DEFINITION,
          "elaboration": "\u8be6\u89e3\uff1a",
          "intuition": "\u76f4\u89c9\uff1a",
          "distinction": "\u4e0e\u76f8\u90bb\u6982\u5ff5\u7684\u533a\u522b\uff1a",
          "confidence": GLOSSARY_LABEL_CONFIDENCE,
      }
      
      OBSIDIAN_LINK_STEM_TRANSLATION = str.maketrans(
          {
              "#": "#",
              "^": "^",
              "[": "[",
              "]": "]",
              "|": "|",
              ":": ":",
              "%": "%",
          }
      )
      
      
      def obsidian_safe_link_stem(value: str) -> str:
          return value.translate(OBSIDIAN_LINK_STEM_TRANSLATION)
      
      
      OPTIONAL_CONCEPT_FIELDS = ("elaboration", "intuition", "distinction")
      
      
      def _decode_frontmatter_scalar(value: str) -> str:
          raw = value.strip()
          if not raw:
              raise SystemExit("Malformed frontmatter scalar: empty value")
          if raw.startswith('"'):
              if len(raw) < 2 or not raw.endswith('"'):
                  raise SystemExit(f"Malformed frontmatter scalar: {raw}")
              try:
                  decoded = json.loads(raw)
              except json.JSONDecodeError:
                  raise SystemExit(f"Malformed frontmatter scalar: {raw}") from None
              if not isinstance(decoded, str):
                  raise SystemExit(f"Malformed frontmatter scalar: {raw}")
              result = normalize_whitespace(decoded)
          elif raw.startswith("'"):
              if len(raw) < 2 or not raw.endswith("'"):
                  raise SystemExit(f"Malformed frontmatter scalar: {raw}")
              inner = raw[1:-1]
              decoded_chars: list[str] = []
              index = 0
              while index < len(inner):
                  if inner[index] == "'":
                      if index + 1 >= len(inner) or inner[index + 1] != "'":
                          raise SystemExit(f"Malformed frontmatter scalar: {raw}")
                      decoded_chars.append("'")
                      index += 2
                      continue
                  decoded_chars.append(inner[index])
                  index += 1
              result = normalize_whitespace("".join(decoded_chars))
          else:
              if any(character in raw for character in "[]{}\"'#"):
                  raise SystemExit(f"Malformed frontmatter scalar: {raw}")
              result = normalize_whitespace(raw)
          if not result:
              raise SystemExit(f"Malformed frontmatter scalar: {raw}")
          return result
      
      
      def _split_inline_aliases(content: str) -> list[str]:
          if not content.strip():
              return []
          items: list[str] = []
          start = 0
          quote = ""
          index = 0
          while index < len(content):
              character = content[index]
              if quote == '"':
                  if character == "\\":
                      index += 2
                      continue
                  if character == quote:
                      quote = ""
              elif quote == "'":
                  if character == quote:
                      if index + 1 < len(content) and content[index + 1] == quote:
                          index += 2
                          continue
                      quote = ""
              elif character in ("'", '"'):
                  quote = character
              elif character == ",":
                  item = content[start:index].strip()
                  if not item:
                      raise SystemExit("Malformed aliases metadata: empty inline item.")
                  items.append(item)
                  start = index + 1
              index += 1
          if quote:
              raise SystemExit("Malformed aliases metadata: unclosed quote.")
          item = content[start:].strip()
          if not item:
              raise SystemExit("Malformed aliases metadata: empty inline item.")
          items.append(item)
          return items
      
      
      def _frontmatter_block(text: str, source: Path | None = None) -> str | None:
          opening = re.match(r"\A---[ \t]*(?:\r?\n|$)", text)
          if opening is None:
              return None
          closing = re.search(r"(?m)^---[ \t]*\r?$", text[opening.end() :])
          if closing is None:
              location = f" in {source}" if source is not None else ""
              raise SystemExit(f"Unclosed frontmatter{location}.")
          return text[opening.end() : opening.end() + closing.start()]
      
      
      def read_frontmatter_aliases(text: str, source: Path | None = None) -> list[str]:
          block = _frontmatter_block(text, source)
          if block is None:
              return []
          lines = block.splitlines()
          declarations = [
              (index, match.group(1).strip())
              for index, line in enumerate(lines)
              if (match := re.match(r"^aliases\s*:(.*)$", line))
          ]
          if not declarations:
              return []
          location = f" in {source}" if source is not None else ""
          if len(declarations) != 1:
              raise SystemExit(f"Unsupported aliases metadata{location}: duplicate aliases key.")
          declaration_index, value = declarations[0]
          if value:
              if not value.startswith("[") or not value.endswith("]"):
                  raise SystemExit(f"Unsupported aliases metadata{location}: expected a list.")
              return [
                  _decode_frontmatter_scalar(item)
                  for item in _split_inline_aliases(value[1:-1])
              ]
      
          aliases: list[str] = []
          for line in lines[declaration_index + 1 :]:
              if not line.strip():
                  continue
              if not line.startswith((" ", "\t")):
                  break
              item = re.match(r"^[ \t]+-[ \t]+(.+?)[ \t]*$", line)
              if item is None:
                  raise SystemExit(f"Unsupported aliases metadata{location}: expected list items.")
              aliases.append(_decode_frontmatter_scalar(item.group(1)))
          return aliases
      
      
      def read_heading_name(text: str) -> str:
          match = re.search(r"(?m)^#\s+(.+?)\s*$", text)
          return normalize_whitespace(match.group(1)) if match else ""
      
      
      def _normalized_key(value: str) -> str:
          return unicodedata.normalize("NFKC", normalize_whitespace(value)).casefold()
      
      
      def read_glossary_note(path: Path) -> str:
          try:
              return path.read_bytes().decode("utf-8-sig")
          except (OSError, UnicodeDecodeError) as exc:
              raise SystemExit(f"Unable to read glossary note {path}: {exc}") from None
      
      
      def build_alias_index(terms_dir: Path) -> dict[str, Path]:
          index: dict[str, Path] = {}
          root = terms_dir.expanduser().resolve()
          if not root.is_dir():
              return index
          for unresolved in sorted(root.glob("*.md"), key=lambda item: item.name.casefold()):
              try:
                  path = unresolved.resolve(strict=True)
              except OSError as exc:
                  raise SystemExit(f"Unable to resolve glossary note {unresolved}: {exc}") from None
              if not path.is_relative_to(root) or not path.is_file():
                  raise SystemExit(f"Glossary note resolves outside terms directory: {unresolved}")
              text = read_glossary_note(path)
              keys = {_normalized_key(unresolved.stem)}
              keys.update(_normalized_key(alias) for alias in read_frontmatter_aliases(text, path))
              heading = read_heading_name(text)
              if heading:
                  keys.add(_normalized_key(heading))
              for key in keys:
                  existing = index.get(key)
                  if existing is not None and existing != path:
                      raise SystemExit(
                          f"Glossary key collision for '{key}' between {existing} and {path}."
                      )
                  index[key] = path
          return index
      
      
      def _section_bounds(text: str) -> tuple[int, int] | None:
          heading = re.search(
              rf"(?m)^##\s+{re.escape(GLOSSARY_CONCEPT_HEADING)}\s*$(?:\r?\n)?", text
          )
          if heading is None:
              return None
          following = re.search(r"(?m)^##\s+", text[heading.end() :])
          end = heading.end() + following.start() if following else len(text)
          return heading.end(), end
      
      
      def _field_present(section: str, label: str) -> bool:
          return bool(
              re.search(rf"(?m)^[ \t]*(?:-[ \t]*)?{re.escape(label)}[ \t]*\S", section)
          )
      
      
      def missing_concept_fields(text: str) -> list[str]:
          bounds = _section_bounds(text)
          if bounds is None:
              return list(CONCEPT_FIELDS)
          section = text[bounds[0] : bounds[1]]
          return [field for field, label in CONCEPT_FIELDS.items() if not _field_present(section, label)]
      
      
      def note_state(text: str) -> tuple[str, list[str]]:
          missing = missing_concept_fields(text)
          optional_present = sum(field not in missing for field in OPTIONAL_CONCEPT_FIELDS)
          required_missing = any(field in missing for field in ("definition", "confidence"))
          state = "existing_thin" if required_missing or optional_present < 2 else "existing_complete"
          return state, missing
      
      
      def _line_ending(text: str) -> str:
          return "\r\n" if "\r\n" in text else "\n"
      
      
      def _field_line(field: str, value: Any, line_ending: str) -> str:
          return f"- {CONCEPT_FIELDS[field]}{normalize_whitespace(str(value))}{line_ending}"
      
      
      def _field_lines(fields: list[str], patch: dict[str, Any], line_ending: str) -> str:
          return "".join(_field_line(field, patch[field], line_ending) for field in fields)
      
      
      def add_missing_concept_fields(text: str, patch: dict[str, Any]) -> tuple[str, list[str]]:
          missing = set(missing_concept_fields(text))
          fields_added = [
              field
              for field in CONCEPT_FIELDS
              if field in missing and normalize_whitespace(str(patch.get(field, "")))
          ]
          if not fields_added:
              return text, []
      
          line_ending = _line_ending(text)
          bounds = _section_bounds(text)
          if bounds is None:
              prefix = "" if not text else (line_ending if text.endswith(("\n", "\r")) else line_ending * 2)
              if text and text.endswith(("\n", "\r")):
                  prefix = line_ending
              section = f"## {GLOSSARY_CONCEPT_HEADING}{line_ending}"
              return text + prefix + section + _field_lines(fields_added, patch, line_ending), fields_added
      
          start, end = bounds
          section = text[start:end]
          existing_confidence = re.search(
              rf"(?m)^\s*(?:-\s*)?{re.escape(CONCEPT_FIELDS['confidence'])}", section
          )
          optional_fields = [field for field in OPTIONAL_CONCEPT_FIELDS if field in fields_added]
          confidence_fields = [field for field in ("confidence",) if field in fields_added]
          definition_fields = [field for field in ("definition",) if field in fields_added]
          insertions: list[tuple[int, str]] = []
          if definition_fields:
              insertions.append((start, _field_lines(definition_fields, patch, line_ending)))
          if optional_fields:
              position = start + existing_confidence.start() if existing_confidence else end
              insertions.append((position, _field_lines(optional_fields, patch, line_ending)))
          if confidence_fields:
              insertions.append((end, _field_lines(confidence_fields, patch, line_ending)))
          updated = text
          for position, insertion in sorted(insertions, key=lambda item: item[0], reverse=True):
              updated = updated[:position] + insertion + updated[position:]
          return updated, fields_added
      
      
      def _surface_forms(item: dict[str, Any]) -> list[str]:
          term = item.get("term")
          surface_forms = item.get("surface_forms")
          if not isinstance(term, str) or not term or normalize_whitespace(term) != term:
              raise SystemExit("Selected term must be a non-empty normalized string.")
          if not isinstance(surface_forms, list) or not surface_forms:
              raise SystemExit(f"surface_forms must be a non-empty list for term: {term}")
          forms: list[str] = []
          for value in surface_forms:
              if not isinstance(value, str) or not value or normalize_whitespace(value) != value:
                  raise SystemExit(f"surface_forms must contain exact non-empty strings: {term}")
              if value in forms:
                  raise SystemExit(f"surface_forms must not contain duplicates: {term}")
              forms.append(value)
          if term not in forms:
              raise SystemExit(f"surface_forms must contain the exact selected term: {term}")
          return forms
      
      
      def inspect_selected_terms(selected: list[dict[str, Any]], terms_dir: Path) -> list[dict[str, Any]]:
          index = build_alias_index(terms_dir)
          results: list[dict[str, Any]] = []
          for item in selected:
              if not isinstance(item, dict):
                  raise SystemExit("Selected terms must contain objects with surface_forms.")
              term = item.get("term")
              if not isinstance(term, str):
                  raise SystemExit("Selected term must be a string with surface_forms.")
              forms = _surface_forms(item)
              matches = {
                  index[_normalized_key(form)]
                  for form in forms
                  if _normalized_key(form) in index
              }
              if len(matches) > 1:
                  raise SystemExit(
                      f"Selected forms resolve to multiple glossary notes for term: {term}"
                  )
              path = next(iter(matches), None)
              if path is None:
                  results.append(
                      {
                          "term": term,
                          "surface_forms": forms,
                          "state": "new",
                          "file": "",
                          "link_stem": term,
                          "missing_fields": [],
                      }
                  )
                  continue
              safe_stem = obsidian_safe_link_stem(path.stem)
              if safe_stem != path.stem:
                  raise SystemExit(
                      f"Existing glossary note has an Obsidian-unsafe filename: {path.name}; "
                      f"rename it to {safe_stem}.md before continuing."
                  )
              text = read_glossary_note(path)
              state, missing = note_state(text)
              results.append(
                  {
                      "term": term,
                      "surface_forms": forms,
                      "state": state,
                      "file": str(path),
                      "link_stem": path.stem,
                      "missing_fields": missing,
                  }
              )
          return results
      
    • inspect_glossary_library.py 2.6 KB
      #!/usr/bin/env python3
      """Inspect selected terms against an existing glossary library."""
      
      from __future__ import annotations
      
      import argparse
      from pathlib import Path
      from time import perf_counter
      from typing import Any
      
      from glossary_common import (
          elapsed_ms,
          emit,
          load_json_file,
          validate_current_provenance_chain,
      )
      from glossary_library import inspect_selected_terms
      
      
      def parser() -> argparse.ArgumentParser:
          result = argparse.ArgumentParser(description=__doc__)
          result.add_argument("--terms", required=True, help="Selected-term JSON path.")
          result.add_argument("--terms-dir", required=True, help="Existing glossary directory.")
          result.add_argument("--source-manifest", required=True, help="Current source manifest.")
          result.add_argument("--raw-sections", default="", help="Optional current raw-sections JSONL.")
          result.add_argument(
              "--reviewed-shortlist", required=True, help="Saved reviewed-shortlist artifact."
          )
          result.add_argument("--output", default="", help="Output JSON status path.")
          return result
      
      
      def _selected_terms(payload: dict[str, Any]) -> list[dict[str, Any]]:
          selected = payload.get("selected", payload.get("terms", []))
          if not isinstance(selected, list):
              raise SystemExit("Selected-term JSON must contain a 'selected' list.")
          return selected
      
      
      def _selection_provenance(
          payload: dict[str, Any],
          selected: list[dict[str, Any]],
          source_manifest: str,
          raw_sections: str,
          reviewed_shortlist: str,
      ) -> dict[str, Any]:
          if payload.get("mode") != "triage":
              raise SystemExit("Selected terms must be a triage artifact with valid provenance.")
          return validate_current_provenance_chain(
              payload,
              selected,
              source_manifest,
              raw_sections,
              reviewed_shortlist,
          )["provenance"]
      
      
      def main() -> None:
          started = perf_counter()
          args = parser().parse_args()
          payload = load_json_file(Path(args.terms).expanduser().resolve())
          selected = _selected_terms(payload)
          provenance = _selection_provenance(
              payload,
              selected,
              args.source_manifest,
              args.raw_sections,
              args.reviewed_shortlist,
          )
          results = inspect_selected_terms(
              selected, Path(args.terms_dir).expanduser().resolve()
          )
          emit(
              {
                  "status": "ok",
                  "script": "inspect_glossary_library.py",
                  "paper_id": payload["paper_id"],
                  "provenance": provenance,
                  "results": results,
                  "elapsed_ms": elapsed_ms(started),
              },
              args.output,
          )
      
      
      if __name__ == "__main__":
          main()
      
    • link_glossary_terms.py 32.6 KB
      #!/usr/bin/env python3
      """Add one safe glossary wiki link per selected term to an explicit article."""
      
      from __future__ import annotations
      
      import argparse
      import hashlib
      import os
      import re
      import tempfile
      from pathlib import Path
      from time import perf_counter
      from typing import Any
      
      from glossary_common import (
          elapsed_ms,
          emit,
          load_json_file,
          validate_authorized_selection,
          writer_mappings_sha256,
      )
      from glossary_config import load_config, resolve_terms_dir, validate_article
      from glossary_library import _normalized_key, build_alias_index
      
      
      REFERENCE_HEADING_RE = re.compile(
          r"^[ \t]{0,3}#{1,6}[ \t]+(?:references|bibliography|参考文献)[ \t]*\r?$",
          re.IGNORECASE | re.MULTILINE,
      )
      
      
      def parser() -> argparse.ArgumentParser:
          command = argparse.ArgumentParser(description=__doc__ or "link glossary terms")
          command.add_argument("--input", required=True, help="Explicit article Markdown path.")
          command.add_argument("--write-result", required=True, help="Successful writer result JSON path.")
          command.add_argument("--expected-sha256", required=True, help="Preview-time article SHA-256.")
          command.add_argument("--config-path", default="", help="Device-local configuration path.")
          command.add_argument("--source-manifest", required=True, help="Current source manifest.")
          command.add_argument("--raw-sections", default="", help="Optional current raw-sections JSONL.")
          command.add_argument(
              "--reviewed-shortlist", required=True, help="Saved reviewed-shortlist artifact."
          )
          command.add_argument("--triage", required=True, help="Saved authorized triage artifact.")
          command.add_argument("--output", default="", help="Output JSON status path.")
          return command
      
      
      def term_pattern(form: str) -> re.Pattern[str]:
          escaped = re.escape(form)
          if form.isascii():
              return re.compile(rf"(?<![A-Za-z0-9_]){escaped}(?![A-Za-z0-9_])", re.IGNORECASE)
          return re.compile(escaped, re.IGNORECASE)
      
      
      def _line_end(text: str, start: int) -> int:
          newline = text.find("\n", start)
          return len(text) if newline == -1 else newline + 1
      
      
      def _offset_in_spans(offset: int, spans: list[tuple[int, int]]) -> bool:
          for start, end in spans:
              if offset < start:
                  return False
              if offset < end:
                  return True
          return False
      
      
      def _list_content_start(content: str) -> int:
          position = 0
          while True:
              marker = re.match(
                  r"(?:[*+-]|\d{1,9}[.)])(?:[ \t]+|$)", content[position:]
              )
              if marker is None:
                  return position
              position += marker.end()
      
      
      def _fenced_code_spans(text: str) -> list[tuple[int, int]]:
          spans: list[tuple[int, int]] = []
          opener: tuple[str, int, int, int] | None = None
          offset = 0
          for line in text.splitlines(keepends=True):
              content_end = offset + len(line.rstrip("\r\n"))
              line_content = _reference_line_content_start(text, offset)
              if line_content is None:
                  content = ""
                  quote_depth = -1
              else:
                  content_start, quote_depth = line_content
                  content = text[content_start:content_end]
              if opener is None:
                  list_content = content[_list_content_start(content) :]
                  match = re.match(r"(`{3,}|~{3,})", list_content)
                  if match:
                      marker = match.group(1)
                      opener = (marker[0], len(marker), offset, quote_depth)
              else:
                  closer = re.fullmatch(r"(`+|~+)[ \t]*", content)
                  if (
                      closer
                      and closer.group(1)[0] == opener[0]
                      and len(closer.group(1)) >= opener[1]
                      and quote_depth == opener[3]
                  ):
                      spans.append((opener[2], offset + len(line)))
                      opener = None
              offset += len(line)
          if opener is not None:
              spans.append((opener[2], len(text)))
          return spans
      
      
      def _frontmatter_span(text: str) -> tuple[int, int] | None:
          lines = text.splitlines(keepends=True)
          if not lines or not re.fullmatch(r"---[ \t]*", lines[0].rstrip("\r\n")):
              return None
          offset = len(lines[0])
          for line in lines[1:]:
              if re.fullmatch(r"(?:---|\.\.\.)[ \t]*", line.rstrip("\r\n")):
                  return 0, offset + len(line)
              offset += len(line)
          return 0, len(text)
      
      
      def _inline_code_spans(
          text: str, protected: list[tuple[int, int]]
      ) -> list[tuple[int, int]]:
          spans: list[tuple[int, int]] = []
          cursor = 0
          while cursor < len(text):
              start = text.find("`", cursor)
              if start == -1:
                  break
              opener_end = start
              while opener_end < len(text) and text[opener_end] == "`":
                  opener_end += 1
              if _offset_in_spans(start, protected):
                  cursor = opener_end
                  continue
              delimiter_length = opener_end - start
              search = opener_end
              matched = False
              while search < len(text):
                  closer = text.find("`", search)
                  if closer == -1:
                      break
                  closer_end = closer
                  while closer_end < len(text) and text[closer_end] == "`":
                      closer_end += 1
                  if (
                      closer_end - closer == delimiter_length
                      and not _offset_in_spans(closer, protected)
                  ):
                      spans.append((start, closer_end))
                      cursor = closer_end
                      matched = True
                      break
                  search = closer_end
              if not matched:
                  cursor = opener_end
          return spans
      
      
      def _markdown_link_spans(
          text: str, protected: list[tuple[int, int]]
      ) -> list[tuple[int, int]]:
          spans: list[tuple[int, int]] = []
          cursor = 0
          while cursor < len(text):
              label_start = text.find("[", cursor)
              if label_start == -1:
                  break
              if _offset_in_spans(label_start, protected):
                  cursor = label_start + 1
                  continue
              depth = 1
              position = label_start + 1
              while position < len(text) and depth:
                  if text[position] == "\\":
                      position += 2
                      continue
                  if text[position] == "[":
                      depth += 1
                  elif text[position] == "]":
                      depth -= 1
                  position += 1
              if depth or position >= len(text) or text[position] != "(":
                  cursor = label_start + 1
                  continue
              destination_depth = 1
              position += 1
              while position < len(text) and destination_depth:
                  if text[position] == "\\":
                      position += 2
                      continue
                  if text[position] == "(":
                      destination_depth += 1
                  elif text[position] == ")":
                      destination_depth -= 1
                  position += 1
              if destination_depth:
                  cursor = label_start + 1
                  continue
              start = label_start - 1 if label_start and text[label_start - 1] == "!" else label_start
              spans.append((start, position))
              cursor = position
          return spans
      
      
      def _scan_markdown_label(text: str, start: int) -> tuple[str, int] | None:
          if start >= len(text) or text[start] != "[":
              return None
          depth = 1
          position = start + 1
          while position < len(text) and depth:
              if text[position] == "\\":
                  position += 2
                  continue
              if text[position] in "\r\n":
                  return None
              if text[position] == "[":
                  depth += 1
              elif text[position] == "]":
                  depth -= 1
              position += 1
          if depth:
              return None
          return text[start + 1 : position - 1], position
      
      
      def _reference_label_key(label: str) -> str:
          unescaped = re.sub(r"\\(.)", r"\1", label)
          return " ".join(unescaped.split()).casefold()
      
      
      def _skip_spaces_tabs(text: str, position: int) -> int:
          while position < len(text) and text[position] in " \t":
              position += 1
          return position
      
      
      def _after_line_ending(text: str, position: int) -> int | None:
          if text.startswith("\r\n", position):
              return position + 2
          if position < len(text) and text[position] in "\r\n":
              return position + 1
          return None
      
      
      def _scan_reference_destination(text: str, position: int) -> int | None:
          if position >= len(text):
              return None
          if text[position] == "<":
              position += 1
              while position < len(text):
                  if text[position] == "\\" and position + 1 < len(text):
                      position += 2
                      continue
                  if text[position] == ">":
                      return position + 1
                  if text[position] in "<>\r\n":
                      return None
                  position += 1
              return None
      
          start = position
          depth = 0
          while position < len(text) and text[position] not in " \t\r\n":
              if text[position] == "\\" and position + 1 < len(text):
                  position += 2
                  continue
              if text[position] == "(":
                  depth += 1
              elif text[position] == ")":
                  if depth == 0:
                      return None
                  depth -= 1
              position += 1
          return position if position > start and depth == 0 else None
      
      
      def _scan_reference_title(text: str, position: int) -> int | None:
          closer = {'"': '"', "'": "'", "(": ")"}.get(
              text[position] if position < len(text) else ""
          )
          if closer is None:
              return None
          position += 1
          while position < len(text):
              if text[position] == "\\" and position + 1 < len(text):
                  position += 2
                  continue
              if text[position] == closer:
                  trailing = _skip_spaces_tabs(text, position + 1)
                  if trailing == len(text) or _after_line_ending(text, trailing) is not None:
                      return _line_end(text, trailing)
                  return None
              next_line = _after_line_ending(text, position)
              if next_line is not None:
                  content = _skip_spaces_tabs(text, next_line)
                  if content == len(text) or _after_line_ending(text, content) is not None:
                      return None
                  position = next_line
                  continue
              position += 1
          return None
      
      
      def _reference_line_content_start(
          text: str, line_start: int, required_quote_depth: int | None = None
      ) -> tuple[int, int] | None:
          content_end = line_start + len(
              text[line_start : _line_end(text, line_start)].rstrip("\r\n")
          )
          position = line_start
          quote_depth = 0
          while True:
              indent_start = position
              while position < content_end and text[position] in " \t":
                  position += 1
              if position - indent_start > 3:
                  return None
              if position >= content_end or text[position] != ">":
                  break
              quote_depth += 1
              position += 1
              if position < content_end and text[position] in " \t":
                  position += 1
          if required_quote_depth is not None and quote_depth != required_quote_depth:
              return None
          return position, quote_depth
      
      
      def _indented_code_spans(
          text: str, protected: list[tuple[int, int]]
      ) -> list[tuple[int, int]]:
          spans: list[tuple[int, int]] = []
          offset = 0
          while offset < len(text):
              line_end = _line_end(text, offset)
              content_end = offset + len(text[offset:line_end].rstrip("\r\n"))
              position = offset
              quote_depth = 0
              while True:
                  marker = position
                  spaces = 0
                  while marker < content_end and text[marker] == " " and spaces < 3:
                      marker += 1
                      spaces += 1
                  if marker >= content_end or text[marker] != ">":
                      break
                  quote_depth += 1
                  position = marker + 1
                  if position < content_end and text[position] in " \t":
                      position += 1
              if quote_depth == 0:
                  position = offset
              if (
                  not _offset_in_spans(offset, protected)
                  and (
                      text.startswith("    ", position)
                      or (position < content_end and text[position] == "\t")
                  )
              ):
                  spans.append((offset, line_end))
              offset = line_end
          return spans
      
      
      def _heading_spans(
          text: str, protected: list[tuple[int, int]]
      ) -> list[tuple[int, int]]:
          spans: list[tuple[int, int]] = []
          offset = 0
          while offset < len(text):
              line_end = _line_end(text, offset)
              line_content = _reference_line_content_start(text, offset)
              if line_content is not None and not _offset_in_spans(offset, protected):
                  content_start, quote_depth = line_content
                  content = text[content_start:line_end].rstrip("\r\n")
                  heading = content[_list_content_start(content) :]
                  if re.match(r"#{1,6}(?:[ \t]+|$)", heading):
                      spans.append((offset, line_end))
                  elif content.strip() and line_end < len(text):
                      underline_end = _line_end(text, line_end)
                      underline_content = _reference_line_content_start(
                          text, line_end, required_quote_depth=quote_depth
                      )
                      if (
                          underline_content is not None
                          and not _offset_in_spans(line_end, protected)
                      ):
                          underline_start, _ = underline_content
                          underline = text[underline_start:underline_end].rstrip("\r\n")
                          if re.fullmatch(r"(?:=+|-+)[ \t]*", underline):
                              spans.append((offset, underline_end))
              offset = line_end
          return spans
      
      
      def _scan_reference_definition(
          text: str, line_start: int, protected: list[tuple[int, int]]
      ) -> tuple[str, int] | None:
          content_end = line_start + len(
              text[line_start : _line_end(text, line_start)].rstrip("\r\n")
          )
          line_content = _reference_line_content_start(text, line_start)
          if line_content is None:
              return None
          start, quote_depth = line_content
          if _offset_in_spans(start, protected):
              return None
          label = _scan_markdown_label(text, start)
          if label is None:
              return None
          value, position = label
          if not value or position >= content_end or text[position] != ":":
              return None
      
          position = _skip_spaces_tabs(text, position + 1)
          next_line = _after_line_ending(text, position)
          if next_line is not None:
              continuation = _reference_line_content_start(
                  text, next_line, required_quote_depth=quote_depth
              )
              if continuation is None:
                  return None
              position, _ = continuation
          destination_end = _scan_reference_destination(text, position)
          if destination_end is None:
              return None
          block_end = _line_end(text, destination_end)
      
          title_start = _skip_spaces_tabs(text, destination_end)
          if title_start < len(text) and text[title_start] not in "\r\n":
              if title_start == destination_end:
                  return None
              title_end = _scan_reference_title(text, title_start)
              if title_end is None:
                  return None
              block_end = title_end
          else:
              title_line = _after_line_ending(text, title_start)
              if title_line is not None:
                  continuation = _reference_line_content_start(
                      text, title_line, required_quote_depth=quote_depth
                  )
                  if continuation is not None:
                      possible_title, _ = continuation
                      title_end = _scan_reference_title(text, possible_title)
                      if title_end is not None:
                          block_end = title_end
          return _reference_label_key(value), block_end
      
      
      def _markdown_reference_spans(
          text: str, protected: list[tuple[int, int]]
      ) -> list[tuple[int, int]]:
          definitions: set[str] = set()
          spans: list[tuple[int, int]] = []
          offset = 0
          while offset < len(text):
              definition = _scan_reference_definition(text, offset, protected)
              if definition is not None:
                  label, end = definition
                  definitions.add(label)
                  spans.append((offset, end))
              offset = _line_end(text, offset)
      
          cursor = 0
          while cursor < len(text):
              start = text.find("[", cursor)
              if start == -1:
                  break
              if _offset_in_spans(start, protected):
                  cursor = start + 1
                  continue
              label = _scan_markdown_label(text, start)
              if label is None:
                  cursor = start + 1
                  continue
              value, position = label
              reference_key = _reference_label_key(value)
              end = position
              if position < len(text) and text[position] == "[":
                  reference = _scan_markdown_label(text, position)
                  if reference is None:
                      cursor = position + 1
                      continue
                  reference_value, end = reference
                  reference_key = _reference_label_key(reference_value or value)
              if value and reference_key in definitions:
                  link_start = start - 1 if start and text[start - 1] == "!" else start
                  spans.append((link_start, end))
              cursor = end
          return spans
      
      
      _MARKDOWN_AUTOLINK = re.compile(
          r"<(?:"
          r"[A-Za-z][A-Za-z0-9+.-]{1,31}:[^<>\x00-\x20]*"
          r"|[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@"
          r"[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?"
          r")>"
      )
      
      
      def _markdown_autolink_spans(
          text: str, protected: list[tuple[int, int]]
      ) -> list[tuple[int, int]]:
          return [
              match.span()
              for match in _MARKDOWN_AUTOLINK.finditer(text)
              if not _offset_in_spans(match.start(), protected)
          ]
      
      
      _HTML_VOID_ELEMENTS = {
          "area",
          "base",
          "br",
          "col",
          "embed",
          "hr",
          "img",
          "input",
          "link",
          "meta",
          "param",
          "source",
          "track",
          "wbr",
      }
      
      _HTML_LINKABLE_TEXT_ELEMENTS = {"b", "em", "i", "strong"}
      
      
      def _scan_html_tag(
          text: str, start: int
      ) -> tuple[str, int, bool, bool, bool] | None:
          position = start + 1
          closing = position < len(text) and text[position] == "/"
          if closing:
              position += 1
          name_match = re.match(r"[A-Za-z][A-Za-z0-9:-]*", text[position:])
          if name_match is None:
              return None
          name = name_match.group(0).casefold()
          position += name_match.end()
          if position < len(text) and not (
              text[position].isspace() or text[position] in "/>"
          ):
              return None
      
          quote = ""
          while position < len(text):
              character = text[position]
              if quote:
                  if character == quote:
                      quote = ""
              elif character in ("'", '"'):
                  quote = character
              elif character == ">":
                  before_close = text[start + 1 : position].rstrip()
                  self_closing = not closing and before_close.endswith("/")
                  return name, position + 1, closing, self_closing, True
              position += 1
          return name, len(text), closing, False, False
      
      
      def _html_spans(
          text: str, protected: list[tuple[int, int]]
      ) -> list[tuple[int, int]]:
          spans: list[tuple[int, int]] = []
          stack: list[tuple[str, int]] = []
          malformed_start: int | None = None
          cursor = 0
          while cursor < len(text):
              start = text.find("<", cursor)
              if start == -1:
                  break
              if _offset_in_spans(start, protected):
                  cursor = start + 1
                  continue
              if text.startswith("<!--", start):
                  comment_end = text.find("-->", start + 4)
                  if comment_end == -1:
                      spans.append((start, len(text)))
                      break
                  spans.append((start, comment_end + 3))
                  cursor = comment_end + 3
                  continue
              tag = _scan_html_tag(text, start)
              if tag is None:
                  cursor = start + 1
                  continue
              name, end, closing, self_closing, terminated = tag
              spans.append((start, end))
              if not terminated:
                  break
              if closing:
                  if stack and stack[-1][0] == name:
                      element_name, element_start = stack.pop()
                      if element_name not in _HTML_LINKABLE_TEXT_ELEMENTS:
                          spans.append((element_start, end))
                      if malformed_start is not None and not stack:
                          spans.append((malformed_start, end))
                          malformed_start = None
                  elif stack and malformed_start is None:
                      malformed_start = min(element_start for _, element_start in stack)
              elif not self_closing and name not in _HTML_VOID_ELEMENTS:
                  stack.append((name, start))
              cursor = end
          if stack or malformed_start is not None:
              open_starts = [start for _, start in stack]
              if malformed_start is not None:
                  open_starts.append(malformed_start)
              spans.append((min(open_starts), len(text)))
          return spans
      
      
      def _merge_spans(spans: list[tuple[int, int]]) -> list[tuple[int, int]]:
          merged: list[tuple[int, int]] = []
          for start, end in sorted((start, end) for start, end in spans if start < end):
              if merged and start <= merged[-1][1]:
                  merged[-1] = (merged[-1][0], max(merged[-1][1], end))
              else:
                  merged.append((start, end))
          return merged
      
      
      def _wiki_link_context(
          text: str, protected: list[tuple[int, int]]
      ) -> tuple[list[tuple[int, int]], set[str]]:
          spans: list[tuple[int, int]] = []
          targets: set[str] = set()
          for match in re.finditer(r"\[\[([^\]\r\n]*)\]\]", text):
              if _offset_in_spans(match.start(), protected):
                  continue
              spans.append(match.span())
              target = match.group(1).split("|", 1)[0].split("#", 1)[0].strip()
              if target:
                  targets.add(target.casefold())
          return spans, targets
      
      
      def _protected_context(text: str) -> tuple[list[tuple[int, int]], set[str]]:
          spans = _fenced_code_spans(text)
          frontmatter = _frontmatter_span(text)
          if frontmatter:
              spans.append(frontmatter)
          spans = _merge_spans(spans)
      
          spans = _merge_spans([*spans, *_indented_code_spans(text, spans)])
          spans = _merge_spans([*spans, *_inline_code_spans(text, spans)])
          spans = _merge_spans([*spans, *_markdown_autolink_spans(text, spans)])
          spans = _merge_spans([*spans, *_html_spans(text, spans)])
          spans = _merge_spans([*spans, *_markdown_link_spans(text, spans)])
          spans = _merge_spans([*spans, *_markdown_reference_spans(text, spans)])
          wiki_spans, targets = _wiki_link_context(text, spans)
          spans = _merge_spans([*spans, *wiki_spans])
          high_priority_spans = spans
      
          headings = _heading_spans(text, spans)
          spans = _merge_spans([*spans, *headings])
          html_tags = [
              match.span()
              for match in re.finditer(r"<[^>\r\n]+>", text)
              if not _offset_in_spans(match.start(), spans)
          ]
          spans = _merge_spans([*spans, *html_tags])
          urls = [
              match.span()
              for match in re.finditer(r"https?://[^\s<>\]\)]+", text)
              if not _offset_in_spans(match.start(), spans)
          ]
          spans = _merge_spans([*spans, *urls])
          references = next(
              (
                  match
                  for match in REFERENCE_HEADING_RE.finditer(text)
                  if not _offset_in_spans(match.start(), high_priority_spans)
              ),
              None,
          )
          if references:
              spans.append((references.start(), len(text)))
          return _merge_spans(spans), targets
      
      
      def protected_spans(text: str) -> list[tuple[int, int]]:
          spans, _ = _protected_context(text)
          return spans
      
      
      def _overlaps(start: int, end: int, spans: list[tuple[int, int]]) -> bool:
          return any(start < protected_end and end > protected_start for protected_start, protected_end in spans)
      
      
      def _normalized_mappings(mappings: list[dict[str, Any]]) -> list[dict[str, Any]]:
          normalized: list[dict[str, Any]] = []
          by_target: dict[str, dict[str, Any]] = {}
          seen_forms: dict[str, set[str]] = {}
          for mapping in mappings:
              if not isinstance(mapping, dict):
                  raise SystemExit("Writer results must contain objects.")
              link_stem = str(mapping.get("link_stem", "")).strip()
              forms = mapping.get("forms", [])
              if not link_stem or not isinstance(forms, list):
                  raise SystemExit("Writer result requires link_stem and forms.")
              target_key = link_stem.casefold()
              item = by_target.get(target_key)
              if item is None:
                  item = {"link_stem": link_stem, "forms": []}
                  by_target[target_key] = item
                  seen_forms[target_key] = set()
                  normalized.append(item)
              for value in forms:
                  if not isinstance(value, str) or not value.strip():
                      raise SystemExit(f"Writer result has an invalid form for {link_stem}.")
                  form = value.strip()
                  key = form.casefold()
                  if key not in seen_forms[target_key]:
                      seen_forms[target_key].add(key)
                      item["forms"].append(form)
              if not item["forms"]:
                  raise SystemExit(f"Writer result has no forms for {link_stem}.")
          return normalized
      
      
      def _write_atomically(path: Path, data: bytes, original: bytes) -> None:
          descriptor, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
          temp_path = Path(temp_name)
          replaced = False
          try:
              with os.fdopen(descriptor, "wb") as handle:
                  handle.write(data)
                  handle.flush()
                  os.fsync(handle.fileno())
              if path.read_bytes() != original:
                  raise SystemExit(f"Article changed while linking: {path}")
              os.replace(temp_path, path)
              replaced = True
          finally:
              if not replaced:
                  temp_path.unlink(missing_ok=True)
      
      
      def link_article_terms(
          article: Path, mappings: list[dict[str, Any]], expected_sha256: str
      ) -> dict[str, Any]:
          path = article.expanduser().resolve()
          original = path.read_bytes()
          actual_sha256 = hashlib.sha256(original).hexdigest()
          if actual_sha256 != expected_sha256:
              raise SystemExit(f"Article changed since preview: {path}")
      
          had_bom = original.startswith(b"\xef\xbb\xbf")
          text = original[3 if had_bom else 0 :].decode("utf-8")
          protected, targets = _protected_context(text)
          normalized = _normalized_mappings(mappings)
          planned: list[tuple[int, int, str]] = []
          planned_targets: set[str] = set()
          statuses: dict[int, str] = {}
      
          order = sorted(
              range(len(normalized)),
              key=lambda index: max(len(form) for form in normalized[index]["forms"]),
              reverse=True,
          )
          for index in order:
              mapping = normalized[index]
              link_stem = mapping["link_stem"]
              target_key = link_stem.casefold()
              if target_key in targets or target_key in planned_targets:
                  statuses[index] = "already_linked"
                  continue
              matched = None
              for form in sorted(mapping["forms"], key=len, reverse=True):
                  for candidate in term_pattern(form).finditer(text):
                      if _overlaps(candidate.start(), candidate.end(), protected):
                          continue
                      if _overlaps(candidate.start(), candidate.end(), [(start, end) for start, end, _ in planned]):
                          continue
                      matched = candidate
                      break
                  if matched is not None:
                      break
              if matched is None:
                  statuses[index] = "not_found"
                  continue
              surface = matched.group(0)
              replacement = f"[[{link_stem}]]" if surface == link_stem else f"[[{link_stem}|{surface}]]"
              planned.append((matched.start(), matched.end(), replacement))
              planned_targets.add(target_key)
              statuses[index] = "linked"
      
          if planned:
              updated = text
              for start, end, replacement in sorted(planned, reverse=True):
                  updated = updated[:start] + replacement + updated[end:]
              data = (b"\xef\xbb\xbf" if had_bom else b"") + updated.encode("utf-8")
              _write_atomically(path, data, original)
      
          results = [
              {"link_stem": mapping["link_stem"], "status": statuses[index]}
              for index, mapping in enumerate(normalized)
          ]
          summary = {status: sum(item["status"] == status for item in results) for status in ("linked", "already_linked", "not_found")}
          return {"status": "ok", "article_path": str(path), "results": results, "summary": summary}
      
      
      def _validated_writer_results(
          path: Path,
          terms_dir: Path,
          article_path: str,
          source_manifest: str,
          raw_sections: str,
          reviewed_shortlist: str,
          triage_artifact: str,
      ) -> list[dict[str, Any]]:
          payload = load_json_file(path)
          results = payload.get("results")
          if (
              payload.get("status") != "ok"
              or payload.get("script") != "write_glossary_terms.py"
              or not isinstance(results, list)
          ):
              raise SystemExit("Linker requires a successful writer result with results.")
          provenance = payload.get("provenance")
          context = payload.get("context")
          if not isinstance(provenance, dict) or not isinstance(context, dict):
              raise SystemExit("Writer result requires provenance and article context.")
          current = validate_authorized_selection(
              payload,
              results,
              triage_artifact,
              source_manifest,
              raw_sections,
              reviewed_shortlist,
              term_field="name",
              forms_field="forms",
          )
          triage_digest = payload.get("triage_sha256")
          if triage_digest != current["triage_sha256"]:
              raise SystemExit("Writer triage identity does not match the authorized artifact.")
          if payload.get("mappings_sha256") != writer_mappings_sha256(
              provenance, results, context, triage_digest
          ):
              raise SystemExit("Writer mapping digest does not match the ordered mappings.")
          expected_context = {
              "paper_id": current["paper_id"],
              "paper_link": Path(article_path).stem,
              "article_path": article_path,
          }
          if not context.get("article_path"):
              raise SystemExit("Linker requires a writer artifact with bound article context.")
          if context != expected_context:
              raise SystemExit("Writer article context does not match the explicit article.")
      
          root = terms_dir.resolve(strict=True)
          index = build_alias_index(root)
          seen_paths: set[Path] = set()
          seen_stems: set[str] = set()
          for item in results:
              file_value = item.get("file")
              link_stem = item.get("link_stem")
              forms = item.get("forms")
              if (
                  not isinstance(file_value, str)
                  or not file_value
                  or not isinstance(link_stem, str)
                  or not link_stem
                  or not isinstance(forms, list)
                  or not forms
              ):
                  raise SystemExit("Writer mapping requires file, link_stem, and forms.")
              unresolved = Path(file_value).expanduser()
              try:
                  resolved = unresolved.resolve(strict=True)
              except OSError as exc:
                  raise SystemExit(f"Writer mapping glossary file is invalid: {file_value}: {exc}") from None
              if (
                  not unresolved.is_absolute()
                  or file_value != str(resolved)
                  or not resolved.is_file()
                  or resolved.suffix.lower() != ".md"
                  or not resolved.is_relative_to(root)
              ):
                  raise SystemExit(f"Writer mapping file is outside the configured glossary: {file_value}")
              if link_stem != resolved.stem:
                  raise SystemExit("Writer mapping link_stem does not match its glossary file stem.")
              if resolved in seen_paths or link_stem.casefold() in seen_stems:
                  raise SystemExit("Writer mappings must have unique glossary files and stems.")
              seen_paths.add(resolved)
              seen_stems.add(link_stem.casefold())
              for form in forms:
                  if not isinstance(form, str) or not form:
                      raise SystemExit("Writer mapping forms must be non-empty strings.")
                  owner = index.get(_normalized_key(form))
                  if owner is None or not owner.samefile(resolved):
                      raise SystemExit(
                          f"Writer mapping form does not resolve to its glossary file: {form}"
                      )
          return results
      
      
      def main() -> None:
          started = perf_counter()
          args = parser().parse_args()
          config = load_config(Path(args.config_path)) if args.config_path else load_config()
          if config is None:
              raise SystemExit("Paper-glossary is not configured; configure a term directory first.")
          terms_dir = resolve_terms_dir(config)
          article_info = validate_article(Path(args.input), config)
          article = Path(article_info["article_path"])
          result = link_article_terms(
              article,
              _validated_writer_results(
                  Path(args.write_result),
                  terms_dir,
                  article_info["article_path"],
                  args.source_manifest,
                  args.raw_sections,
                  args.reviewed_shortlist,
                  args.triage,
              ),
              args.expected_sha256,
          )
          emit(
              {
                  **result,
                  "script": "link_glossary_terms.py",
                  "elapsed_ms": elapsed_ms(started),
              },
              args.output,
          )
      
      
      if __name__ == "__main__":
          main()
      
    • lint_glossary.py 4.5 KB
      #!/usr/bin/env python3
      """Validate central term-library Markdown notes."""
      
      from __future__ import annotations
      
      import argparse
      import re
      from pathlib import Path
      from time import perf_counter
      from typing import Any
      
      from glossary_contracts import (
          GLOSSARY_CONCEPT_HEADING,
          GLOSSARY_CONFIDENCE_VALUES,
          GLOSSARY_DISCLAIMER,
          GLOSSARY_LABEL_CONFIDENCE,
          GLOSSARY_LABEL_DEFINITION,
          GLOSSARY_OCCURRENCE_HEADING,
      )
      from glossary_common import elapsed_ms, emit
      
      DISCLAIMER_KEYPHRASE = "非某篇论文"
      OCCURRENCE_LINK_RE = re.compile(r"(?m)^\s*-\s*\[\[[^\]]+\]\]")
      
      
      def parser() -> argparse.ArgumentParser:
          p = argparse.ArgumentParser(description=__doc__ or "lint glossary notes")
          p.add_argument(
              "--input",
              action="append",
              default=[],
              help="Term note Markdown path. Repeat for multiple changed notes.",
          )
          p.add_argument("--terms-dir", default="", help="Folder of term notes to lint.")
          p.add_argument("--output", default="", help="Output JSON path.")
          return p
      
      
      def _issue(code: str, **details: Any) -> dict[str, Any]:
          payload = {"code": code}
          payload.update(details)
          return payload
      
      
      def _section_body(text: str, heading: str) -> str | None:
          lines = text.splitlines()
          start = None
          for index, line in enumerate(lines):
              if re.match(rf"^##\s+{re.escape(heading)}\s*$", line.strip()):
                  start = index + 1
                  break
          if start is None:
              return None
          end = len(lines)
          for index in range(start, len(lines)):
              if re.match(r"^##\s+", lines[index].strip()):
                  end = index
                  break
          return "\n".join(lines[start:end])
      
      
      def _field_value(body: str, label: str) -> str:
          match = re.search(rf"{re.escape(label)}\s*(.+)", body)
          return match.group(1).strip() if match else ""
      
      
      def _has_valid_confidence(body: str) -> bool:
          value = _field_value(body, GLOSSARY_LABEL_CONFIDENCE)
          return value in GLOSSARY_CONFIDENCE_VALUES
      
      
      def lint_term_file_text(text: str) -> dict[str, Any]:
          text = text.replace("\r\n", "\n").replace("\r", "\n")
          issues: list[dict[str, Any]] = []
      
          concept = _section_body(text, GLOSSARY_CONCEPT_HEADING)
          occurrence = _section_body(text, GLOSSARY_OCCURRENCE_HEADING)
      
          if "[!warning]" not in text or DISCLAIMER_KEYPHRASE not in text.replace(" ", ""):
              issues.append(_issue("term_disclaimer_missing"))
          if concept is None:
              issues.append(_issue("term_concept_zone_missing"))
          else:
              if not _field_value(concept, GLOSSARY_LABEL_DEFINITION):
                  issues.append(_issue("term_definition_missing"))
              if not _has_valid_confidence(concept):
                  issues.append(_issue("term_confidence_invalid"))
          if occurrence is None:
              issues.append(_issue("term_occurrence_zone_missing"))
          elif not OCCURRENCE_LINK_RE.search(occurrence):
              issues.append(_issue("term_occurrence_reference_missing"))
      
          return {"passes": not issues, "issues": issues}
      
      
      def main() -> None:
          started = perf_counter()
          args = parser().parse_args()
          if not args.input and not args.terms_dir:
              raise SystemExit("lint_glossary.py requires --input or --terms-dir.")
      
          files: list[Path] = []
          seen: set[Path] = set()
          for value in args.input:
              path = Path(value).expanduser().resolve()
              if path not in seen:
                  seen.add(path)
                  files.append(path)
          if args.terms_dir:
              terms_dir = Path(args.terms_dir).expanduser().resolve()
              if not terms_dir.is_dir():
                  raise SystemExit(f"--terms-dir must be an existing directory: {terms_dir}")
              for path in sorted(terms_dir.glob("*.md")):
                  path = path.resolve()
                  if path not in seen:
                      seen.add(path)
                      files.append(path)
          if not files:
              raise SystemExit("No glossary markdown files found.")
      
          results: list[dict[str, Any]] = []
          for path in files:
              result = lint_term_file_text(path.read_text(encoding="utf-8-sig"))
              results.append({"path": str(path), **result})
      
          passed = sum(1 for result in results if result["passes"])
          emit(
              {
                  "status": "ok",
                  "script": "lint_glossary.py",
                  "passes_glossary": all(result["passes"] for result in results),
                  "files": results,
                  "summary": {"total": len(results), "passed": passed, "failed": len(results) - passed},
                  "elapsed_ms": elapsed_ms(started),
              },
              args.output,
          )
      
      
      if __name__ == "__main__":
          main()
      
    • plan_glossary.py 27.4 KB
      #!/usr/bin/env python3
      """Propose glossary candidates and triage selected terms from raw paper sections."""
      
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import unicodedata
      from functools import lru_cache
      from pathlib import Path
      from time import perf_counter
      from typing import Any
      
      from glossary_common import (
          canonical_sha256,
          elapsed_ms,
          emit,
          load_json_file,
          maybe_load_json_record,
          normalize_whitespace,
          proposal_sha256,
          review_sha256,
          selection_sha256,
      )
      from link_glossary_terms import (
          _fenced_code_spans,
          _inline_code_spans,
          _markdown_link_spans,
          _scan_markdown_label,
      )
      
      NON_EVIDENCE_SECTION_KINDS = frozenset({"references"})
      SNIPPET_RADIUS = 70
      MAX_ANCHORS_PER_TERM = 3
      MAX_EMPHASIS_WORDS = 6
      POOL_BUFFER = 10
      
      EMPHASIS_CONNECTORS = frozenset(
          {"and", "by", "for", "from", "in", "of", "on", "or", "the", "to", "via", "with"}
      )
      CHINESE_SENTENCE_PREFIXES = (
          "我们",
          "本文",
          "本研究",
          "本方法",
          "本模型",
          "该方法",
          "该模型",
          "这种方法",
          "这种模型",
      )
      CHINESE_SENTENCE_PREDICATES = ("提出", "表明", "证明", "显示", "可以", "能够")
      
      TOKEN_RE = re.compile(r"[A-Za-z][A-Za-z0-9]*(?:[+\-][A-Za-z0-9]+)*")
      CANDIDATE_STOPWORDS = frozenset({"ID", "OK", "OOD", "DOI", "URL", "PDF"})
      PAREN_EXPANSION_RE = re.compile(
          r"([A-Z][A-Za-z][\w-]*(?:\s+[A-Za-z][\w-]+){0,4})\s*\(([A-Za-z][A-Za-z-]+?)s?\)"
      )
      TITLECASE_RE = re.compile(r"[A-Z][a-z]{2,}(?:[ -][A-Z][a-z]+){1,3}")
      KEYWORDS_LINE_RE = re.compile(r"(?im)^[ \t]*(?:keywords|关键词)[ \t]*[::·]?[ \t]*(.+)$")
      GREEK_RE = re.compile(r"[α-ωΑ-Ω]")
      CJK_RE = re.compile(r"[\u3400-\u9fff]")
      CAPITALIZED_TERM_TOKEN_RE = re.compile(r"[A-Z0-9][A-Za-z0-9+./-]*")
      URL_RE = re.compile(r"https?://\S+")
      REFERENCE_TAIL_RE = re.compile(
          r"(?ims)^#{1,6}\s*(?:references|参考文献|引用)\s*$.*\Z"
      )
      EMPHASIS_RE = re.compile(
          r"(?:(?:\*\*|__)([^\n*_]{2,80})(?:\*\*|__)|"
          r"(?:\*|_)([^\n*_]{2,80})(?:\*|_))"
      )
      STRONG_RE = re.compile(r"(?is)<strong>([^<]{2,80})</strong>")
      UNRESOLVED_SELECTION_RE = re.compile(
          r"^\s*[#((\[]?\s*\d+\s*[))\]]?[.。]?"
          r"(?:\s*[-,,、;;/\s]\s*[#((\[]?\s*\d+\s*[))\]]?[.。]?)*\s*$"
      )
      WRITE_ALL_SELECTION_RE = re.compile(r"^全部写入\s*[。.!!]?\s*$")
      TITLECASE_STOP_HEADS = frozenset(
          {
              "The",
              "This",
              "That",
              "These",
              "Those",
              "We",
              "Our",
              "In",
              "On",
              "For",
              "As",
              "At",
              "By",
              "To",
              "It",
              "Its",
              "If",
              "When",
              "While",
              "However",
              "Moreover",
              "Specifically",
              "Following",
              "Given",
              "Since",
              "Thus",
              "Table",
              "Figure",
              "Fig",
              "Section",
              "Center",
              "Centers",
              "Hospital",
              "Both",
              "Each",
          }
      )
      CATEGORY_ORDER = {
          "keyword": 0,
          "emphasis": 1,
          "full-name": 2,
          "acronym-or-model": 3,
          "term-phrase": 4,
          "symbol": 5,
      }
      
      
      def parser() -> argparse.ArgumentParser:
          p = argparse.ArgumentParser(description=__doc__ or "plan glossary")
          mode = p.add_mutually_exclusive_group()
          mode.add_argument("--propose", action="store_true", help="List candidate glossary terms.")
          mode.add_argument(
              "--review-proposal",
              default="",
              help="Saved proposal JSON path to bind a host-reviewed shortlist.",
          )
          mode.add_argument(
              "--reviewed-shortlist",
              default="",
              help="Saved reviewed-shortlist JSON path required for triage.",
          )
          p.add_argument(
              "--terms",
              default="",
              help="Exact selected names from a reviewed shortlist.",
          )
          p.add_argument(
              "--reviewed-terms",
              default="",
              help="Exact host-reviewed proposal names in display order.",
          )
          p.add_argument("--source-manifest", required=True, help="Manifest JSON path or JSON object.")
          p.add_argument(
              "--raw-sections",
              default="",
              help="Raw sections JSONL path. Defaults to manifest raw_sections_path.",
          )
          p.add_argument("--output", default="", help="Output JSON path.")
          return p
      
      
      def _load_exact_names(
          value: str, label: str, *, allow_empty: bool = False
      ) -> list[str]:
          raw = value.strip()
          if not raw:
              raise SystemExit(f"{label} requires at least one exact candidate name.")
          path = Path(raw).expanduser()
          text = path.read_text(encoding="utf-8-sig").strip() if path.is_file() else raw
          parsed = _maybe_json_list(text) if text.startswith("[") else None
          items: list[Any] = parsed if parsed is not None else re.split(r"[,\r\n]+", text)
          names: list[str] = []
          for item in items:
              if not isinstance(item, str):
                  raise SystemExit(f"{label} must contain exact candidate name strings.")
              if not item or normalize_whitespace(item) != item:
                  raise SystemExit(f"{label} contains a malformed exact candidate name.")
              names.append(item)
          if not names and not allow_empty:
              raise SystemExit(f"{label} requires at least one exact candidate name.")
          _reject_unresolved_selection(names)
          return names
      
      
      def load_terms(value: str) -> list[str]:
          raw = value.strip()
          _reject_unresolved_selection([raw])
          parsed = _maybe_json_list(raw)
          if parsed is not None:
              terms = _clean_terms(parsed)
              _reject_unresolved_selection(terms)
              return terms
          path = Path(raw).expanduser()
          if path.exists() and path.is_file():
              text = path.read_text(encoding="utf-8-sig")
              parsed = _maybe_json_list(text.strip())
              if parsed is not None:
                  terms = _clean_terms(parsed)
              else:
                  terms = _clean_terms(re.split(r"[\r\n]+", text))
          else:
              terms = _clean_terms(re.split(r"[,\r\n]+", raw))
          _reject_unresolved_selection(terms)
          return terms
      
      
      def _reject_unresolved_selection(terms: list[str]) -> None:
          if any(
              WRITE_ALL_SELECTION_RE.fullmatch(term) or UNRESOLVED_SELECTION_RE.fullmatch(term)
              for term in terms
          ):
              raise SystemExit(
                  "Resolve shortlist numbers or 全部写入 to candidate names before passing --terms."
              )
      
      
      def _maybe_json_list(text: str) -> list[Any] | None:
          if not text.startswith("["):
              return None
          try:
              data = json.loads(text)
          except json.JSONDecodeError as exc:
              raise SystemExit(f"Invalid terms JSON list: {exc.msg}") from exc
          return data if isinstance(data, list) else None
      
      
      def _clean_terms(items: list[Any]) -> list[str]:
          seen: set[str] = set()
          terms: list[str] = []
          for item in items:
              term = normalize_whitespace(str(item))
              key = term.lower()
              if term and key not in seen:
                  seen.add(key)
                  terms.append(term)
          return terms
      
      
      def load_record(value: str) -> dict[str, Any]:
          record = maybe_load_json_record(value)
          if record is not None:
              return record
          raise SystemExit(f"Expected JSON object or JSON file path for {value!r}.")
      
      
      def read_raw_sections(path: Path) -> list[dict[str, Any]]:
          records: list[dict[str, Any]] = []
          for line_number, line in enumerate(
              path.read_text(encoding="utf-8-sig").splitlines(), 1
          ):
              line = line.strip()
              if not line:
                  continue
              try:
                  record = json.loads(line)
              except json.JSONDecodeError as exc:
                  raise SystemExit(
                      f"Invalid raw sections JSONL at {path}:{line_number}: {exc.msg}"
                  ) from exc
              if isinstance(record, dict) and record.get("record_type", "section") == "section":
                  records.append(record)
          return records
      
      
      def _record_path(value: str) -> Path | None:
          raw = value.strip()
          if raw.startswith("{"):
              return None
          path = Path(raw).expanduser()
          return path.resolve() if path.is_file() else None
      
      
      def load_manifest_and_sections(
          source_manifest: str, raw_sections: str
      ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
          manifest = load_record(source_manifest)
          manifest_path = _record_path(source_manifest)
          raw_path = _resolve_raw_path(
              manifest, raw_sections, manifest_path.parent if manifest_path else None
          )
          return manifest, read_raw_sections(raw_path)
      
      
      def _resolve_raw_path(
          manifest: dict[str, Any], explicit: str, manifest_dir: Path | None = None
      ) -> Path:
          raw_path_value = explicit or str(manifest.get("raw_sections_path", ""))
          if not raw_path_value:
              raise SystemExit("Pass --raw-sections or use a manifest with raw_sections_path.")
          raw_path = Path(raw_path_value).expanduser()
          if not explicit and manifest_dir is not None and not raw_path.is_absolute():
              raw_path = manifest_dir / raw_path
          raw_path = raw_path.resolve()
          if not raw_path.is_file():
              raise SystemExit(f"Raw sections file not found: {raw_path}")
          return raw_path
      
      
      def _evidence_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
          return [
              record
              for record in records
              if str(record.get("kind", "")) not in NON_EVIDENCE_SECTION_KINDS
          ]
      
      
      def _mask_span(characters: list[str], start: int, end: int) -> None:
          for index in range(start, end):
              if characters[index] not in "\r\n":
                  characters[index] = " "
      
      
      @lru_cache(maxsize=256)
      def _candidate_prose_with_offsets(text: str) -> str:
          characters = list(text)
          fenced = _fenced_code_spans(text)
          inline = _inline_code_spans(text, fenced)
          protected = sorted([*fenced, *inline])
          links = _markdown_link_spans(text, protected)
          for start, end in links:
              label_start = start + 1 if text[start] == "!" else start
              label = _scan_markdown_label(text, label_start)
              if label is None:
                  _mask_span(characters, start, end)
                  continue
              _, label_end = label
              _mask_span(characters, start, label_start + 1)
              _mask_span(characters, label_end - 1, end)
          for start, end in [*fenced, *inline]:
              _mask_span(characters, start, end)
          for pattern in (URL_RE, REFERENCE_TAIL_RE):
              for match in pattern.finditer(text):
                  _mask_span(characters, match.start(), match.end())
          return "".join(characters)
      
      
      def _candidate_prose(text: str) -> str:
          return _candidate_prose_with_offsets(text)
      
      
      def effective_prose_text(records: list[dict[str, Any]]) -> str:
          return "\n".join(
              _candidate_prose(str(record.get("text", "")))
              for record in _evidence_records(records)
          )
      
      
      def shortlist_limit(effective_characters: int) -> int:
          if effective_characters < 10_000:
              return 10
          if effective_characters < 30_000:
              return 18
          if effective_characters < 60_000:
              return 25
          return 35
      
      
      def _term_pattern(term: str) -> re.Pattern[str]:
          if re.fullmatch(r"[\x00-\x7f]+", term):
              return re.compile(rf"(?<![A-Za-z0-9]){re.escape(term)}(?![A-Za-z0-9])", re.IGNORECASE)
          return re.compile(re.escape(term), re.IGNORECASE)
      
      
      def _snippet(text: str, start: int, end: int) -> str:
          left = max(0, start - SNIPPET_RADIUS)
          right = min(len(text), end + SNIPPET_RADIUS)
          prefix = "..." if left > 0 else ""
          suffix = "..." if right < len(text) else ""
          return prefix + normalize_whitespace(text[left:right]) + suffix
      
      
      def _non_overlapping_matches(forms: list[str], text: str) -> list[tuple[int, int]]:
          spans = {
              (match.start(), match.end())
              for form in forms
              for match in _term_pattern(form).finditer(text)
          }
          selected: list[tuple[int, int]] = []
          for start, end in sorted(
              spans, key=lambda span: (span[0], -(span[1] - span[0]), span[1])
          ):
              if any(
                  start < selected_end and selected_start < end
                  for selected_start, selected_end in selected
              ):
                  continue
              selected.append((start, end))
          return selected
      
      
      def _find_occurrences_for_forms(
          forms: list[str], records: list[dict[str, Any]]
      ) -> tuple[int, list[dict[str, Any]]]:
          total = 0
          anchors: list[dict[str, Any]] = []
          for record in _evidence_records(records):
              text = str(record.get("text", ""))
              matches = _non_overlapping_matches(forms, _candidate_prose_with_offsets(text))
              if not matches:
                  continue
              total += len(matches)
              if len(anchors) < MAX_ANCHORS_PER_TERM:
                  first_start, first_end = matches[0]
                  anchors.append(
                      {
                          "section_id": record.get("section_id", ""),
                          "title": record.get("title", ""),
                          "page_start": record.get("page_start"),
                          "page_end": record.get("page_end"),
                          "snippet": _snippet(text, first_start, first_end),
                      }
                  )
          return total, anchors
      
      
      def find_occurrences(term: str, records: list[dict[str, Any]]) -> tuple[int, list[dict[str, Any]]]:
          return _find_occurrences_for_forms([term], records)
      
      
      def triage_terms(terms: list[str], records: list[dict[str, Any]]) -> list[dict[str, Any]]:
          candidates: list[dict[str, Any]] = []
          for term in terms:
              if (
                  not isinstance(term, str)
                  or not term
                  or normalize_whitespace(term) != term
                  or "|" in term
              ):
                  raise SystemExit("Triage terms must contain exact names without alias syntax.")
              candidates.append({"term": term, "surface_forms": [term]})
          return _triage_candidates(candidates, records)
      
      
      def _is_acronym(token: str) -> bool:
          if len(token) < 2 or token in CANDIDATE_STOPWORDS:
              return False
          return sum(1 for ch in token if ch.isupper()) >= 2
      
      
      def _titlecase_ok(phrase: str) -> bool:
          head = re.split(r"[ -]", phrase, maxsplit=1)[0]
          return head not in TITLECASE_STOP_HEADS
      
      
      def _extract_keywords(records: list[dict[str, Any]]) -> list[str]:
          for record in records:
              match = KEYWORDS_LINE_RE.search(str(record.get("text", "")))
              if match:
                  parts = re.split(r"[·;,、]|\s{2,}", match.group(1))
                  return [normalize_whitespace(part) for part in parts if normalize_whitespace(part)]
          return []
      
      
      def _english_emphasis_term_shape(candidate: str) -> bool:
          words = candidate.split()
          if len(words) == 1:
              return True
          if words[0].casefold() in EMPHASIS_CONNECTORS or words[-1].casefold() in EMPHASIS_CONNECTORS:
              return False
          return all(
              word.casefold() in EMPHASIS_CONNECTORS
              or CAPITALIZED_TERM_TOKEN_RE.fullmatch(word)
              or CJK_RE.search(word)
              for word in words
          )
      
      
      def _chinese_emphasis_term_shape(candidate: str) -> bool:
          compact = "".join(candidate.split())
          for prefix in CHINESE_SENTENCE_PREFIXES:
              if compact.startswith(prefix) and any(
                  predicate in compact[len(prefix) :]
                  for predicate in CHINESE_SENTENCE_PREDICATES
              ):
                  return False
          return True
      
      
      def _emphasis_candidate(value: str) -> str:
          candidate = normalize_whitespace(value)
          if (
              not candidate
              or len(candidate) > 80
              or len(candidate.split()) > MAX_EMPHASIS_WORDS
              or re.search(r"[.!?。!?,:;;]", candidate)
              or not re.search(r"[A-Za-z\u0080-\uffff]", candidate)
          ):
              return ""
          if re.search(r"[A-Za-z]", candidate) and not _english_emphasis_term_shape(candidate):
              return ""
          if CJK_RE.search(candidate) and not _chinese_emphasis_term_shape(candidate):
              return ""
          return candidate
      
      
      def propose_candidates(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
          evidence = _evidence_records(records)
          cleaned_evidence = [
              {**record, "text": _candidate_prose(str(record.get("text", "")))}
              for record in evidence
          ]
          categories: dict[str, tuple[str, str, list[str]]] = {}
      
          def add(term: str, category: str) -> None:
              cleaned = normalize_whitespace(term)
              if not cleaned:
                  return
              key = unicodedata.normalize("NFKC", cleaned).casefold()
              existing = categories.get(key)
              if existing is None:
                  categories[key] = (cleaned, category, [cleaned])
              elif cleaned not in existing[2]:
                  existing[2].append(cleaned)
      
          for keyword in _extract_keywords(cleaned_evidence):
              add(keyword, "keyword")
          for record in cleaned_evidence:
              for match in EMPHASIS_RE.finditer(str(record.get("text", ""))):
                  candidate = _emphasis_candidate(match.group(1) or match.group(2) or "")
                  if candidate:
                      add(candidate, "emphasis")
              for match in STRONG_RE.finditer(str(record.get("text", ""))):
                  candidate = _emphasis_candidate(match.group(1))
                  if candidate:
                      add(candidate, "emphasis")
          for record in cleaned_evidence:
              for match in PAREN_EXPANSION_RE.finditer(str(record.get("text", ""))):
                  if _titlecase_ok(match.group(1)):
                      add(match.group(1), "full-name")
                  if _is_acronym(match.group(2)):
                      add(match.group(2), "acronym-or-model")
          for record in cleaned_evidence:
              for match in TOKEN_RE.finditer(str(record.get("text", ""))):
                  if _is_acronym(match.group(0)):
                      add(match.group(0), "acronym-or-model")
          for record in cleaned_evidence:
              for match in TITLECASE_RE.finditer(str(record.get("text", ""))):
                  if _titlecase_ok(match.group(0)):
                      add(match.group(0), "term-phrase")
          for record in cleaned_evidence:
              for match in GREEK_RE.finditer(str(record.get("text", ""))):
                  add(match.group(0), "symbol")
      
          results: list[dict[str, Any]] = []
          for term, category, surface_forms in categories.values():
              occurrences, anchors = _find_occurrences_for_forms(surface_forms, records)
              if category == "term-phrase" and occurrences < 2:
                  continue
              if occurrences == 0 and category not in ("keyword", "symbol"):
                  continue
              anchor = anchors[0] if anchors else {}
              results.append(
                  {
                      "term": term,
                      "surface_forms": surface_forms,
                      "category": category,
                      "occurrences": occurrences,
                      "section_id": anchor.get("section_id", ""),
                      "page_start": anchor.get("page_start"),
                      "snippet": anchor.get("snippet", ""),
                  }
              )
          results.sort(
              key=lambda item: (
                  CATEGORY_ORDER.get(str(item["category"]), 9),
                  -int(item["occurrences"]),
                  str(item["term"]).lower(),
              )
          )
          effective_characters = sum(
              1 for character in effective_prose_text(records) if not character.isspace()
          )
          return results[: shortlist_limit(effective_characters) + POOL_BUFFER]
      
      
      def _proposal_summary(
          records: list[dict[str, Any]], candidates: list[dict[str, Any]]
      ) -> dict[str, int]:
          effective_characters = sum(
              1 for character in effective_prose_text(records) if not character.isspace()
          )
          return {
              "effective_body_characters": effective_characters,
              "shortlist_limit": shortlist_limit(effective_characters),
              "pool_candidates": len(candidates),
          }
      
      
      def _proposal_artifact(
          manifest: dict[str, Any], records: list[dict[str, Any]]
      ) -> dict[str, Any]:
          paper_id = str(manifest.get("paper_id", ""))
          candidates = propose_candidates(records)
          summary = _proposal_summary(records, candidates)
          source_sha256 = canonical_sha256(
              {"paper_id": paper_id, "manifest": manifest, "records": records}
          )
          proposal_digest = proposal_sha256(paper_id, source_sha256, candidates, summary)
          proposal_provenance = {
              "paper_id": paper_id,
              "source_sha256": source_sha256,
              "candidates": candidates,
              "summary": summary,
              "proposal_sha256": proposal_digest,
          }
          has_candidates = bool(candidates)
          return {
              "status": "ok",
              "script": "plan_glossary.py",
              "mode": "propose",
              "workflow_state": "awaiting_semantic_review" if has_candidates else "no_candidates",
              "next_action": (
                  "record_reviewed_shortlist_then_present_and_wait"
                  if has_candidates
                  else "report_no_candidates"
              ),
              "paper_id": paper_id,
              "candidates": candidates,
              "summary": summary,
              "provenance": proposal_provenance,
          }
      
      
      def _validated_proposal(
          proposal: dict[str, Any], manifest: dict[str, Any], records: list[dict[str, Any]]
      ) -> dict[str, Any]:
          expected = _proposal_artifact(manifest, records)
          fields = (
              "status",
              "script",
              "mode",
              "workflow_state",
              "next_action",
              "paper_id",
              "candidates",
              "summary",
              "provenance",
          )
          if any(proposal.get(field) != expected[field] for field in fields):
              raise SystemExit("Invalid proposal artifact for the current paper and source data.")
          return expected
      
      
      def _review_artifact(
          proposal: dict[str, Any], reviewed_names: list[str]
      ) -> dict[str, Any]:
          if len(reviewed_names) != len(set(reviewed_names)):
              raise SystemExit("Host-reviewed candidate names must not contain duplicates.")
          limit = int(proposal["summary"]["shortlist_limit"])
          if len(reviewed_names) > limit:
              raise SystemExit(f"Host-reviewed candidate names exceed shortlist limit {limit}.")
          by_name = {candidate["term"]: candidate for candidate in proposal["candidates"]}
          unknown = [name for name in reviewed_names if name not in by_name]
          if unknown:
              raise SystemExit(
                  f"Host-reviewed name must exactly match a proposal term: {unknown[0]}"
              )
          reviewed = [by_name[name] for name in reviewed_names]
          proposal_provenance = proposal["provenance"]
          review_digest = review_sha256(proposal_provenance, reviewed)
          has_reviewed_candidates = bool(reviewed)
          return {
              "status": "ok",
              "script": "plan_glossary.py",
              "mode": "review",
              "workflow_state": (
                  "awaiting_user_selection" if has_reviewed_candidates else "no_candidates"
              ),
              "next_action": (
                  "present_numbered_shortlist_and_wait"
                  if has_reviewed_candidates
                  else "report_no_candidates"
              ),
              "paper_id": proposal["paper_id"],
              "proposal_provenance": proposal_provenance,
              "reviewed_shortlist": reviewed,
              "summary": {"shortlist_limit": limit, "reviewed_candidates": len(reviewed)},
              "provenance": {
                  **proposal_provenance,
                  "reviewed_shortlist": reviewed,
                  "review_sha256": review_digest,
              },
          }
      
      
      def _validated_review(
          review: dict[str, Any], manifest: dict[str, Any], records: list[dict[str, Any]]
      ) -> tuple[dict[str, Any], dict[str, Any]]:
          proposal = _proposal_artifact(manifest, records)
          reviewed = review.get("reviewed_shortlist")
          if not isinstance(reviewed, list) or any(not isinstance(item, dict) for item in reviewed):
              raise SystemExit("Invalid reviewed-shortlist artifact.")
          names = [item.get("term") for item in reviewed]
          if any(not isinstance(name, str) for name in names):
              raise SystemExit("Invalid reviewed candidate name.")
          expected = _review_artifact(proposal, names)
          fields = (
              "status",
              "script",
              "mode",
              "workflow_state",
              "next_action",
              "paper_id",
              "proposal_provenance",
              "reviewed_shortlist",
              "summary",
              "provenance",
          )
          if any(review.get(field) != expected[field] for field in fields):
              raise SystemExit("Invalid reviewed-shortlist artifact identity or proposal binding.")
          return expected, proposal
      
      
      def _triage_candidates(
          candidates: list[dict[str, Any]], records: list[dict[str, Any]]
      ) -> list[dict[str, Any]]:
          results: list[dict[str, Any]] = []
          for candidate in candidates:
              forms = candidate["surface_forms"]
              occurrences, anchors = _find_occurrences_for_forms(forms, records)
              found = occurrences > 0
              results.append(
                  {
                      "term": candidate["term"],
                      "surface_forms": forms,
                      "found_in_paper": found,
                      "occurrences": occurrences,
                      "routing": "anchor_only" if found else "needs_explanation",
                      "paper_anchors": anchors,
                  }
              )
          return results
      
      
      def main() -> None:
          started = perf_counter()
          args = parser().parse_args()
          if not (args.propose or args.review_proposal or args.reviewed_shortlist):
              if args.terms.strip():
                  raise SystemExit("Triage requires --reviewed-shortlist with exact selected names.")
              raise SystemExit(
                  "plan_glossary.py requires --propose, --review-proposal, or --reviewed-shortlist."
              )
          manifest, records = load_manifest_and_sections(args.source_manifest, args.raw_sections)
      
          if args.propose:
              if args.terms.strip() or args.reviewed_terms.strip():
                  raise SystemExit("Proposal mode does not accept selected terms.")
              emit({**_proposal_artifact(manifest, records), "elapsed_ms": elapsed_ms(started)}, args.output)
              return
      
          if args.review_proposal:
              if args.terms.strip() or not args.reviewed_terms.strip():
                  raise SystemExit("Review mode requires --reviewed-terms and does not accept --terms.")
              proposal = _validated_proposal(
                  load_json_file(Path(args.review_proposal).expanduser().resolve()),
                  manifest,
                  records,
              )
              reviewed_names = _load_exact_names(
                  args.reviewed_terms, "--reviewed-terms", allow_empty=True
              )
              emit(
                  {
                      **_review_artifact(proposal, reviewed_names),
                      "elapsed_ms": elapsed_ms(started),
                  },
                  args.output,
              )
              return
      
          if not args.terms.strip() or args.reviewed_terms.strip():
              raise SystemExit("Triage requires --terms and does not accept --reviewed-terms.")
          review, proposal = _validated_review(
              load_json_file(Path(args.reviewed_shortlist).expanduser().resolve()),
              manifest,
              records,
          )
          selected_names = _load_exact_names(args.terms, "--terms")
          if len(selected_names) != len(set(selected_names)):
              raise SystemExit("Selected shortlist names must not contain duplicates.")
          reviewed_by_name = {
              candidate["term"]: candidate for candidate in review["reviewed_shortlist"]
          }
          unknown = [name for name in selected_names if name not in reviewed_by_name]
          if unknown:
              raise SystemExit(
                  f"Selected name must exactly match the reviewed shortlist: {unknown[0]}"
              )
          selected_candidates = [reviewed_by_name[name] for name in selected_names]
          triaged = _triage_candidates(selected_candidates, records)
          anchor_only = sum(1 for item in triaged if item["routing"] == "anchor_only")
          emit(
              {
                  "status": "ok",
                  "script": "plan_glossary.py",
                  "mode": "triage",
                  "paper_id": proposal["paper_id"],
                  "terms": triaged,
                  "provenance": {
                      "proposal": proposal["provenance"],
                      "review": review["provenance"],
                      "selection_sha256": selection_sha256(
                          review["provenance"]["review_sha256"], triaged
                      ),
                  },
                  "summary": {
                      "total": len(triaged),
                      "anchor_only": anchor_only,
                      "needs_explanation": len(triaged) - anchor_only,
                  },
                  "elapsed_ms": elapsed_ms(started),
              },
              args.output,
          )
      
      
      if __name__ == "__main__":
          main()
      
    • write_glossary_terms.py 30.1 KB
      #!/usr/bin/env python3
      """Create or update central term-library Markdown notes."""
      
      from __future__ import annotations
      
      import argparse
      import json
      import os
      import re
      import tempfile
      from pathlib import Path
      from time import perf_counter
      from typing import Any
      
      from glossary_common import (
          elapsed_ms,
          emit,
          maybe_load_json_record,
          normalize_whitespace,
          validate_authorized_selection,
          validate_provenance_chain,
          writer_mappings_sha256,
      )
      from glossary_config import load_config, resolve_terms_dir, validate_article
      from glossary_contracts import (
          GLOSSARY_CONFIDENCE_VALUES,
          GLOSSARY_CONCEPT_HEADING,
          GLOSSARY_DISCLAIMER,
          GLOSSARY_OCCURRENCE_HEADING,
          GLOSSARY_TERM_TAG,
      )
      from glossary_library import (
          CONCEPT_FIELDS,
          _decode_frontmatter_scalar,
          _normalized_key,
          add_missing_concept_fields,
          build_alias_index as _library_build_alias_index,
          inspect_selected_terms,
          note_state,
          obsidian_safe_link_stem,
          read_frontmatter_aliases as _read_frontmatter_aliases,
          read_heading_name as _read_heading_name,
      )
      
      ILLEGAL_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
      MAX_FILENAME_COMPONENT_LENGTH = 240
      WINDOWS_RESERVED_NAMES = {
          "CON",
          "PRN",
          "AUX",
          "NUL",
          *(f"COM{number}" for number in range(1, 10)),
          *(f"LPT{number}" for number in range(1, 10)),
      }
      
      
      def parser() -> argparse.ArgumentParser:
          p = argparse.ArgumentParser(description=__doc__ or "write glossary terms")
          p.add_argument("--glossary", required=True, help="Glossary JSON path or JSON object.")
          p.add_argument("--inventory", required=True, help="Glossary inventory JSON path or JSON object.")
          p.add_argument("--config-path", default="", help="Optional device-local config path override.")
          p.add_argument("--source-manifest", required=True, help="Current source manifest.")
          p.add_argument("--raw-sections", default="", help="Optional current raw-sections JSONL.")
          p.add_argument(
              "--reviewed-shortlist", required=True, help="Saved reviewed-shortlist artifact."
          )
          p.add_argument("--triage", required=True, help="Saved authorized triage artifact.")
          p.add_argument("--article", default="", help="Optional explicit article Markdown path.")
          p.add_argument("--output", default="", help="Output JSON status path.")
          return p
      
      
      def safe_term_filename(name: str) -> str:
          raw = obsidian_safe_link_stem(str(name))
          if not raw or raw in (".", "..") or raw.endswith((" ", ".")):
              raise SystemExit(f"Unsafe glossary filename: {raw!r}")
          cleaned = ILLEGAL_FILENAME_CHARS.sub(" ", raw)
          cleaned = normalize_whitespace(cleaned).strip(" .")
          if not cleaned or cleaned in (".", ".."):
              raise SystemExit(f"Unsafe glossary filename: {raw!r}")
          if cleaned.split(".", 1)[0].upper() in WINDOWS_RESERVED_NAMES:
              raise SystemExit(f"Unsafe glossary filename: reserved Windows name {raw!r}")
          component = f"{cleaned}.md"
          try:
              component_lengths = (
                  len(component.encode("utf-16-le")) // 2,
                  len(component.encode("utf-8")),
              )
          except UnicodeEncodeError:
              raise SystemExit(f"Unsafe glossary filename: invalid Unicode in {raw!r}") from None
          if any(length > MAX_FILENAME_COMPONENT_LENGTH for length in component_lengths):
              raise SystemExit(f"Unsafe glossary filename: component too long for {raw!r}")
          return cleaned
      
      
      def _unique_term_path(terms_dir: Path, stem: str) -> Path:
          path = terms_dir / f"{stem}.md"
          if not path.exists():
              return path
          counter = 2
          while True:
              candidate = terms_dir / f"{stem}-{counter}.md"
              if not candidate.exists():
                  return candidate
              counter += 1
      
      
      def _frontmatter_string(value: str) -> str:
          return json.dumps(value, ensure_ascii=False)
      
      
      def _alias_forms(entry: dict[str, Any]) -> list[str]:
          forms = [normalize_whitespace(str(entry.get("name", "")))]
          aliases = entry.get("aliases", [])
          if not isinstance(aliases, list):
              raise SystemExit("Glossary entry aliases must be a list.")
          forms.extend(normalize_whitespace(str(alias)) for alias in aliases)
          seen: set[str] = set()
          ordered: list[str] = []
          for form in forms:
              key = _normalized_key(form)
              if form and key not in seen:
                  seen.add(key)
                  ordered.append(form)
          return ordered
      
      
      def _concept_zone(entry: dict[str, Any]) -> list[str]:
          lines = [f"## {GLOSSARY_CONCEPT_HEADING}", f"- 定义:{entry.get('definition', '')}"]
          for label, key in (("详解", "elaboration"), ("直觉", "intuition"), ("与相邻概念的区别", "distinction")):
              value = normalize_whitespace(str(entry.get(key, "")))
              if value:
                  lines.append(f"- {label}:{value}")
          lines.append(f"- 置信度:{entry.get('confidence', '')}")
          return lines
      
      
      def occurrence_line(entry: dict[str, Any], paper_link: str) -> str:
          note = normalize_whitespace(str(entry.get("occurrence", "")))
          suffix = f":{note}" if note else ""
          return f"- [[{paper_link}]]{suffix}"
      
      
      def render_term_file(
          entry: dict[str, Any], paper_link: str, link_stem: str | None = None
      ) -> str:
          name = normalize_whitespace(str(entry.get("name", "")))
          actual_stem = name if link_stem is None else link_stem
          aliases = [
              alias for alias in _alias_forms(entry) if alias.casefold() != actual_stem.casefold()
          ]
          front = ["---", "aliases:"]
          front.extend(f"  - {_frontmatter_string(alias)}" for alias in aliases)
          front.extend([f"tags: [{GLOSSARY_TERM_TAG}]", "---", ""])
          body = [f"# {name}", "", GLOSSARY_DISCLAIMER, ""]
          body.extend(_concept_zone(entry))
          body.extend(["", f"## {GLOSSARY_OCCURRENCE_HEADING}", occurrence_line(entry, paper_link), ""])
          return "\n".join(front + body)
      
      
      def upsert_term_file(
          entry: dict[str, Any], paper_link: str, terms_dir: Path, index: dict[str, Path]
      ) -> dict[str, Any]:
          existing = None
          for form in _alias_forms(entry):
              existing = index.get(form.lower())
              if existing is not None:
                  break
          if existing is not None and existing.is_file():
              text = existing.read_text(encoding="utf-8-sig")
              updated = append_occurrence(text, entry, paper_link)
              if updated != text:
                  existing.write_text(updated, encoding="utf-8")
              return {
                  "name": entry.get("name", ""),
                  "file": str(existing),
                  "action": "updated" if updated != text else "unchanged",
                  "link_stem": existing.stem,
              }
      
          name = normalize_whitespace(str(entry.get("name", "")))
          terms_dir.mkdir(parents=True, exist_ok=True)
          path = _unique_term_path(terms_dir, safe_term_filename(name))
          path.write_text(render_term_file(entry, paper_link, path.stem), encoding="utf-8")
          for form in _alias_forms(entry):
              index.setdefault(form.lower(), path)
          index.setdefault(path.stem.lower(), path)
          return {"name": name, "file": str(path), "action": "created", "link_stem": path.stem}
      
      
      def _file_identity(path: Path) -> tuple[int, int, int, int]:
          stat = path.stat()
          return (stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns)
      
      
      def _read_note_snapshot(path: Path) -> tuple[str, bool, bytes, tuple[int, int, int, int]]:
          try:
              identity_before = _file_identity(path)
              raw = path.read_bytes()
              identity_after = _file_identity(path)
              if identity_before != identity_after:
                  raise SystemExit(f"Glossary note changed while planning: {path}")
              return (
                  raw.decode("utf-8-sig"),
                  raw.startswith(b"\xef\xbb\xbf"),
                  raw,
                  identity_after,
              )
          except (OSError, UnicodeDecodeError) as exc:
              raise SystemExit(f"Unable to read glossary note {path}: {exc}") from None
      
      
      def append_occurrence(text: str, entry: dict[str, Any], paper_link: str) -> str:
          line_ending = "\r\n" if "\r\n" in text else "\n"
          line = occurrence_line(entry, paper_link)
          heading = re.search(
              rf"(?m)^##\s+{re.escape(GLOSSARY_OCCURRENCE_HEADING)}\s*$(?:\r?\n)?", text
          )
          if heading is not None:
              following = re.search(r"(?m)^##\s+", text[heading.end() :])
              position = heading.end() + following.start() if following else len(text)
              section = text[heading.end() : position]
              occurrence = re.search(
                  rf"(?m)^[ \t]*-[ \t]+\[\[{re.escape(paper_link)}\]\]", section
              )
              if occurrence is not None:
                  return text
              prefix = "" if text[:position].endswith(("\n", "\r")) else line_ending
              suffix = "" if text[position:].startswith(("\n", "\r")) else line_ending
              return text[:position] + prefix + line + line_ending + suffix + text[position:]
          separator = "" if not text else (line_ending if text.endswith(("\n", "\r")) else line_ending * 2)
          if text and text.endswith(("\n", "\r")):
              separator = line_ending
          return text + separator + f"## {GLOSSARY_OCCURRENCE_HEADING}{line_ending}{line}{line_ending}"
      
      
      # Existing consumers import this name from this module. The implementation lives
      # in glossary_library so inventory and write paths use identical alias matching.
      build_alias_index = _library_build_alias_index
      
      
      def _entry_forms(entry: dict[str, Any], inventory_item: dict[str, Any]) -> list[str]:
          values: list[Any] = [entry.get("name", "")]
          values.extend(inventory_item.get("surface_forms", []))
          aliases = entry.get("aliases", [])
          if isinstance(aliases, list):
              values.extend(aliases)
          forms: list[str] = []
          seen: set[str] = set()
          for value in values:
              form = normalize_whitespace(str(value))
              key = _normalized_key(form)
              if form and key not in seen:
                  seen.add(key)
                  forms.append(form)
          return forms
      
      
      def _entries_by_name(glossary: dict[str, Any]) -> dict[str, dict[str, Any]]:
          entries = glossary.get("entries", [])
          if not isinstance(entries, list):
              raise SystemExit("Glossary 'entries' must be a list.")
          indexed: dict[str, dict[str, Any]] = {}
          for entry in entries:
              if not isinstance(entry, dict):
                  raise SystemExit("Glossary entries must contain objects.")
              name = normalize_whitespace(str(entry.get("name", "")))
              key = name.casefold()
              if not name or key in indexed:
                  raise SystemExit("Glossary entry names must be unique and non-empty.")
              _alias_forms(entry)
              indexed[key] = entry
          return indexed
      
      
      def _inventory_by_term(inventory: dict[str, Any]) -> dict[str, dict[str, Any]]:
          results = inventory.get("results", [])
          if not isinstance(results, list):
              raise SystemExit("Inventory 'results' must be a list.")
          indexed: dict[str, dict[str, Any]] = {}
          for item in results:
              if not isinstance(item, dict):
                  raise SystemExit("Inventory results must contain objects.")
              term = normalize_whitespace(str(item.get("term", "")))
              if not term or term.casefold() in indexed:
                  raise SystemExit("Inventory terms must be unique and non-empty.")
              if not isinstance(item.get("surface_forms"), list):
                  raise SystemExit(f"Inventory surface_forms must be a list for term: {term}")
              indexed[term.casefold()] = item
          return indexed
      
      
      def _inventory_files_match(external_item: dict[str, Any], fresh_item: dict[str, Any]) -> bool:
          external_value = str(external_item.get("file", ""))
          fresh_value = str(fresh_item.get("file", ""))
          if not external_value or not fresh_value:
              return external_value == fresh_value
          try:
              return Path(external_value).expanduser().samefile(Path(fresh_value).expanduser())
          except OSError:
              return False
      
      
      def _bound_forms(entry: dict[str, Any], inventory_item: dict[str, Any]) -> list[str]:
          entry_name = normalize_whitespace(str(entry.get("name", "")))
          inventory_term = normalize_whitespace(str(inventory_item.get("term", "")))
          if entry_name.casefold() != inventory_term.casefold():
              raise SystemExit(
                  f"Inventory term does not match entry name: {inventory_term!r} != {entry_name!r}"
              )
          inventory_forms = inventory_item.get("surface_forms", [])
          if not isinstance(inventory_forms, list) or not inventory_forms:
              raise SystemExit(f"Inventory surface_forms must be a non-empty list for term: {inventory_term}")
          exact_inventory: list[str] = []
          for value in inventory_forms:
              if not isinstance(value, str):
                  raise SystemExit(f"Inventory surface_forms must contain strings: {inventory_term}")
              if not value or normalize_whitespace(value) != value:
                  raise SystemExit(f"Inventory surface_forms must contain exact values: {inventory_term}")
              if value in exact_inventory:
                  raise SystemExit(f"Inventory surface_forms must not contain duplicates: {inventory_term}")
              exact_inventory.append(value)
          if inventory_term not in exact_inventory:
              raise SystemExit(f"Inventory canonical name must be present in surface_forms: {entry_name}")
          return exact_inventory
      
      
      def _validated_fresh_inventory(
          glossary: dict[str, Any], inventory: dict[str, Any], terms_dir: Path
      ) -> dict[str, dict[str, Any]]:
          external = _inventory_by_term(inventory)
          entries = _entries_by_name(glossary)
          if set(entries) != set(external):
              raise SystemExit("Inventory terms must exactly match glossary entry names.")
          selected = []
          for key, item in external.items():
              entry = entries[key]
              selected.append(
                  {
                      "term": normalize_whitespace(str(entry["name"])),
                      "surface_forms": _bound_forms(entry, item),
                  }
              )
          fresh_items = inspect_selected_terms(selected, terms_dir)
          fresh = _inventory_by_term({"results": fresh_items})
          checked_fields = ("state", "link_stem", "missing_fields")
          for key, external_item in external.items():
              fresh_item = fresh.get(key)
              if fresh_item is None:
                  raise SystemExit(f"Inventory mismatch for term: {external_item['term']}")
              mismatch = any(external_item.get(field) != fresh_item.get(field) for field in checked_fields)
              mismatch = mismatch or not _inventory_files_match(external_item, fresh_item)
              if mismatch:
                  raise SystemExit(f"Inventory mismatch for term: {external_item['term']}")
          return fresh
      
      
      def _entry_patch(entry: dict[str, Any]) -> dict[str, Any]:
          return {
              field: entry[field]
              for field in CONCEPT_FIELDS
              if normalize_whitespace(str(entry.get(field, "")))
          }
      
      
      def _valid_confidence(value: Any) -> bool:
          normalized = normalize_whitespace(str(value))
          return normalized in GLOSSARY_CONFIDENCE_VALUES
      
      
      def _preflight_entries(
          glossary: dict[str, Any], inventory_by_term: dict[str, dict[str, Any]]
      ) -> list[tuple[dict[str, Any], dict[str, Any]]]:
          entries = glossary.get("entries", [])
          if not isinstance(entries, list):
              raise SystemExit("Glossary 'entries' must be a list.")
          planned: list[tuple[dict[str, Any], dict[str, Any]]] = []
          seen: set[str] = set()
          for entry in entries:
              if not isinstance(entry, dict):
                  raise SystemExit("Glossary entries must contain objects.")
              name = normalize_whitespace(str(entry.get("name", "")))
              if not name or name.casefold() in seen:
                  raise SystemExit("Glossary entry names must be unique and non-empty.")
              seen.add(name.casefold())
              inventory_item = inventory_by_term.get(name.casefold())
              if inventory_item is None:
                  raise SystemExit(f"No inventory item found for term: {name}")
              operation = entry.get("operation")
              state = inventory_item.get("state")
              occurrence = normalize_whitespace(str(entry.get("occurrence", "")))
              if not occurrence:
                  raise SystemExit(f"Operation for {name} requires occurrence data.")
              patch = _entry_patch(entry)
              if operation == "create":
                  if state != "new":
                      raise SystemExit(f"Invalid operation create for inventory state {state}: {name}")
                  if not normalize_whitespace(str(entry.get("definition", ""))) or not _valid_confidence(
                      entry.get("confidence", "")
                  ):
                      raise SystemExit(f"Create for {name} requires definition and valid confidence.")
              elif operation == "enrich":
                  if state != "existing_thin":
                      raise SystemExit(f"Invalid operation enrich for inventory state {state}: {name}")
                  missing = set(inventory_item.get("missing_fields", []))
                  if not set(patch).issubset(missing):
                      raise SystemExit(f"Enrich for {name} may only add inventory missing fields.")
                  if "confidence" in missing and not _valid_confidence(entry.get("confidence", "")):
                      raise SystemExit(f"Enrich for {name} requires valid confidence when confidence is missing.")
              elif operation == "reuse":
                  if state != "existing_complete":
                      raise SystemExit(f"Invalid operation reuse for inventory state {state}: {name}")
                  if patch:
                      raise SystemExit(f"reuse for {name} cannot replace concept fields.")
              else:
                  raise SystemExit(f"Invalid operation for {name}: {operation}")
              if state != "new" and not Path(str(inventory_item.get("file", ""))).is_file():
                  raise SystemExit(f"Inventory file is missing for term: {name}")
              planned.append((entry, inventory_item))
          return planned
      
      
      def _contained_existing_path(path_value: Any, terms_dir: Path) -> Path:
          try:
              path = Path(str(path_value)).expanduser().resolve(strict=True)
          except OSError as exc:
              raise SystemExit(f"Unable to resolve glossary target {path_value}: {exc}") from None
          if not path.is_relative_to(terms_dir) or not path.is_file():
              raise SystemExit(f"Glossary target is outside terms directory: {path}")
          return path
      
      
      def _allocate_create_path(terms_dir: Path, stem: str, reserved: set[Path]) -> Path:
          counter = 1
          while True:
              suffix = "" if counter == 1 else f"-{counter}"
              candidate = (terms_dir / f"{stem}{suffix}.md").resolve()
              if not candidate.is_relative_to(terms_dir):
                  raise SystemExit(f"Glossary target is outside terms directory: {candidate}")
              if candidate not in reserved and not candidate.exists():
                  return candidate
              counter += 1
      
      
      def _prepare_write_plans(
          glossary: dict[str, Any],
          inventory: dict[str, Any],
          terms_dir: Path,
          paper_link: str,
      ) -> list[dict[str, Any]]:
          root = terms_dir.expanduser().resolve()
          if root.exists() and not root.is_dir():
              raise SystemExit(f"Terms directory is not a directory: {root}")
          fresh_inventory = _validated_fresh_inventory(glossary, inventory, root)
          entries = _preflight_entries(glossary, fresh_inventory)
          library_index = _library_build_alias_index(root)
          target_owners: dict[Path, str] = {}
          logical_owners: dict[str, str] = {}
          reserved: set[Path] = set()
          plans: list[dict[str, Any]] = []
      
          for entry, inventory_item in entries:
              operation = str(entry["operation"])
              name = normalize_whitespace(str(entry["name"]))
              existing_path = (
                  None
                  if operation == "create"
                  else _contained_existing_path(inventory_item["file"], root)
              )
              collision_forms = _entry_forms(entry, inventory_item)
              for form in collision_forms:
                  key = _normalized_key(form)
                  library_owner = library_index.get(key)
                  if library_owner is not None and (
                      existing_path is None or not library_owner.samefile(existing_path)
                  ):
                      raise SystemExit(
                          f"Glossary alias collision for '{form}' with existing note {library_owner}."
                      )
                  owner = logical_owners.get(key)
                  if owner is not None and owner != name:
                      raise SystemExit(f"Duplicate logical term '{form}' for {owner} and {name}.")
                  logical_owners[key] = name
      
              if operation == "create":
                  path = _allocate_create_path(
                      root, safe_term_filename(str(entry.get("name", ""))), reserved
                  )
                  text = render_term_file(entry, paper_link, path.stem)
                  had_bom = False
                  original_bytes = None
                  identity = None
                  fields_added = [field for field in CONCEPT_FIELDS if field in _entry_patch(entry)]
                  action = "created"
                  occurrence_added = True
                  changed = True
              else:
                  path = existing_path
                  assert path is not None
                  text, had_bom, original_bytes, identity = _read_note_snapshot(path)
                  current_state, current_missing = note_state(text)
                  if (
                      current_state != inventory_item["state"]
                      or current_missing != inventory_item["missing_fields"]
                  ):
                      raise SystemExit(f"Inventory mismatch for term: {name}")
                  enriched, fields_added = add_missing_concept_fields(text, _entry_patch(entry))
                  updated = append_occurrence(enriched, entry, paper_link)
                  occurrence_added = updated != enriched
                  changed = updated != text
                  text = updated
                  if fields_added:
                      action = "enriched"
                  elif occurrence_added:
                      action = "updated"
                  else:
                      action = "unchanged"
      
              owner = target_owners.get(path)
              if owner is not None:
                  raise SystemExit(f"Duplicate target {path} for {owner} and {name}.")
              target_owners[path] = name
              reserved.add(path)
              plans.append(
                  {
                      "path": path,
                      "text": text,
                      "had_bom": had_bom,
                      "operation": operation,
                      "original_bytes": original_bytes,
                      "identity": identity,
                      "changed": changed,
                      "result": {
                          "name": name,
                          "forms": list(inventory_item["surface_forms"]),
                          "file": str(path),
                          "action": action,
                          "link_stem": path.stem,
                          "fields_added": fields_added,
                          "occurrence_added": occurrence_added,
                      },
                  }
              )
          return plans
      
      
      def _encoded_plan_text(plan: dict[str, Any]) -> bytes:
          prefix = b"\xef\xbb\xbf" if plan["had_bom"] else b""
          return prefix + str(plan["text"]).encode("utf-8")
      
      
      def _stage_existing_temp(plan: dict[str, Any]) -> Path:
          path = Path(plan["path"])
          descriptor, temp_name = tempfile.mkstemp(
              dir=path.parent,
              prefix=f".{path.name}.",
              suffix=".tmp",
          )
          temp_path = Path(temp_name)
          try:
              with os.fdopen(descriptor, "wb") as handle:
                  handle.write(_encoded_plan_text(plan))
                  handle.flush()
                  os.fsync(handle.fileno())
              os.chmod(temp_path, path.stat().st_mode)
              return temp_path
          except BaseException:
              temp_path.unlink(missing_ok=True)
              raise
      
      
      def _verify_plans_unchanged(plans: list[dict[str, Any]], terms_dir: Path) -> None:
          for plan in plans:
              path = Path(plan["path"])
              if plan["operation"] == "create":
                  if path.exists() or path.is_symlink():
                      raise SystemExit(f"Create target appeared after planning: {path}")
                  try:
                      parent = path.parent.resolve(strict=True)
                  except OSError as exc:
                      raise SystemExit(f"Unable to resolve create target parent {path.parent}: {exc}") from None
                  if parent != terms_dir or not path.resolve().is_relative_to(terms_dir):
                      raise SystemExit(f"Glossary target is outside terms directory: {path}")
                  continue
      
              try:
                  resolved = path.resolve(strict=True)
                  identity_before = _file_identity(path)
                  raw = path.read_bytes()
                  identity_after = _file_identity(path)
              except OSError as exc:
                  raise SystemExit(f"Glossary note changed after planning: {path}: {exc}") from None
              if (
                  resolved != path
                  or not resolved.is_relative_to(terms_dir)
                  or identity_before != identity_after
                  or identity_after != plan["identity"]
                  or raw != plan["original_bytes"]
              ):
                  raise SystemExit(f"Glossary note changed after planning: {path}")
      
      
      def _close_reservations(reservations: list[tuple[dict[str, Any], int]]) -> None:
          for _, descriptor in reservations:
              try:
                  os.close(descriptor)
              except OSError:
                  pass
      
      
      def _remove_created_paths(plans: list[dict[str, Any]]) -> None:
          for plan in plans:
              Path(plan["path"]).unlink(missing_ok=True)
      
      
      def _commit_write_plans(plans: list[dict[str, Any]], terms_dir: Path) -> None:
          terms_dir.mkdir(parents=True, exist_ok=True)
          try:
              resolved_root = terms_dir.resolve(strict=True)
          except OSError as exc:
              raise SystemExit(f"Unable to resolve terms directory {terms_dir}: {exc}") from None
          if resolved_root != terms_dir or not resolved_root.is_dir():
              raise SystemExit(f"Terms directory changed after planning: {terms_dir}")
      
          staged: list[dict[str, Any]] = []
          reservations: list[tuple[dict[str, Any], int]] = []
          created_plans: list[dict[str, Any]] = []
          try:
              for plan in plans:
                  if plan["operation"] != "create" and plan["changed"]:
                      plan["temp_path"] = _stage_existing_temp(plan)
                      staged.append(plan)
      
              _verify_plans_unchanged(plans, terms_dir)
      
              flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
              if hasattr(os, "O_BINARY"):
                  flags |= os.O_BINARY
              for plan in plans:
                  if plan["operation"] == "create" and plan["changed"]:
                      try:
                          descriptor = os.open(plan["path"], flags, 0o666)
                      except FileExistsError:
                          raise SystemExit(f"Create target appeared during commit: {plan['path']}") from None
                      reservations.append((plan, descriptor))
      
              for plan, descriptor in reservations:
                  with os.fdopen(descriptor, "wb") as handle:
                      handle.write(_encoded_plan_text(plan))
                      handle.flush()
                      os.fsync(handle.fileno())
                  created_plans.append(plan)
              reservations.clear()
      
              for plan in staged:
                  os.replace(plan["temp_path"], plan["path"])
                  plan["temp_path"] = None
          except BaseException:
              reserved_plans = [plan for plan, _ in reservations]
              _close_reservations(reservations)
              _remove_created_paths(created_plans + reserved_plans)
              raise
          finally:
              for plan in staged:
                  temp_path = plan.get("temp_path")
                  if temp_path is not None:
                      Path(temp_path).unlink(missing_ok=True)
      
      
      def write_glossary_entries(
          glossary: dict[str, Any], inventory: dict[str, Any], terms_dir: Path, paper_link: str
      ) -> dict[str, Any]:
          validate_provenance_chain(inventory, inventory.get("results"))
          plans = _prepare_write_plans(glossary, inventory, terms_dir, paper_link)
          planned_mappings = [
              (plan["result"]["name"], plan["result"]["forms"]) for plan in plans
          ]
          inventory_mappings = [
              (item["term"], item["surface_forms"]) for item in inventory["results"]
          ]
          if planned_mappings != inventory_mappings:
              raise SystemExit(
                  "Planned glossary mappings must exactly match the ordered inventory "
                  "term and surface_forms projection."
              )
          _commit_write_plans(plans, terms_dir.expanduser().resolve())
          return {"status": "ok", "results": [plan["result"] for plan in plans]}
      
      
      def _validated_wiki_link(value: str, label: str) -> str:
          if (
              not isinstance(value, str)
              or not value.strip()
              or value != value.strip()
              or any(ord(character) < 32 for character in value)
              or any(character in value for character in "[]|#^")
          ):
              raise SystemExit(f"{label} must be a non-empty wiki-link-safe value.")
          return value
      
      
      def _writer_context(
          paper_id: str, article_value: str, config: dict[str, str]
      ) -> dict[str, str]:
          if article_value:
              article = validate_article(Path(article_value), config)["article_path"]
              paper_link = Path(article).stem
          else:
              article = ""
              paper_link = paper_id
          return {
              "paper_id": paper_id,
              "paper_link": _validated_wiki_link(paper_link, "Derived backlink"),
              "article_path": article,
          }
      
      
      def main() -> None:
          started = perf_counter()
          args = parser().parse_args()
          config = load_config(Path(args.config_path) if args.config_path else None)
          if config is None:
              raise SystemExit("Paper-glossary configuration is missing; configure it first.")
          terms_dir = resolve_terms_dir(config)
          inventory = maybe_load_json_record(args.inventory)
          if inventory is None:
              raise SystemExit(f"Expected JSON object or JSON file path for --inventory: {args.inventory}")
          current = validate_authorized_selection(
              inventory,
              inventory.get("results"),
              args.triage,
              args.source_manifest,
              args.raw_sections,
              args.reviewed_shortlist,
          )
          context = _writer_context(current["paper_id"], args.article, config)
          glossary = maybe_load_json_record(args.glossary)
          if glossary is None:
              raise SystemExit(f"Expected JSON object or JSON file path for --glossary: {args.glossary}")
      
          result = write_glossary_entries(
              glossary, inventory, terms_dir, context["paper_link"]
          )
          mappings_digest = writer_mappings_sha256(
              current["provenance"],
              result["results"],
              context,
              current["triage_sha256"],
          )
          emit({
              **result,
              "script": "write_glossary_terms.py",
              "paper_id": current["paper_id"],
              "provenance": current["provenance"],
              "context": context,
              "triage_sha256": current["triage_sha256"],
              "mappings_sha256": mappings_digest,
              "terms_dir": str(terms_dir),
              "paper_link": context["paper_link"],
              "article_path": context["article_path"],
              "links": [f"[[{item['link_stem']}]]" for item in result["results"]],
              "elapsed_ms": elapsed_ms(started),
          }, args.output)
      
      
      if __name__ == "__main__":
          main()
      
  • tests
    • conftest.py 5.3 KB
      from __future__ import annotations
      
      from copy import deepcopy
      import json
      import sys
      from pathlib import Path
      from typing import Any
      
      SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts"
      sys.path.insert(0, str(SCRIPTS_DIR))
      
      from glossary_common import canonical_sha256, selection_sha256
      from glossary_library import inspect_selected_terms
      from plan_glossary import _proposal_artifact, _review_artifact, _triage_candidates
      
      
      def build_triage_payload(
          terms: list[dict[str, Any]], *, paper_id: str = "paper-fixture"
      ) -> dict[str, Any]:
          candidates = deepcopy(terms)
          summary = {
              "effective_body_characters": 100,
              "shortlist_limit": max(1, len(candidates)),
              "pool_candidates": len(candidates),
          }
          source_sha256 = canonical_sha256(
              {"paper_id": paper_id, "fixture_source": "paper-glossary tests"}
          )
          proposal = {
              "paper_id": paper_id,
              "source_sha256": source_sha256,
              "candidates": candidates,
              "summary": summary,
          }
          proposal["proposal_sha256"] = canonical_sha256(proposal)
          review = {
              **deepcopy(proposal),
              "reviewed_shortlist": deepcopy(candidates),
          }
          review["review_sha256"] = canonical_sha256(
              {
                  "paper_id": paper_id,
                  "proposal_provenance": proposal,
                  "reviewed_shortlist": review["reviewed_shortlist"],
              }
          )
          return {
              "mode": "triage",
              "paper_id": paper_id,
              "provenance": {
                  "proposal": proposal,
                  "review": review,
                  "selection_sha256": selection_sha256(review["review_sha256"], terms),
              },
              "terms": deepcopy(terms),
          }
      
      
      def build_inventory_payload(
          terms: list[dict[str, Any]], terms_dir: Path, *, paper_id: str = "paper-fixture"
      ) -> dict[str, Any]:
          return bind_inventory_results(
              inspect_selected_terms(terms, terms_dir), paper_id=paper_id
          )
      
      
      def bind_inventory_results(
          results: list[dict[str, Any]], *, paper_id: str = "paper-fixture"
      ) -> dict[str, Any]:
          exact_results = deepcopy(results)
          terms = [
              {"term": item.get("term"), "surface_forms": item.get("surface_forms")}
              for item in exact_results
          ]
          triage = build_triage_payload(terms, paper_id=paper_id)
          return {
              "status": "ok",
              "script": "inspect_glossary_library.py",
              "paper_id": paper_id,
              "provenance": triage["provenance"],
              "results": exact_results,
          }
      
      
      def write_current_workflow(
          root: Path,
          reviewed_names: list[str],
          *,
          selected_names: list[str] | None = None,
          paper_id: str = "paper-fixture",
          source_text: str | None = None,
      ) -> dict[str, Any]:
          root.mkdir(parents=True, exist_ok=True)
          selected_names = reviewed_names if selected_names is None else selected_names
          text = source_text or " ".join(
              f"**{name}** is a paper concept." for name in reviewed_names
          )
          records = [
              {
                  "record_type": "section",
                  "section_id": "sec:fixture",
                  "kind": "body",
                  "title": "Fixture",
                  "text": text,
              }
          ]
          raw_path = root / "paper_raw_sections.jsonl"
          raw_path.write_text(
              "\n".join(json.dumps(record, ensure_ascii=False) for record in records) + "\n",
              encoding="utf-8",
          )
          manifest = {"paper_id": paper_id, "raw_sections_path": str(raw_path.resolve())}
          manifest_path = root / "paper_source_manifest.json"
          manifest_path.write_text(
              json.dumps(manifest, ensure_ascii=False), encoding="utf-8"
          )
      
          proposal = _proposal_artifact(manifest, records)
          review = _review_artifact(proposal, reviewed_names)
          reviewed_by_name = {
              candidate["term"]: candidate for candidate in review["reviewed_shortlist"]
          }
          selected_candidates = [reviewed_by_name[name] for name in selected_names]
          triaged = _triage_candidates(selected_candidates, records)
          triage = {
              "status": "ok",
              "script": "plan_glossary.py",
              "mode": "triage",
              "paper_id": paper_id,
              "terms": triaged,
              "provenance": {
                  "proposal": proposal["provenance"],
                  "review": review["provenance"],
                  "selection_sha256": selection_sha256(
                      review["provenance"]["review_sha256"], triaged
                  ),
              },
          }
          proposal_path = root / "proposal.json"
          review_path = root / "reviewed.json"
          triage_path = root / "triage.json"
          for path, payload in (
              (proposal_path, proposal),
              (review_path, review),
              (triage_path, triage),
          ):
              path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
          return {
              "manifest": manifest,
              "manifest_path": manifest_path,
              "raw_path": raw_path,
              "proposal": proposal,
              "proposal_path": proposal_path,
              "review": review,
              "review_path": review_path,
              "triage": triage,
              "triage_path": triage_path,
          }
      
      
      def build_current_inventory(workflow: dict[str, Any], terms_dir: Path) -> dict[str, Any]:
          triage = workflow["triage"]
          return {
              "status": "ok",
              "script": "inspect_glossary_library.py",
              "paper_id": triage["paper_id"],
              "provenance": deepcopy(triage["provenance"]),
              "results": inspect_selected_terms(triage["terms"], terms_dir),
          }
      
    • test_glossary_config.py 12.1 KB
      from __future__ import annotations
      
      import hashlib
      import json
      import sys
      from pathlib import Path
      
      import pytest
      
      from glossary_config import (
          configure_terms_dir,
          find_vault_root,
          load_config,
          resolve_terms_dir,
          validate_article,
      )
      
      
      INVALID_CONFIGURE_DESTINATIONS = (
          "vault-root",
          "normalized-root",
          "blank-child",
          "blank-component",
      )
      
      
      def _invalid_terms_dir(vault: Path, case: str) -> Path:
          if case == "vault-root":
              return vault
          if case == "normalized-root":
              return vault / "not-created" / ".."
          if case == "blank-child":
              return vault / "   "
          return vault / "   " / "Glossary"
      
      
      def _tree_snapshot(root: Path) -> list[str]:
          return sorted(str(path.relative_to(root)) for path in root.rglob("*"))
      
      
      def test_configure_terms_dir_persists_nearest_vault_and_relative_subdir(
          tmp_path: Path,
      ) -> None:
          outer = tmp_path / "outer"
          inner = outer / "research"
          (outer / ".obsidian").mkdir(parents=True)
          (inner / ".obsidian").mkdir(parents=True)
          terms_dir = inner / "book" / "术语"
          config_path = tmp_path / "device" / "config.json"
      
          payload = configure_terms_dir(terms_dir, config_path)
      
          assert payload == {
              "vault_root": str(inner.resolve()),
              "terms_subdir": str(Path("book") / "术语"),
          }
          assert terms_dir.is_dir()
          assert load_config(config_path) == payload
          assert resolve_terms_dir(payload) == terms_dir.resolve()
      
      
      def test_configure_terms_dir_creates_a_missing_child_of_existing_vault(
          tmp_path: Path,
      ) -> None:
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          terms_dir = vault / "new" / "nested" / "术语"
      
          payload = configure_terms_dir(terms_dir, tmp_path / "config.json")
      
          assert terms_dir.is_dir()
          assert payload["vault_root"] == str(vault.resolve())
          assert payload["terms_subdir"] == str(Path("new") / "nested" / "术语")
      
      
      def test_configure_terms_dir_reuses_an_existing_directory(tmp_path: Path) -> None:
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          terms_dir = vault / "existing-terms"
          terms_dir.mkdir()
          config_path = tmp_path / "config.json"
      
          payload = configure_terms_dir(terms_dir, config_path)
      
          assert load_config(config_path) == payload
          assert resolve_terms_dir(payload) == terms_dir.resolve()
      
      
      def test_find_vault_root_returns_none_for_non_vault_path(tmp_path: Path) -> None:
          assert find_vault_root(tmp_path / "outside") is None
      
      
      def test_configure_terms_dir_rejects_non_vault_path(tmp_path: Path) -> None:
          with pytest.raises(SystemExit, match="inside an Obsidian vault"):
              configure_terms_dir(tmp_path / "outside" / "术语", tmp_path / "config.json")
      
      
      @pytest.mark.parametrize("case", INVALID_CONFIGURE_DESTINATIONS)
      @pytest.mark.parametrize("existing_config", [False, True], ids=["absent", "existing"])
      def test_configure_terms_dir_rejects_invalid_child_without_config_mutation(
          tmp_path: Path, case: str, existing_config: bool
      ) -> None:
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          config_path = tmp_path / "device" / "config.json"
          original = b'{"sentinel":"keep"}\r\n'
          if existing_config:
              config_path.parent.mkdir()
              config_path.write_bytes(original)
          before = _tree_snapshot(vault)
      
          with pytest.raises(SystemExit, match="real child"):
              configure_terms_dir(_invalid_terms_dir(vault, case), config_path)
      
          assert _tree_snapshot(vault) == before
          if existing_config:
              assert config_path.read_bytes() == original
          else:
              assert not config_path.exists()
              assert not config_path.parent.exists()
      
      
      @pytest.mark.parametrize("case", INVALID_CONFIGURE_DESTINATIONS)
      @pytest.mark.parametrize("existing_config", [False, True], ids=["absent", "existing"])
      def test_configure_cli_rejects_invalid_child_without_config_mutation(
          tmp_path: Path,
          monkeypatch: pytest.MonkeyPatch,
          case: str,
          existing_config: bool,
      ) -> None:
          from configure_glossary import main
      
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          config_path = tmp_path / "device" / "config.json"
          original = b'{"sentinel":"keep"}\r\n'
          if existing_config:
              config_path.parent.mkdir()
              config_path.write_bytes(original)
          before = _tree_snapshot(vault)
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "configure_glossary.py",
                  "--terms-dir",
                  str(_invalid_terms_dir(vault, case)),
                  "--config-path",
                  str(config_path),
              ],
          )
      
          with pytest.raises(SystemExit, match="real child"):
              main()
      
          assert _tree_snapshot(vault) == before
          if existing_config:
              assert config_path.read_bytes() == original
          else:
              assert not config_path.exists()
              assert not config_path.parent.exists()
      
      
      def test_load_config_rejects_blank_subdirectory_component(tmp_path: Path) -> None:
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          config_path = tmp_path / "config.json"
          config_path.write_text(
              json.dumps(
                  {
                      "vault_root": str(vault),
                      "terms_subdir": str(Path("   ") / "Glossary"),
                  }
              ),
              encoding="utf-8",
          )
      
          with pytest.raises(SystemExit, match="Invalid paper-glossary config object"):
              load_config(config_path)
      
      
      @pytest.mark.parametrize("vault_root", [".", "C:drive-relative"])
      def test_load_config_rejects_relative_and_drive_relative_vault_roots(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch, vault_root: str
      ) -> None:
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          (vault / "Glossary").mkdir()
          config_path = tmp_path / "config.json"
          config_path.write_text(
              json.dumps({"vault_root": vault_root, "terms_subdir": "Glossary"}),
              encoding="utf-8",
          )
          monkeypatch.chdir(vault)
      
          with pytest.raises(SystemExit, match="Invalid paper-glossary config object"):
              load_config(config_path)
      
      
      def test_load_config_returns_canonical_absolute_vault_root(tmp_path: Path) -> None:
          vault = tmp_path / "vault"
          config_path = tmp_path / "config.json"
          config_path.write_text(
              json.dumps(
                  {
                      "vault_root": str(tmp_path / "nested" / ".." / "vault"),
                      "terms_subdir": "Glossary",
                  }
              ),
              encoding="utf-8",
          )
      
          loaded = load_config(config_path)
      
          assert loaded is not None
          assert loaded["vault_root"] == str(vault.resolve())
      
      
      def test_validate_article_returns_path_and_hash_for_same_vault(tmp_path: Path) -> None:
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          terms_dir = vault / "术语"
          article = vault / "papers" / "Paper.md"
          article.parent.mkdir()
          article.write_text("# Paper\n", encoding="utf-8")
          config = configure_terms_dir(terms_dir, tmp_path / "config.json")
      
          result = validate_article(article, config)
      
          assert result == {
              "article_path": str(article.resolve()),
              "article_sha256": hashlib.sha256(article.read_bytes()).hexdigest(),
          }
      
      
      def test_validate_article_rejects_other_vault(tmp_path: Path) -> None:
          first = tmp_path / "first"
          second = tmp_path / "second"
          (first / ".obsidian").mkdir(parents=True)
          (second / ".obsidian").mkdir(parents=True)
          article = second / "Paper.md"
          article.write_text("# Paper\n", encoding="utf-8")
          config = configure_terms_dir(first / "术语", tmp_path / "config.json")
      
          with pytest.raises(SystemExit, match="same Obsidian vault"):
              validate_article(article, config)
      
      
      def test_load_config_returns_none_before_first_use(tmp_path: Path) -> None:
          assert load_config(tmp_path / "missing.json") is None
      
      
      def test_resolve_terms_dir_rejects_missing_configured_paths(tmp_path: Path) -> None:
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
      
          with pytest.raises(SystemExit, match="missing or invalid"):
              resolve_terms_dir(
                  {"vault_root": str(vault), "terms_subdir": "missing"}
              )
      
      
      def test_resolve_terms_dir_rejects_new_nearest_nested_vault(tmp_path: Path) -> None:
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          terms_dir = vault / "research" / "terms"
          config = configure_terms_dir(terms_dir, tmp_path / "config.json")
          (terms_dir.parent / ".obsidian").mkdir()
      
          with pytest.raises(SystemExit, match="missing or invalid"):
              resolve_terms_dir(config)
      
      
      def test_configure_cli_show_reset_and_validate(tmp_path: Path, monkeypatch, capsys) -> None:
          from configure_glossary import main
      
          config_path = tmp_path / "device" / "config.json"
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          terms_dir = vault / "术语"
          article = vault / "Paper.md"
          article.write_text("# Paper\n", encoding="utf-8")
      
          monkeypatch.setattr(
              sys,
              "argv",
              ["configure_glossary.py", "--show", "--config-path", str(config_path)],
          )
          main()
          show_result = json.loads(capsys.readouterr().out)
          assert isinstance(show_result.pop("elapsed_ms"), int)
          assert show_result == {
              "workflow_state": "needs_configuration"
          }
      
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "configure_glossary.py",
                  "--terms-dir",
                  str(terms_dir),
                  "--config-path",
                  str(config_path),
              ],
          )
          main()
          configure_result = json.loads(capsys.readouterr().out)
          assert configure_result.pop("elapsed_ms") >= 0
          assert configure_result == {
              "vault_root": str(vault.resolve()),
              "terms_subdir": str(Path("术语")),
          }
      
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "configure_glossary.py",
                  "--validate-article",
                  str(article),
                  "--config-path",
                  str(config_path),
              ],
          )
          main()
          validation = json.loads(capsys.readouterr().out)
          assert validation.pop("elapsed_ms") >= 0
          assert validation == {
              "terms_dir": str(terms_dir.resolve()),
              "article_path": str(article.resolve()),
              "article_sha256": hashlib.sha256(article.read_bytes()).hexdigest(),
          }
      
          monkeypatch.setattr(
              sys,
              "argv",
              ["configure_glossary.py", "--reset", "--config-path", str(config_path)],
          )
          main()
          reset_result = json.loads(capsys.readouterr().out)
          assert reset_result.pop("elapsed_ms") >= 0
          assert reset_result == {"workflow_state": "reset"}
          assert not config_path.exists()
          assert terms_dir.is_dir()
          assert (vault / ".obsidian").is_dir()
      
      
      def test_configure_cli_output_file_modes_emit_elapsed_ms(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          from configure_glossary import main
      
          config_path = tmp_path / "device" / "config.json"
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          terms_dir = vault / "terms"
          article = vault / "Paper.md"
          article.write_text("# Paper\n", encoding="utf-8")
      
          def run_mode(name: str, *arguments: str) -> dict:
              output = tmp_path / f"{name}.json"
              monkeypatch.setattr(
                  sys,
                  "argv",
                  [
                      "configure_glossary.py",
                      *arguments,
                      "--config-path",
                      str(config_path),
                      "--output",
                      str(output),
                  ],
              )
              main()
              payload = json.loads(output.read_text(encoding="utf-8"))
              assert isinstance(payload["elapsed_ms"], int)
              assert payload["elapsed_ms"] >= 0
              return payload
      
          show_result = run_mode("show", "--show")
          assert show_result["workflow_state"] == "needs_configuration"
      
          configure_result = run_mode("configure", "--terms-dir", str(terms_dir))
          assert configure_result["vault_root"] == str(vault.resolve())
      
          validation = run_mode("validate", "--validate-article", str(article))
          assert validation["article_sha256"] == hashlib.sha256(article.read_bytes()).hexdigest()
      
          reset_result = run_mode("reset", "--reset")
          assert reset_result["workflow_state"] == "reset"
          assert not config_path.exists()
      
    • test_glossary_library.py 19.1 KB
      from __future__ import annotations
      
      from copy import deepcopy
      import json
      import sys
      from pathlib import Path
      
      import pytest
      
      from glossary_contracts import (
          GLOSSARY_CONCEPT_HEADING,
          GLOSSARY_CONFIDENCE_VALUES,
          GLOSSARY_LABEL_CONFIDENCE,
          GLOSSARY_LABEL_DEFINITION,
          GLOSSARY_OCCURRENCE_HEADING,
      )
      from glossary_library import (
          add_missing_concept_fields,
          build_alias_index,
          inspect_selected_terms,
          missing_concept_fields,
          read_frontmatter_aliases,
      )
      from inspect_glossary_library import main as inspect_main
      from conftest import build_triage_payload, write_current_workflow
      
      
      def _triage_payload(terms: list[dict]) -> dict:
          return build_triage_payload(terms)
      
      
      def _current_flags(workflow: dict) -> list[str]:
          return [
              "--source-manifest",
              str(workflow["manifest_path"]),
              "--reviewed-shortlist",
              str(workflow["review_path"]),
          ]
      
      
      def _note(*, name: str, fields: dict[str, str], aliases: list[str] | None = None) -> str:
          aliases = aliases or []
          lines = ["---", "aliases:"]
          lines.extend(f'  - "{alias}"' for alias in aliases)
          lines.extend(["---", "", f"# {name}", "", f"## {GLOSSARY_CONCEPT_HEADING}"])
          labels = {
              "definition": GLOSSARY_LABEL_DEFINITION,
              "elaboration": "\u8be6\u89e3\uff1a",
              "intuition": "\u76f4\u89c9\uff1a",
              "distinction": "\u4e0e\u76f8\u90bb\u6982\u5ff5\u7684\u533a\u522b\uff1a",
              "confidence": GLOSSARY_LABEL_CONFIDENCE,
          }
          lines.extend(f"- {labels[field]}{value}" for field, value in fields.items())
          lines.extend(["", f"## {GLOSSARY_OCCURRENCE_HEADING}", "- [[OldPaper]]: old evidence", ""])
          return "\n".join(lines)
      
      
      def test_inventory_classifies_alias_heading_and_new_notes(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          (terms_dir / "LLM.md").write_text(
              _note(
                  name="LLM",
                  aliases=["large language model"],
                  fields={
                      "definition": "definition",
                      "confidence": GLOSSARY_CONFIDENCE_VALUES[1],
                  },
              ),
              encoding="utf-8",
          )
          (terms_dir / "ReAct.md").write_text(
              _note(
                  name="ReAct",
                  fields={
                      "definition": "definition",
                      "elaboration": "elaboration",
                      "intuition": "intuition",
                      "confidence": GLOSSARY_CONFIDENCE_VALUES[0],
                  },
              ),
              encoding="utf-8",
          )
      
          results = inspect_selected_terms(
              [
                  {"term": "large language model", "surface_forms": ["LLM", "large language model"]},
                  {"term": "ReAct", "surface_forms": ["ReAct"]},
                  {"term": "Reflection", "surface_forms": ["Reflection"]},
              ],
              terms_dir,
          )
      
          by_term = {item["term"]: item for item in results}
          assert by_term["large language model"]["state"] == "existing_thin"
          assert by_term["large language model"]["file"] == str(terms_dir / "LLM.md")
          assert by_term["large language model"]["link_stem"] == "LLM"
          assert by_term["large language model"]["missing_fields"] == [
              "elaboration",
              "intuition",
              "distinction",
          ]
          assert by_term["ReAct"]["state"] == "existing_complete"
          assert by_term["Reflection"] == {
              "term": "Reflection",
              "surface_forms": ["Reflection"],
              "state": "new",
              "file": "",
              "link_stem": "Reflection",
              "missing_fields": [],
          }
      
      
      def test_add_missing_concept_fields_preserves_existing_content_and_is_idempotent() -> None:
          original = _note(
              name="LLM",
              fields={
                  "definition": "user definition",
                  "confidence": GLOSSARY_CONFIDENCE_VALUES[1],
              },
          ).replace("- [[OldPaper]]: old evidence\n", "## Custom\nkeep this text\n")
      
          updated, fields_added = add_missing_concept_fields(
              original,
              {"elaboration": "new explanation", "intuition": "new intuition"},
          )
      
          assert fields_added == ["elaboration", "intuition"]
          assert "user definition" in updated
          assert "## Custom\nkeep this text\n" in updated
          assert updated.count("- \u8be6\u89e3\uff1anew explanation") == 1
          assert updated.count("- \u76f4\u89c9\uff1anew intuition") == 1
          assert updated.index("- \u8be6\u89e3\uff1anew explanation") < updated.index(
              f"- {GLOSSARY_LABEL_CONFIDENCE}{GLOSSARY_CONFIDENCE_VALUES[1]}"
          )
          repeated, repeated_fields = add_missing_concept_fields(
              updated,
              {"elaboration": "ignored", "intuition": "ignored"},
          )
          assert repeated == updated
          assert repeated_fields == []
      
      
      def test_add_missing_concept_fields_appends_missing_concept_section() -> None:
          original = "# Existing\n\n## Custom\nkeep this text\n"
      
          updated, fields_added = add_missing_concept_fields(
              original,
              {
                  "definition": "definition",
                  "confidence": GLOSSARY_CONFIDENCE_VALUES[0],
              },
          )
      
          assert updated.startswith(original)
          assert fields_added == ["definition", "confidence"]
          assert f"## {GLOSSARY_CONCEPT_HEADING}" in updated
          assert missing_concept_fields(updated) == ["elaboration", "intuition", "distinction"]
      
      
      def test_inspect_cli_emits_inventory_contract(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          workflow = write_current_workflow(tmp_path / "workflow", ["Reflection"])
          selected = workflow["triage_path"]
          output = tmp_path / "inventory.json"
          triage = workflow["triage"]
          monkeypatch.setattr(
              "sys.argv",
              [
                  "inspect_glossary_library.py",
                  "--terms",
                  str(selected),
                  "--terms-dir",
                  str(terms_dir),
                  *_current_flags(workflow),
                  "--output",
                  str(output),
              ],
          )
      
          inspect_main()
      
          payload = json.loads(output.read_text(encoding="utf-8"))
          assert isinstance(payload.pop("elapsed_ms"), int)
          assert payload == {
              "status": "ok",
              "script": "inspect_glossary_library.py",
              "paper_id": triage["paper_id"],
              "provenance": triage["provenance"],
              "results": [
                  {
                      "term": "Reflection",
                      "surface_forms": ["Reflection"],
                      "state": "new",
                      "file": "",
                      "link_stem": "Reflection",
                      "missing_fields": [],
                  }
              ],
          }
      
      
      def test_inspect_cli_preserves_selection_provenance_and_exact_surface_forms(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          workflow = write_current_workflow(
              tmp_path / "workflow",
              ["ReAct"],
              source_text="**ReAct** and **ReAct** are equivalent forms.",
          )
          triage = workflow["triage_path"]
          triage_payload = workflow["triage"]
          provenance = triage_payload["provenance"]
          output = tmp_path / "inventory.json"
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "inspect_glossary_library.py",
                  "--terms",
                  str(triage),
                  "--terms-dir",
                  str(terms_dir),
                  *_current_flags(workflow),
                  "--output",
                  str(output),
              ],
          )
      
          inspect_main()
      
          payload = json.loads(output.read_text(encoding="utf-8"))
          assert payload["provenance"] == provenance
          assert payload["results"][0]["surface_forms"] == ["ReAct", "ReAct"]
      
      
      def test_inspect_cli_rejects_missing_selection_provenance(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          workflow = write_current_workflow(tmp_path / "workflow", ["ReAct"])
          selected = tmp_path / "selected.json"
          selected.write_text(
              json.dumps({"selected": [{"term": "ReAct", "surface_forms": ["ReAct"]}]}),
              encoding="utf-8",
          )
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "inspect_glossary_library.py",
                  "--terms",
                  str(selected),
                  "--terms-dir",
                  str(terms_dir),
                  *_current_flags(workflow),
              ],
          )
      
          with pytest.raises(SystemExit, match="triage|provenance"):
              inspect_main()
      
      
      def test_inspect_cli_rejects_surface_forms_changed_after_triage(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          workflow = write_current_workflow(
              tmp_path / "workflow",
              ["ReAct"],
              source_text="**ReAct** and **ReAct** are equivalent forms.",
          )
          selected = tmp_path / "triage.json"
          triage = deepcopy(workflow["triage"])
          triage["terms"][0]["surface_forms"].append("invented alias")
          selected.write_text(json.dumps(triage, ensure_ascii=False), encoding="utf-8")
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "inspect_glossary_library.py",
                  "--terms",
                  str(selected),
                  "--terms-dir",
                  str(terms_dir),
                  *_current_flags(workflow),
              ],
          )
      
          with pytest.raises(SystemExit, match="selection|provenance|surface_forms"):
              inspect_main()
      
      
      def test_inventory_cli_rejects_source_changed_after_triage_before_inspection(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          workflow = write_current_workflow(tmp_path / "workflow", ["ReAct"])
          workflow["raw_path"].write_text(
              workflow["raw_path"].read_text(encoding="utf-8")
              + json.dumps(
                  {
                      "record_type": "section",
                      "section_id": "sec:changed",
                      "kind": "body",
                      "title": "Changed",
                      "text": "Reflection was added after triage.",
                  }
              )
              + "\n",
              encoding="utf-8",
          )
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          sentinel = terms_dir / "Sentinel.md"
          sentinel.write_bytes(b"# Sentinel\n")
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "inspect_glossary_library.py",
                  "--terms",
                  str(workflow["triage_path"]),
                  "--terms-dir",
                  str(terms_dir),
                  "--source-manifest",
                  str(workflow["manifest_path"]),
                  "--reviewed-shortlist",
                  str(workflow["review_path"]),
              ],
          )
      
          with pytest.raises(SystemExit, match="current|source|review|proposal"):
              inspect_main()
      
          assert sentinel.read_bytes() == b"# Sentinel\n"
      
      
      def test_inventory_cli_rejects_fully_recomputed_forged_chain(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          workflow = write_current_workflow(tmp_path / "workflow", ["ReAct"])
          forged = tmp_path / "forged-triage.json"
          forged.write_text(
              json.dumps(
                  build_triage_payload(
                      [{"term": "Reflection", "surface_forms": ["Reflection"]}],
                      paper_id="other-paper",
                  )
              ),
              encoding="utf-8",
          )
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "inspect_glossary_library.py",
                  "--terms",
                  str(forged),
                  "--terms-dir",
                  str(terms_dir),
                  "--source-manifest",
                  str(workflow["manifest_path"]),
                  "--reviewed-shortlist",
                  str(workflow["review_path"]),
              ],
          )
      
          with pytest.raises(SystemExit, match="current|source|review|proposal|paper"):
              inspect_main()
      
          assert list(terms_dir.iterdir()) == []
      
      
      @pytest.mark.parametrize("field", ["paper_id", "source_sha256", "proposal_sha256"])
      def test_inventory_rejects_mismatched_proposal_review_common_fields(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch, field: str
      ) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          workflow = write_current_workflow(tmp_path / "workflow", ["ReAct"])
          triage = deepcopy(workflow["triage"])
          triage["provenance"]["review"][field] = (
              "other-paper" if field == "paper_id" else "0" * 64
          )
          selected = tmp_path / "triage.json"
          selected.write_text(json.dumps(triage), encoding="utf-8")
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "inspect_glossary_library.py",
                  "--terms",
                  str(selected),
                  "--terms-dir",
                  str(terms_dir),
                  *_current_flags(workflow),
              ],
          )
      
          with pytest.raises(SystemExit, match="proposal|review|provenance|paper|source"):
              inspect_main()
      
      
      @pytest.mark.parametrize("stage", ["proposal", "review"])
      def test_inventory_rejects_digest_not_bound_to_embedded_material(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch, stage: str
      ) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          workflow = write_current_workflow(tmp_path / "workflow", ["ReAct"])
          triage = deepcopy(workflow["triage"])
          if stage == "proposal":
              triage["provenance"]["proposal"]["summary"]["pool_candidates"] = 2
          else:
              triage["provenance"]["review"]["reviewed_shortlist"].append(
                  {"term": "Reflection", "surface_forms": ["Reflection"]}
              )
          selected = tmp_path / "triage.json"
          selected.write_text(json.dumps(triage), encoding="utf-8")
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "inspect_glossary_library.py",
                  "--terms",
                  str(selected),
                  "--terms-dir",
                  str(terms_dir),
                  *_current_flags(workflow),
              ],
          )
      
          with pytest.raises(SystemExit, match="proposal|review|digest|provenance"):
              inspect_main()
      
      
      @pytest.mark.parametrize("change", ["term", "append_form", "reorder_forms"])
      def test_inventory_rejects_selected_term_or_forms_absent_from_reviewed_shortlist(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch, change: str
      ) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          workflow = write_current_workflow(
              tmp_path / "workflow",
              ["ReAct"],
              source_text="**ReAct** and **\uff32\uff45\uff21\uff43\uff54** are equivalent forms.",
          )
          triage = deepcopy(workflow["triage"])
          if change == "term":
              triage["terms"][0]["term"] = "Reflection"
              triage["terms"][0]["surface_forms"] = ["Reflection"]
          elif change == "append_form":
              triage["terms"][0]["surface_forms"].append("invented")
          else:
              triage["terms"][0]["surface_forms"].reverse()
          review_sha = triage["provenance"]["review"]["review_sha256"]
          from glossary_common import selection_sha256
      
          triage["provenance"]["selection_sha256"] = selection_sha256(
              review_sha, triage["terms"]
          )
          selected = tmp_path / "triage.json"
          selected.write_text(json.dumps(triage, ensure_ascii=False), encoding="utf-8")
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "inspect_glossary_library.py",
                  "--terms",
                  str(selected),
                  "--terms-dir",
                  str(terms_dir),
                  *_current_flags(workflow),
              ],
          )
      
          with pytest.raises(SystemExit, match="reviewed|shortlist|surface_forms|provenance"):
              inspect_main()
      
      
      @pytest.mark.parametrize(
          "selected",
          [
              [{"term": "ReAct"}],
              [{"term": "ReAct", "surface_forms": "ReAct"}],
              [{"term": "ReAct", "surface_forms": ["ReAct", 7]}],
              [{"term": "ReAct", "surface_forms": [" ReAct"]}],
              [{"term": "ReAct", "surface_forms": ["ReAct"]}],
          ],
      )
      def test_inventory_rejects_malformed_or_altered_surface_forms(
          tmp_path: Path, selected: list[dict]
      ) -> None:
          with pytest.raises(SystemExit, match="surface_forms"):
              inspect_selected_terms(selected, tmp_path / "terms")
      
      
      def test_inventory_rejects_unreadable_existing_note(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          (terms_dir / "ReAct.md").write_bytes(b"\xff\xfe\x00")
      
          with pytest.raises(SystemExit, match="Unable to read glossary note.*ReAct.md"):
              inspect_selected_terms(
                  [{"term": "ReAct", "surface_forms": ["ReAct"]}],
                  terms_dir,
              )
      
      
      def test_inventory_rejects_unclosed_frontmatter(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          (terms_dir / "ReAct.md").write_text(
              "---\naliases:\n  - ReAct\n# ReAct\n",
              encoding="utf-8",
          )
      
          with pytest.raises(SystemExit, match="Unclosed frontmatter.*ReAct.md"):
              build_alias_index(terms_dir)
      
      
      def test_inventory_rejects_malformed_quoted_alias(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          (terms_dir / "ReAct.md").write_text(
              "---\naliases:\n  - \"unterminated\n---\n# ReAct\n",
              encoding="utf-8",
          )
      
          with pytest.raises(SystemExit, match="Malformed frontmatter scalar"):
              build_alias_index(terms_dir)
      
      
      def test_inventory_rejects_normalized_alias_collision(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          (terms_dir / "Alpha.md").write_text(
              _note(name="Alpha", aliases=["Shared   Alias"], fields={}),
              encoding="utf-8",
          )
          (terms_dir / "Beta.md").write_text(
              _note(name="Beta", aliases=["shared alias"], fields={}),
              encoding="utf-8",
          )
      
          with pytest.raises(SystemExit, match="Glossary key collision.*shared alias"):
              build_alias_index(terms_dir)
      
      
      def test_blank_field_is_missing_when_followed_by_another_label() -> None:
          text = (
              f"# Empty\n\n## {GLOSSARY_CONCEPT_HEADING}\n"
              f"- {GLOSSARY_LABEL_DEFINITION}\n"
              f"- {GLOSSARY_LABEL_CONFIDENCE}{GLOSSARY_CONFIDENCE_VALUES[0]}\n"
          )
      
          assert "definition" in missing_concept_fields(text)
      
      
      def test_frontmatter_alias_subset_supports_quoted_and_unquoted_scalars() -> None:
          text = (
              "---\n"
              "aliases: [Plain, \"Double, alias\", 'Single ''quoted'' alias']\n"
              "---\n"
              "# Term\n"
          )
      
          assert read_frontmatter_aliases(text) == [
              "Plain",
              "Double, alias",
              "Single 'quoted' alias",
          ]
      
      
      def test_inventory_rejects_unclosed_single_quoted_alias(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          (terms_dir / "ReAct.md").write_text(
              "---\naliases:\n  - 'unterminated\n---\n# ReAct\n",
              encoding="utf-8",
          )
      
          with pytest.raises(SystemExit, match="Malformed frontmatter scalar"):
              build_alias_index(terms_dir)
      
      
      def test_inventory_rejects_scalar_alias_structure(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          (terms_dir / "ReAct.md").write_text(
              "---\naliases: ScalarAlias\n---\n# ReAct\n",
              encoding="utf-8",
          )
      
          with pytest.raises(SystemExit, match="Unsupported aliases metadata"):
              build_alias_index(terms_dir)
      
      
      def test_inventory_rejects_one_item_matching_multiple_files(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          (terms_dir / "Alpha.md").write_text("# Alpha\n", encoding="utf-8")
          (terms_dir / "Beta.md").write_text("# Beta\n", encoding="utf-8")
      
          with pytest.raises(SystemExit, match="Selected forms resolve to multiple glossary notes"):
              inspect_selected_terms(
                  [{"term": "Alpha", "surface_forms": ["Alpha", "Beta"]}],
                  terms_dir,
              )
      
    • test_link_glossary_terms.py 44.5 KB
      from __future__ import annotations
      
      from copy import deepcopy
      import hashlib
      import json
      import sys
      from pathlib import Path
      
      import pytest
      
      import link_glossary_terms as linker
      from conftest import write_current_workflow
      from glossary_common import canonical_sha256, selection_sha256
      from glossary_config import configure_terms_dir
      from link_glossary_terms import link_article_terms, main
      from write_glossary_terms import render_term_file
      
      
      def digest(path: Path) -> str:
          return hashlib.sha256(path.read_bytes()).hexdigest()
      
      
      def _writer_mappings_sha256(payload: dict) -> str:
          mappings = [
              {
                  field: item[field]
                  for field in ("name", "forms", "file", "link_stem")
              }
              for item in payload["results"]
          ]
          return canonical_sha256(
              {
                  "provenance": payload["provenance"],
                  "mappings": mappings,
                  "context": payload["context"],
                  "triage_sha256": payload["triage_sha256"],
              }
          )
      
      
      def _triage_sha256(payload: dict) -> str:
          return canonical_sha256(
              {
                  "paper_id": payload["paper_id"],
                  "provenance": payload["provenance"],
                  "terms": payload["terms"],
              }
          )
      
      
      def test_linker_links_first_safe_alias_and_skips_all_protected_regions(
          tmp_path: Path,
      ) -> None:
          article = tmp_path / "Paper.md"
          article.write_text(
              "---\ntitle: ReAct\n---\n"
              "# ReAct heading\n\n"
              "`ReAct` and [ReAct](https://example.test/ReAct) are protected.\n"
              "<span>ReAct</span> https://example.test/ReAct [[Other|ReAct]]\n\n"
              "REACT combines reasoning and acting. ReAct appears again.\n\n"
              "```python\nReAct = 'code'\n```\n\n"
              "## References\nReAct paper\n",
              encoding="utf-8",
          )
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct", "REACT"]}],
              digest(article),
          )
      
          text = article.read_text(encoding="utf-8")
          assert "[[ReAct|REACT]] combines reasoning" in text
          assert "# ReAct heading" in text
          assert "`ReAct`" in text
          assert "[ReAct](https://example.test/ReAct)" in text
          assert "<span>ReAct</span>" in text
          assert "https://example.test/ReAct" in text
          assert "[[Other|ReAct]]" in text
          assert "```python\nReAct = 'code'\n```" in text
          assert "## References\nReAct paper" in text
          assert text.count("[[ReAct|REACT]]") == 1
          assert result["summary"] == {"linked": 1, "already_linked": 0, "not_found": 0}
      
      
      def test_linker_uses_safe_stem_and_does_not_duplicate_link(tmp_path: Path) -> None:
          article = tmp_path / "Paper.md"
          article.write_text("C# is a programming language.\n", encoding="utf-8")
          mappings = [{"link_stem": "C#", "forms": ["C#"]}]
      
          first = link_article_terms(article, mappings, digest(article))
          second = link_article_terms(article, mappings, digest(article))
      
          assert article.read_text(encoding="utf-8") == (
              "[[C#|C#]] is a programming language.\n"
          )
          assert first["results"] == [{"link_stem": "C#", "status": "linked"}]
          assert second["results"] == [
              {"link_stem": "C#", "status": "already_linked"}
          ]
      
      
      def test_linker_rejects_stale_article_hash_before_writing(tmp_path: Path) -> None:
          article = tmp_path / "Paper.md"
          original = b"ReAct text\n"
          article.write_bytes(original)
      
          with pytest.raises(SystemExit, match="changed since preview"):
              link_article_terms(article, [{"link_stem": "ReAct", "forms": ["ReAct"]}], "0" * 64)
      
          assert article.read_bytes() == original
      
      
      def test_linker_prefers_longer_forms_and_rejects_substrings(tmp_path: Path) -> None:
          article = tmp_path / "Paper.md"
          article.write_text("SerpApi is an API. XAPI and APIClient are not API terms.\n", encoding="utf-8")
      
          result = link_article_terms(
              article,
              [
                  {"link_stem": "API", "forms": ["API"]},
                  {"link_stem": "SerpApi", "forms": ["SerpApi"]},
              ],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == (
              "[[SerpApi]] is an [[API]]. XAPI and APIClient are not API terms.\n"
          )
          assert result["summary"] == {"linked": 2, "already_linked": 0, "not_found": 0}
      
      
      def test_linker_reports_existing_target_as_already_linked_without_rewriting(
          tmp_path: Path,
      ) -> None:
          article = tmp_path / "Paper.md"
          original = b"[[ReAct|REACT]] is already linked. ReAct remains plain.\n"
          article.write_bytes(original)
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct", "REACT"]}],
              digest(article),
          )
      
          assert article.read_bytes() == original
          assert result["results"] == [{"link_stem": "ReAct", "status": "already_linked"}]
          assert result["summary"] == {"linked": 0, "already_linked": 1, "not_found": 0}
      
      
      def test_linker_preserves_utf8_bom_and_crlf_newlines(tmp_path: Path) -> None:
          article = tmp_path / "Paper.md"
          article.write_bytes(b"\xef\xbb\xbfReAct text\r\nSecond line\r\n")
      
          link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_bytes() == b"\xef\xbb\xbf[[ReAct]] text\r\nSecond line\r\n"
      
      
      def test_linker_reports_not_found_and_leaves_bytes_unchanged(tmp_path: Path) -> None:
          article = tmp_path / "Paper.md"
          original = b"Only plain prose here.\n"
          article.write_bytes(original)
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_bytes() == original
          assert result["results"] == [{"link_stem": "ReAct", "status": "not_found"}]
          assert result["summary"] == {"linked": 0, "already_linked": 0, "not_found": 1}
      
      
      def test_linker_is_idempotent(tmp_path: Path) -> None:
          article = tmp_path / "Paper.md"
          article.write_text("ReAct text\n", encoding="utf-8")
          mappings = [{"link_stem": "ReAct", "forms": ["ReAct"]}]
      
          link_article_terms(article, mappings, digest(article))
          linked_bytes = article.read_bytes()
          result = link_article_terms(article, mappings, digest(article))
      
          assert article.read_bytes() == linked_bytes
          assert result["summary"] == {"linked": 0, "already_linked": 1, "not_found": 0}
      
      
      def test_cli_rejects_article_outside_configured_vault_before_linking(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          configured_vault = tmp_path / "configured"
          other_vault = tmp_path / "other"
          (configured_vault / ".obsidian").mkdir(parents=True)
          (other_vault / ".obsidian").mkdir(parents=True)
          config_path = tmp_path / "device" / "config.json"
          configure_terms_dir(configured_vault / "Terms", config_path)
          article = other_vault / "Paper.md"
          original = b"ReAct text\n"
          article.write_bytes(original)
          writer_result = tmp_path / "writer-result.json"
          writer_result.write_text(
              json.dumps(
                  {
                      "status": "ok",
                      "results": [{"link_stem": "ReAct", "forms": ["ReAct"]}],
                  }
              ),
              encoding="utf-8",
          )
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "link_glossary_terms.py",
                  "--input",
                  str(article),
                  "--write-result",
                  str(writer_result),
                  "--expected-sha256",
                  digest(article),
                  "--config-path",
                  str(config_path),
                  "--source-manifest",
                  str(tmp_path / "unused-manifest.json"),
                  "--reviewed-shortlist",
                  str(tmp_path / "unused-review.json"),
                  "--triage",
                  str(tmp_path / "unused-triage.json"),
              ],
          )
      
          with pytest.raises(SystemExit, match="same Obsidian vault"):
              main()
      
          assert article.read_bytes() == original
      
      
      def test_cli_consumes_only_successful_writer_results(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          config_path = tmp_path / "device" / "config.json"
          configure_terms_dir(vault / "Terms", config_path)
          article = vault / "Paper.md"
          original = b"ReAct text\n"
          article.write_bytes(original)
          writer_result = tmp_path / "writer-result.json"
          writer_result.write_text(
              json.dumps({"status": "failed", "results": []}), encoding="utf-8"
          )
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "link_glossary_terms.py",
                  "--input",
                  str(article),
                  "--write-result",
                  str(writer_result),
                  "--expected-sha256",
                  digest(article),
                  "--config-path",
                  str(config_path),
                  "--source-manifest",
                  str(tmp_path / "unused-manifest.json"),
                  "--reviewed-shortlist",
                  str(tmp_path / "unused-review.json"),
                  "--triage",
                  str(tmp_path / "unused-triage.json"),
              ],
          )
      
          with pytest.raises(SystemExit, match="successful writer result"):
              main()
      
          assert article.read_bytes() == original
      
      
      @pytest.mark.parametrize(
          "original",
          [
              b"---\ntitle: ReAct\nbody: ReAct\n",
              b"```python\nReAct = 'protected'\n",
              b"````python\nReAct = 'protected'\n```\nstill ReAct\n",
          ],
          ids=["unclosed-frontmatter", "unclosed-fence", "short-fence-closer"],
      )
      def test_linker_protects_unclosed_frontmatter_and_fences_to_eof(
          tmp_path: Path, original: bytes
      ) -> None:
          article = tmp_path / "Paper.md"
          article.write_bytes(original)
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_bytes() == original
          assert result["summary"] == {"linked": 0, "already_linked": 0, "not_found": 1}
      
      
      def test_linker_protects_nested_same_name_html_elements(tmp_path: Path) -> None:
          article = tmp_path / "Paper.md"
          article.write_text(
              "<span><span>nested</span> ReAct</span>\nReAct prose\n", encoding="utf-8"
          )
      
          link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == (
              "<span><span>nested</span> ReAct</span>\n[[ReAct]] prose\n"
          )
      
      
      @pytest.mark.parametrize("tag", ["strong", "b", "em", "i"])
      def test_linker_links_visible_text_inside_simple_html_emphasis(
          tmp_path: Path, tag: str
      ) -> None:
          article = tmp_path / "Paper.md"
          article.write_text(f"<{tag}>ReAct</{tag}> prose\n", encoding="utf-8")
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == (
              f"<{tag}>[[ReAct]]</{tag}> prose\n"
          )
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
      
      
      def test_linker_keeps_emphasis_text_protected_inside_html_container(
          tmp_path: Path,
      ) -> None:
          article = tmp_path / "Paper.md"
          article.write_text(
              "<div><strong>ReAct</strong></div>\nReAct prose\n", encoding="utf-8"
          )
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == (
              "<div><strong>ReAct</strong></div>\n[[ReAct]] prose\n"
          )
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
      
      
      def test_linker_handles_gt_and_term_text_inside_emphasis_attributes(
          tmp_path: Path,
      ) -> None:
          article = tmp_path / "Paper.md"
          article.write_text(
              '<strong title="attribute > ReAct">Other</strong>\n'
              '<strong title="1 > 0">ReAct</strong> prose\n',
              encoding="utf-8",
          )
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == (
              '<strong title="attribute > ReAct">Other</strong>\n'
              '<strong title="1 > 0">[[ReAct]]</strong> prose\n'
          )
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
      
      
      def test_linker_does_not_treat_attribute_markup_as_an_html_container(
          tmp_path: Path,
      ) -> None:
          article = tmp_path / "Paper.md"
          article.write_text(
              '<div title="<br>"><strong>ReAct</strong></div>\nReAct prose\n',
              encoding="utf-8",
          )
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == (
              '<div title="<br>"><strong>ReAct</strong></div>\n[[ReAct]] prose\n'
          )
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
      
      
      def test_linker_protects_mismatched_html_then_links_following_prose(
          tmp_path: Path,
      ) -> None:
          article = tmp_path / "Paper.md"
          article.write_text("<strong>ReAct</em></strong>\nReAct prose\n", encoding="utf-8")
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == (
              "<strong>ReAct</em></strong>\n[[ReAct]] prose\n"
          )
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
      
      
      @pytest.mark.parametrize(
          "autolink",
          ["<https://example.test/ReAct>", "<ReAct@example.test>"],
          ids=["uri", "email"],
      )
      def test_linker_preserves_markdown_autolinks_and_links_following_prose(
          tmp_path: Path, autolink: str
      ) -> None:
          article = tmp_path / "Paper.md"
          article.write_text(f"{autolink}\nReAct prose\n", encoding="utf-8")
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == f"{autolink}\n[[ReAct]] prose\n"
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
      
      
      @pytest.mark.parametrize(
          "original",
          [b"<div>\nReAct remains protected\n", b"<!-- ReAct remains protected\n"],
          ids=["unclosed-element", "unclosed-comment"],
      )
      def test_linker_protects_unclosed_html_and_comments_to_eof(
          tmp_path: Path, original: bytes
      ) -> None:
          article = tmp_path / "Paper.md"
          article.write_bytes(original)
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_bytes() == original
          assert result["results"] == [{"link_stem": "ReAct", "status": "not_found"}]
      
      
      def test_linker_rejects_external_update_before_replace_and_removes_temp(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          article = tmp_path / "Paper.md"
          article.write_bytes(b"ReAct text\n")
          external = b"external update\n"
          real_fsync = linker.os.fsync
      
          def fsync_then_update(descriptor: int) -> None:
              real_fsync(descriptor)
              article.write_bytes(external)
      
          monkeypatch.setattr(linker.os, "fsync", fsync_then_update)
      
          with pytest.raises(SystemExit, match="changed while linking"):
              link_article_terms(
                  article,
                  [{"link_stem": "ReAct", "forms": ["ReAct"]}],
                  digest(article),
              )
      
          assert article.read_bytes() == external
          assert list(tmp_path.glob(f".{article.name}.*.tmp")) == []
      
      
      def test_linker_coalesces_case_insensitive_targets_and_merged_forms(
          tmp_path: Path,
      ) -> None:
          article = tmp_path / "Paper.md"
          article.write_text("ReAct then reasoning and acting.\n", encoding="utf-8")
      
          result = link_article_terms(
              article,
              [
                  {"link_stem": "ReAct", "forms": ["ReAct", "REACT"]},
                  {"link_stem": "react", "forms": ["reasoning and acting", "ReAct"]},
              ],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == (
              "ReAct then [[ReAct|reasoning and acting]].\n"
          )
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
          assert result["summary"] == {"linked": 1, "already_linked": 0, "not_found": 0}
      
      
      def test_linker_detects_case_insensitive_wiki_target_with_fragment_and_alias(
          tmp_path: Path,
      ) -> None:
          article = tmp_path / "Paper.md"
          original = "[[rEaCt#Heading|显示]] exists. ReAct remains plain.\n".encode("utf-8")
          article.write_bytes(original)
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_bytes() == original
          assert result["results"] == [{"link_stem": "ReAct", "status": "already_linked"}]
      
      
      def test_linker_protects_nested_parentheses_in_markdown_destination(
          tmp_path: Path,
      ) -> None:
          article = tmp_path / "Paper.md"
          article.write_text(
              "[docs](https://example.test/a_(b)/ReAct) ReAct prose\n", encoding="utf-8"
          )
      
          link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == (
              "[docs](https://example.test/a_(b)/ReAct) [[ReAct]] prose\n"
          )
      
      
      def test_atomic_replace_failure_preserves_article_and_removes_temp(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          article = tmp_path / "Paper.md"
          original = b"ReAct text\n"
          article.write_bytes(original)
      
          def fail_replace(source: Path, destination: Path) -> None:
              raise OSError("replace failed")
      
          monkeypatch.setattr(linker.os, "replace", fail_replace)
      
          with pytest.raises(OSError, match="replace failed"):
              link_article_terms(
                  article,
                  [{"link_stem": "ReAct", "forms": ["ReAct"]}],
                  digest(article),
              )
      
          assert article.read_bytes() == original
          assert list(tmp_path.glob(f".{article.name}.*.tmp")) == []
      
      
      def test_cli_links_same_vault_article_and_writes_output_contract(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          workflow = write_current_workflow(tmp_path / "workflow", ["ReAct"])
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          config_path = tmp_path / "device" / "config.json"
          terms_dir = vault / "Terms"
          configure_terms_dir(terms_dir, config_path)
          article = vault / "Paper.md"
          article.write_bytes(b"ReAct text\n")
          note = terms_dir / "ReAct.md"
          note.write_text(
              render_term_file(
                  {
                      "name": "ReAct",
                      "aliases": [],
                      "definition": "definition",
                      "confidence": "\u9ad8",
                      "occurrence": "evidence",
                  },
                  "Paper",
              ),
              encoding="utf-8",
          )
          writer_result = tmp_path / "writer-result.json"
          writer_payload = {
              "status": "ok",
              "script": "write_glossary_terms.py",
              "paper_id": "paper-fixture",
              "provenance": deepcopy(workflow["triage"]["provenance"]),
              "context": {
                  "paper_id": "paper-fixture",
                  "paper_link": "Paper",
                  "article_path": str(article.resolve()),
              },
              "results": [
                  {
                      "name": "ReAct",
                      "action": "created",
                      "file": str(note.resolve()),
                      "link_stem": "ReAct",
                      "forms": ["ReAct"],
                  }
              ],
          }
          writer_payload["triage_sha256"] = _triage_sha256(workflow["triage"])
          writer_payload["mappings_sha256"] = _writer_mappings_sha256(writer_payload)
          writer_result.write_text(json.dumps(writer_payload), encoding="utf-8")
          output = tmp_path / "link-result.json"
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "link_glossary_terms.py",
                  "--input",
                  str(article),
                  "--write-result",
                  str(writer_result),
                  "--expected-sha256",
                  digest(article),
                  "--config-path",
                  str(config_path),
                  "--source-manifest",
                  str(workflow["manifest_path"]),
                  "--reviewed-shortlist",
                  str(workflow["review_path"]),
                  "--triage",
                  str(workflow["triage_path"]),
                  "--output",
                  str(output),
              ],
          )
      
          main()
      
          assert article.read_bytes() == b"[[ReAct]] text\n"
          payload = json.loads(output.read_text(encoding="utf-8"))
          assert isinstance(payload.pop("elapsed_ms"), int)
          assert payload == {
              "status": "ok",
              "article_path": str(article.resolve()),
              "results": [{"link_stem": "ReAct", "status": "linked"}],
              "summary": {"linked": 1, "already_linked": 0, "not_found": 0},
              "script": "link_glossary_terms.py",
          }
      
      
      @pytest.mark.parametrize(
          "tamper",
          ["forms", "stem", "file", "order", "provenance", "context", "mapping_digest"],
      )
      def test_linker_cli_rejects_tampered_writer_artifact_without_article_writes(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch, tamper: str
      ) -> None:
          workflow = write_current_workflow(
              tmp_path / "workflow", ["ReAct", "Reflection"]
          )
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          terms_dir = vault / "Glossary"
          config_path = tmp_path / "config.json"
          configure_terms_dir(terms_dir, config_path)
          article = vault / "Paper.md"
          original = b"ReAct and Reflection are article concepts.\n"
          article.write_bytes(original)
          results = []
          for name in ("ReAct", "Reflection"):
              note = terms_dir / f"{name}.md"
              note.write_text(
                  render_term_file(
                      {
                          "name": name,
                          "aliases": [],
                          "definition": "definition",
                          "confidence": "\u9ad8",
                          "occurrence": "evidence",
                      },
                      "Paper",
                  ),
                  encoding="utf-8",
              )
              results.append(
                  {
                      "name": name,
                      "forms": [name],
                      "file": str(note.resolve()),
                      "link_stem": name,
                      "action": "created",
                  }
              )
          payload = {
              "status": "ok",
              "script": "write_glossary_terms.py",
              "paper_id": "paper-fixture",
              "provenance": deepcopy(workflow["triage"]["provenance"]),
              "context": {
                  "paper_id": "paper-fixture",
                  "paper_link": "Paper",
                  "article_path": str(article.resolve()),
              },
              "results": results,
          }
          payload["triage_sha256"] = _triage_sha256(workflow["triage"])
          payload["mappings_sha256"] = _writer_mappings_sha256(payload)
      
          if tamper == "forms":
              payload["results"][0]["forms"] = ["Injected"]
          elif tamper == "stem":
              payload["results"][0]["link_stem"] = "Injected"
          elif tamper == "file":
              outside = tmp_path / "ReAct.md"
              outside.write_text("# ReAct\n", encoding="utf-8")
              payload["results"][0]["file"] = str(outside.resolve())
          elif tamper == "order":
              payload["results"].reverse()
          elif tamper == "provenance":
              payload["provenance"]["proposal"]["source_sha256"] = "0" * 64
          elif tamper == "context":
              other = vault / "Other.md"
              other.write_text("# Other\n", encoding="utf-8")
              payload["context"]["article_path"] = str(other.resolve())
          elif tamper == "mapping_digest":
              payload["mappings_sha256"] = "0" * 64
          if tamper != "mapping_digest":
              payload["mappings_sha256"] = _writer_mappings_sha256(payload)
      
          writer_result = tmp_path / "writer-result.json"
          writer_result.write_text(json.dumps(payload), encoding="utf-8")
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "link_glossary_terms.py",
                  "--input",
                  str(article),
                  "--write-result",
                  str(writer_result),
                  "--expected-sha256",
                  digest(article),
                  "--config-path",
                  str(config_path),
                  "--source-manifest",
                  str(workflow["manifest_path"]),
                  "--reviewed-shortlist",
                  str(workflow["review_path"]),
                  "--triage",
                  str(workflow["triage_path"]),
              ],
          )
      
          with pytest.raises(
              SystemExit,
              match="writer|mapping|provenance|digest|glossary|current|review|file|stem|form|article",
          ):
              main()
      
          assert article.read_bytes() == original
      
      
      def test_linker_cli_rejects_reviewed_but_unselected_forged_mapping(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          workflow = write_current_workflow(
              tmp_path / "workflow",
              ["ReAct", "Reflection"],
              selected_names=["ReAct"],
          )
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          terms_dir = vault / "Glossary"
          config_path = tmp_path / "config.json"
          configure_terms_dir(terms_dir, config_path)
          article = vault / "Paper.md"
          original = b"Reflection is an article concept.\n"
          article.write_bytes(original)
          note = terms_dir / "Reflection.md"
          note.write_text(
              render_term_file(
                  {
                      "name": "Reflection",
                      "aliases": [],
                      "definition": "A reflection step.",
                      "confidence": "\u9ad8",
                      "occurrence": "Paper evidence.",
                  },
                  "Paper",
              ),
              encoding="utf-8",
          )
          reflection = next(
              item
              for item in workflow["review"]["reviewed_shortlist"]
              if item["term"] == "Reflection"
          )
          forged_provenance = deepcopy(workflow["triage"]["provenance"])
          forged_provenance["selection_sha256"] = selection_sha256(
              forged_provenance["review"]["review_sha256"], [reflection]
          )
          payload = {
              "status": "ok",
              "script": "write_glossary_terms.py",
              "paper_id": workflow["triage"]["paper_id"],
              "provenance": forged_provenance,
              "context": {
                  "paper_id": workflow["triage"]["paper_id"],
                  "paper_link": "Paper",
                  "article_path": str(article.resolve()),
              },
              "results": [
                  {
                      "name": "Reflection",
                      "forms": list(reflection["surface_forms"]),
                      "file": str(note.resolve()),
                      "link_stem": "Reflection",
                      "action": "created",
                  }
              ],
          }
          forged_triage = deepcopy(workflow["triage"])
          forged_triage["provenance"] = deepcopy(forged_provenance)
          forged_triage["terms"] = [deepcopy(reflection)]
          payload["triage_sha256"] = _triage_sha256(forged_triage)
          payload["mappings_sha256"] = _writer_mappings_sha256(payload)
          writer_result = tmp_path / "forged-writer.json"
          writer_result.write_text(json.dumps(payload), encoding="utf-8")
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "link_glossary_terms.py",
                  "--input",
                  str(article),
                  "--write-result",
                  str(writer_result),
                  "--expected-sha256",
                  digest(article),
                  "--config-path",
                  str(config_path),
                  "--source-manifest",
                  str(workflow["manifest_path"]),
                  "--reviewed-shortlist",
                  str(workflow["review_path"]),
                  "--triage",
                  str(workflow["triage_path"]),
              ],
          )
      
          with pytest.raises(SystemExit, match="authorized|triage|selection"):
              main()
      
          assert article.read_bytes() == original
      
      
      def test_linker_cli_rejects_glossary_only_writer_artifact(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          workflow = write_current_workflow(tmp_path / "workflow", ["ReAct"])
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          terms_dir = vault / "Glossary"
          config_path = tmp_path / "config.json"
          configure_terms_dir(terms_dir, config_path)
          article = vault / "Paper.md"
          original = b"ReAct is an article concept.\n"
          article.write_bytes(original)
          note = terms_dir / "ReAct.md"
          note.write_text(
              render_term_file(
                  {
                      "name": "ReAct",
                      "aliases": [],
                      "definition": "definition",
                      "confidence": "\u9ad8",
                      "occurrence": "evidence",
                  },
                  "paper-fixture",
              ),
              encoding="utf-8",
          )
          payload = {
              "status": "ok",
              "script": "write_glossary_terms.py",
              "paper_id": "paper-fixture",
              "provenance": deepcopy(workflow["triage"]["provenance"]),
              "context": {
                  "paper_id": "paper-fixture",
                  "paper_link": "paper-fixture",
                  "article_path": "",
              },
              "results": [
                  {
                      "name": "ReAct",
                      "forms": ["ReAct"],
                      "file": str(note.resolve()),
                      "link_stem": "ReAct",
                      "action": "created",
                  }
              ],
          }
          payload["triage_sha256"] = _triage_sha256(workflow["triage"])
          payload["mappings_sha256"] = _writer_mappings_sha256(payload)
          writer_result = tmp_path / "writer-result.json"
          writer_result.write_text(json.dumps(payload), encoding="utf-8")
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "link_glossary_terms.py",
                  "--input",
                  str(article),
                  "--write-result",
                  str(writer_result),
                  "--expected-sha256",
                  digest(article),
                  "--config-path",
                  str(config_path),
                  "--source-manifest",
                  str(workflow["manifest_path"]),
                  "--reviewed-shortlist",
                  str(workflow["review_path"]),
                  "--triage",
                  str(workflow["triage_path"]),
              ],
          )
      
          with pytest.raises(SystemExit, match="bound article|article context"):
              main()
      
          assert article.read_bytes() == original
      
      
      @pytest.mark.parametrize(
          ("literal", "suffix"),
          [("<div>", ""), ("[x](", ")")],
          ids=["html-opener", "markdown-destination"],
      )
      def test_second_review_context_scanners_ignore_fenced_code_literals(
          tmp_path: Path, literal: str, suffix: str
      ) -> None:
          article = tmp_path / "Paper.md"
          article.write_text(
              f"```text\n{literal}\n```\nReAct prose{suffix}\n", encoding="utf-8"
          )
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == (
              f"```text\n{literal}\n```\n[[ReAct]] prose{suffix}\n"
          )
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
      
      
      @pytest.mark.parametrize(
          ("literal", "suffix"),
          [("<div>", ""), ("[x](", ")")],
          ids=["html-opener", "markdown-destination"],
      )
      def test_second_review_context_scanners_ignore_inline_code_literals(
          tmp_path: Path, literal: str, suffix: str
      ) -> None:
          article = tmp_path / "Paper.md"
          article.write_text(f"`{literal}` then ReAct prose{suffix}\n", encoding="utf-8")
      
          link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == (
              f"`{literal}` then [[ReAct]] prose{suffix}\n"
          )
      
      
      @pytest.mark.parametrize(
          ("metadata", "suffix"),
          [("example: <div>", ""), ('example: "[x]("', ")")],
          ids=["html-opener", "markdown-destination"],
      )
      def test_second_review_context_scanners_ignore_closed_frontmatter_literals(
          tmp_path: Path, metadata: str, suffix: str
      ) -> None:
          article = tmp_path / "Paper.md"
          article.write_text(
              f"---\n{metadata}\n---\nReAct prose{suffix}\n", encoding="utf-8"
          )
      
          link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == (
              f"---\n{metadata}\n---\n[[ReAct]] prose{suffix}\n"
          )
      
      
      @pytest.mark.parametrize(
          ("source", "expected"),
          [
              ("`[[ReAct]]` then ReAct prose\n", "`[[ReAct]]` then [[ReAct]] prose\n"),
              (
                  "```text\n[[ReAct]]\n```\nReAct prose\n",
                  "```text\n[[ReAct]]\n```\n[[ReAct]] prose\n",
              ),
              (
                  "---\nexample: '[[ReAct]]'\n---\nReAct prose\n",
                  "---\nexample: '[[ReAct]]'\n---\n[[ReAct]] prose\n",
              ),
              (
                  "<!-- [[ReAct]] -->\nReAct prose\n",
                  "<!-- [[ReAct]] -->\n[[ReAct]] prose\n",
              ),
          ],
          ids=["inline", "fence", "frontmatter", "html-comment"],
      )
      def test_second_review_wiki_literals_in_protected_context_are_not_targets(
          tmp_path: Path, source: str, expected: str
      ) -> None:
          article = tmp_path / "Paper.md"
          article.write_text(source, encoding="utf-8")
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == expected
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
      
      
      @pytest.mark.parametrize(
          "source",
          ["<br> ReAct prose\n", '<img alt="ReAct"> ReAct prose\n'],
          ids=["br", "img"],
      )
      def test_second_review_void_html_tags_do_not_protect_following_prose(
          tmp_path: Path, source: str
      ) -> None:
          article = tmp_path / "Paper.md"
          article.write_text(source, encoding="utf-8")
      
          link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == source.replace(
              " ReAct prose", " [[ReAct]] prose"
          )
      
      
      def test_second_review_coalesced_target_exact_canonical_uses_direct_link(
          tmp_path: Path,
      ) -> None:
          article = tmp_path / "Paper.md"
          article.write_text("ReAct prose\n", encoding="utf-8")
      
          result = link_article_terms(
              article,
              [
                  {"link_stem": "ReAct", "forms": ["REACT"]},
                  {"link_stem": "react", "forms": ["ReAct"]},
              ],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == "[[ReAct]] prose\n"
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
      
      
      def test_final_review_reference_heading_skips_fenced_literal(tmp_path: Path) -> None:
          article = tmp_path / "Paper.md"
          original = (
              "```text\n"
              "## References\n"
              "```\n\n"
              "## References\n"
              "ReAct citation\n"
          ).encode("utf-8")
          article.write_bytes(original)
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_bytes() == original
          assert result["results"] == [{"link_stem": "ReAct", "status": "not_found"}]
          assert result["summary"] == {"linked": 0, "already_linked": 0, "not_found": 1}
      
      
      def test_crlf_review_reference_heading_protects_tail(tmp_path: Path) -> None:
          article = tmp_path / "Paper.md"
          original = b"## References\r\nReAct citation\r\n"
          article.write_bytes(original)
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_bytes() == original
          assert result["results"] == [{"link_stem": "ReAct", "status": "not_found"}]
      
      
      def test_crlf_review_reference_heading_skips_fenced_literal(tmp_path: Path) -> None:
          article = tmp_path / "Paper.md"
          original = (
              b"```text\r\n"
              b"## References\r\n"
              b"```\r\n\r\n"
              b"## References\r\n"
              b"ReAct citation\r\n"
          )
          article.write_bytes(original)
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_bytes() == original
          assert result["results"] == [{"link_stem": "ReAct", "status": "not_found"}]
      
      
      @pytest.mark.parametrize(
          ("reference_link", "definition"),
          [
              ("[ReAct][method]", "[method]: https://example.test/react"),
              ("[ReAct][]", "[ReAct]: https://example.test/react"),
              ("[ReAct]", "[ReAct]: https://example.test/react"),
          ],
          ids=["full", "collapsed", "shortcut"],
      )
      def test_final_review_protects_valid_reference_links_and_definitions(
          tmp_path: Path, reference_link: str, definition: str
      ) -> None:
          article = tmp_path / "Paper.md"
          original = f"{reference_link}\n\n{definition}\nReAct prose\n"
          article.write_text(original, encoding="utf-8")
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == (
              f"{reference_link}\n\n{definition}\n[[ReAct]] prose\n"
          )
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
      
      
      @pytest.mark.parametrize(
          ("reference_link", "definition"),
          [
              ("[ReAct][method]", "[method]:\n  https://example.test/react"),
              ("[ReAct][]", "[ReAct]:\n  https://example.test/react"),
              ("[ReAct]", "[ReAct]:\n  https://example.test/react"),
          ],
          ids=["full", "collapsed", "shortcut"],
      )
      def test_second_final_review_protects_next_line_reference_destinations(
          tmp_path: Path, reference_link: str, definition: str
      ) -> None:
          article = tmp_path / "Paper.md"
          original = f"{reference_link}\n\n{definition}\nReAct prose\n"
          article.write_text(original, encoding="utf-8")
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == (
              f"{reference_link}\n\n{definition}\n[[ReAct]] prose\n"
          )
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
      
      
      @pytest.mark.parametrize(
          "definition",
          [
              '[method]: /react\n  "ReAct title"',
              '[method]: /react\n  "A title\n  containing ReAct"',
          ],
          ids=["continuation-title", "multiline-title"],
      )
      def test_second_final_review_protects_complete_reference_definition_titles(
          tmp_path: Path, definition: str
      ) -> None:
          article = tmp_path / "Paper.md"
          original = f"[ReAct][method]\n\n{definition}\nReAct prose\n"
          article.write_text(original, encoding="utf-8")
      
          link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == (
              f"[ReAct][method]\n\n{definition}\n[[ReAct]] prose\n"
          )
      
      
      @pytest.mark.parametrize(
          "definition",
          [
              b"[method]:\r\n  https://example.test/react",
              b'[method]: /react\r\n  "ReAct title"',
          ],
          ids=["destination-next-line", "title-next-line"],
      )
      def test_second_final_review_preserves_crlf_reference_definition_blocks(
          tmp_path: Path, definition: bytes
      ) -> None:
          article = tmp_path / "Paper.md"
          original = b"[ReAct][method]\r\n\r\n" + definition + b"\r\nReAct prose\r\n"
          article.write_bytes(original)
      
          link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_bytes() == (
              b"[ReAct][method]\r\n\r\n" + definition + b"\r\n[[ReAct]] prose\r\n"
          )
      
      
      @pytest.mark.parametrize(
          ("reference_link", "definition"),
          [
              ("[ReAct][method]", "[method]: /react"),
              ("[ReAct][]", "[ReAct]: /react"),
              ("[ReAct]", "[ReAct]: /react"),
          ],
          ids=["full", "collapsed", "shortcut"],
      )
      def test_final_review_protects_reference_links_inside_block_quotes(
          tmp_path: Path, reference_link: str, definition: str
      ) -> None:
          article = tmp_path / "Paper.md"
          original = f"> {reference_link}\n>\n> {definition}\n\nReAct prose\n"
          article.write_text(original, encoding="utf-8")
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == original.replace(
              "ReAct prose", "[[ReAct]] prose"
          )
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
      
      
      def test_final_review_protects_multiline_reference_definitions_in_crlf_block_quotes(
          tmp_path: Path,
      ) -> None:
          article = tmp_path / "Paper.md"
          original = (
              b"> [ReAct][method]\r\n>\r\n> [method]:\r\n"
              b">   /react\r\n>   \"ReAct title\"\r\n\r\nReAct prose\r\n"
          )
          article.write_bytes(original)
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_bytes() == original.replace(
              b"ReAct prose", b"[[ReAct]] prose"
          )
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
      
      
      def test_final_review_skips_setext_and_quoted_atx_headings(tmp_path: Path) -> None:
          article = tmp_path / "Paper.md"
          original = (
              b"ReAct setext heading\r\n====================\r\n\r\n"
              b"> ReAct quoted setext\r\n> --------------------\r\n\r\n"
              b"> ## ReAct quoted heading\r\n\r\nReAct prose\r\n"
          )
          article.write_bytes(original)
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_bytes() == original.replace(
              b"ReAct prose", b"[[ReAct]] prose"
          )
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
      
      
      @pytest.mark.parametrize(
          "code_line",
          ["    ReAct code", ">     ReAct quoted code"],
          ids=["top-level", "blockquote"],
      )
      def test_final_review_skips_indented_code_lines(
          tmp_path: Path, code_line: str
      ) -> None:
          article = tmp_path / "Paper.md"
          original = f"{code_line}\n\nReAct prose\n"
          article.write_text(original, encoding="utf-8")
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == original.replace(
              "ReAct prose", "[[ReAct]] prose"
          )
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
      
      
      @pytest.mark.parametrize(
          "line",
          ["   ReAct prose", ">    ReAct prose"],
          ids=["top-level", "blockquote"],
      )
      def test_final_review_keeps_three_space_indented_prose_linkable(
          tmp_path: Path, line: str
      ) -> None:
          article = tmp_path / "Paper.md"
          article.write_text(f"{line}\n", encoding="utf-8")
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          expected = f"{line.replace('ReAct', '[[ReAct]]')}\n"
          assert article.read_text(encoding="utf-8") == expected
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
      
      
      def test_final_review_skips_nested_blockquote_fenced_code(tmp_path: Path) -> None:
          article = tmp_path / "Paper.md"
          original = "> > ```text\n> > ReAct code\n> > ```\n\nReAct prose\n"
          article.write_text(original, encoding="utf-8")
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == original.replace(
              "ReAct prose", "[[ReAct]] prose"
          )
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
      
      
      @pytest.mark.parametrize("marker", ["-", "1."], ids=["bullet", "ordered"])
      def test_final_review_skips_atx_headings_inside_list_items(
          tmp_path: Path, marker: str
      ) -> None:
          article = tmp_path / "Paper.md"
          original = f"{marker} ## ReAct list heading\n\nReAct prose\n"
          article.write_text(original, encoding="utf-8")
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == original.replace(
              "ReAct prose", "[[ReAct]] prose"
          )
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
      
      
      @pytest.mark.parametrize("marker", ["-", "1."], ids=["bullet", "ordered"])
      def test_final_review_skips_fenced_code_inside_list_items(
          tmp_path: Path, marker: str
      ) -> None:
          article = tmp_path / "Paper.md"
          indent = " " * (len(marker) + 1)
          original = (
              f"{marker} ```text\n{indent}ReAct code\n{indent}```\n\nReAct prose\n"
          )
          article.write_text(original, encoding="utf-8")
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == original.replace(
              "ReAct prose", "[[ReAct]] prose"
          )
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
      
      
      def test_final_review_protects_long_delimiter_inline_code(tmp_path: Path) -> None:
          article = tmp_path / "Paper.md"
          original = "``prefix ` ReAct code``\n\nReAct prose\n"
          article.write_text(original, encoding="utf-8")
      
          result = link_article_terms(
              article,
              [{"link_stem": "ReAct", "forms": ["ReAct"]}],
              digest(article),
          )
      
          assert article.read_text(encoding="utf-8") == original.replace(
              "ReAct prose", "[[ReAct]] prose"
          )
          assert result["results"] == [{"link_stem": "ReAct", "status": "linked"}]
      
    • test_plan_glossary.py 29.4 KB
      from __future__ import annotations
      
      import json
      import sys
      import unicodedata
      from pathlib import Path
      
      import pytest
      
      import plan_glossary
      from plan_glossary import (
          find_occurrences,
          load_manifest_and_sections,
          load_terms,
          main,
          propose_candidates,
          read_raw_sections,
          triage_terms,
      )
      
      
      def _records() -> list[dict]:
          return [
              {
                  "record_type": "section",
                  "section_id": "sec:method",
                  "kind": "method",
                  "title": "Method",
                  "page_start": 3,
                  "page_end": 5,
                  "text": (
                      "We use a sparse MoE student trained with knowledge distillation. "
                      "MoE routing and SSDG. F10 and F1 score. EEG signals."
                  ),
              },
              {
                  "record_type": "section",
                  "section_id": "sec:references",
                  "kind": "references",
                  "title": "References",
                  "page_start": 9,
                  "page_end": 10,
                  "text": "Smith et al. Diffusion models for generation. 2020.",
              },
          ]
      
      
      def _run_proposal(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch, records: list[dict]
      ) -> dict:
          raw = tmp_path / "paper_raw_sections.jsonl"
          raw.write_text(
              "\n".join(json.dumps(record, ensure_ascii=False) for record in records),
              encoding="utf-8",
          )
          manifest = tmp_path / "paper_source_manifest.json"
          manifest.write_text(
              json.dumps({"paper_id": "paper-1", "raw_sections_path": str(raw)}),
              encoding="utf-8",
          )
          output = tmp_path / "glossary_candidates.json"
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "plan_glossary.py",
                  "--propose",
                  "--source-manifest",
                  str(manifest),
                  "--output",
                  str(output),
              ],
          )
          main()
          payload = json.loads(output.read_text(encoding="utf-8"))
          assert isinstance(payload["elapsed_ms"], int)
          assert payload["elapsed_ms"] >= 0
          return payload
      
      
      def _run_review(
          tmp_path: Path,
          monkeypatch: pytest.MonkeyPatch,
          reviewed_terms: list[str],
      ) -> dict:
          output = tmp_path / "glossary_reviewed.json"
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "plan_glossary.py",
                  "--review-proposal",
                  str(tmp_path / "glossary_candidates.json"),
                  "--reviewed-terms",
                  json.dumps(reviewed_terms, ensure_ascii=False),
                  "--source-manifest",
                  str(tmp_path / "paper_source_manifest.json"),
                  "--output",
                  str(output),
              ],
          )
          main()
          return json.loads(output.read_text(encoding="utf-8"))
      
      
      def _run_triage(
          tmp_path: Path,
          monkeypatch: pytest.MonkeyPatch,
          selected_terms: list[str] | str,
      ) -> dict:
          output = tmp_path / "glossary_plan.json"
          terms = (
              json.dumps(selected_terms, ensure_ascii=False)
              if isinstance(selected_terms, list)
              else selected_terms
          )
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "plan_glossary.py",
                  "--reviewed-shortlist",
                  str(tmp_path / "glossary_reviewed.json"),
                  "--terms",
                  terms,
                  "--source-manifest",
                  str(tmp_path / "paper_source_manifest.json"),
                  "--output",
                  str(output),
              ],
          )
          main()
          return json.loads(output.read_text(encoding="utf-8"))
      
      
      def test_loads_deeppapernote_manifest_raw_sections_contract(tmp_path: Path) -> None:
          raw = tmp_path / "paper_raw_sections.jsonl"
          raw.write_text("\n".join(json.dumps(record, ensure_ascii=False) for record in _records()))
          manifest = tmp_path / "paper_source_manifest.json"
          manifest.write_text(
              json.dumps({"paper_id": "paper-1", "raw_sections_path": str(raw)}, ensure_ascii=False),
              encoding="utf-8",
          )
      
          loaded_manifest, records = load_manifest_and_sections(str(manifest), "")
      
          assert loaded_manifest["paper_id"] == "paper-1"
          assert [record["section_id"] for record in records] == ["sec:method", "sec:references"]
      
      
      def test_loads_relative_raw_sections_path_from_manifest_directory(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          raw = tmp_path / "paper_raw_sections.jsonl"
          raw.write_text("\n".join(json.dumps(record, ensure_ascii=False) for record in _records()))
          manifest = tmp_path / "paper_source_manifest.json"
          manifest.write_text(
              json.dumps({"paper_id": "paper-1", "raw_sections_path": raw.name}, ensure_ascii=False),
              encoding="utf-8",
          )
          other_cwd = tmp_path / "other-cwd"
          other_cwd.mkdir()
          monkeypatch.chdir(other_cwd)
      
          loaded_manifest, records = load_manifest_and_sections(str(manifest), "")
      
          assert loaded_manifest["paper_id"] == "paper-1"
          assert [record["section_id"] for record in records] == ["sec:method", "sec:references"]
      
      
      def test_read_raw_sections_rejects_malformed_jsonl(tmp_path: Path) -> None:
          raw = tmp_path / "paper_raw_sections.jsonl"
          raw.write_text(json.dumps(_records()[0], ensure_ascii=False) + "\n{not-json}\n")
      
          with pytest.raises(SystemExit) as exc:
              read_raw_sections(raw)
      
          assert "Invalid raw sections JSONL" in str(exc.value)
      
      
      def test_term_found_in_paper_routes_to_anchor_only() -> None:
          result = triage_terms(["MoE"], _records())[0]
          assert result["routing"] == "anchor_only"
          assert result["found_in_paper"] is True
          assert result["occurrences"] == 2
          assert result["paper_anchors"][0]["section_id"] == "sec:method"
          assert result["paper_anchors"][0]["page_start"] == 3
      
      
      def test_triage_cli_emits_elapsed_ms_to_output_file(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          _run_proposal(tmp_path, monkeypatch, _records())
          _run_review(tmp_path, monkeypatch, ["MoE"])
          payload = _run_triage(tmp_path, monkeypatch, ["MoE"])
          assert isinstance(payload["elapsed_ms"], int)
          assert payload["elapsed_ms"] >= 0
          assert payload["mode"] == "triage"
          assert [item["term"] for item in payload["terms"]] == ["MoE"]
      
      
      def test_references_only_occurrence_does_not_count_as_found() -> None:
          occurrences, anchors = find_occurrences("Diffusion", _records())
          assert occurrences == 0
          assert anchors == []
      
      
      def test_triage_helper_rejects_caller_supplied_alias_syntax() -> None:
          with pytest.raises(SystemExit, match="exact name|alias"):
              triage_terms(["知识蒸馏|knowledge distillation|KD"], _records())
      
      
      def test_ascii_term_matches_whole_word_only() -> None:
          occurrences, _ = find_occurrences("F1", _records())
          assert occurrences == 1
      
      
      def test_load_terms_accepts_json_list_and_delimited_string() -> None:
          assert load_terms('["MoE", "SSDG"]') == ["MoE", "SSDG"]
          assert load_terms("MoE, SSDG\nKD") == ["MoE", "SSDG", "KD"]
          assert load_terms('["MoE", "moe"]') == ["MoE"]
          assert load_terms('["F1", "3D Gaussian Splatting"]') == ["F1", "3D Gaussian Splatting"]
      
      
      def test_load_terms_rejects_malformed_json_list(tmp_path: Path) -> None:
          terms = tmp_path / "terms.json"
          terms.write_text('["MoE",', encoding="utf-8")
      
          with pytest.raises(SystemExit) as inline_exc:
              load_terms('["MoE",')
          with pytest.raises(SystemExit) as file_exc:
              load_terms(str(terms))
      
          assert "Invalid terms JSON list" in str(inline_exc.value)
          assert "Invalid terms JSON list" in str(file_exc.value)
      
      
      @pytest.mark.parametrize(
          "selection",
          [
              "全部写入",
              "全部写入。",
              "1,3",
              "1、3",
              "1;3",
              "1;3",
              "1.",
              "1)",
              "#1",
              "(1)",
              '["1", "3"]',
          ],
      )
      def test_load_terms_rejects_unresolved_shortlist_selection(selection: str) -> None:
          with pytest.raises(SystemExit, match="Resolve shortlist"):
              load_terms(selection)
      
      
      def test_propose_ranks_acronyms_model_names_and_keywords() -> None:
          records = [
              {
                  "record_type": "section",
                  "section_id": "sec:intro",
                  "kind": "introduction",
                  "title": "Introduction",
                  "page_start": 1,
                  "page_end": 2,
                  "text": (
                      "Interictal Epileptiform Discharges (IEDs) are key. "
                      "IED detection is hard.\n"
                      "Keywords: Domain Generalization, Mixture-of-Experts, Knowledge Distillation\n"
                      "Knowledge Distillation helps. Knowledge Distillation again. "
                      "Interictal Epileptiform Discharges matter. The Method works. The Method again."
                  ),
              }
          ]
          candidates = {
              candidate["term"]: candidate["category"]
              for candidate in propose_candidates(records)
          }
          ordered_terms = [candidate["term"] for candidate in propose_candidates(records)]
      
          assert candidates["Domain Generalization"] == "keyword"
          assert candidates["Mixture-of-Experts"] == "keyword"
          assert candidates["Interictal Epileptiform Discharges"] == "full-name"
          assert candidates["IED"] == "acronym-or-model"
          assert candidates["Knowledge Distillation"] == "keyword"
          assert "The Method" not in candidates
          assert max(
              ordered_terms.index(term)
              for term in ("Domain Generalization", "Mixture-of-Experts", "Knowledge Distillation")
          ) < ordered_terms.index("Interictal Epileptiform Discharges")
          assert ordered_terms.index("Interictal Epileptiform Discharges") < ordered_terms.index("IED")
      
      
      def test_propose_cli_emits_selection_gate_state(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          payload = _run_proposal(tmp_path, monkeypatch, _records())
          assert payload["status"] == "ok"
          assert payload["mode"] == "propose"
          assert payload["workflow_state"] == "awaiting_semantic_review"
          assert payload["next_action"] == "record_reviewed_shortlist_then_present_and_wait"
          assert payload["summary"]["effective_body_characters"] == len(
              "".join(plan_glossary.effective_prose_text(_records()).split())
          )
          assert payload["summary"]["shortlist_limit"] == 10
          assert payload["summary"]["pool_candidates"] == len(payload["candidates"])
      
      
      def test_propose_cli_reports_no_candidates_without_waiting(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          records = [
              {
                  "record_type": "section",
                  "section_id": "sec:intro",
                  "kind": "introduction",
                  "title": "Introduction",
                  "page_start": 1,
                  "page_end": 1,
                  "text": "ordinary lowercase prose without candidate terminology.",
              }
          ]
      
          payload = _run_proposal(tmp_path, monkeypatch, records)
      
          assert payload["candidates"] == []
          assert payload["workflow_state"] == "no_candidates"
          assert payload["next_action"] == "report_no_candidates"
          assert payload["summary"] == {
              "effective_body_characters": len(
                  "".join(plan_glossary.effective_prose_text(records).split())
              ),
              "shortlist_limit": 10,
              "pool_candidates": 0,
          }
      
      
      def test_review_can_drop_all_candidates_and_reports_no_candidates(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          proposal = _run_proposal(tmp_path, monkeypatch, _records())
          assert proposal["candidates"]
      
          review = _run_review(tmp_path, monkeypatch, [])
      
          assert review["reviewed_shortlist"] == []
          assert review["workflow_state"] == "no_candidates"
          assert review["next_action"] == "report_no_candidates"
          assert review["summary"]["reviewed_candidates"] == 0
      
      
      @pytest.mark.parametrize(
          ("characters", "expected"),
          [
              (9_999, 10),
              (10_000, 18),
              (29_999, 18),
              (30_000, 25),
              (59_999, 25),
              (60_000, 35),
          ],
      )
      def test_shortlist_limit_uses_effective_body_length(
          characters: int, expected: int
      ) -> None:
          assert plan_glossary.shortlist_limit(characters) == expected
      
      
      def test_propose_filters_code_noise_and_keeps_emphasized_core_terms() -> None:
          highlighted_sentence = (
              "This is one entire highlighted sentence with several ordinary prose words"
          )
          records = [
              {
                  "record_type": "section",
                  "section_id": "sec:chapter",
                  "kind": "chapter",
                  "title": "Chapter",
                  "text": (
                      "**ReAct**. *Plan-and-Solve* and **Reflection** are core methods.\n"
                      "<strong>SerpApi</strong> provides search.\n"
                      "**[LinkedTerm](https://example.invalid/SECRET_MODEL)** is linked prose.\n"
                      f"**{highlighted_sentence}**\n"
                      "```python\nLLM_API_KEY = 'KEY'\n"
                      "REACT_PROMPT_TEMPLATE = 'MODEL'\n"
                      "print('HUAWEI phone result')\n```\n"
                      "# References\n**CitationNoise** appears only in the reference tail.\n"
                  ),
              },
              {
                  "record_type": "section",
                  "section_id": "sec:refs",
                  "kind": "references",
                  "title": "References",
                  "text": "SERPAPI TEMPLATE PLANNER",
              },
          ]
      
          terms = {item["term"] for item in propose_candidates(records)}
      
          assert {"ReAct", "Plan-and-Solve", "Reflection", "SerpApi", "LinkedTerm"} <= terms
          assert terms.isdisjoint({"KEY", "MODEL", "TEMPLATE", "HUAWEI", "SECRET", "CitationNoise"})
          assert highlighted_sentence not in terms
      
      
      def test_occurrence_grounding_masks_non_prose_and_keeps_original_snippet_offsets() -> None:
          records = [
              {
                  "record_type": "section",
                  "section_id": "sec:chapter",
                  "kind": "chapter",
                  "title": "Chapter",
                  "text": (
                      "```text\nReAct fenced code\n```\n"
                      "`ReAct inline code`\n"
                      "https://example.invalid/ReAct\n"
                      + "x" * 100
                      + "\n**ReAct** body evidence.\n"
                      + "y" * 100
                      + "\n# References\n**ReAct** citation only.\n"
                  ),
              }
          ]
      
          candidate = next(
              item for item in propose_candidates(records) if item["term"] == "ReAct"
          )
          occurrences, anchors = plan_glossary._find_occurrences_for_forms(
              candidate["surface_forms"], records
          )
          triaged = plan_glossary._triage_candidates([candidate], records)[0]
      
          assert occurrences == candidate["occurrences"] == triaged["occurrences"] == 1
          assert anchors == triaged["paper_anchors"]
          assert "body evidence" in anchors[0]["snippet"]
          assert "fenced code" not in anchors[0]["snippet"]
          assert "citation only" not in anchors[0]["snippet"]
      
      
      def test_candidate_mask_handles_exact_fences_code_spans_and_nested_link_destinations() -> None:
          records = [
              {
                  "record_type": "section",
                  "section_id": "sec:chapter",
                  "kind": "chapter",
                  "title": "Chapter",
                  "text": (
                      "````python\nignored\n```\n**FenceLeak**\n````\n"
                      "``prefix ` **InlineLeak**``\n"
                      "[LinkedTerm](docs/topic_(SECRET)_MODEL)\n"
                      "**CoreTerm** is body evidence.\n"
                  ),
              }
          ]
      
          candidates = {item["term"]: item for item in propose_candidates(records)}
      
          assert {"LinkedTerm", "CoreTerm"} <= candidates.keys()
          assert candidates["LinkedTerm"]["occurrences"] == 1
          assert {"FenceLeak", "InlineLeak", "SECRET", "MODEL"}.isdisjoint(candidates)
          for term in ("FenceLeak", "InlineLeak", "SECRET", "MODEL"):
              assert find_occurrences(term, records) == (0, [])
      
      
      @pytest.mark.parametrize(
          "sentence",
          [
              "We propose a new method",
              "This model improves benchmark accuracy",
              "我们提出一种新的方法",
          ],
      )
      def test_propose_rejects_emphasized_sentences(sentence: str) -> None:
          records = [
              {
                  "record_type": "section",
                  "section_id": "sec:method",
                  "kind": "method",
                  "title": "Method",
                  "text": f"**{sentence}**",
              }
          ]
      
          terms = {item["term"] for item in propose_candidates(records)}
      
          assert sentence not in terms
      
      
      @pytest.mark.parametrize(
          "term",
          [
              "ReAct",
              "Plan-and-Solve",
              "Reflection",
              "SerpApi",
              "Graph Memory Augmented Routing",
              "智能体路由",
              "归纳式与传导式推理",
          ],
      )
      def test_propose_keeps_term_shaped_emphasis(term: str) -> None:
          records = [
              {
                  "record_type": "section",
                  "section_id": "sec:method",
                  "kind": "method",
                  "title": "Method",
                  "text": f"**{term}** appears once.",
              }
          ]
      
          candidate = next(item for item in propose_candidates(records) if item["term"] == term)
      
          assert candidate["category"] == "emphasis"
          assert candidate["occurrences"] == 1
      
      
      @pytest.mark.parametrize("heading", ["参考文献", "引用"])
      def test_propose_removes_chinese_reference_tail_from_single_record(
          heading: str,
      ) -> None:
          records = [
              {
                  "record_type": "section",
                  "section_id": "sec:chapter",
                  "kind": "chapter",
                  "title": "Chapter",
                  "text": (
                      "**CoreTerm** is discussed in the chapter.\n"
                      f"# {heading}\n"
                      "**CitationNoise** appears only in the reference tail.\n"
                  ),
              }
          ]
      
          terms = {item["term"] for item in propose_candidates(records)}
      
          assert "CoreTerm" in terms
          assert "CitationNoise" not in terms
          assert f"# {heading}" not in plan_glossary.effective_prose_text(records)
      
      
      def test_propose_merges_nfkc_equivalent_surface_form_occurrences() -> None:
          records = [
              {
                  "record_type": "section",
                  "section_id": "sec:method",
                  "kind": "method",
                  "title": "Method",
                  "text": "**ReAct** and **ReAct** are equivalent spellings.",
              }
          ]
      
          matches = [
              item
              for item in propose_candidates(records)
              if unicodedata.normalize("NFKC", item["term"]).casefold() == "react"
          ]
      
          assert len(matches) == 1
          assert matches[0]["term"] == "ReAct"
          assert matches[0]["occurrences"] == 2
          assert "ReAct" in matches[0]["snippet"]
      
      
      def test_propose_deduplicates_overlapping_casefold_surface_matches() -> None:
          records = [
              {
                  "record_type": "section",
                  "section_id": "sec:method",
                  "kind": "method",
                  "title": "Method",
                  "text": "**ReAct** and **react** are two original occurrences.",
              }
          ]
      
          candidate = next(
              item
              for item in propose_candidates(records)
              if unicodedata.normalize("NFKC", item["term"]).casefold() == "react"
          )
      
          assert candidate["occurrences"] == 2
      
      
      def test_equivalent_surface_forms_preserve_record_order_and_anchor_limit() -> None:
          forms = ["ReAct", "ReAct"]
          records = [
              {
                  "record_type": "section",
                  "section_id": f"sec:{index}",
                  "kind": "method",
                  "title": f"Method {index}",
                  "page_start": index + 1,
                  "page_end": index + 1,
                  "text": f"**{forms[index % 2]}** appears in record {index}.",
              }
              for index in range(5)
          ]
      
          candidate = next(
              item
              for item in propose_candidates(records)
              if unicodedata.normalize("NFKC", item["term"]).casefold() == "react"
          )
          occurrences, anchors = plan_glossary._find_occurrences_for_forms(forms, records)
      
          assert candidate["occurrences"] == 5
          assert occurrences == 5
          assert [anchor["section_id"] for anchor in anchors] == ["sec:0", "sec:1", "sec:2"]
          assert [anchor["page_start"] for anchor in anchors] == [1, 2, 3]
      
      
      def test_propose_caps_pool_at_dynamic_shortlist_limit_plus_buffer() -> None:
          terms = [f"Term{index:02d}" for index in range(21)]
          records = [
              {
                  "record_type": "section",
                  "section_id": "sec:intro",
                  "kind": "introduction",
                  "title": "Introduction",
                  "page_start": 1,
                  "page_end": 1,
                  "text": "Keywords: " + ", ".join(reversed(terms)),
              }
          ]
      
          candidates = propose_candidates(records)
      
          assert len(candidates) == 20
      
      
      @pytest.mark.parametrize(
          ("effective_characters", "expected_pool"),
          [(10_000, 28), (30_000, 35), (60_000, 45)],
      )
      def test_propose_caps_pool_at_higher_dynamic_tiers(
          effective_characters: int, expected_pool: int
      ) -> None:
          terms = [f"Term{index:02d}" for index in range(50)]
          keyword_line = "Keywords: " + ", ".join(terms)
          keyword_characters = sum(1 for character in keyword_line if not character.isspace())
          records = [
              {
                  "record_type": "section",
                  "section_id": "sec:intro",
                  "kind": "introduction",
                  "title": "Introduction",
                  "page_start": 1,
                  "page_end": 1,
                  "text": keyword_line + "\n" + "x" * (effective_characters - keyword_characters),
              }
          ]
      
          assert sum(
              1
              for character in plan_glossary.effective_prose_text(records)
              if not character.isspace()
          ) == effective_characters
          assert len(propose_candidates(records)) == expected_pool
      
      
      def test_proposal_emits_ordered_surface_forms_and_stable_provenance(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          records = [
              {
                  "record_type": "section",
                  "section_id": "sec:method",
                  "kind": "method",
                  "title": "Method",
                  "page_start": 1,
                  "text": "**ReAct** and **ReAct** are equivalent source spellings.",
              }
          ]
      
          first = _run_proposal(tmp_path, monkeypatch, records)
          second = _run_proposal(tmp_path, monkeypatch, records)
          candidate = next(item for item in first["candidates"] if item["term"] == "ReAct")
      
          assert candidate["surface_forms"] == ["ReAct", "ReAct"]
          assert first["provenance"] == second["provenance"]
          assert first["provenance"]["paper_id"] == "paper-1"
          assert len(first["provenance"]["source_sha256"]) == 64
          assert len(first["provenance"]["proposal_sha256"]) == 64
      
      
      def test_review_recording_preserves_exact_ordered_candidate_objects(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          proposal = _run_proposal(tmp_path, monkeypatch, _records())
          names = [item["term"] for item in proposal["candidates"][:2]][::-1]
      
          reviewed = _run_review(tmp_path, monkeypatch, names)
      
          expected = [
              next(item for item in proposal["candidates"] if item["term"] == name)
              for name in names
          ]
          assert reviewed["mode"] == "review"
          assert reviewed["reviewed_shortlist"] == expected
          assert reviewed["proposal_provenance"] == proposal["provenance"]
          assert reviewed["provenance"]["proposal_sha256"] == proposal["provenance"][
              "proposal_sha256"
          ]
      
      
      @pytest.mark.parametrize(
          "reviewed_terms",
          [
              ["renamed candidate"],
              ["MoE", "MoE"],
              ["MoE|invented-alias"],
          ],
      )
      def test_review_recording_rejects_non_exact_or_duplicate_names(
          tmp_path: Path,
          monkeypatch: pytest.MonkeyPatch,
          reviewed_terms: list[str],
      ) -> None:
          _run_proposal(tmp_path, monkeypatch, _records())
      
          with pytest.raises(SystemExit, match="reviewed|proposal|duplicate|exact"):
              _run_review(tmp_path, monkeypatch, reviewed_terms)
      
      
      def test_review_recording_rejects_more_than_dynamic_shortlist_limit(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          records = [
              {
                  "record_type": "section",
                  "section_id": "sec:intro",
                  "kind": "introduction",
                  "title": "Introduction",
                  "text": "Keywords: " + ", ".join(f"Term{index:02d}" for index in range(12)),
              }
          ]
          proposal = _run_proposal(tmp_path, monkeypatch, records)
          names = [item["term"] for item in proposal["candidates"][:11]]
      
          with pytest.raises(SystemExit, match="shortlist limit"):
              _run_review(tmp_path, monkeypatch, names)
      
      
      def test_review_recording_rejects_tampered_proposal(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          proposal = _run_proposal(tmp_path, monkeypatch, _records())
          proposal["candidates"][0]["term"] = "Tampered"
          (tmp_path / "glossary_candidates.json").write_text(
              json.dumps(proposal, ensure_ascii=False), encoding="utf-8"
          )
      
          with pytest.raises(SystemExit, match="proposal"):
              _run_review(tmp_path, monkeypatch, ["Tampered"])
      
      
      def test_review_recording_rejects_proposal_from_another_paper(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          proposal = _run_proposal(tmp_path, monkeypatch, _records())
          manifest = tmp_path / "paper_source_manifest.json"
          payload = json.loads(manifest.read_text(encoding="utf-8"))
          payload["paper_id"] = "paper-2"
          manifest.write_text(json.dumps(payload), encoding="utf-8")
      
          with pytest.raises(SystemExit, match="paper|source|proposal"):
              _run_review(tmp_path, monkeypatch, [proposal["candidates"][0]["term"]])
      
      
      def test_review_recording_rejects_changed_source_data(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          proposal = _run_proposal(tmp_path, monkeypatch, _records())
          raw = tmp_path / "paper_raw_sections.jsonl"
          raw.write_text(raw.read_text(encoding="utf-8") + "\n" + json.dumps({
              "record_type": "section",
              "section_id": "sec:new",
              "kind": "method",
              "text": "**NewTerm** changed the source.",
          }), encoding="utf-8")
      
          with pytest.raises(SystemExit, match="source|proposal"):
              _run_review(tmp_path, monkeypatch, [proposal["candidates"][0]["term"]])
      
      
      def test_triage_requires_reviewed_shortlist_artifact(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          _run_proposal(tmp_path, monkeypatch, _records())
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "plan_glossary.py",
                  "--terms",
                  '["MoE"]',
                  "--source-manifest",
                  str(tmp_path / "paper_source_manifest.json"),
              ],
          )
      
          with pytest.raises(SystemExit, match="reviewed shortlist|--reviewed-shortlist"):
              main()
      
      
      def test_triage_uses_reviewed_surface_forms_and_preserves_provenance(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          records = [
              {
                  "record_type": "section",
                  "section_id": "sec:method",
                  "kind": "method",
                  "title": "Method",
                  "page_start": 1,
                  "text": "**ReAct** and **ReAct** are equivalent source spellings.",
              }
          ]
          proposal = _run_proposal(tmp_path, monkeypatch, records)
          reviewed = _run_review(tmp_path, monkeypatch, ["ReAct"])
      
          triaged = _run_triage(tmp_path, monkeypatch, ["ReAct"])
      
          assert triaged["terms"][0]["surface_forms"] == ["ReAct", "ReAct"]
          assert triaged["terms"][0]["occurrences"] == 2
          assert triaged["provenance"]["proposal"] == proposal["provenance"]
          assert triaged["provenance"]["review"] == reviewed["provenance"]
          assert triaged["provenance"]["selection_sha256"]
      
      
      @pytest.mark.parametrize(
          "selection",
          [["unknown"], ["ReAct|invented-alias"], "全部写入", "1", '["1"]'],
      )
      def test_triage_rejects_names_outside_review_or_unresolved_controls(
          tmp_path: Path,
          monkeypatch: pytest.MonkeyPatch,
          selection: list[str] | str,
      ) -> None:
          proposal = _run_proposal(tmp_path, monkeypatch, _records())
          _run_review(tmp_path, monkeypatch, [proposal["candidates"][0]["term"]])
      
          with pytest.raises(SystemExit, match="shortlist|Resolve|exact"):
              _run_triage(tmp_path, monkeypatch, selection)
      
      
      @pytest.mark.parametrize("field", ["reviewed_shortlist", "provenance"])
      def test_triage_rejects_tampered_reviewed_candidate_or_identity(
          tmp_path: Path,
          monkeypatch: pytest.MonkeyPatch,
          field: str,
      ) -> None:
          proposal = _run_proposal(tmp_path, monkeypatch, _records())
          name = proposal["candidates"][0]["term"]
          reviewed = _run_review(tmp_path, monkeypatch, [name])
          if field == "reviewed_shortlist":
              reviewed[field][0]["surface_forms"] = ["invented"]
          else:
              reviewed[field]["review_sha256"] = "0" * 64
          (tmp_path / "glossary_reviewed.json").write_text(
              json.dumps(reviewed, ensure_ascii=False), encoding="utf-8"
          )
      
          with pytest.raises(SystemExit, match="review|proposal|identity"):
              _run_triage(tmp_path, monkeypatch, [name])
      
      
      def test_triage_rejects_reviewed_artifact_after_source_proposal_changes(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          proposal = _run_proposal(tmp_path, monkeypatch, _records())
          name = proposal["candidates"][0]["term"]
          _run_review(tmp_path, monkeypatch, [name])
          raw = tmp_path / "paper_raw_sections.jsonl"
          raw.write_text(
              raw.read_text(encoding="utf-8")
              + "\n"
              + json.dumps(
                  {
                      "record_type": "section",
                      "section_id": "sec:changed",
                      "kind": "method",
                      "text": "**ChangedTerm** changes the proposal source.",
                  }
              ),
              encoding="utf-8",
          )
      
          with pytest.raises(SystemExit, match="source|proposal|review"):
              _run_triage(tmp_path, monkeypatch, [name])
      
      
      def test_triage_rejects_reviewed_artifact_from_another_paper(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          proposal = _run_proposal(tmp_path, monkeypatch, _records())
          name = proposal["candidates"][0]["term"]
          _run_review(tmp_path, monkeypatch, [name])
          manifest = tmp_path / "paper_source_manifest.json"
          payload = json.loads(manifest.read_text(encoding="utf-8"))
          payload["paper_id"] = "paper-2"
          manifest.write_text(json.dumps(payload), encoding="utf-8")
      
          with pytest.raises(SystemExit, match="proposal|review"):
              _run_triage(tmp_path, monkeypatch, [name])
      
    • test_skill_boundary.py 15.2 KB
      from __future__ import annotations
      
      import json
      import re
      import subprocess
      import sys
      from pathlib import Path
      
      
      SKILL_DIR = Path(__file__).resolve().parents[1]
      
      
      def _section(text: str, start: str, end: str | None = None) -> str:
          body = text.split(start, 1)[1]
          return body.split(end, 1)[0] if end else body
      
      
      def _assert_in_order(text: str, phrases: tuple[str, ...]) -> None:
          positions = [text.index(phrase) for phrase in phrases]
          assert positions == sorted(positions)
      
      
      def test_skill_documents_required_manifest_override_file_boundary() -> None:
          text = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
      
          assert "*_source_manifest.json" in text
          assert "raw_sections_path" in text
          assert "*_raw_sections.jsonl" in text
          assert "`--source-manifest` is always required" in text
          assert "`--raw-sections` only overrides" in text
          assert "scripts/run_pipeline.py" not in text
      
      
      def test_scripts_are_self_contained_within_paper_glossary() -> None:
          scripts = sorted((SKILL_DIR / "scripts").glob("*.py"))
          assert scripts
          for path in scripts:
              text = path.read_text(encoding="utf-8")
              assert "skills.deeppapernote" not in text
              assert "deeppapernote" not in text.lower()
      
      
      def test_skill_orders_preview_gate_before_post_selection_writes() -> None:
          text = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
          preview = _section(text, "## Preview And Wait (Default)", "## After Selection")
          after = _section(text, "## After Selection", "## Grounding")
      
          _assert_in_order(
              preview,
              (
                  "Show saved configuration",
                  "Require an explicit article Markdown path",
                  "Run deterministic proposal",
                  "Perform exactly one grounded host semantic review",
                  "Record the reviewed shortlist",
                  "Present every retained term",
                  "No glossary notes or article Markdown have been written",
                  "End the response and wait",
              ),
          )
          _assert_in_order(
              after,
              (
                  "Accept only numbers",
                  "Triage",
                  "inventory",
                  "generate one action-aware batch",
                  "Run one writer invocation",
                  "whole-batch preflight",
                  "create/enrich/reuse commit",
                  "Link each successful writer result",
                  "Lint writer-returned changed glossary note files",
                  "Report observable wall-clock timing",
              ),
          )
      
      
      def test_task7_acceptance_preview_records_review_before_display_and_wait() -> None:
          text = (SKILL_DIR / "references" / "optimization-implementation-plan.md").read_text(
              encoding="utf-8"
          )
          task7 = _section(text, "### Task 7:", "## Execution Completion Criteria")
      
          _assert_in_order(
              task7,
              (
                  "--review-proposal",
                  "Display every retained item from this reviewed artifact",
                  "End the response and wait",
              ),
          )
      
      
      def test_preview_discloses_setup_write_and_limits_no_write_claim() -> None:
          text = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
          preview = _section(text, "## Preview And Wait (Default)", "## After Selection")
      
          assert "Setup may create `~/.paper-glossary/config.json`" in preview
          assert "No glossary notes or article Markdown have been written" in preview
          assert "no files have been written" not in preview.lower()
          assert "before selection" in preview
      
      
      def test_semantic_review_is_an_exact_term_subset_permutation() -> None:
          skill = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
          contract = (SKILL_DIR / "references" / "file-contract.md").read_text(
              encoding="utf-8"
          )
      
          for text in (skill, contract):
              assert "only drop or reorder candidates" in text
              assert "exact `term` string" in text
              assert not re.search(
                  r"\b(?:merge|rename|canonical|canonicalize|invent)\b", text, re.IGNORECASE
              )
      
      
      def test_linker_is_conditional_and_glossary_only_skips_it() -> None:
          skill = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
          contract = (SKILL_DIR / "references" / "file-contract.md").read_text(
              encoding="utf-8"
          )
          after = _section(skill, "## After Selection", "## Grounding")
      
          for text in (after, contract):
              assert "only if an article Markdown was supplied/requested" in text
              assert "For a glossary-only request, skip `link_glossary_terms.py`" in text
      
      
      def test_lint_scope_is_writer_returned_changed_glossary_notes_only() -> None:
          skill = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
          contract = (SKILL_DIR / "references" / "file-contract.md").read_text(
              encoding="utf-8"
          )
          after = _section(skill, "## After Selection", "## Grounding")
      
          for text in (after, contract):
              assert "writer-returned changed glossary note files" in text
              assert "`action` is `created`, `enriched`, or `updated`" in text
              assert "one `lint_glossary.py` invocation with repeated `--input PATH`" in text
              assert "Do not pass article Markdown to `lint_glossary.py`" in text
      
      
      def test_term_note_linter_rejects_an_ordinary_article() -> None:
          scripts_dir = SKILL_DIR / "scripts"
          sys.path.insert(0, str(scripts_dir))
          from lint_glossary import lint_term_file_text
      
          result = lint_term_file_text("# Paper\n\nOrdinary article prose.\n")
          codes = {issue["code"] for issue in result["issues"]}
      
          assert result["passes"] is False
          assert {
              "term_disclaimer_missing",
              "term_concept_zone_missing",
              "term_occurrence_zone_missing",
          }.issubset(codes)
      
      
      def test_timing_matches_host_and_cli_observation_boundaries() -> None:
          skill = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
          contract = (SKILL_DIR / "references" / "file-contract.md").read_text(
              encoding="utf-8"
          )
          after = _section(skill, "## After Selection", "## Grounding")
      
          for text in (after, contract):
              assert "Time host-only phases separately" in text
              assert "Each CLI emits top-level `elapsed_ms`" in text
              assert "single writer invocation includes whole-batch preflight and commit" in text
              assert "Do not report separate preflight and commit timings" in text
              assert "around each phase" not in text
          assert "current CLIs do not emit `elapsed_ms`" not in after
          assert '"elapsed_ms"' in contract
      
      
      def test_default_prompt_enters_preview_and_wait_before_content_writes() -> None:
          text = (SKILL_DIR / "agents" / "openai.yaml").read_text(encoding="utf-8")
          default_prompt = next(
              line.strip() for line in text.splitlines() if line.strip().startswith("default_prompt:")
          ).lower()
      
          assert "preview-and-wait" in default_prompt
          assert "numbered shortlist" in default_prompt
          assert "stop and wait" in default_prompt
          assert "before writing glossary notes or article links" in default_prompt
      
      
      def test_readme_has_copy_paste_commands_for_each_launcher() -> None:
          text = (SKILL_DIR / "README.md").read_text(encoding="utf-8")
          windows = _section(text, "## Windows PowerShell", "## macOS / Linux Bash")
          unix = _section(text, "## macOS / Linux Bash")
      
          windows_commands = [
              line.strip() for line in windows.splitlines() if line.startswith("py -3.12 ")
          ]
          unix_commands = [
              line.strip() for line in unix.splitlines() if line.startswith("python3 ")
          ]
      
          assert len(windows_commands) == 5
          assert len(unix_commands) == 5
          assert "python3 " not in windows
          assert "py -3.12 " not in unix
          assert any("--terms-dir '<vault>\\Glossary'" in line for line in windows_commands)
          assert any(
              "--validate-article '<vault>\\Papers\\example.md'" in line
              for line in windows_commands
          )
          assert any("--terms-dir '<vault>/Glossary'" in line for line in unix_commands)
          assert any(
              "--validate-article '<vault>/Papers/example.md'" in line
              for line in unix_commands
          )
          for flag in ("--show", "--reset", "-m pytest"):
              assert any(flag in line for line in windows_commands)
              assert any(flag in line for line in unix_commands)
      
      
      def test_writer_docs_use_configured_destination_only() -> None:
          documents = [
              SKILL_DIR / "SKILL.md",
              SKILL_DIR / "README.md",
              SKILL_DIR / "references" / "file-contract.md",
          ]
      
          for path in documents:
              text = path.read_text(encoding="utf-8")
              assert "configured glossary directory" in text
      
      
      def test_test_suite_is_self_contained_within_skill() -> None:
          forbidden_fragments = (
              "." + "superpowers",
              "SKILL_DIR.parents" + "[1]",
          )
      
          for path in (SKILL_DIR / "tests").glob("test_*.py"):
              text = path.read_text(encoding="utf-8")
              for fragment in forbidden_fragments:
                  assert fragment not in text, f"{path.name} depends on repo-local scratch: {fragment}"
      
      
      def test_workflow_integrations_use_config_backed_writer_cli() -> None:
          text = (SKILL_DIR / "tests" / "test_workflow_integration.py").read_text(
              encoding="utf-8"
          )
      
          assert "write_glossary_entries" not in text
          assert 'SCRIPTS_DIR / "write_glossary_terms.py"' in text
          assert text.count("_run_writer_cli(") == 4
          assert '"--config-path"' in text
          assert text.count('"--triage"') == 2
      
      
      def test_contract_documents_current_json_and_required_manifest_override() -> None:
          text = (SKILL_DIR / "references" / "file-contract.md").read_text(encoding="utf-8")
      
          assert "`--source-manifest` is always required" in text
          assert "`--raw-sections` only overrides" in text
          assert "cannot replace the manifest" in text
          for field in (
              '"shortlist_limit"',
              '"awaiting_semantic_review"',
              '"existing_thin"',
              '"operation": "enrich"',
              '"article_sha256"',
              '"already_linked"',
              '"surface_forms"',
              '"proposal_sha256"',
              '"review_sha256"',
          ):
              assert field in text
      
          for flag in ("--review-proposal", "--reviewed-terms", "--reviewed-shortlist"):
              assert flag in text
      
      
      def test_docs_stop_when_semantic_review_drops_every_candidate() -> None:
          documents = [
              SKILL_DIR / "SKILL.md",
              SKILL_DIR / "README.md",
              SKILL_DIR / "references" / "file-contract.md",
          ]
      
          for path in documents:
              text = path.read_text(encoding="utf-8")
              assert "empty reviewed shortlist" in text
              assert "`no_candidates`" in text
      
      
      def test_post_triage_docs_define_current_chain_digest_and_writer_contexts() -> None:
          documents = [
              SKILL_DIR / "SKILL.md",
              SKILL_DIR / "README.md",
              SKILL_DIR / "references" / "file-contract.md",
          ]
      
          for path in documents:
              text = path.read_text(encoding="utf-8")
              assert "current `--source-manifest`" in text
              assert "saved `--reviewed-shortlist`" in text
              assert "saved `--triage`" in text
              assert "`mappings_sha256`" in text
              assert "`triage_sha256`" in text
              assert "exact ordered `term` and `surface_forms`" in text
              assert "derived from the resolved article Markdown stem" in text
              assert "validated manifest `paper_id`" in text
              assert "no bound article path" in text
              assert "--paper-link" not in text
      
      
      def test_plan_parser_rejects_raw_sections_without_required_manifest() -> None:
          script = SKILL_DIR / "scripts" / "plan_glossary.py"
          result = subprocess.run(
              [sys.executable, str(script), "--propose", "--raw-sections", "paper.jsonl"],
              capture_output=True,
              text=True,
              encoding="utf-8",
              check=False,
          )
      
          assert result.returncode == 2
          assert "the following arguments are required: --source-manifest" in result.stderr
      
      
      def test_optimization_design_preserves_reviewed_candidates_and_forms() -> None:
          text = (SKILL_DIR / "references" / "optimization-design.md").read_text(
              encoding="utf-8"
          )
          review = _section(text, "### Semantic review", "## Selection and Generation")
      
          assert "only drop or reorder exact proposal candidates" in review
          assert "exact `term`" in review
          assert "ordered `surface_forms`" in review
          assert "may not merge aliases" in review
          assert "remove residual noise, merge aliases" not in review
      
      
      def test_optimization_design_uses_config_only_writer_authority() -> None:
          text = (SKILL_DIR / "references" / "optimization-design.md").read_text(
              encoding="utf-8"
          )
          configuration = _section(text, "## First-use Configuration", "## Candidate Workflow")
      
          assert "Writer destination authority comes only from validated device config" in configuration
          assert "`--config-path` selects a config file" in configuration
          assert "reset and configure" in configuration
          assert "explicit path supplied for the current run overrides" not in configuration
          assert "transient destination override" not in configuration
      
      
      def test_implementation_plan_step5_inventory_cli_lists_every_required_flag() -> None:
          text = (SKILL_DIR / "references" / "optimization-implementation-plan.md").read_text(
              encoding="utf-8"
          )
          task3 = _section(text, "### Task 3:", "### Task 4:")
          inventory_step = _section(
              task3,
              "- [ ] **Step 5: Add inventory CLI**",
              "- [ ] **Step 6:",
          )
      
          for flag in (
              "--terms",
              "--terms-dir",
              "--source-manifest",
              "--raw-sections",
              "--reviewed-shortlist",
              "--output",
          ):
              assert flag in inventory_step
          assert "triage JSON" in inventory_step
          assert "optional `--raw-sections`" in inventory_step
      
      
      def test_implementation_plan_documents_authenticated_integration_flow() -> None:
          text = (SKILL_DIR / "references" / "optimization-implementation-plan.md").read_text(
              encoding="utf-8"
          )
          integration = _section(text, "### Task 6:", "### Task 7:")
      
          assert "proposal -> recorded review -> explicit selection -> triage -> authenticated inventory" in integration
          assert "config-backed writer -> authenticated linker" in integration
          for helper in (
              "inspect_selected_terms(",
              "write_glossary_entries(",
              "link_article_terms(",
          ):
              assert helper not in integration
      
      
      def test_file_contract_writer_example_is_parseable_and_consistently_bound() -> None:
          text = (SKILL_DIR / "references" / "file-contract.md").read_text(encoding="utf-8")
          writer_section = text.split(
              "`write_glossary_terms.py` returns successful results", 1
          )[1]
          match = re.search(
              r"```json\r?\n(?P<payload>.*?)\r?\n```",
              writer_section,
              re.DOTALL,
          )
          assert match is not None
          payload = json.loads(match.group("payload"))
      
          proposal = payload["provenance"]["proposal"]
          review = payload["provenance"]["review"]
          result = payload["results"][0]
          candidate = proposal["candidates"][0]
      
          assert payload["paper_id"] == proposal["paper_id"] == review["paper_id"]
          assert payload["context"]["paper_id"] == payload["paper_id"]
          assert proposal["candidates"] == review["candidates"]
          assert review["reviewed_shortlist"] == [candidate]
          assert candidate["term"] == result["name"] == result["link_stem"] == "MoE"
          assert candidate["surface_forms"] == result["forms"] == ["MoE", "MoE"]
          assert payload["provenance"]["selection_sha256"] == "sha256"
          assert payload["triage_sha256"] == "sha256"
          assert payload["mappings_sha256"] == "sha256"
      
    • test_workflow_integration.py 12.2 KB
      from __future__ import annotations
      
      import json
      import subprocess
      import sys
      from pathlib import Path
      
      import glossary_common
      from conftest import build_current_inventory, write_current_workflow
      from glossary_common import elapsed_ms
      from glossary_config import configure_terms_dir, validate_article
      from lint_glossary import lint_term_file_text
      from write_glossary_terms import render_term_file
      
      
      SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts"
      
      
      def _run_writer_cli(
          tmp_path: Path,
          entries: dict,
          inventory: dict,
          config_path: Path,
          source_manifest: Path,
          reviewed_shortlist: Path,
          triage: Path,
          article: Path | None = None,
      ) -> dict:
          glossary_path = tmp_path / "writer_glossary.json"
          inventory_path = tmp_path / "writer_inventory.json"
          output_path = tmp_path / "writer_output.json"
          glossary_path.write_text(
              json.dumps(entries, ensure_ascii=False), encoding="utf-8"
          )
          inventory_path.write_text(
              json.dumps(inventory, ensure_ascii=False), encoding="utf-8"
          )
          arguments = [
              sys.executable,
              str(SCRIPTS_DIR / "write_glossary_terms.py"),
              "--glossary",
              str(glossary_path),
              "--inventory",
              str(inventory_path),
              "--config-path",
              str(config_path),
              "--source-manifest",
              str(source_manifest),
              "--reviewed-shortlist",
              str(reviewed_shortlist),
              "--triage",
              str(triage),
              "--output",
              str(output_path),
          ]
          if article is not None:
              arguments.extend(["--article", str(article)])
          subprocess.run(
              arguments,
              check=True,
              capture_output=True,
              text=True,
              encoding="utf-8",
          )
          return json.loads(output_path.read_text(encoding="utf-8"))
      
      
      def _run_linker_cli(
          tmp_path: Path,
          article: Path,
          expected_sha256: str,
          config_path: Path,
          source_manifest: Path,
          reviewed_shortlist: Path,
          triage: Path,
      ) -> dict:
          output_path = tmp_path / "linker_output.json"
          subprocess.run(
              [
                  sys.executable,
                  str(SCRIPTS_DIR / "link_glossary_terms.py"),
                  "--input",
                  str(article),
                  "--write-result",
                  str(tmp_path / "writer_output.json"),
                  "--expected-sha256",
                  expected_sha256,
                  "--config-path",
                  str(config_path),
                  "--source-manifest",
                  str(source_manifest),
                  "--reviewed-shortlist",
                  str(reviewed_shortlist),
                  "--triage",
                  str(triage),
                  "--output",
                  str(output_path),
              ],
              check=True,
              capture_output=True,
              text=True,
              encoding="utf-8",
          )
          return json.loads(output_path.read_text(encoding="utf-8"))
      
      
      def test_elapsed_ms_returns_non_negative_integer(monkeypatch) -> None:
          monkeypatch.setattr(glossary_common, "perf_counter", lambda: 2.125)
      
          assert elapsed_ms(2.0) == 125
          assert elapsed_ms(3.0) == 0
          assert isinstance(elapsed_ms(2.0), int)
      
      
      def test_complete_selected_flow_creates_enriches_links_and_lints(
          tmp_path: Path,
      ) -> None:
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          article = vault / "Chapter.md"
          article.write_text(
              "# Agent Patterns\n\nReAct and Reflection are two agent patterns.\n",
              encoding="utf-8",
          )
          terms_dir = vault / "book" / "Glossary"
          config_path = tmp_path / "config.json"
          config = configure_terms_dir(terms_dir, config_path)
          article_info = validate_article(article, config)
      
          reflection = terms_dir / "Reflection.md"
          reflection.write_text(
              render_term_file(
                  {
                      "name": "Reflection",
                      "aliases": ["self-reflection"],
                      "definition": "An agent pattern that reviews prior results.",
                      "confidence": "\u9ad8",
                      "occurrence": "Earlier source evidence.",
                  },
                  "Earlier Chapter",
              ),
              encoding="utf-8",
          )
          selected = [
              {"term": "ReAct", "surface_forms": ["ReAct"]},
              {"term": "Reflection", "surface_forms": ["Reflection"]},
          ]
          workflow = write_current_workflow(
              tmp_path / "workflow", [item["term"] for item in selected]
          )
          inventory = build_current_inventory(workflow, terms_dir)
          entries = {
              "entries": [
                  {
                      "name": "ReAct",
                      "aliases": ["Reasoning and Acting"],
                      "operation": "create",
                      "definition": "An agent pattern that interleaves reasoning and action.",
                      "confidence": "\u9ad8",
                      "occurrence": "The chapter introduces ReAct.",
                  },
                  {
                      "name": "Reflection",
                      "aliases": ["self-reflection"],
                      "operation": "enrich",
                      "elaboration": "The agent uses feedback to improve a prior result.",
                      "intuition": "Review the last attempt before trying again.",
                      "distinction": "Unlike ReAct, it focuses on reviewing prior output.",
                      "occurrence": "The chapter presents Reflection as an agent pattern.",
                  },
              ]
          }
      
          write_result = _run_writer_cli(
              tmp_path,
              entries,
              inventory,
              config_path,
              workflow["manifest_path"],
              workflow["review_path"],
              workflow["triage_path"],
              article,
          )
          assert write_result["script"] == "write_glossary_terms.py"
          assert write_result["terms_dir"] == str(terms_dir.resolve())
          link_result = _run_linker_cli(
              tmp_path,
              article,
              article_info["article_sha256"],
              config_path,
              workflow["manifest_path"],
              workflow["review_path"],
              workflow["triage_path"],
          )
          changed_notes = [
              Path(item["file"])
              for item in write_result["results"]
              if item["action"] in {"created", "enriched", "updated"}
          ]
          lint_results = [
              lint_term_file_text(path.read_text(encoding="utf-8-sig"))
              for path in changed_notes
          ]
      
          assert [item["action"] for item in write_result["results"]] == [
              "created",
              "enriched",
          ]
          assert link_result["summary"] == {
              "linked": 2,
              "already_linked": 0,
              "not_found": 0,
          }
          article_text = article.read_text(encoding="utf-8")
          assert "[[ReAct]]" in article_text
          assert "[[Reflection]]" in article_text
          assert len(changed_notes) == 2
          assert all(result["passes"] for result in lint_results)
      
      
      def test_model_only_alias_is_note_metadata_but_never_an_article_match_form(
          tmp_path: Path,
      ) -> None:
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          article = vault / "Chapter.md"
          article.write_text("# Paper\n\nReasoning and Acting appears here.\n", encoding="utf-8")
          terms_dir = vault / "Glossary"
          config_path = tmp_path / "config.json"
          config = configure_terms_dir(terms_dir, config_path)
          article_info = validate_article(article, config)
          workflow = write_current_workflow(
              tmp_path / "workflow",
              ["ReAct"],
              source_text="**ReAct** and **\uff32\uff45\uff21\uff43\uff54** are equivalent forms.",
          )
          inventory = build_current_inventory(workflow, terms_dir)
      
          write_result = _run_writer_cli(
              tmp_path,
              {
                  "entries": [
                      {
                          "name": "ReAct",
                          "aliases": ["Reasoning and Acting"],
                          "operation": "create",
                          "definition": "A reasoning and acting pattern.",
                          "confidence": "高",
                          "occurrence": "The paper uses ReAct.",
                      }
                  ]
              },
              inventory,
              config_path,
              workflow["manifest_path"],
              workflow["review_path"],
              workflow["triage_path"],
              article,
          )
      
          link_result = _run_linker_cli(
              tmp_path,
              article,
              article_info["article_sha256"],
              config_path,
              workflow["manifest_path"],
              workflow["review_path"],
              workflow["triage_path"],
          )
      
          assert write_result["results"][0]["forms"] == ["ReAct", "ReAct"]
          assert '  - "Reasoning and Acting"' in (terms_dir / "ReAct.md").read_text(
              encoding="utf-8"
          )
          assert link_result["summary"] == {
              "linked": 0,
              "already_linked": 0,
              "not_found": 1,
          }
          assert "[[" not in article.read_text(encoding="utf-8")
      
      
      def test_grounded_equivalent_form_survives_the_complete_artifact_chain(
          tmp_path: Path,
      ) -> None:
          raw = tmp_path / "paper_raw_sections.jsonl"
          raw.write_text(
              json.dumps(
                  {
                      "record_type": "section",
                      "section_id": "sec:method",
                      "kind": "method",
                      "title": "Method",
                      "text": "**ReAct** and **ReAct** are equivalent source spellings.",
                  },
                  ensure_ascii=False,
              ),
              encoding="utf-8",
          )
          manifest = tmp_path / "paper_source_manifest.json"
          manifest.write_text(
              json.dumps({"paper_id": "paper-1", "raw_sections_path": str(raw)}),
              encoding="utf-8",
          )
          proposal = tmp_path / "proposal.json"
          reviewed = tmp_path / "reviewed.json"
          triage = tmp_path / "triage.json"
          inventory_path = tmp_path / "inventory.json"
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          terms_dir = vault / "terms"
          config_path = tmp_path / "config.json"
          configure_terms_dir(terms_dir, config_path)
      
          commands = [
              [
                  sys.executable,
                  str(SCRIPTS_DIR / "plan_glossary.py"),
                  "--propose",
                  "--source-manifest",
                  str(manifest),
                  "--output",
                  str(proposal),
              ],
              [
                  sys.executable,
                  str(SCRIPTS_DIR / "plan_glossary.py"),
                  "--review-proposal",
                  str(proposal),
                  "--reviewed-terms",
                  '["ReAct"]',
                  "--source-manifest",
                  str(manifest),
                  "--output",
                  str(reviewed),
              ],
              [
                  sys.executable,
                  str(SCRIPTS_DIR / "plan_glossary.py"),
                  "--reviewed-shortlist",
                  str(reviewed),
                  "--terms",
                  '["ReAct"]',
                  "--source-manifest",
                  str(manifest),
                  "--output",
                  str(triage),
              ],
              [
                  sys.executable,
                  str(SCRIPTS_DIR / "inspect_glossary_library.py"),
                  "--terms",
                  str(triage),
                  "--terms-dir",
                  str(terms_dir),
                  "--source-manifest",
                  str(manifest),
                  "--reviewed-shortlist",
                  str(reviewed),
                  "--output",
                  str(inventory_path),
              ],
          ]
          for command in commands:
              subprocess.run(command, check=True, capture_output=True, text=True, encoding="utf-8")
      
          proposal_payload = json.loads(proposal.read_text(encoding="utf-8"))
          reviewed_payload = json.loads(reviewed.read_text(encoding="utf-8"))
          triage_payload = json.loads(triage.read_text(encoding="utf-8"))
          inventory = json.loads(inventory_path.read_text(encoding="utf-8"))
          write_result = _run_writer_cli(
              tmp_path,
              {
                  "entries": [
                      {
                          "name": "ReAct",
                          "aliases": [],
                          "operation": "create",
                          "definition": "A reasoning and acting pattern.",
                          "confidence": "高",
                          "occurrence": "The paper uses both forms.",
                      }
                  ]
              },
              inventory,
              config_path,
              manifest,
              reviewed,
              triage,
          )
      
          assert write_result["script"] == "write_glossary_terms.py"
          assert write_result["terms_dir"] == str(terms_dir.resolve())
          forms = ["ReAct", "ReAct"]
          assert proposal_payload["candidates"][0]["surface_forms"] == forms
          assert reviewed_payload["reviewed_shortlist"][0]["surface_forms"] == forms
          assert triage_payload["terms"][0]["surface_forms"] == forms
          assert inventory["results"][0]["surface_forms"] == forms
          assert write_result["results"][0]["forms"] == forms
      
    • test_write_and_lint_glossary.py 66.6 KB
      from __future__ import annotations
      
      from copy import deepcopy
      import json
      import os
      import sys
      from pathlib import Path
      
      import pytest
      import write_glossary_terms as writer_module
      from conftest import (
          bind_inventory_results,
          build_current_inventory,
          build_inventory_payload,
          write_current_workflow,
      )
      
      from glossary_config import configure_terms_dir
      from glossary_common import selection_sha256
      from glossary_contracts import (
          GLOSSARY_CONCEPT_HEADING,
          GLOSSARY_OCCURRENCE_HEADING,
      )
      from glossary_library import inspect_selected_terms
      from lint_glossary import GLOSSARY_DISCLAIMER, lint_term_file_text, main as lint_main
      from write_glossary_terms import (
          append_occurrence,
          build_alias_index,
          main as write_main,
          render_term_file,
          safe_term_filename,
          upsert_term_file,
          write_glossary_entries,
      )
      
      ENTRY = {
          "name": "KL 散度",
          "aliases": ["KL divergence", "相对熵"],
          "routing": "needs_explanation",
          "definition": "衡量两个概率分布差异的非对称度量。",
          "elaboration": "常用于把学生分布拉近教师分布。",
          "intuition": "把 Q 当作近似 P 时的信息损失。",
          "confidence": "高",
          "occurrence": "方法 式(4),第 3-6 页",
      }
      
      
      def _codes(result: dict) -> set[str]:
          return {issue["code"] for issue in result["issues"]}
      
      
      def _inventory(results: list[dict]) -> dict:
          return bind_inventory_results(results)
      
      
      def test_render_term_file_is_lintable() -> None:
          text = render_term_file(ENTRY, "CAD-MoE")
          result = lint_term_file_text(text)
          assert result["passes"] is True
          assert "aliases:" in text and "KL divergence" in text
          assert "[[CAD-MoE]]" in text
      
      
      def test_safe_term_filename_strips_illegal_chars() -> None:
          assert "/" not in safe_term_filename("KL/散度")
          assert ":" not in safe_term_filename("KL: 散度?")
          assert safe_term_filename("KL 散度") == "KL 散度"
      
      
      @pytest.mark.parametrize(
          ("name", "expected"),
          [
              ("C#", "C#"),
              ("term^block", "term^block"),
              ("[term]", "[term]"),
              ("left|right", "left|right"),
              ("key:value", "key:value"),
              ("100%", "100%"),
          ],
      )
      def test_safe_term_filename_translates_obsidian_link_syntax(
          name: str, expected: str
      ) -> None:
          assert safe_term_filename(name) == expected
      
      
      def test_writer_preserves_exact_name_when_safe_link_stem_differs(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          selected = [{"term": "C#", "surface_forms": ["C#"]}]
          inventory = _inventory(inspect_selected_terms(selected, terms_dir))
      
          result = write_glossary_entries(
              {
                  "entries": [
                      {
                          "name": "C#",
                          "operation": "create",
                          "definition": "A programming language.",
                          "confidence": "高",
                          "occurrence": "Paper evidence.",
                      }
                  ]
              },
              inventory,
              terms_dir,
              "Paper",
          )
      
          note = terms_dir / "C#.md"
          assert result["results"][0]["link_stem"] == "C#"
          assert note.is_file()
          text = note.read_text(encoding="utf-8")
          assert '  - "C#"' in text
          assert "# C#\n" in text
          existing = inspect_selected_terms(selected, terms_dir)[0]
          assert existing["file"] == str(note)
          assert existing["link_stem"] == "C#"
      
      
      def test_writer_rejects_unsafe_existing_stem_before_batch_write(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          unsafe = terms_dir / "C#.md"
          unsafe.write_text(
              render_term_file({**ENTRY, "name": "C#", "aliases": []}, "Paper"),
              encoding="utf-8",
          )
          before = unsafe.read_bytes()
          inventory = _inventory(
              [
                  {
                      "term": "Created",
                      "surface_forms": ["Created"],
                      "state": "new",
                      "file": "",
                      "link_stem": "Created",
                      "missing_fields": [],
                  },
                  {
                      "term": "C#",
                      "surface_forms": ["C#"],
                      "state": "existing_complete",
                      "file": str(unsafe),
                      "link_stem": "C#",
                      "missing_fields": [],
                  },
              ]
          )
      
          with pytest.raises(SystemExit, match=r"rename.*C#\.md"):
              write_glossary_entries(
                  {
                      "entries": [
                          {
                              "name": name,
                              "operation": "create" if name == "Created" else "reuse",
                              "definition": "definition",
                              "confidence": "高",
                              "occurrence": "evidence",
                          }
                          for name in ("Created", "C#")
                      ]
                  },
                  inventory,
                  terms_dir,
                  "Paper",
              )
      
          assert unsafe.read_bytes() == before
          assert not (terms_dir / "Created.md").exists()
          assert not (terms_dir / "C#.md").exists()
      
      
      def test_writer_and_lint_require_exact_confidence_value(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          selected = [{"term": "ReAct", "surface_forms": ["ReAct"]}]
          inventory = _inventory(inspect_selected_terms(selected, terms_dir))
      
          with pytest.raises(SystemExit, match="valid confidence"):
              write_glossary_entries(
                  {
                      "entries": [
                          {
                              "name": "ReAct",
                              "operation": "create",
                              "definition": "Reasoning and acting.",
                              "confidence": "不高",
                              "occurrence": "Paper evidence.",
                          }
                      ]
                  },
                  inventory,
                  terms_dir,
                  "Paper",
              )
      
          invalid = render_term_file({**ENTRY, "confidence": "不高"}, "Paper")
          assert "term_confidence_invalid" in _codes(lint_term_file_text(invalid))
          assert not terms_dir.exists()
      
      
      def test_upsert_creates_then_dedupes_by_alias(tmp_path: Path) -> None:
          terms_dir = tmp_path / "术语"
          index = build_alias_index(terms_dir)
      
          r1 = upsert_term_file(ENTRY, "CAD-MoE", terms_dir, index)
          assert r1["action"] == "created"
      
          entry2 = {"name": "KL divergence", "definition": "...", "confidence": "中", "occurrence": "eq 3"}
          r2 = upsert_term_file(entry2, "OtherPaper", terms_dir, index)
          assert r2["action"] == "updated"
          assert r1["file"] == r2["file"]
      
          files = list(terms_dir.glob("*.md"))
          assert len(files) == 1
          text = files[0].read_text(encoding="utf-8")
          assert "[[CAD-MoE]]" in text and "[[OtherPaper]]" in text
      
      
      def test_upsert_idempotent_for_same_paper(tmp_path: Path) -> None:
          terms_dir = tmp_path / "术语"
          index = build_alias_index(terms_dir)
          upsert_term_file(ENTRY, "CAD-MoE", terms_dir, index)
          again = upsert_term_file(ENTRY, "CAD-MoE", terms_dir, index)
          assert again["action"] == "unchanged"
          text = (terms_dir / f"{safe_term_filename(ENTRY['name'])}.md").read_text(encoding="utf-8")
          assert text.count("[[CAD-MoE]]") == 1
      
      
      def test_upsert_rebuilt_index_dedupes_sanitized_filename(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          entry = {**ENTRY, "name": "A/B", "aliases": []}
      
          first = upsert_term_file(entry, "PaperOne", terms_dir, build_alias_index(terms_dir))
          second = upsert_term_file(entry, "PaperOne", terms_dir, build_alias_index(terms_dir))
      
          assert second["action"] == "unchanged"
          assert second["file"] == first["file"]
          assert sorted(path.name for path in terms_dir.glob("*.md")) == ["A B.md"]
      
      
      def test_upsert_does_not_overwrite_sanitized_filename_collision(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          index = build_alias_index(terms_dir)
          first = {**ENTRY, "name": "A/B", "aliases": []}
          second = {**ENTRY, "name": "A:B", "aliases": []}
      
          r1 = upsert_term_file(first, "PaperOne", terms_dir, index)
          r2 = upsert_term_file(second, "PaperTwo", terms_dir, index)
      
          assert r1["file"] != r2["file"]
          assert len(list(terms_dir.glob("*.md"))) == 2
          assert "# A/B" in Path(r1["file"]).read_text(encoding="utf-8")
          assert "# A:B" in Path(r2["file"]).read_text(encoding="utf-8")
      
      
      def test_render_term_file_quotes_frontmatter_alias_strings() -> None:
          entry = {
              **ENTRY,
              "name": "Term",
              "aliases": ["foo: bar", "[bracket]", 'quote "value"'],
          }
      
          text = render_term_file(entry, "PaperOne")
      
          assert '  - "foo: bar"' in text
          assert '  - "[bracket]"' in text
          assert '  - "quote \\"value\\""' in text
      
      
      def test_append_occurrence_inserts_inside_existing_occurrence_section() -> None:
          text = render_term_file(ENTRY, "PaperOne") + "\n## Extra\nbody\n"
      
          updated = append_occurrence(text, {"occurrence": "new evidence"}, "PaperTwo")
      
          occurrence = updated.split(f"## {GLOSSARY_OCCURRENCE_HEADING}", 1)[1].split(
              "\n## Extra", 1
          )[0]
          assert "[[PaperTwo]]" in occurrence
          assert updated.index("[[PaperTwo]]") < updated.index("## Extra")
      
      
      def test_lint_main_rejects_empty_terms_dir(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          output = tmp_path / "lint.json"
          empty_terms = tmp_path / "empty"
          empty_terms.mkdir()
          monkeypatch.setattr(
              "sys.argv",
              ["lint_glossary.py", "--terms-dir", str(empty_terms), "--output", str(output)],
          )
      
          with pytest.raises(SystemExit) as exc:
              lint_main()
      
          assert "No glossary markdown files found" in str(exc.value)
          assert not output.exists()
      
      
      def test_lint_main_accepts_repeated_inputs_and_lints_exact_files(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          first = tmp_path / "First.md"
          second = tmp_path / "Second.md"
          ignored = tmp_path / "Ignored.md"
          for path, name in ((first, "First"), (second, "Second"), (ignored, "Ignored")):
              path.write_text(render_term_file({**ENTRY, "name": name}, "Paper"), encoding="utf-8")
          output = tmp_path / "lint.json"
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "lint_glossary.py",
                  "--input",
                  str(first),
                  "--input",
                  str(second),
                  "--output",
                  str(output),
              ],
          )
      
          lint_main()
      
          payload = json.loads(output.read_text(encoding="utf-8"))
          assert isinstance(payload["elapsed_ms"], int)
          assert payload["elapsed_ms"] >= 0
          assert [item["path"] for item in payload["files"]] == [
              str(first.resolve()),
              str(second.resolve()),
          ]
          assert payload["summary"] == {"total": 2, "passed": 2, "failed": 0}
      
      
      def test_lint_main_rejects_missing_terms_dir_even_with_valid_input(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          note = tmp_path / "Term.md"
          note.write_text(render_term_file({**ENTRY, "name": "Term"}, "Paper"), encoding="utf-8")
          missing_terms = tmp_path / "missing"
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "lint_glossary.py",
                  "--input",
                  str(note),
                  "--terms-dir",
                  str(missing_terms),
              ],
          )
      
          with pytest.raises(SystemExit, match="--terms-dir must be an existing directory"):
              lint_main()
      
      
      def test_lint_main_rejects_file_terms_dir_even_with_valid_input(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          note = tmp_path / "Term.md"
          note.write_text(render_term_file({**ENTRY, "name": "Term"}, "Paper"), encoding="utf-8")
          terms_file = tmp_path / "not-a-directory"
          terms_file.write_text("not a directory", encoding="utf-8")
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "lint_glossary.py",
                  "--input",
                  str(note),
                  "--terms-dir",
                  str(terms_file),
              ],
          )
      
          with pytest.raises(SystemExit, match="--terms-dir must be an existing directory"):
              lint_main()
      
      
      def test_lint_main_terms_dir_only_emits_elapsed_ms(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          note = terms_dir / "Term.md"
          note.write_text(render_term_file({**ENTRY, "name": "Term"}, "Paper"), encoding="utf-8")
          output = tmp_path / "lint.json"
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "lint_glossary.py",
                  "--terms-dir",
                  str(terms_dir),
                  "--output",
                  str(output),
              ],
          )
      
          lint_main()
      
          payload = json.loads(output.read_text(encoding="utf-8"))
          assert isinstance(payload["elapsed_ms"], int)
          assert payload["elapsed_ms"] >= 0
          assert [item["path"] for item in payload["files"]] == [str(note.resolve())]
      
      
      def test_lint_main_merges_terms_dir_and_deduplicates_resolved_inputs(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          first = terms_dir / "First.md"
          second = terms_dir / "Second.md"
          for path, name in ((first, "First"), (second, "Second")):
              path.write_text(render_term_file({**ENTRY, "name": name}, "Paper"), encoding="utf-8")
          output = tmp_path / "lint.json"
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "lint_glossary.py",
                  "--input",
                  str(first),
                  "--input",
                  str(first.parent / "." / first.name),
                  "--terms-dir",
                  str(terms_dir),
                  "--output",
                  str(output),
              ],
          )
      
          lint_main()
      
          payload = json.loads(output.read_text(encoding="utf-8"))
          assert isinstance(payload["elapsed_ms"], int)
          assert payload["elapsed_ms"] >= 0
          assert [item["path"] for item in payload["files"]] == [
              str(first.resolve()),
              str(second.resolve()),
          ]
          assert payload["summary"]["total"] == 2
      
      
      def test_write_cli_emits_elapsed_ms(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
          workflow = write_current_workflow(tmp_path / "workflow", ["ReAct"])
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          terms_dir = vault / "Glossary"
          config_path = tmp_path / "config.json"
          configure_terms_dir(terms_dir, config_path)
          glossary = tmp_path / "glossary.json"
          inventory = tmp_path / "inventory.json"
          output = tmp_path / "write.json"
          glossary.write_text(
              json.dumps(
                  {
                      "entries": [
                          {
                              "name": "ReAct",
                              "operation": "create",
                              "definition": "Reasoning and acting pattern.",
                              "confidence": "\u9ad8",
                              "occurrence": "Chapter evidence.",
                          }
                      ]
                  }
              ),
              encoding="utf-8",
          )
          inventory.write_text(
              json.dumps(build_current_inventory(workflow, terms_dir)),
              encoding="utf-8",
          )
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "write_glossary_terms.py",
                  "--glossary",
                  str(glossary),
                  "--inventory",
                  str(inventory),
                  "--config-path",
                  str(config_path),
                  "--source-manifest",
                  str(workflow["manifest_path"]),
                  "--reviewed-shortlist",
                  str(workflow["review_path"]),
                  "--triage",
                  str(workflow["triage_path"]),
                  "--output",
                  str(output),
              ],
          )
      
          write_main()
      
          payload = json.loads(output.read_text(encoding="utf-8"))
          assert isinstance(payload["elapsed_ms"], int)
          assert payload["elapsed_ms"] >= 0
          assert payload["results"][0]["action"] == "created"
      
      
      @pytest.mark.parametrize(
          ("authorized_names", "generated_names", "source_text"),
          [
              (["ReAct", "Reflection"], ["Reflection", "ReAct"], None),
              (
                  ["ReAct"],
                  ["react"],
                  "**ReAct** and **react** are the same paper concept.",
              ),
          ],
          ids=["reversed-entries", "case-only-name"],
      )
      def test_writer_cli_rejects_generated_mapping_drift_before_writes(
          tmp_path: Path,
          monkeypatch: pytest.MonkeyPatch,
          authorized_names: list[str],
          generated_names: list[str],
          source_text: str | None,
      ) -> None:
          workflow = write_current_workflow(
              tmp_path / "workflow", authorized_names, source_text=source_text
          )
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          terms_dir = vault / "Glossary"
          terms_dir.mkdir()
          (terms_dir / ".sentinel").write_bytes(b"unchanged glossary directory\n")
          if source_text is not None:
              (terms_dir / "ReAct.md").write_text(
                  render_term_file(
                      {
                          "name": "ReAct",
                          "aliases": ["react"],
                          "definition": "A reasoning and acting pattern.",
                          "elaboration": "It interleaves reasoning with actions.",
                          "intuition": "Think, act, and observe repeatedly.",
                          "distinction": "It is not a single reasoning pass.",
                          "confidence": "\u9ad8",
                          "occurrence": "Existing occurrence.",
                      },
                      "ExistingPaper",
                  ),
                  encoding="utf-8",
              )
          config_path = tmp_path / "config.json"
          configure_terms_dir(terms_dir, config_path)
          inventory_path = tmp_path / "inventory.json"
          inventory_path.write_text(
              json.dumps(build_current_inventory(workflow, terms_dir)), encoding="utf-8"
          )
          glossary_path = tmp_path / "generated-glossary.json"
          generated_entries = []
          for name in generated_names:
              entry = {
                  "name": name,
                  "operation": "reuse" if source_text is not None else "create",
                  "occurrence": f"Occurrence for {name}.",
              }
              if entry["operation"] == "create":
                  entry.update(
                      {
                          "definition": f"Definition for {name}.",
                          "confidence": "\u9ad8",
                      }
                  )
              generated_entries.append(entry)
          glossary_path.write_text(
              json.dumps({"entries": generated_entries}),
              encoding="utf-8",
          )
          before = {
              path.relative_to(terms_dir): path.read_bytes()
              for path in terms_dir.rglob("*")
              if path.is_file()
          }
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "write_glossary_terms.py",
                  "--glossary",
                  str(glossary_path),
                  "--inventory",
                  str(inventory_path),
                  "--config-path",
                  str(config_path),
                  "--source-manifest",
                  str(workflow["manifest_path"]),
                  "--reviewed-shortlist",
                  str(workflow["review_path"]),
                  "--triage",
                  str(workflow["triage_path"]),
              ],
          )
      
          with pytest.raises(SystemExit, match="ordered inventory"):
              try:
                  write_main()
              finally:
                  after = {
                      path.relative_to(terms_dir): path.read_bytes()
                      for path in terms_dir.rglob("*")
                      if path.is_file()
                  }
                  assert after == before
      
      
      @pytest.mark.parametrize(
          ("with_article", "expected_paper_link"),
          [(True, "Paper"), (False, "paper-fixture")],
          ids=["article", "glossary-only"],
      )
      def test_writer_cli_derives_and_binds_backlink_context(
          tmp_path: Path,
          monkeypatch: pytest.MonkeyPatch,
          with_article: bool,
          expected_paper_link: str,
      ) -> None:
          workflow = write_current_workflow(tmp_path / "workflow", ["ReAct"])
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          terms_dir = vault / "Glossary"
          config_path = tmp_path / "config.json"
          configure_terms_dir(terms_dir, config_path)
          article = vault / "Paper.md"
          article.write_text("# Paper\n\nReAct text.\n", encoding="utf-8")
          glossary_path = tmp_path / "glossary.json"
          inventory_path = tmp_path / "inventory.json"
          output_path = tmp_path / "writer.json"
          glossary_path.write_text(
              json.dumps(
                  {
                      "entries": [
                          {
                              "name": "ReAct",
                              "operation": "create",
                              "definition": "A reasoning and acting pattern.",
                              "confidence": "\u9ad8",
                              "occurrence": "Paper evidence.",
                          }
                      ]
                  }
              ),
              encoding="utf-8",
          )
          inventory_path.write_text(
              json.dumps(build_current_inventory(workflow, terms_dir)), encoding="utf-8"
          )
          arguments = [
              "write_glossary_terms.py",
              "--glossary",
              str(glossary_path),
              "--inventory",
              str(inventory_path),
              "--config-path",
              str(config_path),
              "--source-manifest",
              str(workflow["manifest_path"]),
              "--reviewed-shortlist",
              str(workflow["review_path"]),
              "--triage",
              str(workflow["triage_path"]),
              "--output",
              str(output_path),
          ]
          if with_article:
              arguments.extend(["--article", str(article)])
          monkeypatch.setattr(sys, "argv", arguments)
      
          write_main()
      
          payload = json.loads(output_path.read_text(encoding="utf-8"))
          assert payload["context"] == {
              "paper_id": "paper-fixture",
              "paper_link": expected_paper_link,
              "article_path": str(article.resolve()) if with_article else "",
          }
          assert len(payload["triage_sha256"]) == 64
          assert len(payload["mappings_sha256"]) == 64
          assert f"[[{expected_paper_link}]]" in (terms_dir / "ReAct.md").read_text(
              encoding="utf-8"
          )
      
      
      def test_writer_cli_rejects_unsafe_glossary_only_paper_id_without_writes(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          workflow = write_current_workflow(
              tmp_path / "workflow", ["ReAct"], paper_id="unsafe|paper"
          )
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          terms_dir = vault / "Glossary"
          config_path = tmp_path / "config.json"
          configure_terms_dir(terms_dir, config_path)
          glossary_path = tmp_path / "glossary.json"
          inventory_path = tmp_path / "inventory.json"
          glossary_path.write_text(
              json.dumps(
                  {
                      "entries": [
                          {
                              "name": "ReAct",
                              "operation": "create",
                              "definition": "definition",
                              "confidence": "\u9ad8",
                              "occurrence": "evidence",
                          }
                      ]
                  }
              ),
              encoding="utf-8",
          )
          inventory_path.write_text(
              json.dumps(build_current_inventory(workflow, terms_dir)), encoding="utf-8"
          )
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "write_glossary_terms.py",
                  "--glossary",
                  str(glossary_path),
                  "--inventory",
                  str(inventory_path),
                  "--config-path",
                  str(config_path),
                  "--source-manifest",
                  str(workflow["manifest_path"]),
                  "--reviewed-shortlist",
                  str(workflow["review_path"]),
                  "--triage",
                  str(workflow["triage_path"]),
              ],
          )
      
          with pytest.raises(SystemExit, match="paper_id|wiki|backlink"):
              write_main()
      
          assert not (terms_dir / "ReAct.md").exists()
      
      
      def test_writer_cli_rejects_changed_source_before_loading_generated_glossary(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          workflow = write_current_workflow(tmp_path / "workflow", ["ReAct"])
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          terms_dir = vault / "Glossary"
          config_path = tmp_path / "config.json"
          configure_terms_dir(terms_dir, config_path)
          inventory_path = tmp_path / "inventory.json"
          inventory_path.write_text(
              json.dumps(build_current_inventory(workflow, terms_dir)), encoding="utf-8"
          )
          workflow["raw_path"].write_text(
              workflow["raw_path"].read_text(encoding="utf-8")
              + json.dumps(
                  {
                      "record_type": "section",
                      "section_id": "sec:changed",
                      "kind": "body",
                      "title": "Changed",
                      "text": "Source changed after inventory.",
                  }
              )
              + "\n",
              encoding="utf-8",
          )
          malformed_glossary = tmp_path / "malformed-glossary.json"
          malformed_glossary.write_text("{", encoding="utf-8")
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "write_glossary_terms.py",
                  "--glossary",
                  str(malformed_glossary),
                  "--inventory",
                  str(inventory_path),
                  "--config-path",
                  str(config_path),
                  "--source-manifest",
                  str(workflow["manifest_path"]),
                  "--reviewed-shortlist",
                  str(workflow["review_path"]),
                  "--triage",
                  str(workflow["triage_path"]),
              ],
          )
      
          with pytest.raises(SystemExit, match="current|source|review|proposal"):
              write_main()
      
          assert not (terms_dir / "ReAct.md").exists()
      
      
      def test_writer_cli_rejects_reviewed_but_unselected_forgery_before_loading_glossary(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          workflow = write_current_workflow(
              tmp_path / "workflow",
              ["ReAct", "Reflection"],
              selected_names=["ReAct"],
          )
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          terms_dir = vault / "Glossary"
          config_path = tmp_path / "config.json"
          configure_terms_dir(terms_dir, config_path)
      
          reflection = next(
              item
              for item in workflow["review"]["reviewed_shortlist"]
              if item["term"] == "Reflection"
          )
          forged_results = inspect_selected_terms([reflection], terms_dir)
          forged_provenance = deepcopy(workflow["triage"]["provenance"])
          forged_provenance["selection_sha256"] = selection_sha256(
              forged_provenance["review"]["review_sha256"], forged_results
          )
          inventory_path = tmp_path / "forged-inventory.json"
          inventory_path.write_text(
              json.dumps(
                  {
                      "status": "ok",
                      "script": "inspect_glossary_library.py",
                      "paper_id": workflow["triage"]["paper_id"],
                      "provenance": forged_provenance,
                      "results": forged_results,
                  }
              ),
              encoding="utf-8",
          )
          glossary_path = tmp_path / "generated-glossary.json"
          glossary_path.write_text(
              json.dumps(
                  {
                      "entries": [
                          {
                              "name": "Reflection",
                              "operation": "create",
                              "definition": "A reflection step.",
                              "confidence": "\u9ad8",
                              "occurrence": "Forged occurrence.",
                          }
                      ]
                  }
              ),
              encoding="utf-8",
          )
          generated_loaded = False
          real_loader = writer_module.maybe_load_json_record
      
          def tracked_loader(value: str) -> dict | None:
              nonlocal generated_loaded
              if value == str(glossary_path):
                  generated_loaded = True
              return real_loader(value)
      
          monkeypatch.setattr(writer_module, "maybe_load_json_record", tracked_loader)
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "write_glossary_terms.py",
                  "--glossary",
                  str(glossary_path),
                  "--inventory",
                  str(inventory_path),
                  "--config-path",
                  str(config_path),
                  "--source-manifest",
                  str(workflow["manifest_path"]),
                  "--reviewed-shortlist",
                  str(workflow["review_path"]),
                  "--triage",
                  str(workflow["triage_path"]),
              ],
          )
      
          with pytest.raises(SystemExit, match="authorized|triage|selection"):
              write_main()
      
          assert generated_loaded is False
          assert not (terms_dir / "Reflection.md").exists()
      
      
      @pytest.mark.parametrize("vault_root", [".", "C:drive-relative"])
      def test_writer_cli_rejects_relative_vault_root_without_writes(
          tmp_path: Path,
          monkeypatch: pytest.MonkeyPatch,
          vault_root: str,
      ) -> None:
          workflow = write_current_workflow(tmp_path / "workflow", ["ReAct"])
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          terms_dir = vault / "Glossary"
          terms_dir.mkdir()
          config_path = tmp_path / "config.json"
          config_path.write_text(
              json.dumps({"vault_root": vault_root, "terms_subdir": "Glossary"}),
              encoding="utf-8",
          )
          glossary_path = tmp_path / "glossary.json"
          inventory_path = tmp_path / "inventory.json"
          glossary_path.write_text(
              json.dumps(
                  {
                      "entries": [
                          {
                              "name": "ReAct",
                              "operation": "create",
                              "definition": "definition",
                              "confidence": "\u9ad8",
                              "occurrence": "evidence",
                          }
                      ]
                  }
              ),
              encoding="utf-8",
          )
          inventory_path.write_text(
              json.dumps(build_current_inventory(workflow, terms_dir)), encoding="utf-8"
          )
          monkeypatch.chdir(vault)
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "write_glossary_terms.py",
                  "--glossary",
                  str(glossary_path),
                  "--inventory",
                  str(inventory_path),
                  "--config-path",
                  str(config_path),
                  "--source-manifest",
                  str(workflow["manifest_path"]),
                  "--reviewed-shortlist",
                  str(workflow["review_path"]),
                  "--triage",
                  str(workflow["triage_path"]),
              ],
          )
      
          with pytest.raises(SystemExit, match="Invalid paper-glossary config object"):
              write_main()
      
          assert not (terms_dir / "ReAct.md").exists()
      
      
      def test_write_cli_rejects_missing_config_before_creating_terms_dir(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch
      ) -> None:
          terms_dir = tmp_path / "arbitrary-destination"
          glossary = tmp_path / "glossary.json"
          inventory = tmp_path / "inventory.json"
          glossary.write_text(
              json.dumps(
                  {
                      "entries": [
                          {
                              "name": "ReAct",
                              "operation": "create",
                              "definition": "Reasoning and acting pattern.",
                              "confidence": "\u9ad8",
                              "occurrence": "Chapter evidence.",
                          }
                      ]
                  }
              ),
              encoding="utf-8",
          )
          inventory.write_text(
              json.dumps(build_inventory_payload([{"term": "ReAct", "surface_forms": ["ReAct"]}], terms_dir)),
              encoding="utf-8",
          )
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "write_glossary_terms.py",
                  "--glossary",
                  str(glossary),
                  "--inventory",
                  str(inventory),
                  "--config-path",
                  str(tmp_path / "missing-config.json"),
                  "--source-manifest",
                  str(tmp_path / "unused-manifest.json"),
                  "--reviewed-shortlist",
                  str(tmp_path / "unused-review.json"),
                  "--triage",
                  str(tmp_path / "unused-triage.json"),
              ],
          )
      
          with pytest.raises(SystemExit, match="configuration"):
              write_main()
      
          assert not terms_dir.exists()
      
      
      @pytest.mark.parametrize(
          "config_payload, terms_dir, error",
          [
              ("{", "Glossary", "Invalid paper-glossary config"),
              (
                  {"vault_root": "{vault}", "terms_subdir": "Missing"},
                  "Missing",
                  "Configured Obsidian term directory",
              ),
              (
                  {"vault_root": "{non_vault}", "terms_subdir": "Glossary"},
                  "Glossary",
                  "Configured Obsidian term directory",
              ),
              (
                  {"vault_root": "{vault}", "terms_subdir": "../outside"},
                  "../outside",
                  "Configured Obsidian term directory",
              ),
          ],
          ids=["malformed", "stale", "non-obsidian", "outside-vault"],
      )
      def test_write_cli_rejects_invalid_config_before_mutating_glossary(
          tmp_path: Path,
          monkeypatch: pytest.MonkeyPatch,
          config_payload: str | dict[str, str],
          terms_dir: str,
          error: str,
      ) -> None:
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          non_vault = tmp_path / "not-a-vault"
          (non_vault / "Glossary").mkdir(parents=True)
          outside = tmp_path / "outside"
          outside.mkdir()
          glossary = tmp_path / "glossary.json"
          inventory = tmp_path / "inventory.json"
          config_path = tmp_path / "config.json"
          glossary.write_text(
              json.dumps(
                  {
                      "entries": [
                          {
                              "name": "ReAct",
                              "operation": "create",
                              "definition": "Reasoning and acting pattern.",
                              "confidence": "\u9ad8",
                              "occurrence": "Chapter evidence.",
                          }
                      ]
                  }
              ),
              encoding="utf-8",
          )
          inventory.write_text(
              json.dumps(
                  build_inventory_payload(
                      [{"term": "ReAct", "surface_forms": ["ReAct"]}], vault / "Glossary"
                  )
              ),
              encoding="utf-8",
          )
          if isinstance(config_payload, str):
              config_path.write_text(config_payload, encoding="utf-8")
          else:
              config_path.write_text(
                  json.dumps(
                      {
                          key: value.format(vault=vault, non_vault=non_vault)
                          for key, value in config_payload.items()
                      }
                  ),
                  encoding="utf-8",
              )
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "write_glossary_terms.py",
                  "--glossary",
                  str(glossary),
                  "--inventory",
                  str(inventory),
                  "--config-path",
                  str(config_path),
                  "--source-manifest",
                  str(tmp_path / "unused-manifest.json"),
                  "--reviewed-shortlist",
                  str(tmp_path / "unused-review.json"),
                  "--triage",
                  str(tmp_path / "unused-triage.json"),
              ],
          )
      
          with pytest.raises(SystemExit, match=error):
              write_main()
      
          assert not (vault / "Glossary" / "ReAct.md").exists()
          assert not (non_vault / "Glossary" / "ReAct.md").exists()
          assert not (outside / "ReAct.md").exists()
          assert not (vault / terms_dir / "ReAct.md").exists()
      
      
      @pytest.mark.parametrize(
          "case",
          [
              "missing-vault-root",
              "missing-terms-subdir",
              "empty-vault-root",
              "whitespace-vault-root",
              "empty-terms-subdir",
              "whitespace-terms-subdir",
              "non-string-vault-root",
              "non-string-terms-subdir",
              "absolute-terms-subdir",
              "root-dot-terms-subdir",
              "root-normalized-terms-subdir",
          ],
      )
      def test_write_cli_rejects_invalid_config_schema_without_writes(
          tmp_path: Path, monkeypatch: pytest.MonkeyPatch, case: str
      ) -> None:
          vault = tmp_path / "vault"
          (vault / ".obsidian").mkdir(parents=True)
          terms_dir = vault / "Glossary"
          terms_dir.mkdir()
          outside = tmp_path / "outside"
          outside.mkdir()
          config_path = tmp_path / "config.json"
          glossary = tmp_path / "glossary.json"
          inventory = tmp_path / "inventory.json"
          glossary.write_text(
              json.dumps(
                  {
                      "entries": [
                          {
                              "name": "ReAct",
                              "operation": "create",
                              "definition": "Reasoning and acting pattern.",
                              "confidence": "\u9ad8",
                              "occurrence": "Chapter evidence.",
                          }
                      ]
                  }
              ),
              encoding="utf-8",
          )
          inventory.write_text(
              json.dumps(
                  build_inventory_payload(
                      [{"term": "ReAct", "surface_forms": ["ReAct"]}], terms_dir
                  )
              ),
              encoding="utf-8",
          )
          payload: dict[str, object] = {
              "vault_root": str(vault),
              "terms_subdir": "Glossary",
          }
          if case == "missing-vault-root":
              del payload["vault_root"]
          elif case == "missing-terms-subdir":
              del payload["terms_subdir"]
          elif case == "empty-vault-root":
              payload["vault_root"] = ""
          elif case == "whitespace-vault-root":
              payload["vault_root"] = "  \t"
          elif case == "empty-terms-subdir":
              payload["terms_subdir"] = ""
          elif case == "whitespace-terms-subdir":
              payload["terms_subdir"] = "  \t"
          elif case == "non-string-vault-root":
              payload["vault_root"] = 7
          elif case == "non-string-terms-subdir":
              payload["terms_subdir"] = ["Glossary"]
          elif case == "absolute-terms-subdir":
              payload["terms_subdir"] = str(outside)
          elif case == "root-dot-terms-subdir":
              payload["terms_subdir"] = "."
          elif case == "root-normalized-terms-subdir":
              payload["terms_subdir"] = str(Path("Glossary") / "..")
          config_path.write_text(json.dumps(payload), encoding="utf-8")
          monkeypatch.setattr(
              sys,
              "argv",
              [
                  "write_glossary_terms.py",
                  "--glossary",
                  str(glossary),
                  "--inventory",
                  str(inventory),
                  "--config-path",
                  str(config_path),
                  "--source-manifest",
                  str(tmp_path / "unused-manifest.json"),
                  "--reviewed-shortlist",
                  str(tmp_path / "unused-review.json"),
                  "--triage",
                  str(tmp_path / "unused-triage.json"),
              ],
          )
      
          error = None
          try:
              write_main()
          except SystemExit as exc:
              error = str(exc)
      
          assert not (vault / "ReAct.md").exists()
          assert not (terms_dir / "ReAct.md").exists()
          assert not (outside / "ReAct.md").exists()
          assert error is not None
          assert "Invalid paper-glossary config object" in error
      
      
      def test_writer_preflight_rejects_invalid_batch_without_creating_files(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          entries = {
              "entries": [
                  {
                      "name": "Valid",
                      "aliases": [],
                      "operation": "create",
                      "definition": "definition",
                      "confidence": "\u9ad8",
                      "occurrence": "evidence",
                  },
                  {
                      "name": "Invalid",
                      "aliases": [],
                      "operation": "enrich",
                      "occurrence": "evidence",
                  },
              ]
          }
          inventory = _inventory(
              [
                  {
                      "term": "Valid",
                      "surface_forms": ["Valid"],
                      "state": "new",
                      "file": "",
                      "link_stem": "Valid",
                      "missing_fields": [],
                  },
                  {
                      "term": "Invalid",
                      "surface_forms": ["Invalid"],
                      "state": "new",
                      "file": "",
                      "link_stem": "Invalid",
                      "missing_fields": [],
                  },
              ]
          )
      
          with pytest.raises(SystemExit, match="Invalid operation"):
              write_glossary_entries(entries, inventory, terms_dir, "Paper")
      
          assert not terms_dir.exists()
      
      
      def test_writer_enriches_only_missing_fields_and_appends_occurrence_once(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          note = terms_dir / "LLM.md"
          original = (
              "# LLM\n\n## \u6982\u5ff5\u89e3\u91ca\n- \u5b9a\u4e49\uff1auser definition\n- \u7f6e\u4fe1\u5ea6\uff1a\u4e2d\n"
              "\n## Custom\nkeep this text\n"
          )
          note.write_text(original, encoding="utf-8")
          inventory = _inventory(
              [
                  {
                      "term": "LLM",
                      "surface_forms": ["LLM", "large language model"],
                      "state": "existing_thin",
                      "file": str(note),
                      "link_stem": "LLM",
                      "missing_fields": ["elaboration", "intuition", "distinction"],
                  }
              ]
          )
          entries = {
              "entries": [
                  {
                      "name": "LLM",
                      "aliases": ["large language model"],
                      "operation": "enrich",
                      "elaboration": "new explanation",
                      "intuition": "new intuition",
                      "occurrence": "paper evidence",
                  }
              ]
          }
      
          first = write_glossary_entries(entries, inventory, terms_dir, "Paper")
          first_bytes = note.read_bytes()
          refreshed_inventory = _inventory(
              inspect_selected_terms(
                  [{"term": "LLM", "surface_forms": ["LLM", "large language model"]}],
                  terms_dir,
              )
          )
          second = write_glossary_entries(
              {
                  "entries": [
                      {
                          "name": "LLM",
                          "aliases": ["large language model"],
                          "operation": "reuse",
                          "occurrence": "paper evidence",
                      }
                  ]
              },
              refreshed_inventory,
              terms_dir,
              "Paper",
          )
      
          text = note.read_text(encoding="utf-8")
          assert "- \u5b9a\u4e49\uff1auser definition" in text
          assert "## Custom\nkeep this text" in text
          assert first["results"] == [
              {
                  "name": "LLM",
                  "forms": ["LLM", "large language model"],
                  "file": str(note),
                  "action": "enriched",
                  "link_stem": "LLM",
                  "fields_added": ["elaboration", "intuition"],
                  "occurrence_added": True,
              }
          ]
          assert second["results"][0]["action"] == "unchanged"
          assert second["results"][0]["fields_added"] == []
          assert second["results"][0]["occurrence_added"] is False
          assert note.read_bytes() == first_bytes
          assert text.count("[[Paper]]") == 1
      
      
      def test_writer_preserves_bom_crlf_custom_content_and_second_run_bytes(
          tmp_path: Path,
      ) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          note = terms_dir / "BOM Term.md"
          original_text = (
              f"# BOM Term\r\n\r\n## {GLOSSARY_CONCEPT_HEADING}\r\n"
              "- \u5b9a\u4e49\uff1auser definition\r\n"
              "- \u8be6\u89e3\uff1auser elaboration\r\n"
              "- \u76f4\u89c9\uff1auser intuition\r\n"
              "- \u7f6e\u4fe1\u5ea6\uff1a\u4e2d\r\n\r\n"
              "## Custom\r\nkeep this text\r\n\r\n"
              f"## {GLOSSARY_OCCURRENCE_HEADING}\r\n"
              "- [[OldPaper]]: old evidence\r\n"
          )
          original_bytes = b"\xef\xbb\xbf" + original_text.encode("utf-8")
          note.write_bytes(original_bytes)
          inventory = _inventory(
              inspect_selected_terms(
                  [{"term": "BOM Term", "surface_forms": ["BOM Term"]}], terms_dir
              )
          )
          glossary = {
              "entries": [
                  {
                      "name": "BOM Term",
                      "aliases": [],
                      "operation": "reuse",
                      "occurrence": "new evidence",
                  }
              ]
          }
      
          first = write_glossary_entries(glossary, inventory, terms_dir, "Paper")
          first_bytes = note.read_bytes()
          second = write_glossary_entries(glossary, inventory, terms_dir, "Paper")
      
          assert first["results"][0]["action"] == "updated"
          assert second["results"][0]["action"] == "unchanged"
          assert first_bytes != original_bytes
          assert first_bytes.startswith(b"\xef\xbb\xbf")
          assert b"\n" not in first_bytes[3:].replace(b"\r\n", b"")
          assert b"## Custom\r\nkeep this text\r\n" in first_bytes
          assert first_bytes.count(b"[[Paper]]") == 1
          assert note.read_bytes() == first_bytes
      
      
      def test_writer_reuse_rejects_concept_replacement_and_can_add_occurrence(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          note = terms_dir / "ReAct.md"
          note.write_text(render_term_file({**ENTRY, "name": "ReAct"}, "OldPaper"), encoding="utf-8")
          inventory = _inventory(
              [
                  {
                      "term": "ReAct",
                      "surface_forms": ["ReAct"],
                      "state": "existing_complete",
                      "file": str(note),
                      "link_stem": "ReAct",
                      "missing_fields": ["distinction"],
                  }
              ]
          )
      
          with pytest.raises(SystemExit, match="reuse"):
              write_glossary_entries(
                  {"entries": [{"name": "ReAct", "operation": "reuse", "definition": "replace", "occurrence": "evidence"}]},
                  inventory,
                  terms_dir,
                  "Paper",
              )
      
          result = write_glossary_entries(
              {"entries": [{"name": "ReAct", "operation": "reuse", "occurrence": "evidence"}]},
              inventory,
              terms_dir,
              "Paper",
          )
      
          assert result["results"][0]["action"] == "updated"
          assert result["results"][0]["fields_added"] == []
          assert result["results"][0]["occurrence_added"] is True
      
      
      def test_writer_rejects_forged_outside_target_and_link_stem(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          inside = terms_dir / "LLM.md"
          outside = tmp_path / "outside.md"
          text = (
              f"# LLM\n\n## {GLOSSARY_CONCEPT_HEADING}\n"
              "- \u5b9a\u4e49\uff1adefinition\n- \u7f6e\u4fe1\u5ea6\uff1a\u4e2d\n"
          )
          inside.write_text(text, encoding="utf-8")
          outside.write_text(text, encoding="utf-8")
          outside_before = outside.read_bytes()
          inventory = _inventory(
              [
                  {
                      "term": "LLM",
                      "surface_forms": ["LLM"],
                      "state": "existing_thin",
                      "file": str(outside),
                      "link_stem": "Forged",
                      "missing_fields": ["elaboration", "intuition", "distinction"],
                  }
              ]
          )
      
          with pytest.raises(SystemExit, match="Inventory mismatch"):
              write_glossary_entries(
                  {
                      "entries": [
                          {
                              "name": "LLM",
                              "operation": "enrich",
                              "elaboration": "new",
                              "occurrence": "evidence",
                          }
                      ]
                  },
                  inventory,
                  terms_dir,
                  "Paper",
              )
      
          assert outside.read_bytes() == outside_before
          assert "new" not in inside.read_text(encoding="utf-8")
      
      
      def test_writer_rejects_stale_inventory_state_before_writing(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          note = terms_dir / "ReAct.md"
          note.write_text(render_term_file({**ENTRY, "name": "ReAct"}, "OldPaper"), encoding="utf-8")
          before = note.read_bytes()
          stale = _inventory(
              [
                  {
                      "term": "ReAct",
                      "surface_forms": ["ReAct"],
                      "state": "existing_thin",
                      "file": str(note),
                      "link_stem": "ReAct",
                      "missing_fields": ["distinction"],
                  }
              ]
          )
      
          with pytest.raises(SystemExit, match="Inventory mismatch"):
              write_glossary_entries(
                  {
                      "entries": [
                          {
                              "name": "ReAct",
                              "operation": "enrich",
                              "distinction": "replacement",
                              "occurrence": "evidence",
                          }
                      ]
                  },
                  stale,
                  terms_dir,
                  "Paper",
              )
      
          assert note.read_bytes() == before
      
      
      def test_writer_rejects_two_entries_resolving_to_same_target(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          note = terms_dir / "Canonical.md"
          note.write_text(
              render_term_file({**ENTRY, "name": "Canonical", "aliases": ["Alias"]}, "OldPaper"),
              encoding="utf-8",
          )
          selected = [
              {"term": "Canonical", "surface_forms": ["Canonical"]},
              {"term": "Alias", "surface_forms": ["Alias"]},
          ]
          inventory = _inventory(inspect_selected_terms(selected, terms_dir))
          before = note.read_bytes()
      
          with pytest.raises(SystemExit, match="Duplicate target"):
              write_glossary_entries(
                  {
                      "entries": [
                          {"name": "Canonical", "operation": "reuse", "occurrence": "one"},
                          {"name": "Alias", "operation": "reuse", "occurrence": "two"},
                      ]
                  },
                  inventory,
                  terms_dir,
                  "Paper",
              )
      
          assert note.read_bytes() == before
      
      
      def test_writer_prepares_all_targets_before_any_create(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          (terms_dir / "Broken.md").write_bytes(b"\xff\xfe\x00")
          inventory = _inventory(
              [
                  {
                      "term": "Created",
                      "surface_forms": ["Created"],
                      "state": "new",
                      "file": "",
                      "link_stem": "Created",
                      "missing_fields": [],
                  },
                  {
                      "term": "Broken",
                      "surface_forms": ["Broken"],
                      "state": "existing_thin",
                      "file": str(terms_dir / "Broken.md"),
                      "link_stem": "Broken",
                      "missing_fields": ["definition", "elaboration", "intuition", "distinction", "confidence"],
                  },
              ]
          )
      
          with pytest.raises(SystemExit, match="Unable to read glossary note.*Broken.md"):
              write_glossary_entries(
                  {
                      "entries": [
                          {
                              "name": "Created",
                              "operation": "create",
                              "definition": "definition",
                              "confidence": "\u9ad8",
                              "occurrence": "evidence",
                          },
                          {
                              "name": "Broken",
                              "operation": "enrich",
                              "definition": "definition",
                              "confidence": "\u9ad8",
                              "occurrence": "evidence",
                          },
                      ]
                  },
                  inventory,
                  terms_dir,
                  "Paper",
              )
      
          assert not (terms_dir / "Created.md").exists()
      
      
      def test_writer_allocates_sanitized_create_collisions_and_reports_actual_stems(
          tmp_path: Path,
      ) -> None:
          terms_dir = tmp_path / "terms"
          selected = [
              {"term": "A/B", "surface_forms": ["A/B"]},
              {"term": r"A\B", "surface_forms": [r"A\B"]},
          ]
          inventory = _inventory(inspect_selected_terms(selected, terms_dir))
          entries = {
              "entries": [
                  {
                      "name": item["term"],
                      "operation": "create",
                      "definition": "definition",
                      "confidence": "\u9ad8",
                      "occurrence": "evidence",
                  }
                  for item in selected
              ]
          }
      
          result = write_glossary_entries(entries, inventory, terms_dir, "Paper")
      
          assert [item["link_stem"] for item in result["results"]] == ["A B", "A B-2"]
          assert sorted(path.name for path in terms_dir.glob("*.md")) == ["A B-2.md", "A B.md"]
          second = (terms_dir / "A B-2.md").read_text(encoding="utf-8")
          assert '  - "A\\\\B"' in second
          assert "# A\\B\n" in second
      
      
      def test_append_occurrence_ignores_same_paper_link_outside_occurrence_section() -> None:
          text = (
              "# Existing\n\nCustom mention [[Paper]] outside the occurrence section.\n\n"
              f"## {GLOSSARY_OCCURRENCE_HEADING}\n- [[OldPaper]]: old evidence\n"
          )
      
          updated = append_occurrence(text, {"occurrence": "new evidence"}, "Paper")
          repeated = append_occurrence(updated, {"occurrence": "new evidence"}, "Paper")
      
          occurrence = updated.split(f"## {GLOSSARY_OCCURRENCE_HEADING}", 1)[1]
          assert occurrence.count("[[Paper]]") == 1
          assert repeated == updated
      
      
      def test_writer_preserves_grounded_forms_when_entry_omits_or_adds_aliases(
          tmp_path: Path,
      ) -> None:
          terms_dir = tmp_path / "terms"
          selected = [
              {"term": "ReAct", "surface_forms": ["ReAct", "ReAct"]}
          ]
          inventory = _inventory(inspect_selected_terms(selected, terms_dir))
      
          result = write_glossary_entries(
              {
                  "entries": [
                      {
                          "name": "ReAct",
                          "aliases": ["Reasoning and Acting"],
                          "operation": "create",
                          "definition": "A reasoning and acting pattern.",
                          "confidence": "高",
                          "occurrence": "Paper evidence.",
                      }
                  ]
              },
              inventory,
              terms_dir,
              "Paper",
          )
      
          assert result["results"][0]["forms"] == ["ReAct", "ReAct"]
          assert '  - "Reasoning and Acting"' in (terms_dir / "ReAct.md").read_text(
              encoding="utf-8"
          )
      
      
      def test_writer_rejects_missing_inventory_provenance_without_writes(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          inventory = {
              "results": inspect_selected_terms(
                  [{"term": "ReAct", "surface_forms": ["ReAct"]}], terms_dir
              )
          }
      
          with pytest.raises(SystemExit, match="provenance"):
              write_glossary_entries(
                  {
                      "entries": [
                          {
                              "name": "ReAct",
                              "operation": "create",
                              "definition": "A reasoning and acting pattern.",
                              "confidence": "高",
                              "occurrence": "Paper evidence.",
                          }
                      ]
                  },
                  inventory,
                  terms_dir,
                  "Paper",
              )
      
          assert not terms_dir.exists()
      
      
      @pytest.mark.parametrize("change", ["append", "reorder", "replace"])
      def test_writer_rejects_changed_inventory_surface_forms_without_writes(
          tmp_path: Path, change: str
      ) -> None:
          terms_dir = tmp_path / "terms"
          source_forms = ["ReAct", "ReAct"]
          inventory = build_inventory_payload(
              [{"term": "ReAct", "surface_forms": source_forms}], terms_dir
          )
          if change == "append":
              inventory["results"][0]["surface_forms"].append("invented")
          elif change == "reorder":
              inventory["results"][0]["surface_forms"].reverse()
          else:
              inventory["results"][0]["surface_forms"][1] = "invented"
      
          with pytest.raises(SystemExit, match="selection|provenance|surface_forms"):
              write_glossary_entries(
                  {
                      "entries": [
                          {
                              "name": "ReAct",
                              "operation": "create",
                              "definition": "A reasoning and acting pattern.",
                              "confidence": "高",
                              "occurrence": "Paper evidence.",
                          }
                      ]
                  },
                  inventory,
                  terms_dir,
                  "Paper",
              )
      
          assert not terms_dir.exists()
      
      
      def test_writer_rejects_entry_only_alias_redirect_to_existing_note(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          victim = terms_dir / "Victim.md"
          victim.write_text(
              render_term_file({**ENTRY, "name": "Victim", "aliases": []}, "OldPaper"),
              encoding="utf-8",
          )
          victim_item = inspect_selected_terms(
              [{"term": "Victim", "surface_forms": ["Victim"]}], terms_dir
          )[0]
          inventory = _inventory(
              [
                  {
                      **victim_item,
                      "term": "Innocent",
                      "surface_forms": ["Innocent"],
                  }
              ]
          )
          before = victim.read_bytes()
      
          with pytest.raises(SystemExit, match="Inventory mismatch"):
              write_glossary_entries(
                  {
                      "entries": [
                          {
                              "name": "Innocent",
                              "aliases": ["Victim"],
                              "operation": "reuse",
                              "occurrence": "redirect",
                          }
                      ]
                  },
                  inventory,
                  terms_dir,
                  "Paper",
              )
      
          assert victim.read_bytes() == before
      
      
      def test_writer_rejects_create_alias_owned_by_unselected_existing_note_without_writes(
          tmp_path: Path,
      ) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          owner = terms_dir / "Existing.md"
          owner.write_text(
              render_term_file(
                  {**ENTRY, "name": "Existing", "aliases": ["Generated Alias"]},
                  "OldPaper",
              ),
              encoding="utf-8",
          )
          inventory = build_inventory_payload(
              [{"term": "ReAct", "surface_forms": ["ReAct"]}], terms_dir
          )
          before = owner.read_bytes()
      
          with pytest.raises(SystemExit, match="alias|logical|collision|existing glossary"):
              write_glossary_entries(
                  {
                      "entries": [
                          {
                              "name": "ReAct",
                              "aliases": ["Generated Alias"],
                              "operation": "create",
                              "definition": "A reasoning and acting pattern.",
                              "confidence": "\u9ad8",
                              "occurrence": "Paper evidence.",
                          }
                      ]
                  },
                  inventory,
                  terms_dir,
                  "Paper",
              )
      
          assert owner.read_bytes() == before
          assert not (terms_dir / "ReAct.md").exists()
      
      
      def test_writer_allows_generated_alias_owned_by_the_same_existing_target(
          tmp_path: Path,
      ) -> None:
          terms_dir = tmp_path / "terms"
          terms_dir.mkdir()
          note = terms_dir / "ReAct.md"
          note.write_text(
              render_term_file(
                  {**ENTRY, "name": "ReAct", "aliases": ["Reasoning and Acting"]},
                  "OldPaper",
              ),
              encoding="utf-8",
          )
          inventory = build_inventory_payload(
              [{"term": "ReAct", "surface_forms": ["ReAct"]}], terms_dir
          )
      
          result = write_glossary_entries(
              {
                  "entries": [
                      {
                          "name": "ReAct",
                          "aliases": ["Reasoning and Acting"],
                          "operation": "reuse",
                          "occurrence": "New paper evidence.",
                      }
                  ]
              },
              inventory,
              terms_dir,
              "Paper",
          )
      
          assert result["results"][0]["file"] == str(note.resolve())
          assert "[[Paper]]" in note.read_text(encoding="utf-8")
      
      
      def test_writer_requires_canonical_name_in_inventory_surface_forms(tmp_path: Path) -> None:
          terms_dir = tmp_path / "terms"
          inventory = _inventory(
              [
                  {
                      "term": "Canonical",
                      "surface_forms": ["Alias"],
                      "state": "new",
                      "file": "",
                      "link_stem": "Canonical",
                      "missing_fields": [],
                  }
              ]
          )
      
          with pytest.raises(SystemExit, match="Canonical|canonical.*surface_forms"):
              write_glossary_entries(
                  {
                      "entries": [
                          {
                              "name": "Canonical",
                              "aliases": ["Alias"],
                              "operation": "create",
                              "definition": "definition",
                              "confidence": "\u9ad8",
                              "occurrence": "evidence",
              
  • README.md 4.5 KB
    # Paper Glossary
    
    `paper-glossary` builds reusable Obsidian glossary notes from `*_source_manifest.json` and `*_raw_sections.jsonl`. It remains self-contained and does not call a paper-reading workflow.
    
    ## One-Time Device Setup
    
    Each person configures one term directory once per device. The directory must be inside an Obsidian vault: its nearest vault ancestor must contain `.obsidian`. The local configuration is `~/.paper-glossary/config.json`; it stores an absolute resolved vault root and the terms subdirectory, not a repository setting. Relative and Windows drive-relative vault roots are rejected. Later runs reuse it.
    
    Setup may create `~/.paper-glossary/config.json`; before selection, no glossary notes or article Markdown are written. Run commands from the repository root and replace each quoted `<vault>` placeholder with the real vault path.
    
    ## Windows PowerShell
    
    ```powershell
    py -3.12 skills\paper-glossary\scripts\configure_glossary.py --terms-dir '<vault>\Glossary'
    py -3.12 skills\paper-glossary\scripts\configure_glossary.py --show
    py -3.12 skills\paper-glossary\scripts\configure_glossary.py --reset
    py -3.12 skills\paper-glossary\scripts\configure_glossary.py --validate-article '<vault>\Papers\example.md'
    py -3.12 -m pytest -q skills\paper-glossary\tests --basetemp .pytest-tmp-paper-glossary
    ```
    
    ## macOS / Linux Bash
    
    ```bash
    python3 skills/paper-glossary/scripts/configure_glossary.py --terms-dir '<vault>/Glossary'
    python3 skills/paper-glossary/scripts/configure_glossary.py --show
    python3 skills/paper-glossary/scripts/configure_glossary.py --reset
    python3 skills/paper-glossary/scripts/configure_glossary.py --validate-article '<vault>/Papers/example.md'
    python3 -m pytest -q skills/paper-glossary/tests --basetemp .pytest-tmp-paper-glossary
    ```
    
    When article links are requested, provide the article Markdown path explicitly. It must be a `.md` file in the same Obsidian vault as the configured term directory; the skill never guesses an article from the paper source artifacts.
    
    `--source-manifest` is required for proposal, review recording, and triage. `--raw-sections` is an optional override for the manifest's `raw_sections_path`, not a standalone replacement. The artifact workflow is:
    
    1. Run `plan_glossary.py --propose ... --output proposal.json`.
    2. Perform one semantic review, then record its exact ordered subset with `--review-proposal proposal.json --reviewed-terms ... --output reviewed.json`.
       If this produces an **empty reviewed shortlist**, report `no_candidates` and stop without displaying an empty selector.
    3. Display the complete reviewed shortlist as numbers and wait. Resolve number responses or `全部写入` to exact names outside the CLI.
    4. Run triage with `--reviewed-shortlist reviewed.json --terms ...`; `--terms` alone is rejected.
    5. Pass triage to inventory, inventory to the writer at the **configured glossary directory** (use `--config-path` only to select that device-local configuration), and successful writer results to the linker when an article was requested.
    
    For inventory, writer, and linker, always pass the current `--source-manifest`, optional `--raw-sections`, and saved `--reviewed-shortlist`. Inventory reads the saved `--triage` as its input; writer and linker also require that same saved `--triage` and enforce exact ordered `term` and `surface_forms` equality with it. These post-triage CLIs recompute the current proposal/review chain before writing. When the writer receives `--article`, `paper_link` is **derived from the resolved article Markdown stem**. A glossary-only writer invocation omits `--article` and derives `paper_link` from the **validated manifest `paper_id`**; do not run the linker for that branch because it rejects a writer artifact with **no bound article path**.
    
    Proposal candidates carry ordered paper-grounded `surface_forms` plus SHA-256 provenance and the bounded candidate/summary material needed to recompute it. Inventory and writer both fail closed unless proposal, review, and selection digests recompute from one matching paper/source chain and the exact ordered forms. Review, triage, inventory, and writer results preserve those forms; model-authored note aliases are metadata and are not article-match forms. Writer output also preserves `triage_sha256`, validated provenance, and context and binds the triage identity plus exact ordered mappings with `mappings_sha256`; the linker recomputes that digest and validates every note path, stem, and grounded form. See `SKILL.md` and `references/file-contract.md` for exact article and glossary-only invocations and JSON contracts.
    
  • SKILL.md 6 KB
    ---
    name: paper-glossary
    description: Use when building reusable Obsidian glossary notes from an existing paper source manifest, optionally with a raw-sections override, especially when a reader needs a reviewed shortlist before glossary notes and article links are changed.
    ---
    
    # Paper Glossary
    
    Build shared glossary notes from `*_source_manifest.json`. `--source-manifest` is always required; `--raw-sections` only overrides its `raw_sections_path` with an explicit `*_raw_sections.jsonl`. This skill never runs or modifies a paper-reading workflow; the manifest and raw-sections file are its only paper-content boundary. See `references/file-contract.md` for JSON and CLI contracts.
    
    ## Preview And Wait (Default)
    
    1. **Show saved configuration** on first use per person/device. If absent, ask for a term directory inside an Obsidian vault and configure it; reuse valid configuration later. **Setup may create `~/.paper-glossary/config.json`**.
    2. **Require an explicit article Markdown path** when article links are requested. Validate it and the configured term directory are in the **same Obsidian vault**; never infer an article path.
    3. **Run deterministic proposal** from the effective body.
    4. **Perform exactly one grounded host semantic review**, bounded by `shortlist_limit`. It may **only drop or reorder candidates** and must preserve each retained proposal candidate's **exact `term` string**.
    5. **Record the reviewed shortlist** with `plan_glossary.py --review-proposal PROPOSAL --reviewed-terms NAMES`; this validates the saved proposal against the current paper source and preserves each full candidate, ordered `surface_forms`, and provenance.
       If review produces an **empty reviewed shortlist**, report `no_candidates` and stop without presenting a selector.
    6. **Present every retained term** from that reviewed artifact as a numbered Markdown list. This is the terminal, Codex, and Claude Code interaction. Show the resolved term directory, the article Markdown path (or that none was requested), and that selection authorizes glossary writes/enrichment plus first-safe-occurrence article links when an article was supplied.
    7. State: **No glossary notes or article Markdown have been written before selection.** **End the response and wait.** Do not triage, inventory, generate, link, or lint during preview.
    
    A broad request, manifest, raw sections path, or article path is not selection approval. A host-native selector is allowed only when it displays the complete same list in one interaction.
    
    ## After Selection
    
    **Accept only numbers**, exact term names, or `全部写入` from the immediately preceding numbered list. Resolve them to the exact displayed `term` strings before invoking a script; `全部写入` applies only to that list. Invalid selections receive the valid range and another wait. Triage requires both `--reviewed-shortlist REVIEW` and the resolved exact names in `--terms`; never use `--terms` alone or add alias syntax.
    
    Pass the current `--source-manifest`, optional `--raw-sections`, and saved `--reviewed-shortlist` to inventory, writer, and linker. Inventory consumes the saved `--triage` artifact as its selected-term input; writer and linker must also receive that same saved `--triage`. They require exact ordered `term` and `surface_forms` equality with this independent authorization before any glossary or article write.
    
    1. **Triage** the selection; its selection identity binds each exact selected name and ordered paper-grounded forms.
    2. Run library **inventory** from that triage artifact; it recomputes the complete proposal/review/selection provenance chain and fails closed on mismatched paper, source, shortlist, or forms.
    3. Then **generate one action-aware batch**.
    4. **Run one writer invocation** against the **configured glossary directory**; it revalidates the same provenance chain and requires its ordered inventory results to match the authorized triage exactly, then performs **whole-batch preflight** followed by the **create/enrich/reuse commit**. The writer resolves device-local configuration (or an explicit `--config-path` for that device) and does not accept a standalone write destination. With `--article`, its backlink is **derived from the resolved article Markdown stem**. Without an article, the glossary-only backlink comes from the **validated manifest `paper_id`**. The successful artifact preserves `triage_sha256`, provenance, article context, ordered mappings, and their deterministic `mappings_sha256`; the mapping digest binds the triage identity.
    5. **Link each successful writer result** at its first safe occurrence **only if an article Markdown was supplied/requested**. The linker authenticates the writer provenance, context, digest, note paths, stems, and forms against the same current source/review, and rejects an artifact with **no bound article path**. **For a glossary-only request, skip `link_glossary_terms.py`**.
    6. **Lint writer-returned changed glossary note files** whose **`action` is `created`, `enriched`, or `updated`** in **one `lint_glossary.py` invocation with repeated `--input PATH`** arguments. **Do not pass article Markdown to `lint_glossary.py`**.
    7. **Report observable wall-clock timing** and statuses. **Time host-only phases separately**. **Each CLI emits top-level `elapsed_ms`** for its own complete invocation. The **single writer invocation includes whole-batch preflight and commit**. **Do not report separate preflight and commit timings**.
    
    `new` entries are created; `existing_thin` entries receive only missing structured fields and a missing occurrence; `existing_complete` entries may receive only a missing occurrence. **Do not overwrite** existing note content. See `references/file-contract.md` for `existing_thin`, entry operations, writer `forms`, and link statuses.
    
    ## Grounding
    
    - `anchor_only`: concise paper use plus a thin general explanation.
    - `needs_explanation`: labeled background explanation with confidence and paper occurrence.
    - Exclude reference-only occurrences as evidence. Keep paper facts in `occurrence` and outside knowledge labeled.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related