From 2aab7c039f6b5ab33f8f3e2b818770f370d6363c Mon Sep 17 00:00:00 2001
From: zhuguang-ZFG <168892777+zhuguang-ZFG@users.noreply.github.com>
Date: Mon, 26 Oct 2026 16:23:53 +0800
Subject: [PATCH 01/21] feat(book): assemble the 20 units into one offline book
yuyu-book/ is a manifest-driven builder that merges the Chinese and English
manuscript into a single self-contained HTML file per language, plus an
A4-typeset PDF. The manuscript is only ever read, never written.
- 314 Mermaid diagrams pre-rendered to SVG with one book-wide theme, cached by
(palette, mermaid version, browser version, source hash); the recolouring pass
folds the 696 classDef / 192 style colours in the sources into seven book
families, so the plates look drawn for this book
- a plain-language layer that only adds: 100-term glossary with hover
explanations on screen and a new-terms panel per chapter on paper, a
vernacular lead band per chapter, and a starter/main/advanced chip per
section, which feeds three reading routes in the foreword
- chapter compass (question / deliverable / boundary + source anchors), a
pinned source registry (35 entries, GitHub refs pinned to a full SHA),
revision history, page-numbered contents, a route map of the whole book,
part dividers, and a subject index generated from appendix D
- 25 hand-drawn line illustrations, currentColor only, so screen, dark mode and
paper share one copy of each drawing
- PDF post-processing: bookmarks, recto/verso running heads, foot folios, and
clickable "see Chapter N" references
- 342 builder tests, including the CSS and print contracts and a real
build.py --check run
---
yuyu-book/build.py | 374 +++
yuyu-book/glosses.json | 2988 +++++++++++++++++++++++
yuyu-book/illustrations/apxa.svg | 1 +
yuyu-book/illustrations/apxb.svg | 1 +
yuyu-book/illustrations/apxc.svg | 1 +
yuyu-book/illustrations/apxd.svg | 1 +
yuyu-book/illustrations/ch01.svg | 1 +
yuyu-book/illustrations/ch02.svg | 1 +
yuyu-book/illustrations/ch03.svg | 1 +
yuyu-book/illustrations/ch04.svg | 1 +
yuyu-book/illustrations/ch05.svg | 1 +
yuyu-book/illustrations/ch06.svg | 1 +
yuyu-book/illustrations/ch07.svg | 1 +
yuyu-book/illustrations/ch08.svg | 1 +
yuyu-book/illustrations/ch09.svg | 1 +
yuyu-book/illustrations/ch10.svg | 1 +
yuyu-book/illustrations/ch11.svg | 1 +
yuyu-book/illustrations/ch12.svg | 1 +
yuyu-book/illustrations/ch13.svg | 1 +
yuyu-book/illustrations/ch14.svg | 1 +
yuyu-book/illustrations/ch15.svg | 1 +
yuyu-book/illustrations/cover.svg | 1 +
yuyu-book/illustrations/front.svg | 1 +
yuyu-book/illustrations/part-p1.svg | 1 +
yuyu-book/illustrations/part-p2.svg | 1 +
yuyu-book/illustrations/part-p3.svg | 1 +
yuyu-book/illustrations/part-p4.svg | 1 +
yuyu-book/manifest.json | 1019 ++++++++
yuyu-book/reading-paths.json | 134 +
yuyu-book/requirements.txt | 6 +
yuyu-book/revisions.json | 94 +
yuyu-book/sources.json | 585 +++++
yuyu-book/subject-index.json | 2554 +++++++++++++++++++
yuyu-book/tests/__init__.py | 22 +
yuyu-book/tests/test_art.py | 138 ++
yuyu-book/tests/test_audit.py | 104 +
yuyu-book/tests/test_build_contracts.py | 454 ++++
yuyu-book/tests/test_crossrefs.py | 117 +
yuyu-book/tests/test_manifest.py | 274 +++
yuyu-book/tests/test_mermaid.py | 472 ++++
yuyu-book/tests/test_pages.py | 435 ++++
yuyu-book/tests/test_plain.py | 526 ++++
yuyu-book/tests/test_render_anchors.py | 343 +++
yuyu-book/tests/test_sources.py | 223 ++
yuyu-book/yuyu_book/__init__.py | 1 +
yuyu-book/yuyu_book/art.py | 76 +
yuyu-book/yuyu_book/audit.py | 105 +
yuyu-book/yuyu_book/core.py | 306 +++
yuyu-book/yuyu_book/mermaid.py | 315 +++
yuyu-book/yuyu_book/pages.py | 462 ++++
yuyu-book/yuyu_book/pdf.py | 250 ++
yuyu-book/yuyu_book/plain.py | 340 +++
yuyu-book/yuyu_book/render.py | 604 +++++
yuyu-book/yuyu_book/style.py | 1227 ++++++++++
54 files changed, 14573 insertions(+)
create mode 100644 yuyu-book/build.py
create mode 100644 yuyu-book/glosses.json
create mode 100644 yuyu-book/illustrations/apxa.svg
create mode 100644 yuyu-book/illustrations/apxb.svg
create mode 100644 yuyu-book/illustrations/apxc.svg
create mode 100644 yuyu-book/illustrations/apxd.svg
create mode 100644 yuyu-book/illustrations/ch01.svg
create mode 100644 yuyu-book/illustrations/ch02.svg
create mode 100644 yuyu-book/illustrations/ch03.svg
create mode 100644 yuyu-book/illustrations/ch04.svg
create mode 100644 yuyu-book/illustrations/ch05.svg
create mode 100644 yuyu-book/illustrations/ch06.svg
create mode 100644 yuyu-book/illustrations/ch07.svg
create mode 100644 yuyu-book/illustrations/ch08.svg
create mode 100644 yuyu-book/illustrations/ch09.svg
create mode 100644 yuyu-book/illustrations/ch10.svg
create mode 100644 yuyu-book/illustrations/ch11.svg
create mode 100644 yuyu-book/illustrations/ch12.svg
create mode 100644 yuyu-book/illustrations/ch13.svg
create mode 100644 yuyu-book/illustrations/ch14.svg
create mode 100644 yuyu-book/illustrations/ch15.svg
create mode 100644 yuyu-book/illustrations/cover.svg
create mode 100644 yuyu-book/illustrations/front.svg
create mode 100644 yuyu-book/illustrations/part-p1.svg
create mode 100644 yuyu-book/illustrations/part-p2.svg
create mode 100644 yuyu-book/illustrations/part-p3.svg
create mode 100644 yuyu-book/illustrations/part-p4.svg
create mode 100644 yuyu-book/manifest.json
create mode 100644 yuyu-book/reading-paths.json
create mode 100644 yuyu-book/requirements.txt
create mode 100644 yuyu-book/revisions.json
create mode 100644 yuyu-book/sources.json
create mode 100644 yuyu-book/subject-index.json
create mode 100644 yuyu-book/tests/__init__.py
create mode 100644 yuyu-book/tests/test_art.py
create mode 100644 yuyu-book/tests/test_audit.py
create mode 100644 yuyu-book/tests/test_build_contracts.py
create mode 100644 yuyu-book/tests/test_crossrefs.py
create mode 100644 yuyu-book/tests/test_manifest.py
create mode 100644 yuyu-book/tests/test_mermaid.py
create mode 100644 yuyu-book/tests/test_pages.py
create mode 100644 yuyu-book/tests/test_plain.py
create mode 100644 yuyu-book/tests/test_render_anchors.py
create mode 100644 yuyu-book/tests/test_sources.py
create mode 100644 yuyu-book/yuyu_book/__init__.py
create mode 100644 yuyu-book/yuyu_book/art.py
create mode 100644 yuyu-book/yuyu_book/audit.py
create mode 100644 yuyu-book/yuyu_book/core.py
create mode 100644 yuyu-book/yuyu_book/mermaid.py
create mode 100644 yuyu-book/yuyu_book/pages.py
create mode 100644 yuyu-book/yuyu_book/pdf.py
create mode 100644 yuyu-book/yuyu_book/plain.py
create mode 100644 yuyu-book/yuyu_book/render.py
create mode 100644 yuyu-book/yuyu_book/style.py
diff --git a/yuyu-book/build.py b/yuyu-book/build.py
new file mode 100644
index 00000000..04c57483
--- /dev/null
+++ b/yuyu-book/build.py
@@ -0,0 +1,374 @@
+#!/usr/bin/env python3
+"""yuyu-book: assemble the offline single-file editions of《御舆》.
+
+Usage (from the repo root):
+ python yuyu-book/build.py --check
+ python yuyu-book/build.py --lang zh
+ python yuyu-book/build.py --lang en --pdf
+ python yuyu-book/build.py --regen-index
+"""
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+import tempfile
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+
+from yuyu_book import (art, audit, mermaid as mm, pages, plain,
+ render) # noqa: E402
+from yuyu_book.core import (BOOK_DIR, REPO_ROOT, BuildError, dump_json,
+ expected_ids, load_configs, load_json, load_unit,
+ read_text, unit_stream) # noqa: E402
+from yuyu_book.style import CSS, key_mark, page, rut, trace, wheel # noqa: E402
+
+
+def glossary_to_index(manifest: dict) -> list[dict]:
+ """Build the subject index from appendix D tables (both languages)."""
+ apxd = next(a for a in manifest["appendices"] if a["id"] == "apxd")
+ entries: dict[str, dict] = {}
+
+ def add(term: str, lang: str, anchor: str, label: str):
+ key = term.strip().lower()
+ if not key or len(term) > 64:
+ return
+ e = entries.setdefault(key, {"key": key, "letter": "A", lang: {"term": term.strip(), "refs": []}})
+ for lk in ("zh", "en"):
+ e.setdefault(lk, {"term": term.strip(), "refs": []})
+ first = re.search(r"[A-Za-z]", term)
+ if first:
+ e["letter"] = first.group(0).upper()
+ refs = e[lang]["refs"]
+ if not any(r["anchor"] == anchor for r in refs):
+ refs.append({"anchor": anchor, "label": label})
+
+ rx_zh = re.compile(r"第\s*(\d{1,2})\s*章")
+ rx_en = re.compile(r"Chapter\s+(\d{1,2})", re.I)
+ for lang in ("zh", "en"):
+ text = read_text(REPO_ROOT / apxd[lang])
+ fence = None
+ for line in text.split("\n"):
+ m = re.match(r"^\s*(`{3,}|~{3,})(.*)$", line)
+ if m:
+ token, info = m.group(1), m.group(2).strip()
+ if fence is None:
+ fence = token
+ elif token[0] == fence[0] and not info:
+ fence = None
+ continue
+ if fence is not None or not line.startswith("|"):
+ continue
+ cells = [c.strip() for c in line.strip("|").split("|")]
+ if len(cells) < 2 or set("".join(cells)) <= set("-: "):
+ continue
+ term = cells[0].strip("*` ")
+ tail = " ".join(cells[2:])
+ rx = rx_zh if lang == "zh" else rx_en
+ nums = [int(n) for n in rx.findall(tail) if 1 <= int(n) <= 15]
+ if not nums or len(term) < 2:
+ continue
+ for n in nums[:4]:
+ add(term, lang, f"ch{n:02d}",
+ f"第 {n} 章" if lang == "zh" else f"Ch {n}")
+ out = sorted(entries.values(), key=lambda e: (e["letter"], e["key"]))
+ return [e for e in out if e.get("zh", {}).get("refs")
+ or e.get("en", {}).get("refs")]
+
+
+def build_edition(lang: str, manifest: dict, sources: dict, *,
+ mode: str, browser: str | None, autolink: bool,
+ out_dir: Path, do_pdf: bool, edge: str | None,
+ glosses: dict | None = None,
+ paths: dict | None = None) -> Path:
+ ids = expected_ids(manifest)
+ mapping = {i: i for i in ids}
+ glosses = glosses or {}
+ paths = paths or {}
+ layer = plain.Layer(glosses, lang)
+
+ units = []
+ for unit_def, kind, part in unit_stream(manifest):
+ u = load_unit(unit_def, kind, lang, REPO_ROOT)
+ u.part = part # type: ignore[attr-defined]
+ units.append(u)
+
+ all_blocks, caps = [], {}
+ for u in units:
+ caps.update(mm.captions_for(read_text(REPO_ROOT / u.path), u.blocks,
+ fallback=pages.ui("plate_fallback", lang)))
+ all_blocks.extend(u.blocks)
+ if mode == "render":
+ diagrams = mm.render_all(all_blocks, caps, browser)
+ expect_svgs = len(diagrams)
+ else:
+ diagrams = mm.source_fallback(all_blocks, caps)
+ expect_svgs = None
+
+ payloads = []
+ for u in units:
+ payloads.append(render.render_unit(u, mapping, diagrams=diagrams,
+ autolink=autolink, gloss=layer,
+ tiers=paths.get(u.id)))
+ by_id = {p["unit"].id: p for p in payloads}
+ layer.check_coverage()
+
+ # ---------------- assemble ----------------
+ body: list[str] = []
+ body.append(pages.cover(manifest, lang))
+ body.append(pages.imprint(manifest, lang))
+ rev = load_json(BOOK_DIR / "revisions.json")["entries"]
+ body.append(pages.revisions_page(rev, lang))
+ body.append(pages.how_to_read(manifest, lang, paths))
+
+ front_id = manifest["front"]["id"]
+ toc_entries: list[dict] = [
+ {"kind": "row", "anchor": front_id, "no": pages.FRONT_NO,
+ "title": by_id[front_id]["unit"].title}]
+ outline: list[tuple[int, str, str]] = [
+ (1, by_id[front_id]["unit"].display or pages.ui("front", lang),
+ front_id)]
+ rail = [f'", "")
+ audit.audit(html_text, n_units=len(units), expect_svgs=expect_svgs)
+
+ # the dashed marks and the chapter panels come from the same hit list; if
+ # they ever diverge, paper readers would chase underlines that are nowhere
+ # explained (there is no hover in print)
+ n_marks = html_text.count('')
+ n_chips = html_text.count('{digits.zfill(2)}'
+ label = u.title
+ elif kind == "appendix":
+ seal = f'{u.id[-1].upper()}'
+ label = u.title
+ else:
+ seal = ""
+ # the emblem rides at the end of the head row, so the top of every unit is
+ # title + seal + drawing rather than a wide empty band over the sheet
+ emblem = art.emblem(u.id)
+ title_html = (f'{seal}
{pages.esc(label)}
{emblem}')
+ if seal or emblem:
+ title_html = f'
{title_html}
'
+ # the foreword is the one unit whose number-free title is the eyebrow text,
+ # so it renders no eyebrow at all rather than "前言" twice above the H1
+ eyebrow_html = (f'
{pages.esc(" · ".join(eyebrow))}
'
+ if eyebrow else "")
+ # the head closes on a drawn wire rather than a ruled line: same job, and it
+ # carries the unit id so no two chapters run the same trace
+ head = (f'{eyebrow_html}{title_html}'
+ f'{trace(u.id)}')
+ cls = "chapter" if kind == "chapter" else ("unit" if kind == "front"
+ else "unit appendix")
+ lead = pages.lead_band(guide, lang)
+ compass = pages.compass(u, guide, sources, lang) if guide else ""
+ panel = pages.new_terms_panel(payload.get("new_terms") or [], lang, kind)
+ # keep rendered body but drop its own H1 (re-synthesised above)
+ body_html = re.sub(r"
]*>.*?
|
.*?
", "", payload["html"],
+ count=1)
+ # the ruts go inside the body, glued to the block they close: left as a
+ # trailing sibling they can fall alone onto a sheet the next unit's forced
+ # page break then empties
+ body_html = render.glue_closer(body_html, rut(u.id))
+ return (f'{head}'
+ f'{lead}{compass}{panel}
{body_html}
'
+ f'')
+
+
+def main(argv=None):
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("--lang", default="both", choices=["zh", "en", "both"])
+ ap.add_argument("--pdf", action="store_true")
+ ap.add_argument("--check", action="store_true",
+ help="build into a temp dir and only validate")
+ ap.add_argument("--out-dir", default=str(BOOK_DIR / "dist"))
+ ap.add_argument("--browser", default=None,
+ help="explicit msedge/chrome executable for mermaid & PDF")
+ ap.add_argument("--edge", default=None, help="PDF browser executable")
+ ap.add_argument("--mermaid", default="render", choices=["render", "source"])
+ ap.add_argument("--no-autolink", action="store_true")
+ ap.add_argument("--no-plain", action="store_true",
+ help="skip the plain-language layer (glosses, tiers)")
+ ap.add_argument("--regen-index", action="store_true")
+ args = ap.parse_args(argv)
+
+ try:
+ manifest, sources = load_configs()
+ glosses, paths = ({}, {}) if args.no_plain else plain.load_plain()
+ if not args.no_plain:
+ plain.validate_plain(glosses, paths, manifest)
+ if args.regen_index:
+ entries = glossary_to_index(manifest)
+ dump_json(BOOK_DIR / "subject-index.json", entries)
+ zh_n = sum(1 for e in entries if e["zh"]["refs"])
+ en_n = sum(1 for e in entries if e["en"]["refs"])
+ print(f"subject index: {len(entries)} terms ({zh_n} zh refs, "
+ f"{en_n} en refs)")
+ if args.lang == "both" and not args.check:
+ pass # index-only run still produces files; build continues
+ langs = ["zh", "en"] if args.lang == "both" else [args.lang]
+ out_dir = Path(args.out_dir)
+ if args.check:
+ out_dir = Path(tempfile.mkdtemp(prefix="yuyu-check-"))
+ for lang in langs:
+ print(f"[{lang}]")
+ build_edition(lang, manifest, sources, mode=args.mermaid,
+ browser=args.browser, autolink=not args.no_autolink,
+ out_dir=out_dir, do_pdf=args.pdf and not args.check,
+ edge=args.edge, glosses=glosses, paths=paths)
+ print("OK" if not args.check else "CHECK OK")
+ except BuildError as e:
+ print(f"BUILD FAILED: {e}", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/yuyu-book/glosses.json b/yuyu-book/glosses.json
new file mode 100644
index 00000000..a88627d0
--- /dev/null
+++ b/yuyu-book/glosses.json
@@ -0,0 +1,2988 @@
+{
+ "async-generator": {
+ "term": {
+ "zh": "异步生成器",
+ "en": "async generator"
+ },
+ "forms": {
+ "zh": [
+ "异步生成器"
+ ],
+ "en": [
+ "async generator",
+ "AsyncGenerator",
+ "Async Generator"
+ ]
+ },
+ "plain": {
+ "zh": "边算边一段段往外交结果的函数,取一段停一段",
+ "en": "a function that hands out results one piece at a time and can pause between pieces"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:18",
+ "第一部分-基础篇/01-智能体编程的新范式.md:240",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:16"
+ ],
+ "analogy": {
+ "zh": "像流水线厨师,做好一盘递一盘,不等全桌菜齐",
+ "en": "a cook passing dishes out as each is ready, not after the whole meal is done"
+ }
+ },
+ "yield": {
+ "term": {
+ "zh": "yield",
+ "en": "yield"
+ },
+ "forms": {
+ "zh": [
+ "yield"
+ ],
+ "en": [
+ "yield"
+ ]
+ },
+ "plain": {
+ "zh": "交出一个结果,然后停在原地等对方来取下一个",
+ "en": "hand out one value, then sit still until the caller asks for the next"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:252",
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:18",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:18"
+ ],
+ "analogy": {
+ "zh": "出菜口:炒好一盘举起来,服务员取走才炒下一盘",
+ "en": "the wok pass: hold up a plate and wait for the runner to take it"
+ }
+ },
+ "backpressure": {
+ "term": {
+ "zh": "背压",
+ "en": "backpressure"
+ },
+ "forms": {
+ "zh": [
+ "背压控制",
+ "背压"
+ ],
+ "en": [
+ "backpressure control",
+ "backpressure"
+ ]
+ },
+ "plain": {
+ "zh": "下游处理不过来,就反过来让上游慢一点",
+ "en": "when the consumer falls behind, it pushes the producer to slow down"
+ },
+ "priority": "core",
+ "evidence": [
+ "00-前言.md:129",
+ "第一部分-基础篇/01-智能体编程的新范式.md:316",
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:314",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:314"
+ ],
+ "analogy": {
+ "zh": "下水道堵了,得有人去把水龙头拧小",
+ "en": "a clogged drain sends someone running to turn the tap down"
+ }
+ },
+ "producer-consumer": {
+ "term": {
+ "zh": "生产者与消费者",
+ "en": "producer and consumer"
+ },
+ "forms": {
+ "zh": [
+ "生产者",
+ "消费者"
+ ],
+ "en": [
+ "producer",
+ "consumer"
+ ]
+ },
+ "plain": {
+ "zh": "一边只管做、一边只管用,两边速度不同就得协调",
+ "en": "one side only makes, the other only takes; their speeds rarely match"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:22",
+ "第一部分-基础篇/01-智能体编程的新范式.md:316",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:22"
+ ],
+ "analogy": {
+ "zh": "分拣带跑得比唯一一名装车员快,包裹就堆成山",
+ "en": "a parcel belt moving faster than the single worker loading trucks"
+ }
+ },
+ "race-condition": {
+ "term": {
+ "zh": "竞态条件",
+ "en": "race condition"
+ },
+ "forms": {
+ "zh": [
+ "竞态条件"
+ ],
+ "en": [
+ "race condition",
+ "Race condition"
+ ]
+ },
+ "plain": {
+ "zh": "两件事同时抢着改同一份数据,结果取决于谁手快",
+ "en": "two paths touch the same data at once; the outcome depends on timing"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:382",
+ "第三部分-高级模式篇/11-技能系统与插件架构.md:87",
+ "en/Part-3-Advanced-Patterns/11-The-Skill-System-and-Plugin-Architecture.md:87"
+ ],
+ "analogy": {
+ "zh": "两个收银员同时从同一格抽屉拿钱找零,账必乱",
+ "en": "two cashiers grabbing change from the same drawer at the same second"
+ }
+ },
+ "idempotent": {
+ "term": {
+ "zh": "幂等",
+ "en": "idempotent"
+ },
+ "forms": {
+ "zh": [
+ "幂等"
+ ],
+ "en": [
+ "idempotent"
+ ]
+ },
+ "plain": {
+ "zh": "同一件事做一次和做五遍,最后结果一模一样",
+ "en": "doing it once or five times leaves exactly the same result"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:208",
+ "附录/D-术语表.md:222",
+ "en/Part-1-Foundations/03-The-Tool-System-Agent-Hands.md:208"
+ ],
+ "analogy": {
+ "zh": "电梯按钮按一次和按十次,都只来一趟",
+ "en": "pressing the lift button once or ten times still brings one trip"
+ }
+ },
+ "serialization": {
+ "term": {
+ "zh": "序列化",
+ "en": "serialization"
+ },
+ "forms": {
+ "zh": [
+ "序列化",
+ "反序列化"
+ ],
+ "en": [
+ "serialization",
+ "serialize",
+ "deserialization"
+ ]
+ },
+ "plain": {
+ "zh": "把内存里的对象摊平成一段文字,好存好发;反过来叫还原",
+ "en": "flattening an object into text so it can be stored or sent, and undoing it"
+ },
+ "priority": "core",
+ "evidence": [
+ "第二部分-核心系统篇/08-钩子系统-Agent的生命周期扩展点.md:165",
+ "第三部分-高级模式篇/09-子智能体与Fork模式.md:329",
+ "en/Part-2-Core-Systems/08-The-Hook-System-Agent-Lifecycle-Extension-Points.md:165"
+ ],
+ "analogy": {
+ "zh": "家具拆平装箱寄走,到货后再按原样拼回去",
+ "en": "flat-packing furniture into a box, then rebuilding it at the other end"
+ }
+ },
+ "state-machine": {
+ "term": {
+ "zh": "状态机",
+ "en": "state machine"
+ },
+ "forms": {
+ "zh": [
+ "状态机"
+ ],
+ "en": [
+ "state machine",
+ "State Machine"
+ ]
+ },
+ "plain": {
+ "zh": "把流程画成几个格子,箭头规定哪格能跳到哪格",
+ "en": "a flow drawn as boxes, with arrows listing the legal jumps"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:328",
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:342",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:220"
+ ],
+ "analogy": {
+ "zh": "售货机:投币、选货、出货,跳错一步机器不认",
+ "en": "a vending machine: coin in, select, dispense, never out of order"
+ }
+ },
+ "dependency-injection": {
+ "term": {
+ "zh": "依赖注入",
+ "en": "dependency injection"
+ },
+ "forms": {
+ "zh": [
+ "依赖注入"
+ ],
+ "en": [
+ "dependency injection",
+ "Dependency Injection"
+ ]
+ },
+ "plain": {
+ "zh": "要用的东西不自己造,而是由外面递进来,随时能换",
+ "en": "you don't build what you need; it's handed in from outside and swappable"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:292",
+ "第一部分-基础篇/01-智能体编程的新范式.md:254",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:12"
+ ],
+ "analogy": {
+ "zh": "食材由助手送进来,主厨换成谁,菜谱照用",
+ "en": "a prep cook delivers the ingredients, so the chef can be swapped out"
+ }
+ },
+ "test-mock": {
+ "term": {
+ "zh": "mock",
+ "en": "mock"
+ },
+ "forms": {
+ "zh": [
+ "mock"
+ ],
+ "en": [
+ "mock",
+ "mocks",
+ "spy"
+ ]
+ },
+ "plain": {
+ "zh": "测试时用的假替身,照剧本回应,不去碰真的外部服务",
+ "en": "a stand-in built for tests that answers from a script instead of the real thing"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:304",
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:418",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:302"
+ ],
+ "analogy": {
+ "zh": "演习时请人扮客户,照台词问答,不是真买家",
+ "en": "an actor playing the customer during rehearsal, reading from a script"
+ }
+ },
+ "singleton": {
+ "term": {
+ "zh": "单例",
+ "en": "singleton"
+ },
+ "forms": {
+ "zh": [
+ "惰性单例",
+ "单例"
+ ],
+ "en": [
+ "singleton"
+ ]
+ },
+ "plain": {
+ "zh": "全程只许存在一份的对象,谁来拿都是同一个",
+ "en": "one shared instance for the whole program; everyone gets the same one"
+ },
+ "priority": "core",
+ "evidence": [
+ "第三部分-高级模式篇/11-技能系统与插件架构.md:87",
+ "第三部分-高级模式篇/11-技能系统与插件架构.md:97",
+ "en/Part-3-Advanced-Patterns/11-The-Skill-System-and-Plugin-Architecture.md:87"
+ ],
+ "analogy": {
+ "zh": "办公室只有一台打印机,所有人都排同一条队",
+ "en": "the office has one printer and everyone queues at the same machine"
+ }
+ },
+ "observer-pattern": {
+ "term": {
+ "zh": "观察者模式",
+ "en": "observer pattern"
+ },
+ "forms": {
+ "zh": [
+ "观察者模式"
+ ],
+ "en": [
+ "observer pattern",
+ "Observer Pattern",
+ "Observer"
+ ]
+ },
+ "plain": {
+ "zh": "谁关心就登记,事一发生挨个通知,发事的不用认识谁",
+ "en": "anyone interested signs up; when something happens, all get told"
+ },
+ "priority": "core",
+ "evidence": [
+ "第二部分-核心系统篇/08-钩子系统-Agent的生命周期扩展点.md:19",
+ "第三部分-高级模式篇/12-MCP集成与外部协议.md:689",
+ "en/Part-2-Core-Systems/08-The-Hook-System-Agent-Lifecycle-Extension-Points.md:19"
+ ],
+ "analogy": {
+ "zh": "物业在业主群发一条通知,不必挨家敲门",
+ "en": "the property manager posts once in the residents' group chat"
+ }
+ },
+ "chain-of-responsibility": {
+ "term": {
+ "zh": "责任链模式",
+ "en": "chain of responsibility"
+ },
+ "forms": {
+ "zh": [
+ "责任链模式",
+ "责任链"
+ ],
+ "en": [
+ "Chain of Responsibility",
+ "chain of responsibility"
+ ]
+ },
+ "plain": {
+ "zh": "一件事依次经手多人,谁有权限谁拦下,否则往后传",
+ "en": "a request passes down a line; whoever can handle it stops it there"
+ },
+ "priority": "core",
+ "evidence": [
+ "第二部分-核心系统篇/08-钩子系统-Agent的生命周期扩展点.md:19",
+ "第三部分-高级模式篇/12-MCP集成与外部协议.md:687",
+ "en/Part-2-Core-Systems/08-The-Hook-System-Agent-Lifecycle-Extension-Points.md:19"
+ ],
+ "analogy": {
+ "zh": "报销单从组长到经理再到财务,一级级往上签",
+ "en": "an expense form travels from team lead to manager to finance"
+ }
+ },
+ "generics": {
+ "term": {
+ "zh": "泛型",
+ "en": "generics"
+ },
+ "forms": {
+ "zh": [
+ "泛型接口",
+ "泛型"
+ ],
+ "en": [
+ "generic",
+ "Generic"
+ ]
+ },
+ "plain": {
+ "zh": "先留个空类型位,用时才填,一份定义套多种数据",
+ "en": "a type slot left blank until use, so one definition fits many data kinds"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:26",
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:32",
+ "en/Part-1-Foundations/03-The-Tool-System-Agent-Hands.md:26"
+ ],
+ "analogy": {
+ "zh": "同一款收纳箱,能装鞋也能装书,看放什么",
+ "en": "one storage bin design that holds shoes or books, depending on you"
+ }
+ },
+ "union-type": {
+ "term": {
+ "zh": "联合类型",
+ "en": "union type"
+ },
+ "forms": {
+ "zh": [
+ "联合类型"
+ ],
+ "en": [
+ "union type"
+ ]
+ },
+ "plain": {
+ "zh": "一个位置只允许列出的几种形状之一,用前得先分辨是哪种",
+ "en": "one slot holding any of several listed shapes; you must tell which first"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:61",
+ "第一部分-基础篇/04-权限管线-Agent的护栏.md:193",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:61"
+ ],
+ "analogy": {
+ "zh": "信封里可能是发票、贺卡或照片,拆开先看是哪样",
+ "en": "an envelope with an invoice, card, or photo - open it to see which"
+ }
+ },
+ "closure": {
+ "term": {
+ "zh": "闭包",
+ "en": "closure"
+ },
+ "forms": {
+ "zh": [
+ "闭包"
+ ],
+ "en": [
+ "closure"
+ ]
+ },
+ "plain": {
+ "zh": "函数带着它出生时的环境一起走,别人够不着那份数据",
+ "en": "a function that carries its birth environment wherever it goes"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:312",
+ "第三部分-高级模式篇/12-MCP集成与外部协议.md:956",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:312"
+ ],
+ "analogy": {
+ "zh": "店员随身带自己那串仓库钥匙,别的柜台借不到",
+ "en": "a clerk keeps her own stockroom keys; no other counter can borrow them"
+ }
+ },
+ "promise": {
+ "term": {
+ "zh": "Promise",
+ "en": "Promise"
+ },
+ "forms": {
+ "zh": [
+ "Promise 链",
+ "Promise"
+ ],
+ "en": [
+ "Promise",
+ "promise"
+ ]
+ },
+ "plain": {
+ "zh": "一张“将来给你结果”的凭条,只兑一次,成或败都算交代",
+ "en": "a receipt for a value that arrives later; it settles exactly once"
+ },
+ "priority": "core",
+ "evidence": [
+ "00-前言.md:129",
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:69",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:67"
+ ],
+ "analogy": {
+ "zh": "修表给张取货单,只兑一次:修好了或没修好",
+ "en": "a claim check from the watchmaker: you redeem it once, done or not"
+ }
+ },
+ "callback": {
+ "term": {
+ "zh": "回调",
+ "en": "callback"
+ },
+ "forms": {
+ "zh": [
+ "回调地狱",
+ "回调"
+ ],
+ "en": [
+ "callback",
+ "callbacks"
+ ]
+ },
+ "plain": {
+ "zh": "把接下来要做的事托给对方,他办完再回头喊你",
+ "en": "you hand over what to do next and get called when it finishes"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:99",
+ "00-前言.md:129",
+ "en/00-Foreword.md:100"
+ ],
+ "analogy": {
+ "zh": "修手机留个号,修好打给你;留十个号就乱成粥",
+ "en": "leave your number for repairs; chain ten of them and you lose track"
+ }
+ },
+ "cancellation-propagation": {
+ "term": {
+ "zh": "取消传播",
+ "en": "cancellation propagation"
+ },
+ "forms": {
+ "zh": [
+ "取消传播",
+ "级联取消"
+ ],
+ "en": [
+ "cancellation propagation"
+ ]
+ },
+ "plain": {
+ "zh": "上面一喊停,底下每一层都收到停,各自收尾",
+ "en": "a stop call at the top reaches every layer below, each wrapping up"
+ },
+ "priority": "core",
+ "evidence": [
+ "00-前言.md:129",
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:316",
+ "en/00-Foreword.md:100"
+ ],
+ "analogy": {
+ "zh": "导演喊卡,摄影、灯光、场务一层层传下去收工",
+ "en": "the director calls cut; camera, lights and crew all wrap up"
+ }
+ },
+ "abort-controller": {
+ "term": {
+ "zh": "AbortController",
+ "en": "AbortController"
+ },
+ "forms": {
+ "zh": [
+ "AbortController"
+ ],
+ "en": [
+ "AbortController",
+ "abortController"
+ ]
+ },
+ "plain": {
+ "zh": "一个能随时按下的停止开关,正在干活的得自己去瞅它",
+ "en": "a stop switch anyone can flip; running work must check it to bail out"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:316",
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:354",
+ "第四部分-工程实践篇/14-Plan模式与结构化工作流.md:685",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:316"
+ ],
+ "analogy": {
+ "zh": "车间的急停按钮,每台机器动手前都先看一眼",
+ "en": "the shop-floor emergency stop that every machine glances at"
+ }
+ },
+ "immutable-state": {
+ "term": {
+ "zh": "不可变",
+ "en": "immutability"
+ },
+ "forms": {
+ "zh": [
+ "不可变性",
+ "不可变"
+ ],
+ "en": [
+ "immutable",
+ "immutability"
+ ]
+ },
+ "plain": {
+ "zh": "数据不涂改,要改就整份抄新的再改,旧的照原样留着",
+ "en": "never overwrite data; copy it, change the copy, keep the original"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:378",
+ "00-前言.md:145",
+ "en/00-Foreword.md:116"
+ ],
+ "analogy": {
+ "zh": "病历不改原页,每次另开一张,原单留档可查",
+ "en": "a chart is never erased; each change goes on a fresh page"
+ }
+ },
+ "side-effect": {
+ "term": {
+ "zh": "副作用",
+ "en": "side effect"
+ },
+ "forms": {
+ "zh": [
+ "副作用"
+ ],
+ "en": [
+ "side effect",
+ "side effects"
+ ]
+ },
+ "plain": {
+ "zh": "算数的同时还改了外面:写文件、发消息、动全局状态",
+ "en": "besides computing, it also changes things outside itself: files, messages, state"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:228",
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:441",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:441"
+ ],
+ "analogy": {
+ "zh": "只是去问个路,顺手把人家的花盆碰倒了",
+ "en": "you only asked for directions, then knocked over their flowerpot"
+ }
+ },
+ "blocking": {
+ "term": {
+ "zh": "阻塞",
+ "en": "blocking"
+ },
+ "forms": {
+ "zh": [
+ "阻塞"
+ ],
+ "en": [
+ "blocking",
+ "block"
+ ]
+ },
+ "plain": {
+ "zh": "卡在原地干等,等的时候什么都干不了",
+ "en": "stuck waiting in place, unable to do anything else meanwhile"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:99",
+ "第一部分-基础篇/01-智能体编程的新范式.md:267",
+ "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md:99"
+ ],
+ "analogy": {
+ "zh": "银行只开一个窗口,前面慢,整队人干站着",
+ "en": "one teller window, and the whole line stands still behind a slow customer"
+ }
+ },
+ "concurrency": {
+ "term": {
+ "zh": "并发",
+ "en": "concurrency"
+ },
+ "forms": {
+ "zh": [
+ "并发安全",
+ "并发"
+ ],
+ "en": [
+ "concurrency",
+ "concurrent"
+ ]
+ },
+ "plain": {
+ "zh": "几件事同时在推进,抢同一份资源就得安排先后",
+ "en": "several tasks in flight at once, competing for shared things"
+ },
+ "priority": "core",
+ "evidence": [
+ "00-前言.md:115",
+ "第一部分-基础篇/01-智能体编程的新范式.md:57",
+ "en/00-Foreword.md:86"
+ ],
+ "analogy": {
+ "zh": "四个厨师一口锅,同时开火就排班定谁先用",
+ "en": "four cooks on one stove: someone has to set the order"
+ }
+ },
+ "circuit-breaker": {
+ "term": {
+ "zh": "断路器",
+ "en": "circuit breaker"
+ },
+ "forms": {
+ "zh": [
+ "断路器模式",
+ "断路器",
+ "熔断"
+ ],
+ "en": [
+ "circuit breaker",
+ "Circuit Breaker"
+ ]
+ },
+ "plain": {
+ "zh": "连着失败够多次就先跳过这步,别硬试拖累全局",
+ "en": "after enough failures in a row it skips the call instead of dragging everything"
+ },
+ "priority": "core",
+ "evidence": [
+ "第二部分-核心系统篇/07-上下文管理-Agent的工作记忆.md:106",
+ "第一部分-基础篇/04-权限管线-Agent的护栏.md:266",
+ "en/Part-2-Core-Systems/07-Context-Management-Agent-Working-Memory.md:6"
+ ],
+ "analogy": {
+ "zh": "家里电闸跳了,先断一路,别让整栋楼烧起来",
+ "en": "the fuse blows and cuts one line before the whole house burns"
+ }
+ },
+ "graceful-degradation": {
+ "term": {
+ "zh": "优雅降级",
+ "en": "graceful degradation"
+ },
+ "forms": {
+ "zh": [
+ "优雅降级",
+ "降级"
+ ],
+ "en": [
+ "graceful degradation",
+ "degrade"
+ ]
+ },
+ "plain": {
+ "zh": "出问题时不整个瘫掉,砍掉非必要部分,保住还能用的",
+ "en": "on failure, drop the nice-to-haves and keep the core running"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:246",
+ "第一部分-基础篇/04-权限管线-Agent的护栏.md:86",
+ "en/Part-1-Foundations/04-The-Permission-Pipeline-Agent-Guardrails.md:84"
+ ],
+ "analogy": {
+ "zh": "航班超售,先减免费餐食,飞机照飞",
+ "en": "the flight is oversold: cut the free meals but still take off"
+ }
+ },
+ "exponential-backoff": {
+ "term": {
+ "zh": "指数退避",
+ "en": "exponential backoff"
+ },
+ "forms": {
+ "zh": [
+ "指数退避"
+ ],
+ "en": [
+ "exponential backoff",
+ "backoff"
+ ]
+ },
+ "plain": {
+ "zh": "每次重试之间等得越来越久,别追着人家猛敲门",
+ "en": "wait longer between each retry instead of hammering a struggling service"
+ },
+ "priority": "core",
+ "evidence": [
+ "第三部分-高级模式篇/12-MCP集成与外部协议.md:248",
+ "en/Part-3-Advanced-Patterns/12-MCP-Integration-and-External-Protocols.md:248"
+ ],
+ "analogy": {
+ "zh": "店没开门,隔一分、两分、四分再去问一次",
+ "en": "if the shop isn't open, ask again after one, two, then four minutes"
+ }
+ },
+ "cache-hit-rate": {
+ "term": {
+ "zh": "缓存命中率",
+ "en": "cache hit rate"
+ },
+ "forms": {
+ "zh": [
+ "缓存命中率"
+ ],
+ "en": [
+ "cache hit rate",
+ "hit rate"
+ ]
+ },
+ "plain": {
+ "zh": "要用的东西已在手边备好的比例,低了就得现跑现取",
+ "en": "how often the thing you want is already kept nearby, ready to grab"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:344",
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:196",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:196"
+ ],
+ "analogy": {
+ "zh": "常用工具挂墙上,伸手就有和得跑仓库的比例",
+ "en": "the share of jobs where the tool is on the wall, not in the warehouse"
+ }
+ },
+ "dead-code-elimination": {
+ "term": {
+ "zh": "死代码消除",
+ "en": "dead code elimination"
+ },
+ "forms": {
+ "zh": [
+ "死代码消除",
+ "死代码"
+ ],
+ "en": [
+ "dead code elimination",
+ "dead code"
+ ]
+ },
+ "plain": {
+ "zh": "出厂前把永远跑不到的代码整块扔掉,成品更小也不透密",
+ "en": "the build strips code that can never run, shrinking the artifact"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:134",
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:136",
+ "en/Part-1-Foundations/03-The-Tool-System-Agent-Hands.md:136"
+ ],
+ "analogy": {
+ "zh": "搬家时从没穿过的衣服不进箱,箱子小一半",
+ "en": "clothes you never wear never make it into the moving box"
+ }
+ },
+ "tree-shaking": {
+ "term": {
+ "zh": "tree-shaking",
+ "en": "tree-shaking"
+ },
+ "forms": {
+ "zh": [
+ "tree-shaking"
+ ],
+ "en": [
+ "tree-shaking"
+ ]
+ },
+ "plain": {
+ "zh": "打包时抖落用不上的分支代码,只留真被摇到的那几根",
+ "en": "shaking unused branches off the bundle so only reachable code ships"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:138",
+ "第四部分-工程实践篇/13-流式架构与性能优化.md:363",
+ "en/Part-1-Foundations/03-The-Tool-System-Agent-Hands.md:138"
+ ],
+ "analogy": {
+ "zh": "装箱前把没贴标签的配件全抖回货架,箱里只剩要用的",
+ "en": "before packing, shake off every unlabelled part back onto the shelf"
+ }
+ },
+ "lazy-loading": {
+ "term": {
+ "zh": "延迟加载",
+ "en": "lazy loading"
+ },
+ "forms": {
+ "zh": [
+ "延迟加载",
+ "懒加载"
+ ],
+ "en": [
+ "lazy loading",
+ "lazy load"
+ ]
+ },
+ "plain": {
+ "zh": "用到的那一刻才去取,没用到就一直不搬进来",
+ "en": "fetch a thing only when it's first needed, not all at the start"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:277",
+ "第三部分-高级模式篇/10-协调器模式-多智能体编排.md:69",
+ "en/Part-3-Advanced-Patterns/10-The-Coordinator-Pattern-Multi-Agent-Orchestration.md:69"
+ ],
+ "analogy": {
+ "zh": "餐馆不预先摆满桌,来一桌客人摆一桌",
+ "en": "the cafe sets out tables only as guests arrive"
+ }
+ },
+ "circular-dependency": {
+ "term": {
+ "zh": "循环依赖",
+ "en": "circular dependency"
+ },
+ "forms": {
+ "zh": [
+ "循环依赖"
+ ],
+ "en": [
+ "circular dependency",
+ "circular dependencies"
+ ]
+ },
+ "plain": {
+ "zh": "A 要等 B 先就绪,B 又回头等 A,谁都起不了步",
+ "en": "A waits for B ready and B waits for A ready, so neither can start"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:277",
+ "第三部分-高级模式篇/10-协调器模式-多智能体编排.md:69",
+ "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md:277"
+ ],
+ "analogy": {
+ "zh": "开箱要钥匙,钥匙又锁在箱子里",
+ "en": "the key to the box is locked inside the box"
+ }
+ },
+ "memory-overflow": {
+ "term": {
+ "zh": "内存溢出",
+ "en": "memory overflow"
+ },
+ "forms": {
+ "zh": [
+ "内存溢出"
+ ],
+ "en": [
+ "memory overflow",
+ "out of memory"
+ ]
+ },
+ "plain": {
+ "zh": "攒着不放的东西太多,撑爆存放处,程序当场倒下",
+ "en": "too much kept at once runs out of room and the process dies"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:316",
+ "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md:316"
+ ],
+ "analogy": {
+ "zh": "购物车越堆越高,最后顶穿了仓库屋顶",
+ "en": "piles keep growing until they punch through the storeroom roof"
+ }
+ },
+ "regex": {
+ "term": {
+ "zh": "正则表达式",
+ "en": "regular expression"
+ },
+ "forms": {
+ "zh": [
+ "正则表达式",
+ "正则匹配",
+ "正则"
+ ],
+ "en": [
+ "regular expression",
+ "regex",
+ "Regex"
+ ]
+ },
+ "plain": {
+ "zh": "写一串模式符号,让机器照它在文本里筛出匹配的内容",
+ "en": "a pattern string the machine uses to pick out matching text"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:222",
+ "第一部分-基础篇/04-权限管线-Agent的护栏.md:328",
+ "en/Part-1-Foundations/03-The-Tool-System-Agent-Hands.md:222"
+ ],
+ "analogy": {
+ "zh": "门卫只认“两位字母加四位数字”,照格式放车",
+ "en": "the gatekeeper accepts only two letters plus four digits"
+ }
+ },
+ "sandbox": {
+ "term": {
+ "zh": "沙箱",
+ "en": "sandbox"
+ },
+ "forms": {
+ "zh": [
+ "沙箱模式",
+ "沙箱"
+ ],
+ "en": [
+ "sandbox",
+ "sandboxed"
+ ]
+ },
+ "plain": {
+ "zh": "圈一块受限的地方让代码玩,出事也溅不到外面",
+ "en": "an enclosed area where code plays without touching anything outside"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:331",
+ "第一部分-基础篇/04-权限管线-Agent的护栏.md:115",
+ "en/Part-1-Foundations/04-The-Permission-Pipeline-Agent-Guardrails.md:115"
+ ],
+ "analogy": {
+ "zh": "小区里的儿童沙坑,玩脏了也不糟蹋别家花园",
+ "en": "a kids' sandpit inside the compound; the mess stays in it"
+ }
+ },
+ "least-privilege": {
+ "term": {
+ "zh": "最小权限",
+ "en": "least privilege"
+ },
+ "forms": {
+ "zh": [
+ "最小权限"
+ ],
+ "en": [
+ "least privilege"
+ ]
+ },
+ "plain": {
+ "zh": "只发刚好够干这活的权限,多一分都不给",
+ "en": "grant only what this task needs and nothing more"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:216",
+ "第二部分-核心系统篇/05-设置与配置-Agent的基因.md:586",
+ "en/Part-1-Foundations/03-The-Tool-System-Agent-Hands.md:216"
+ ],
+ "analogy": {
+ "zh": "保洁的工牌只开储物间,开不了金库",
+ "en": "the cleaner's badge opens the supply room, never the vault"
+ }
+ },
+ "separation-of-concerns": {
+ "term": {
+ "zh": "关注点分离",
+ "en": "separation of concerns"
+ },
+ "forms": {
+ "zh": [
+ "关注点分离"
+ ],
+ "en": [
+ "separation of concerns"
+ ]
+ },
+ "plain": {
+ "zh": "每块代码只管自己那件事,别把判断和跑腿搅一起",
+ "en": "each piece of code handles one job and doesn't mix in another's"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:75",
+ "00-前言.md:130",
+ "en/Part-1-Foundations/03-The-Tool-System-Agent-Hands.md:75"
+ ],
+ "analogy": {
+ "zh": "后厨切配的管切配、掌勺的管掌勺,不互相插手",
+ "en": "the prep cook preps, the wok cook cooks; they don't cross lines"
+ }
+ },
+ "event-stream": {
+ "term": {
+ "zh": "事件流",
+ "en": "event stream"
+ },
+ "forms": {
+ "zh": [
+ "事件流"
+ ],
+ "en": [
+ "event stream"
+ ]
+ },
+ "plain": {
+ "zh": "发生的事一条条排队送出来,边到边处理,不等攒齐",
+ "en": "happenings come out one by one and are handled as they arrive"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:318",
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:69",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:69"
+ ],
+ "analogy": {
+ "zh": "传送带一件件出包裹,扫一件贴一件",
+ "en": "parcels come down the belt one at a time, scanned as they land"
+ }
+ },
+ "streaming": {
+ "term": {
+ "zh": "流式",
+ "en": "streaming"
+ },
+ "forms": {
+ "zh": [
+ "流式"
+ ],
+ "en": [
+ "streaming",
+ "Streaming"
+ ]
+ },
+ "plain": {
+ "zh": "结果一点点到就一点点用;书里指模型回复逐字涌出",
+ "en": "handle bits as they land; here, the model's reply arrives word by word"
+ },
+ "priority": "core",
+ "evidence": [
+ "00-前言.md:24",
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:18",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:18"
+ ],
+ "analogy": {
+ "zh": "接自来水不必等水缸装满,开着龙头就能洗",
+ "en": "wash under the running tap instead of waiting for the tub to fill"
+ }
+ },
+ "event-emitter": {
+ "term": {
+ "zh": "事件发射器",
+ "en": "event emitter"
+ },
+ "forms": {
+ "zh": [
+ "事件发射器"
+ ],
+ "en": [
+ "event emitter",
+ "EventEmitter"
+ ]
+ },
+ "plain": {
+ "zh": "会广播动静的物件,谁想听谁自己来挂上监听",
+ "en": "an object that broadcasts happenings to whoever subscribed"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:99",
+ "第一部分-基础篇/01-智能体编程的新范式.md:252",
+ "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md:99"
+ ],
+ "analogy": {
+ "zh": "门口那台大喇叭,要听的人自己搬椅子来坐",
+ "en": "a loudspeaker by the door; listeners bring their own chairs"
+ }
+ },
+ "runtime-validation": {
+ "term": {
+ "zh": "运行时验证",
+ "en": "runtime validation"
+ },
+ "forms": {
+ "zh": [
+ "运行时验证"
+ ],
+ "en": [
+ "runtime validation",
+ "Runtime Validation"
+ ]
+ },
+ "plain": {
+ "zh": "跑起来那一刻才查数据合不合规格,当场挡住脏输入",
+ "en": "checking the data while the program runs, so bad input stops right there"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:62",
+ "00-前言.md:135",
+ "en/00-Foreword.md:106"
+ ],
+ "analogy": {
+ "zh": "快递进站还得过一台称重和查违禁品的机器",
+ "en": "packages are still weighed and screened as they enter the depot"
+ }
+ },
+ "snapshot": {
+ "term": {
+ "zh": "快照",
+ "en": "snapshot"
+ },
+ "forms": {
+ "zh": [
+ "状态快照",
+ "快照"
+ ],
+ "en": [
+ "snapshot",
+ "Snapshot"
+ ]
+ },
+ "plain": {
+ "zh": "把某一瞬间的全部原样抄一份留着,之后怎么变都不影响它",
+ "en": "a copy of everything at one instant, untouched by later changes"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:182",
+ "第一部分-基础篇/01-智能体编程的新范式.md:380",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:182"
+ ],
+ "analogy": {
+ "zh": "出门前拍张桌面照,回来再乱也有底可查",
+ "en": "photograph the desk before leaving; later mess can't change the photo"
+ }
+ },
+ "serial-execution": {
+ "term": {
+ "zh": "串行",
+ "en": "serial"
+ },
+ "forms": {
+ "zh": [
+ "串行"
+ ],
+ "en": [
+ "serial",
+ "serially"
+ ]
+ },
+ "plain": {
+ "zh": "一件彻底办完才轮到下一件,中间绝不重叠",
+ "en": "one job finishes completely before the next one starts"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:57",
+ "第一部分-基础篇/01-智能体编程的新范式.md:224",
+ "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md:57"
+ ],
+ "analogy": {
+ "zh": "独木桥一次只过一辆车,后头的排队等",
+ "en": "a single-plank bridge: one vehicle at a time, the rest queue"
+ }
+ },
+ "yield-star": {
+ "term": {
+ "zh": "yield*",
+ "en": "yield*"
+ },
+ "forms": {
+ "zh": [
+ "yield*"
+ ],
+ "en": [
+ "yield*"
+ ]
+ },
+ "plain": {
+ "zh": "把往外逐个交活这整件事,转包给另一段同样的代码",
+ "en": "delegate the whole hand-out job to another generator and pass its output along"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:242",
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:63",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:63"
+ ],
+ "analogy": {
+ "zh": "组长说“这段小刘全程负责,你们直接找他”",
+ "en": "the shift lead says: Liu owns this section end to end, go to him"
+ }
+ },
+ "coroutine": {
+ "term": {
+ "zh": "协程",
+ "en": "coroutine"
+ },
+ "forms": {
+ "zh": [
+ "协程"
+ ],
+ "en": [
+ "coroutine"
+ ]
+ },
+ "plain": {
+ "zh": "能主动让位、之后从让处接着跑的一段流程",
+ "en": "a routine that gives up the floor and resumes exactly where it stopped"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:22",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:22"
+ ],
+ "analogy": {
+ "zh": "织毛衣打个结放下,明天从那一针接着织",
+ "en": "tie the yarn and set knitting down; tomorrow you resume that stitch"
+ }
+ },
+ "strategy-pattern": {
+ "term": {
+ "zh": "策略模式",
+ "en": "strategy pattern"
+ },
+ "forms": {
+ "zh": [
+ "策略模式"
+ ],
+ "en": [
+ "Strategy Pattern",
+ "strategy pattern"
+ ]
+ },
+ "plain": {
+ "zh": "同一件事备几套做法,按情况挑一套,主干不动",
+ "en": "several interchangeable ways for one job; pick one, core code unchanged"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第三部分-高级模式篇/09-子智能体与Fork模式.md:32",
+ "en/Part-3-Advanced-Patterns/09-Sub-Agents-and-the-Fork-Pattern.md:32"
+ ],
+ "analogy": {
+ "zh": "外卖平台可切“最快”或“最省”路线,骑手照单跑",
+ "en": "the app swaps fastest or cheapest routing; riders just follow"
+ }
+ },
+ "adapter-pattern": {
+ "term": {
+ "zh": "适配器模式",
+ "en": "adapter pattern"
+ },
+ "forms": {
+ "zh": [
+ "适配器模式",
+ "适配器"
+ ],
+ "en": [
+ "Adapter pattern",
+ "adapter pattern",
+ "adapter"
+ ]
+ },
+ "plain": {
+ "zh": "中间垫一层做转换,两边各说各话也能接上",
+ "en": "a middle layer translates so two mismatched sides connect"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第三部分-高级模式篇/12-MCP集成与外部协议.md:686",
+ "第三部分-高级模式篇/12-MCP集成与外部协议.md:64",
+ "en/Part-3-Advanced-Patterns/12-MCP-Integration-and-External-Protocols.md:64"
+ ],
+ "analogy": {
+ "zh": "旅行转换插头,圆脚插进扁孔,电器不用改",
+ "en": "a travel plug adapter: round pins into flat holes, no rewiring"
+ }
+ },
+ "factory-function": {
+ "term": {
+ "zh": "工厂函数",
+ "en": "factory function"
+ },
+ "forms": {
+ "zh": [
+ "工厂函数"
+ ],
+ "en": [
+ "factory function",
+ "Factory Function"
+ ]
+ },
+ "plain": {
+ "zh": "专门造东西的一道工序,规格统一,要几个现造几个",
+ "en": "a routine whose only job is building objects to one spec, on demand"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:270",
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:98",
+ "en/Part-1-Foundations/03-The-Tool-System-Agent-Hands.md:98"
+ ],
+ "analogy": {
+ "zh": "酱料台每份照同一配方现调,不提前大批做",
+ "en": "the sauce station builds each portion to the same recipe, to order"
+ }
+ },
+ "json-schema": {
+ "term": {
+ "zh": "JSON Schema",
+ "en": "JSON Schema"
+ },
+ "forms": {
+ "zh": [
+ "JSON Schema"
+ ],
+ "en": [
+ "JSON Schema"
+ ]
+ },
+ "plain": {
+ "zh": "一份书面格式说明:这段数据该有哪些字段、各是什么类型",
+ "en": "a written spec of which fields a payload must carry and their types"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:205",
+ "第一部分-基础篇/01-智能体编程的新范式.md:212",
+ "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md:205"
+ ],
+ "analogy": {
+ "zh": "报名表印好必填栏目,填错的当场退回",
+ "en": "the form prints required boxes; a wrongly filled one bounces back"
+ }
+ },
+ "destructuring": {
+ "term": {
+ "zh": "解构",
+ "en": "destructuring"
+ },
+ "forms": {
+ "zh": [
+ "解构"
+ ],
+ "en": [
+ "destructured",
+ "destructuring"
+ ]
+ },
+ "plain": {
+ "zh": "一次把整包数据里的各部件分名别类取出,省得逐个翻",
+ "en": "pull many named parts out of one bundle in a single move"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:378",
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:178",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:182"
+ ],
+ "analogy": {
+ "zh": "快递一到就分拣:笔放桌上、衣服进衣柜",
+ "en": "the parcel lands: pen to the desk, clothes to the closet, at once"
+ }
+ },
+ "atomic-update": {
+ "term": {
+ "zh": "原子更新",
+ "en": "atomic update"
+ },
+ "forms": {
+ "zh": [
+ "原子更新"
+ ],
+ "en": [
+ "atomic update"
+ ]
+ },
+ "plain": {
+ "zh": "一组改动要么全生效要么全不生效,不会改到一半",
+ "en": "a batch of changes either all land or none does, never halfway"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:182",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:182"
+ ],
+ "analogy": {
+ "zh": "点菜要么整单进厨房,不会上两道下两道",
+ "en": "the order goes to the kitchen whole or not at all"
+ }
+ },
+ "tombstone": {
+ "term": {
+ "zh": "墓碑消息",
+ "en": "tombstone"
+ },
+ "forms": {
+ "zh": [
+ "墓碑消息"
+ ],
+ "en": [
+ "tombstone",
+ "Tombstone"
+ ]
+ },
+ "plain": {
+ "zh": "立个牌子写明“这条已作废”,而不是悄悄抹掉",
+ "en": "a marker saying this entry is dead, instead of silently removing it"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:61",
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:57",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:57"
+ ],
+ "analogy": {
+ "zh": "档案柜留一张“此件已撤销”的空夹,免得有人再找",
+ "en": "a folder marked withdrawn stays so nobody hunts for the original"
+ }
+ },
+ "deadlock": {
+ "term": {
+ "zh": "死锁",
+ "en": "deadlock"
+ },
+ "forms": {
+ "zh": [
+ "死锁"
+ ],
+ "en": [
+ "deadlock"
+ ]
+ },
+ "plain": {
+ "zh": "两边各握着对方要的东西都不撒手,一起僵住不动",
+ "en": "each side holds what the other needs and neither lets go"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第四部分-工程实践篇/14-Plan模式与结构化工作流.md:465",
+ "en/Part-4-Engineering-Practice/14-Plan-Mode-and-Structured-Workflows.md:466"
+ ],
+ "analogy": {
+ "zh": "两辆车在独木桥两头互不相让,谁都动不了",
+ "en": "two cars meet on a narrow bridge and neither reverses"
+ }
+ },
+ "fail-fast": {
+ "term": {
+ "zh": "快速失败",
+ "en": "fail fast"
+ },
+ "forms": {
+ "zh": [
+ "快速失败"
+ ],
+ "en": [
+ "fail fast",
+ "failing fast"
+ ]
+ },
+ "plain": {
+ "zh": "一看出事立刻停手报错,不硬撑到后面代价更大",
+ "en": "stop at the first sign of trouble instead of pushing on at higher cost"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:200",
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:191",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:200"
+ ],
+ "analogy": {
+ "zh": "发现鸡蛋坏了就停手,别把整锅料都倒下去",
+ "en": "if the egg is bad, stop; don't pour the whole batter in"
+ }
+ },
+ "avalanche": {
+ "term": {
+ "zh": "雪崩效应",
+ "en": "avalanche"
+ },
+ "forms": {
+ "zh": [
+ "雪崩效应"
+ ],
+ "en": [
+ "avalanche"
+ ]
+ },
+ "plain": {
+ "zh": "一处出问题把压力传给邻居,一路跟着倒,越滚越大",
+ "en": "one failure loads its neighbours, which fail too, compounding outward"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第二部分-核心系统篇/07-上下文管理-Agent的工作记忆.md:106",
+ "第二部分-核心系统篇/07-上下文管理-Agent的工作记忆.md:472",
+ "en/Part-2-Core-Systems/07-Context-Management-Agent-Working-Memory.md:106"
+ ],
+ "analogy": {
+ "zh": "一台收银机死机,旁边几条队全堵住了",
+ "en": "one checkout lane dies and the lines beside it pile up"
+ }
+ },
+ "standard-streams": {
+ "term": {
+ "zh": "标准输入输出",
+ "en": "standard streams"
+ },
+ "forms": {
+ "zh": [
+ "stdin",
+ "stdout",
+ "stderr"
+ ],
+ "en": [
+ "stdin",
+ "stdout",
+ "stderr"
+ ]
+ },
+ "plain": {
+ "zh": "程序约定好的三个口:喂进去的话、打印的话、抱怨的话",
+ "en": "three agreed channels: what you feed in, what it prints, what it complains"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第二部分-核心系统篇/08-钩子系统-Agent的生命周期扩展点.md:991",
+ "第二部分-核心系统篇/08-钩子系统-Agent的生命周期扩展点.md:280",
+ "en/Part-2-Core-Systems/08-The-Hook-System-Agent-Lifecycle-Extension-Points.md:280"
+ ],
+ "analogy": {
+ "zh": "服务窗口分三栏:收件、出件、投诉,各走各的",
+ "en": "the counter has three slots: intake, output, complaints"
+ }
+ },
+ "websocket": {
+ "term": {
+ "zh": "WebSocket",
+ "en": "WebSocket"
+ },
+ "forms": {
+ "zh": [
+ "WebSocket"
+ ],
+ "en": [
+ "WebSocket"
+ ]
+ },
+ "plain": {
+ "zh": "一条一直留着的双向通道,两头随时都能主动开口",
+ "en": "a connection kept open so either side can speak up any time"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第三部分-高级模式篇/12-MCP集成与外部协议.md:92",
+ "第三部分-高级模式篇/12-MCP集成与外部协议.md:110",
+ "en/Part-3-Advanced-Patterns/12-MCP-Integration-and-External-Protocols.md:92"
+ ],
+ "analogy": {
+ "zh": "不是写信等回信,而是电话不挂,随时能插话",
+ "en": "not letters back and forth but a phone line left open"
+ }
+ },
+ "oauth": {
+ "term": {
+ "zh": "OAuth",
+ "en": "OAuth"
+ },
+ "forms": {
+ "zh": [
+ "OAuth2",
+ "OAuth"
+ ],
+ "en": [
+ "OAuth"
+ ]
+ },
+ "plain": {
+ "zh": "给第三方一张限额授权的通行条,不必交出主密码",
+ "en": "hands a third party a limited pass instead of your main password"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第三部分-高级模式篇/12-MCP集成与外部协议.md:1084",
+ "第三部分-高级模式篇/09-子智能体与Fork模式.md:663",
+ "en/Part-3-Advanced-Patterns/12-MCP-Integration-and-External-Protocols.md:1084"
+ ],
+ "analogy": {
+ "zh": "酒店房卡只开你那间,开不了保险柜和别家门",
+ "en": "the hotel key opens your room only, not the safe or other doors"
+ }
+ },
+ "bun-runtime": {
+ "term": {
+ "zh": "Bun",
+ "en": "Bun"
+ },
+ "forms": {
+ "zh": [
+ "Bun"
+ ],
+ "en": [
+ "Bun"
+ ]
+ },
+ "plain": {
+ "zh": "跑这类程序的一套引擎,兼管出厂打包,书里图它起得快",
+ "en": "the engine that runs and builds the program; chosen here for speed"
+ },
+ "priority": "extra",
+ "evidence": [
+ "00-前言.md:135",
+ "第一部分-基础篇/01-智能体编程的新范式.md:202",
+ "en/00-Foreword.md:106"
+ ],
+ "analogy": {
+ "zh": "小作坊一台多功能机床,顶过去两台老机器",
+ "en": "one multi-tool machine in the shop replaces two older ones"
+ }
+ },
+ "bundling": {
+ "term": {
+ "zh": "打包",
+ "en": "bundler"
+ },
+ "forms": {
+ "zh": [
+ "打包"
+ ],
+ "en": [
+ "bundler",
+ "bundle",
+ "Bundle"
+ ]
+ },
+ "plain": {
+ "zh": "把散在各处的零件压成一份成品,好分发、好起跑",
+ "en": "squash many source files into one distributable artifact"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第三部分-高级模式篇/11-技能系统与插件架构.md:480",
+ "附录/D-术语表.md:127",
+ "en/Appendices/D-Glossary.md:125"
+ ],
+ "analogy": {
+ "zh": "寄件前把零散东西装进同一个箱、贴同一张单",
+ "en": "loose parts go into one carton with one label before shipping"
+ }
+ },
+ "compile-time": {
+ "term": {
+ "zh": "编译时",
+ "en": "compile time"
+ },
+ "forms": {
+ "zh": [
+ "编译期",
+ "编译时"
+ ],
+ "en": [
+ "compile time",
+ "Compile-time",
+ "compile-time"
+ ]
+ },
+ "plain": {
+ "zh": "出厂定稿的那一刻,跟跑起来才现场决定相对",
+ "en": "the moment the program is built, as opposed to while it runs"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:136",
+ "附录/C-功能标志速查表.md:272",
+ "en/Part-1-Foundations/03-The-Tool-System-Agent-Hands.md:136"
+ ],
+ "analogy": {
+ "zh": "书在印刷厂定稿,便签是读者拿到后自己贴",
+ "en": "the book is fixed at the printer; sticky notes come later, by readers"
+ }
+ },
+ "cold-start": {
+ "term": {
+ "zh": "冷启动",
+ "en": "cold start"
+ },
+ "forms": {
+ "zh": [
+ "冷启动"
+ ],
+ "en": [
+ "cold start"
+ ]
+ },
+ "plain": {
+ "zh": "啥都没预热,从零把程序拉起来花的那段时间",
+ "en": "bringing the program up from nothing, with nothing warmed beforehand"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第四部分-工程实践篇/15-构建你自己的Agent-Harness.md:113",
+ "en/Part-4-Engineering-Practice/15-Building-Your-Own-Agent-Harness.md:113"
+ ],
+ "analogy": {
+ "zh": "冬天早上第一次发动车,得热一会儿才肯走",
+ "en": "a winter morning: the engine idles a while before it will move"
+ }
+ },
+ "prefetch": {
+ "term": {
+ "zh": "预取",
+ "en": "prefetch"
+ },
+ "forms": {
+ "zh": [
+ "预取"
+ ],
+ "en": [
+ "prefetch",
+ "prefetching",
+ "Prefetch"
+ ]
+ },
+ "plain": {
+ "zh": "还没用上就先把要的东西搬到手边,用时不必现等",
+ "en": "fetch what you'll need before you need it, so nothing waits later"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:224",
+ "第一部分-基础篇/01-智能体编程的新范式.md:228",
+ "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md:224"
+ ],
+ "analogy": {
+ "zh": "高峰前先把米泡上、料切好,客人一到就开炒",
+ "en": "soak the rice and chop the veg before the rush, not during it"
+ }
+ },
+ "garbage-collection": {
+ "term": {
+ "zh": "垃圾回收",
+ "en": "garbage collection"
+ },
+ "forms": {
+ "zh": [
+ "垃圾回收"
+ ],
+ "en": [
+ "garbage collection",
+ "garbage-collected"
+ ]
+ },
+ "plain": {
+ "zh": "没人再引用的内存由系统定时清走,别的资源仍要自己收尾",
+ "en": "the runtime reclaims memory nothing references; other resources don't"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第四部分-工程实践篇/13-流式架构与性能优化.md:18",
+ "en/Part-4-Engineering-Practice/13-Streaming-Architecture-and-Performance-Optimization.md:18"
+ ],
+ "analogy": {
+ "zh": "物业收走没人认领的杂物,你自己的水桶得自己倒",
+ "en": "the building hauls away abandoned stuff; you still empty your own bin"
+ }
+ },
+ "unicode": {
+ "term": {
+ "zh": "Unicode",
+ "en": "Unicode"
+ },
+ "forms": {
+ "zh": [
+ "Unicode"
+ ],
+ "en": [
+ "Unicode"
+ ]
+ },
+ "plain": {
+ "zh": "给天下文字统一编号的一张表,各系统认字不打架",
+ "en": "one shared numbering table so every system reads the same characters"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第三部分-高级模式篇/12-MCP集成与外部协议.md:327",
+ "en/Part-3-Advanced-Patterns/12-MCP-Integration-and-External-Protocols.md:327"
+ ],
+ "analogy": {
+ "zh": "各国商品统一条码,扫码机不管货是哪国产的",
+ "en": "one barcode system so the scanner works regardless of country"
+ }
+ },
+ "cache-invalidation": {
+ "term": {
+ "zh": "缓存失效",
+ "en": "cache invalidation"
+ },
+ "forms": {
+ "zh": [
+ "缓存失效"
+ ],
+ "en": [
+ "cache invalidation",
+ "Cache invalidation"
+ ]
+ },
+ "plain": {
+ "zh": "备好的那份不再算数,得扔掉重做,白白慢一次",
+ "en": "the kept copy no longer counts, so it's thrown out and rebuilt"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:196",
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:177",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:192"
+ ],
+ "analogy": {
+ "zh": "菜单改了价,印好的传单全作废,只能重印",
+ "en": "prices changed, so the flyers are scrap and need reprinting"
+ }
+ },
+ "end-to-end": {
+ "term": {
+ "zh": "端到端",
+ "en": "end-to-end"
+ },
+ "forms": {
+ "zh": [
+ "端到端"
+ ],
+ "en": [
+ "end-to-end"
+ ]
+ },
+ "plain": {
+ "zh": "从最开头一路走到最结尾,中间每一环都真连上",
+ "en": "all the way from the first step to the last, every link real"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第三部分-高级模式篇/10-协调器模式-多智能体编排.md:453",
+ "第四部分-工程实践篇/13-流式架构与性能优化.md:294",
+ "en/Part-3-Advanced-Patterns/10-The-Coordinator-Pattern-Multi-Agent-Orchestration.md:453"
+ ],
+ "analogy": {
+ "zh": "从下单到签收走一遍,不是只在仓库里试打包",
+ "en": "order it and receive it, not just rehearse packing in the warehouse"
+ }
+ },
+ "ab-testing": {
+ "term": {
+ "zh": "A/B 实验",
+ "en": "A/B test"
+ },
+ "forms": {
+ "zh": [
+ "A/B 实验",
+ "A/B 测试"
+ ],
+ "en": [
+ "A/B"
+ ]
+ },
+ "plain": {
+ "zh": "新旧两版同时上线,各给一部分人用,比谁效果好",
+ "en": "run two versions for different slices of users at once and compare"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第二部分-核心系统篇/05-设置与配置-Agent的基因.md:370",
+ "附录/C-功能标志速查表.md:272",
+ "en/Appendices/C-Feature-Flag-Reference.md:272"
+ ],
+ "analogy": {
+ "zh": "收银台左边试新流程、右边留老流程,一周看哪边快",
+ "en": "one lane tries the new flow, the other keeps the old, then compare"
+ }
+ },
+ "whitelist": {
+ "term": {
+ "zh": "白名单",
+ "en": "allowlist"
+ },
+ "forms": {
+ "zh": [
+ "白名单",
+ "黑白名单"
+ ],
+ "en": [
+ "whitelist",
+ "allowlist"
+ ]
+ },
+ "plain": {
+ "zh": "事先列好许可名册,名册外一律挡在门外",
+ "en": "a prepared list of the permitted; everything else is denied"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:335",
+ "00-前言.md:130",
+ "en/00-Foreword.md:101"
+ ],
+ "analogy": {
+ "zh": "小区门禁认名册,不在册的刷不开",
+ "en": "the gate opens only for names on the resident list"
+ }
+ },
+ "symbolic-link": {
+ "term": {
+ "zh": "符号链接",
+ "en": "symbolic link"
+ },
+ "forms": {
+ "zh": [
+ "符号链接"
+ ],
+ "en": [
+ "symbolic link",
+ "symlink"
+ ]
+ },
+ "plain": {
+ "zh": "一个指路牌式的假条目,打开它其实走到别处的真文件",
+ "en": "a shortcut entry that quietly sends you to a file somewhere else"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第三部分-高级模式篇/11-技能系统与插件架构.md:87",
+ "第三部分-高级模式篇/11-技能系统与插件架构.md:93",
+ "en/Part-3-Advanced-Patterns/11-The-Skill-System-and-Plugin-Architecture.md:87"
+ ],
+ "analogy": {
+ "zh": "柜上贴条“东西在储藏室”,你照条去拿,柜里是空的",
+ "en": "a note says the box is in the storage room, so you go there"
+ }
+ },
+ "defense-in-depth": {
+ "term": {
+ "zh": "纵深防御",
+ "en": "defense in depth"
+ },
+ "forms": {
+ "zh": [
+ "纵深防御"
+ ],
+ "en": [
+ "defense in depth",
+ "Defense in Depth"
+ ]
+ },
+ "plain": {
+ "zh": "层层关卡各管一段,漏了一层还有下一层兜着",
+ "en": "several independent checks in a row; if one leaks, the next catches"
+ },
+ "priority": "extra",
+ "evidence": [
+ "00-前言.md:130",
+ "第一部分-基础篇/01-智能体编程的新范式.md:333",
+ "en/00-Foreword.md:101"
+ ],
+ "analogy": {
+ "zh": "机场先值机、再过安检、登机口还查一次证件",
+ "en": "check-in, then security, then another ID check at the gate"
+ }
+ },
+ "technical-debt": {
+ "term": {
+ "zh": "技术债",
+ "en": "technical debt"
+ },
+ "forms": {
+ "zh": [
+ "技术债"
+ ],
+ "en": [
+ "technical debt"
+ ]
+ },
+ "plain": {
+ "zh": "为赶工欠下的马虎账,日后要连本带利花时间还",
+ "en": "the shortcut you take today, paid back with interest later"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第三部分-高级模式篇/12-MCP集成与外部协议.md:993",
+ "第二部分-核心系统篇/06-记忆系统-Agent的长期记忆.md:584",
+ "en/Part-3-Advanced-Patterns/12-MCP-Integration-and-External-Protocols.md:993"
+ ],
+ "analogy": {
+ "zh": "偷懒不洗油烟机,三个月后炒什么都带糊味",
+ "en": "skip cleaning the hood tonight; in three months every dish tastes burnt"
+ }
+ },
+ "feature-flag": {
+ "term": {
+ "zh": "功能开关",
+ "en": "feature flag"
+ },
+ "forms": {
+ "zh": [
+ "功能开关",
+ "功能标志"
+ ],
+ "en": [
+ "feature flag",
+ "Feature Flag",
+ "Feature flag"
+ ]
+ },
+ "plain": {
+ "zh": "代码里留的一道闸,不重装机就能让某功能开或关",
+ "en": "a built-in switch that turns a capability on or off without rebuilding"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第二部分-核心系统篇/05-设置与配置-Agent的基因.md:330",
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:138",
+ "en/Part-1-Foundations/03-The-Tool-System-Agent-Hands.md:136"
+ ],
+ "analogy": {
+ "zh": "店里总电闸旁边的分闸,想关凉菜间就扳下那一路",
+ "en": "a sub-breaker beside the main one: cut the salad station only"
+ }
+ },
+ "token-budget": {
+ "term": {
+ "zh": "Token 预算",
+ "en": "token budget"
+ },
+ "forms": {
+ "zh": [
+ "Token 预算"
+ ],
+ "en": [
+ "Token Budget",
+ "token budget",
+ "Token budget"
+ ]
+ },
+ "plain": {
+ "zh": "一轮允许花掉的篇幅上限,快到线就提醒或收尾",
+ "en": "the cap on how much text a round may spend before it wraps up"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:388",
+ "第四部分-工程实践篇/15-构建你自己的Agent-Harness.md:878",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:216"
+ ],
+ "analogy": {
+ "zh": "逛市场给了固定现钱,快到数就先停手别加菜",
+ "en": "you set a cash cap at the market and stop before it's gone"
+ }
+ },
+ "permission-mode": {
+ "term": {
+ "zh": "权限模式",
+ "en": "permission mode"
+ },
+ "forms": {
+ "zh": [
+ "权限模式",
+ "授权模式"
+ ],
+ "en": [
+ "permission mode",
+ "Permission Mode",
+ "permissionMode",
+ "permission modes"
+ ]
+ },
+ "plain": {
+ "zh": "Agent 动手前该找谁批准、多久问一次的规矩",
+ "en": "the rule for who approves each action, and how often the agent has to stop and ask"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/04-权限管线-Agent的护栏.md:7",
+ "en/Part-1-Foundations/04-The-Permission-Pipeline-Agent-Guardrails.md:7",
+ "第一部分-基础篇/04-权限管线-Agent的护栏.md:175",
+ "en/Part-1-Foundations/04-The-Permission-Pipeline-Agent-Guardrails.md:175"
+ ],
+ "analogy": {
+ "zh": "小区门禁:业主登记过就随便进,访客每次都得刷卡",
+ "en": "a gated community: residents wave through, every visitor is stopped and logged at the gate"
+ }
+ },
+ "permission-pipeline": {
+ "term": {
+ "zh": "权限管线",
+ "en": "permission pipeline"
+ },
+ "forms": {
+ "zh": [
+ "权限管线"
+ ],
+ "en": [
+ "permission pipeline",
+ "Permission Pipeline"
+ ]
+ },
+ "plain": {
+ "zh": "每次调用工具都要连闯的几道关卡,任一道说停就停",
+ "en": "several gates every tool call must pass in order; if any gate says stop, it stops right there"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:247",
+ "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md:247",
+ "第一部分-基础篇/04-权限管线-Agent的护栏.md:13",
+ "en/Part-1-Foundations/04-The-Permission-Pipeline-Agent-Guardrails.md:13"
+ ],
+ "analogy": {
+ "zh": "机场安检:证件、X 光、人身检查,一层不过就登不上机",
+ "en": "airport security: ID check, X-ray, body scan -- fail one station and you never reach the plane"
+ }
+ },
+ "hook": {
+ "term": {
+ "zh": "钩子",
+ "en": "hook"
+ },
+ "forms": {
+ "zh": [
+ "钩子系统",
+ "钩子"
+ ],
+ "en": [
+ "Hook",
+ "hook",
+ "Hooks",
+ "hooks"
+ ]
+ },
+ "plain": {
+ "zh": "在关键节点外挂一段你自己的脚本,时刻一到就自动跑",
+ "en": "your own script hung on a lifecycle moment so it runs by itself when that moment arrives"
+ },
+ "priority": "core",
+ "evidence": [
+ "第二部分-核心系统篇/08-钩子系统-Agent的生命周期扩展点.md:23",
+ "en/Part-2-Core-Systems/08-The-Hook-System-Agent-Lifecycle-Extension-Points.md:23",
+ "第二部分-核心系统篇/08-钩子系统-Agent的生命周期扩展点.md:15"
+ ],
+ "analogy": {
+ "zh": "门框上的门铃磁铁:门一开就响,不用重新布线",
+ "en": "a doorbell magnet on the frame: the door opens, it rings, and the house wiring stays untouched"
+ }
+ },
+ "system-prompt": {
+ "term": {
+ "zh": "系统提示",
+ "en": "system prompt"
+ },
+ "forms": {
+ "zh": [
+ "系统提示词",
+ "系统提示"
+ ],
+ "en": [
+ "system prompt",
+ "System Prompt",
+ "system prompts"
+ ]
+ },
+ "plain": {
+ "zh": "每轮对话都置顶的岗位说明书:它是谁、能做什么、听谁的",
+ "en": "the standing instruction sent on top of every request: who it is, what it may do, what it must obey"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:65",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:65",
+ "第二部分-核心系统篇/07-上下文管理-Agent的工作记忆.md:14",
+ "en/Part-2-Core-Systems/07-Context-Management-Agent-Working-Memory.md:14"
+ ],
+ "analogy": {
+ "zh": "新员工入职手册,每天开工前都要重念一遍",
+ "en": "the onboarding handbook a new hire rereads before every single shift"
+ }
+ },
+ "context-window": {
+ "term": {
+ "zh": "上下文窗口",
+ "en": "context window"
+ },
+ "forms": {
+ "zh": [
+ "上下文窗口"
+ ],
+ "en": [
+ "context window",
+ "Context Window",
+ "context windows"
+ ]
+ },
+ "plain": {
+ "zh": "模型一轮能装下的内容总量上限,装满了就得把旧的挤出去",
+ "en": "how much text the model can hold in mind at once; new material pushes the oldest material off the edge"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:46",
+ "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md:46",
+ "第二部分-核心系统篇/07-上下文管理-Agent的工作记忆.md:16",
+ "en/Part-2-Core-Systems/07-Context-Management-Agent-Working-Memory.md:16"
+ ],
+ "analogy": {
+ "zh": "一块固定大小的白板,要写新东西必须先擦掉最早几行",
+ "en": "a whiteboard of fixed size: to write anything new you first erase the oldest lines"
+ }
+ },
+ "auto-compact": {
+ "term": {
+ "zh": "自动压缩",
+ "en": "auto-compact"
+ },
+ "forms": {
+ "zh": [
+ "自动压缩",
+ "AutoCompact",
+ "Autocompact"
+ ],
+ "en": [
+ "auto-compact",
+ "auto-compaction",
+ "AutoCompact",
+ "Autocompact",
+ "AutoCompact("
+ ]
+ },
+ "plain": {
+ "zh": "聊天快装满时,把前面的内容自动折成一份摘要",
+ "en": "when the chat nears full, the harness summarizes the earlier part so the conversation can keep going"
+ },
+ "priority": "core",
+ "evidence": [
+ "第二部分-核心系统篇/07-上下文管理-Agent的工作记忆.md:28",
+ "en/Part-2-Core-Systems/07-Context-Management-Agent-Working-Memory.md:28",
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:198",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:198"
+ ],
+ "analogy": {
+ "zh": "连续剧太长了,先播两分钟前情提要再继续拍",
+ "en": "a long serialized drama: play two minutes of recap before shooting the next episode"
+ }
+ },
+ "tool-result": {
+ "term": {
+ "zh": "工具结果",
+ "en": "tool result"
+ },
+ "forms": {
+ "zh": [
+ "工具结果",
+ "工具执行结果"
+ ],
+ "en": [
+ "tool result",
+ "tool results",
+ "Tool Result",
+ "Tool results"
+ ]
+ },
+ "plain": {
+ "zh": "一次动作的返回内容:文件正文、命令输出或报错,被塞回对话",
+ "en": "what an action hands back -- file text, command output or an error -- fed into the chat as a new message"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:249",
+ "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md:249",
+ "第二部分-核心系统篇/07-上下文管理-Agent的工作记忆.md:16",
+ "en/Part-2-Core-Systems/07-Context-Management-Agent-Working-Memory.md:16"
+ ],
+ "analogy": {
+ "zh": "跑腿买回来的菜和小票,得摆在桌上让人看得见",
+ "en": "the groceries and the receipt the runner brings back, both laid on the table where you can see them"
+ }
+ },
+ "sub-agent": {
+ "term": {
+ "zh": "子智能体",
+ "en": "sub-agent"
+ },
+ "forms": {
+ "zh": [
+ "子智能体",
+ "子 Agent"
+ ],
+ "en": [
+ "sub-agent",
+ "Sub-agent",
+ "sub-agents",
+ "Sub-Agent"
+ ]
+ },
+ "plain": {
+ "zh": "被派出去办一件小事的另一条对话线,办完只带回结论",
+ "en": "a second agent spawned for one small task with its own message history, reporting back only the answer"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:107",
+ "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md:107",
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:234",
+ "en/Part-1-Foundations/03-The-Tool-System-Agent-Hands.md:234"
+ ],
+ "analogy": {
+ "zh": "老板派实习生查资料,实习生只把结论带回会议室",
+ "en": "the boss sends an intern to dig up data; the intern returns with the conclusion, not the whole archive"
+ }
+ },
+ "coordinator": {
+ "term": {
+ "zh": "协调器",
+ "en": "coordinator"
+ },
+ "forms": {
+ "zh": [
+ "协调器模式",
+ "协调器",
+ "协调者"
+ ],
+ "en": [
+ "Coordinator pattern",
+ "coordinator",
+ "Coordinator",
+ "coordinators"
+ ]
+ },
+ "plain": {
+ "zh": "只派活、收结论,自己从不动手写代码的那个角色",
+ "en": "the one agent that splits the work among workers and merges their answers without touching tools itself"
+ },
+ "priority": "core",
+ "evidence": [
+ "第三部分-高级模式篇/10-协调器模式-多智能体编排.md:21",
+ "en/Part-3-Advanced-Patterns/10-The-Coordinator-Pattern-Multi-Agent-Orchestration.md:21",
+ "第一部分-基础篇/04-权限管线-Agent的护栏.md:140"
+ ],
+ "analogy": {
+ "zh": "工地上的包工头:分派任务、验收成果,不亲自砌砖",
+ "en": "a site foreman: assigns tasks and inspects results, but never lays a brick himself"
+ }
+ },
+ "skill": {
+ "term": {
+ "zh": "技能",
+ "en": "skill"
+ },
+ "forms": {
+ "zh": [
+ "技能",
+ "技能系统"
+ ],
+ "en": [
+ "Skill",
+ "skill",
+ "skills",
+ "Skills"
+ ]
+ },
+ "plain": {
+ "zh": "一份事先写好的操作说明书,被点到时交给模型照着做",
+ "en": "a written-down procedure the model can be handed and follow, instead of being re-taught every time"
+ },
+ "priority": "core",
+ "evidence": [
+ "第三部分-高级模式篇/11-技能系统与插件架构.md:7",
+ "en/Part-3-Advanced-Patterns/11-The-Skill-System-and-Plugin-Architecture.md:7",
+ "第一部分-基础篇/01-智能体编程的新范式.md:358",
+ "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md:358"
+ ],
+ "analogy": {
+ "zh": "厨房墙上的菜谱,客人点这道菜才取下照着做",
+ "en": "a recipe card on the kitchen wall: pulled down only when that dish is ordered"
+ }
+ },
+ "mcp-server": {
+ "term": {
+ "zh": "MCP 服务器",
+ "en": "MCP server"
+ },
+ "forms": {
+ "zh": [
+ "MCP 服务器"
+ ],
+ "en": [
+ "MCP server",
+ "MCP servers",
+ "MCP Server"
+ ]
+ },
+ "plain": {
+ "zh": "按统一协议接在外面的服务,给 Agent 补上额外的工具和资料",
+ "en": "an outside program that speaks one shared protocol, handing the agent extra tools and data"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:152",
+ "en/Part-1-Foundations/03-The-Tool-System-Agent-Hands.md:152",
+ "第二部分-核心系统篇/08-钩子系统-Agent的生命周期扩展点.md:496",
+ "en/Part-2-Core-Systems/08-The-Hook-System-Agent-Lifecycle-Extension-Points.md:496"
+ ],
+ "analogy": {
+ "zh": "家电都插同一种插座,不必为每个牌子配转换头",
+ "en": "appliances all plugging into the same socket, so no brand needs its own adapter"
+ }
+ },
+ "claude-md": {
+ "term": {
+ "zh": "CLAUDE.md",
+ "en": "CLAUDE.md"
+ },
+ "forms": {
+ "zh": [
+ "CLAUDE.md"
+ ],
+ "en": [
+ "CLAUDE.md"
+ ]
+ },
+ "plain": {
+ "zh": "项目里那份说明文件,每次开工都被自动塞进背景里",
+ "en": "the project's note file, dropped into the background on every start so its rules survive a reset"
+ },
+ "priority": "core",
+ "evidence": [
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:132",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:132",
+ "第二部分-核心系统篇/06-记忆系统-Agent的长期记忆.md:144",
+ "en/Part-2-Core-Systems/06-The-Memory-System-Agent-Long-Term-Memory.md:144"
+ ],
+ "analogy": {
+ "zh": "贴在操作台上的店规,每个班次开工先念一遍",
+ "en": "the house rules taped to the counter, read out at the start of every shift"
+ }
+ },
+ "plan-mode": {
+ "term": {
+ "zh": "计划模式",
+ "en": "plan mode"
+ },
+ "forms": {
+ "zh": [
+ "计划模式",
+ "Plan 模式",
+ "规划模式"
+ ],
+ "en": [
+ "plan mode",
+ "Plan mode",
+ "Plan Mode",
+ "Planning Mode"
+ ]
+ },
+ "plain": {
+ "zh": "先只看不改,把方案写清楚给人过目,点头之后才动手",
+ "en": "a look-but-do-not-touch phase: the agent drafts a proposal first and only edits after you approve it"
+ },
+ "priority": "core",
+ "evidence": [
+ "第四部分-工程实践篇/14-Plan模式与结构化工作流.md:13",
+ "en/Part-4-Engineering-Practice/14-Plan-Mode-and-Structured-Workflows.md:13",
+ "第四部分-工程实践篇/14-Plan模式与结构化工作流.md:5",
+ "en/Part-4-Engineering-Practice/14-Plan-Mode-and-Structured-Workflows.md:5"
+ ],
+ "analogy": {
+ "zh": "装修先出图纸,签字确认后才允许砸墙",
+ "en": "an architect draws the blueprint and gets it signed before a single wall is knocked down"
+ }
+ },
+ "fork-pattern": {
+ "term": {
+ "zh": "Fork 模式",
+ "en": "Fork pattern"
+ },
+ "forms": {
+ "zh": [
+ "Fork 模式",
+ "完美分叉"
+ ],
+ "en": [
+ "Fork pattern",
+ "Fork Pattern",
+ "fork pattern",
+ "Fork mode"
+ ]
+ },
+ "plain": {
+ "zh": "把此刻的对话原样复制成几份,让几路任务同时开工",
+ "en": "copying the conversation as it stands into several branches so parallel jobs share one starting point"
+ },
+ "priority": "core",
+ "evidence": [
+ "第三部分-高级模式篇/09-子智能体与Fork模式.md:228",
+ "en/Part-3-Advanced-Patterns/09-Sub-Agents-and-the-Fork-Pattern.md:228",
+ "第一部分-基础篇/01-智能体编程的新范式.md:344",
+ "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md:344"
+ ],
+ "analogy": {
+ "zh": "同一张底片洗出好几张照片,再各自上色",
+ "en": "printing several photos from one negative, then retouching each print separately"
+ }
+ },
+ "memory": {
+ "term": {
+ "zh": "记忆",
+ "en": "memory"
+ },
+ "forms": {
+ "zh": [
+ "记忆系统",
+ "记忆"
+ ],
+ "en": [
+ "memory",
+ "Memory"
+ ]
+ },
+ "plain": {
+ "zh": "跨对话留在硬盘上的小纸条,下次开机还会被读进来",
+ "en": "notes kept on disk between conversations and re-read next time, so the agent does not start from zero"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第二部分-核心系统篇/06-记忆系统-Agent的长期记忆.md:9",
+ "en/Part-2-Core-Systems/06-The-Memory-System-Agent-Long-Term-Memory.md:9",
+ "第二部分-核心系统篇/06-记忆系统-Agent的长期记忆.md:216",
+ "en/Part-2-Core-Systems/06-The-Memory-System-Agent-Long-Term-Memory.md:216"
+ ],
+ "analogy": {
+ "zh": "抽屉里的老客户档案,换人接待也不会重新自我介绍",
+ "en": "a drawer of regular-client files: each new clerk opens it instead meeting an old friend as a stranger"
+ }
+ },
+ "plugin": {
+ "term": {
+ "zh": "插件",
+ "en": "plugin"
+ },
+ "forms": {
+ "zh": [
+ "插件",
+ "插件系统"
+ ],
+ "en": [
+ "plugin",
+ "Plugin",
+ "plugins",
+ "Plugins"
+ ]
+ },
+ "plain": {
+ "zh": "别人打包好的一包能力,装上就多一批工具和命令",
+ "en": "someone else's bundled package of tools, commands and settings that you install in one go"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第二部分-核心系统篇/05-设置与配置-Agent的基因.md:19",
+ "en/Part-2-Core-Systems/05-Settings-and-Configuration-Agent-DNA.md:19",
+ "第一部分-基础篇/01-智能体编程的新范式.md:359",
+ "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md:359"
+ ],
+ "analogy": {
+ "zh": "给相机换个镜头,机身不必重造",
+ "en": "swapping a lens on the camera rather than rebuilding the body"
+ }
+ },
+ "session": {
+ "term": {
+ "zh": "会话",
+ "en": "session"
+ },
+ "forms": {
+ "zh": [
+ "会话"
+ ],
+ "en": [
+ "session",
+ "Session"
+ ]
+ },
+ "plain": {
+ "zh": "从打开到退出的一段连续对话,中途记录会被存下来",
+ "en": "one continuous run of conversation whose history can be saved and picked up again later"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/04-权限管线-Agent的护栏.md:11",
+ "en/Part-1-Foundations/04-The-Permission-Pipeline-Agent-Guardrails.md:11",
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:83",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:83"
+ ],
+ "analogy": {
+ "zh": "一通电话:挂了可以重拨,之前说过的还在录音里",
+ "en": "a phone call: hang up and redial, and the earlier words are still on the recording"
+ }
+ },
+ "transcript": {
+ "term": {
+ "zh": "会话记录",
+ "en": "transcript"
+ },
+ "forms": {
+ "zh": [
+ "会话记录",
+ "Transcript"
+ ],
+ "en": [
+ "transcript",
+ "Transcript"
+ ]
+ },
+ "plain": {
+ "zh": "一整段对话被逐条写成的流水账,恢复和审计都靠它",
+ "en": "the message-by-message log of a conversation, used to resume it later or to audit what actually happened"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第四部分-工程实践篇/14-Plan模式与结构化工作流.md:689",
+ "en/Part-4-Engineering-Practice/14-Plan-Mode-and-Structured-Workflows.md:690",
+ "第三部分-高级模式篇/10-协调器模式-多智能体编排.md:73",
+ "en/Part-4-Engineering-Practice/14-Plan-Mode-and-Structured-Workflows.md:255"
+ ],
+ "analogy": {
+ "zh": "法庭速记稿:散庭后照着它复原当天每句话",
+ "en": "the court stenographer's pages: after the session ends, they are what the day is rebuilt from"
+ }
+ },
+ "prompt-cache": {
+ "term": {
+ "zh": "提示缓存",
+ "en": "prompt cache"
+ },
+ "forms": {
+ "zh": [
+ "提示缓存",
+ "Prompt 缓存",
+ "Prompt Caching"
+ ],
+ "en": [
+ "prompt cache",
+ "Prompt cache",
+ "Prompt Cache",
+ "prompt caching",
+ "Prompt Caching"
+ ]
+ },
+ "plain": {
+ "zh": "前面那段没变的话服务端已经记着,不必重新算钱重新算时间",
+ "en": "the service remembers the unchanged front part of your request, so it is neither re-processed nor re-charged"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第二部分-核心系统篇/06-记忆系统-Agent的长期记忆.md:296",
+ "en/Part-2-Core-Systems/06-The-Memory-System-Agent-Long-Term-Memory.md:296",
+ "第二部分-核心系统篇/07-上下文管理-Agent的工作记忆.md:170"
+ ],
+ "analogy": {
+ "zh": "演员已背熟的台词不必再排,只排新加的那几句",
+ "en": "lines the cast already knows need no rehearsal; only the new ones take stage time"
+ }
+ },
+ "permission-classifier": {
+ "term": {
+ "zh": "分类器",
+ "en": "classifier"
+ },
+ "forms": {
+ "zh": [
+ "分类器",
+ "模式分类器"
+ ],
+ "en": [
+ "classifier",
+ "Classifier"
+ ]
+ },
+ "plain": {
+ "zh": "另一个模型替你估计这次操作危不危险,决定放行还是来问你",
+ "en": "a second model that judges how risky an action looks, so it can approve or escalate without waiting for you"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/04-权限管线-Agent的护栏.md:144",
+ "en/Part-1-Foundations/04-The-Permission-Pipeline-Agent-Guardrails.md:144"
+ ],
+ "analogy": {
+ "zh": "地铁口的预检员:看一眼就放行,可疑的才送去人工开包",
+ "en": "the clerk at the station entrance: waves most people through and sends only the odd bag to a hand check"
+ }
+ },
+ "deferred-tool-discovery": {
+ "term": {
+ "zh": "延迟工具发现",
+ "en": "deferred tool discovery"
+ },
+ "forms": {
+ "zh": [
+ "延迟工具发现",
+ "延迟发现"
+ ],
+ "en": [
+ "deferred tool discovery",
+ "Deferred tool discovery",
+ "deferred discovery"
+ ]
+ },
+ "plain": {
+ "zh": "开场先只报工具名字,真要用了才把完整说明书取来",
+ "en": "the model first sees only a list of tool names; the full manual is fetched only once it actually needs one"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:146",
+ "en/Part-1-Foundations/03-The-Tool-System-Agent-Hands.md:146"
+ ],
+ "analogy": {
+ "zh": "餐厅先递目录页,点了菜厨房才拿出详细菜谱",
+ "en": "the waiter hands you the list of dish names first; the full recipe sheet comes out only after you order"
+ }
+ },
+ "mcp-resource": {
+ "term": {
+ "zh": "MCP 资源",
+ "en": "MCP resource"
+ },
+ "forms": {
+ "zh": [
+ "MCP 资源",
+ "资源列表",
+ "资源"
+ ],
+ "en": [
+ "MCP resource",
+ "MCP resources",
+ "resource",
+ "resources",
+ "Resource"
+ ]
+ },
+ "plain": {
+ "zh": "外部服务摆出来给 Agent 翻阅的现成资料,只能读不能执行",
+ "en": "readable data an outside service exposes -- files, records, docs -- as opposed to actions it can run"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第三部分-高级模式篇/12-MCP集成与外部协议.md:90",
+ "en/Part-3-Advanced-Patterns/12-MCP-Integration-and-External-Protocols.md:90",
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:127",
+ "en/Part-1-Foundations/03-The-Tool-System-Agent-Hands.md:127"
+ ],
+ "analogy": {
+ "zh": "冰箱里的食材:可以拿出来看,不能指望它自己变熟",
+ "en": "the pantry: you may take things out and look at them, but nothing in there cooks itself"
+ }
+ },
+ "interrupt": {
+ "term": {
+ "zh": "中断",
+ "en": "interrupt"
+ },
+ "forms": {
+ "zh": [
+ "中断"
+ ],
+ "en": [
+ "interrupt",
+ "interrupts",
+ "Interrupt",
+ "interruption",
+ "Interruption",
+ "Interrupting"
+ ]
+ },
+ "plain": {
+ "zh": "你按停那一刻:正在跑的活要不要立刻收手、留下什么",
+ "en": "what happens the instant you hit stop: whether the running action is killed and what it leaves behind"
+ },
+ "priority": "extra",
+ "evidence": [
+ "00-前言.md:131",
+ "en/00-Foreword.md:102",
+ "第一部分-基础篇/01-智能体编程的新范式.md:107",
+ "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md:107"
+ ],
+ "analogy": {
+ "zh": "你喊停的那一秒,厨师关火,锅里半成品怎么处理得先说清",
+ "en": "the second you shout stop: the burner goes off, and someone must say what becomes of the half-cooked dish"
+ }
+ },
+ "destructive": {
+ "term": {
+ "zh": "破坏性",
+ "en": "destructive"
+ },
+ "forms": {
+ "zh": [
+ "破坏性",
+ "破坏性标记"
+ ],
+ "en": [
+ "destructive",
+ "Destructive",
+ "isDestructive"
+ ]
+ },
+ "plain": {
+ "zh": "这一步做完就退不回去,比如删文件、覆盖数据",
+ "en": "a flag meaning the action cannot be undone afterwards, so it deserves a stricter warning"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:268",
+ "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md:268",
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:210",
+ "en/Part-1-Foundations/03-The-Tool-System-Agent-Hands.md:210"
+ ],
+ "analogy": {
+ "zh": "碎纸机不是文件柜:纸进去就出不来",
+ "en": "a shredder, not a filing cabinet: paper that goes in does not come out"
+ }
+ },
+ "telemetry": {
+ "term": {
+ "zh": "遥测",
+ "en": "telemetry"
+ },
+ "forms": {
+ "zh": [
+ "遥测"
+ ],
+ "en": [
+ "telemetry",
+ "Telemetry"
+ ]
+ },
+ "plain": {
+ "zh": "程序运行途中悄悄记下发生了什么,事后传回来供分析",
+ "en": "the program writes down what happened while running and ships the numbers back for later analysis"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第二部分-核心系统篇/07-上下文管理-Agent的工作记忆.md:352",
+ "en/Part-2-Core-Systems/07-Context-Management-Agent-Working-Memory.md:352",
+ "第四部分-工程实践篇/13-流式架构与性能优化.md:562",
+ "en/Part-4-Engineering-Practice/13-Streaming-Architecture-and-Performance-Optimization.md:565"
+ ],
+ "analogy": {
+ "zh": "航班黑匣子:平日没人看,出事时第一个被打开",
+ "en": "a flight recorder: nobody reads it day to day, but it is the first thing opened after an incident"
+ }
+ },
+ "settings-json": {
+ "term": {
+ "zh": "settings.json",
+ "en": "settings.json"
+ },
+ "forms": {
+ "zh": [
+ "settings.json",
+ "配置文件"
+ ],
+ "en": [
+ "settings.json"
+ ]
+ },
+ "plain": {
+ "zh": "存放选项的纯文本文件,改里面的字段就等于改 Agent 的行为",
+ "en": "the plain text file where options live: edit a field there and the agent behaves differently next run"
+ },
+ "priority": "extra",
+ "evidence": [
+ "第二部分-核心系统篇/05-设置与配置-Agent的基因.md:148",
+ "en/Part-2-Core-Systems/05-Settings-and-Configuration-Agent-DNA.md:148",
+ "第二部分-核心系统篇/05-设置与配置-Agent的基因.md:223",
+ "en/Part-2-Core-Systems/05-Settings-and-Configuration-Agent-DNA.md:223"
+ ],
+ "analogy": {
+ "zh": "门口的总开关面板,拨哪个开关,哪个房间就变",
+ "en": "the switch panel by the door: flip one breaker and that room changes, no rewiring needed"
+ }
+ }
+}
diff --git a/yuyu-book/illustrations/apxa.svg b/yuyu-book/illustrations/apxa.svg
new file mode 100644
index 00000000..51056340
--- /dev/null
+++ b/yuyu-book/illustrations/apxa.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/apxb.svg b/yuyu-book/illustrations/apxb.svg
new file mode 100644
index 00000000..0101817e
--- /dev/null
+++ b/yuyu-book/illustrations/apxb.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/apxc.svg b/yuyu-book/illustrations/apxc.svg
new file mode 100644
index 00000000..1ea8897f
--- /dev/null
+++ b/yuyu-book/illustrations/apxc.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/apxd.svg b/yuyu-book/illustrations/apxd.svg
new file mode 100644
index 00000000..616d50aa
--- /dev/null
+++ b/yuyu-book/illustrations/apxd.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/ch01.svg b/yuyu-book/illustrations/ch01.svg
new file mode 100644
index 00000000..e38ec242
--- /dev/null
+++ b/yuyu-book/illustrations/ch01.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/ch02.svg b/yuyu-book/illustrations/ch02.svg
new file mode 100644
index 00000000..03a660ac
--- /dev/null
+++ b/yuyu-book/illustrations/ch02.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/ch03.svg b/yuyu-book/illustrations/ch03.svg
new file mode 100644
index 00000000..bf7c2ca2
--- /dev/null
+++ b/yuyu-book/illustrations/ch03.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/ch04.svg b/yuyu-book/illustrations/ch04.svg
new file mode 100644
index 00000000..815b9c39
--- /dev/null
+++ b/yuyu-book/illustrations/ch04.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/ch05.svg b/yuyu-book/illustrations/ch05.svg
new file mode 100644
index 00000000..847ea652
--- /dev/null
+++ b/yuyu-book/illustrations/ch05.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/ch06.svg b/yuyu-book/illustrations/ch06.svg
new file mode 100644
index 00000000..95885f46
--- /dev/null
+++ b/yuyu-book/illustrations/ch06.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/ch07.svg b/yuyu-book/illustrations/ch07.svg
new file mode 100644
index 00000000..6d2ee95e
--- /dev/null
+++ b/yuyu-book/illustrations/ch07.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/ch08.svg b/yuyu-book/illustrations/ch08.svg
new file mode 100644
index 00000000..11ab48b7
--- /dev/null
+++ b/yuyu-book/illustrations/ch08.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/ch09.svg b/yuyu-book/illustrations/ch09.svg
new file mode 100644
index 00000000..eb26c9a6
--- /dev/null
+++ b/yuyu-book/illustrations/ch09.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/ch10.svg b/yuyu-book/illustrations/ch10.svg
new file mode 100644
index 00000000..95326670
--- /dev/null
+++ b/yuyu-book/illustrations/ch10.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/ch11.svg b/yuyu-book/illustrations/ch11.svg
new file mode 100644
index 00000000..de8ace41
--- /dev/null
+++ b/yuyu-book/illustrations/ch11.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/ch12.svg b/yuyu-book/illustrations/ch12.svg
new file mode 100644
index 00000000..b1cafc79
--- /dev/null
+++ b/yuyu-book/illustrations/ch12.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/ch13.svg b/yuyu-book/illustrations/ch13.svg
new file mode 100644
index 00000000..9286a18b
--- /dev/null
+++ b/yuyu-book/illustrations/ch13.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/ch14.svg b/yuyu-book/illustrations/ch14.svg
new file mode 100644
index 00000000..5d1c001d
--- /dev/null
+++ b/yuyu-book/illustrations/ch14.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/ch15.svg b/yuyu-book/illustrations/ch15.svg
new file mode 100644
index 00000000..97841911
--- /dev/null
+++ b/yuyu-book/illustrations/ch15.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/cover.svg b/yuyu-book/illustrations/cover.svg
new file mode 100644
index 00000000..4ec2145f
--- /dev/null
+++ b/yuyu-book/illustrations/cover.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/yuyu-book/illustrations/front.svg b/yuyu-book/illustrations/front.svg
new file mode 100644
index 00000000..54315512
--- /dev/null
+++ b/yuyu-book/illustrations/front.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/part-p1.svg b/yuyu-book/illustrations/part-p1.svg
new file mode 100644
index 00000000..86134ae6
--- /dev/null
+++ b/yuyu-book/illustrations/part-p1.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/part-p2.svg b/yuyu-book/illustrations/part-p2.svg
new file mode 100644
index 00000000..a8d66f79
--- /dev/null
+++ b/yuyu-book/illustrations/part-p2.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/part-p3.svg b/yuyu-book/illustrations/part-p3.svg
new file mode 100644
index 00000000..8c43ae4b
--- /dev/null
+++ b/yuyu-book/illustrations/part-p3.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/illustrations/part-p4.svg b/yuyu-book/illustrations/part-p4.svg
new file mode 100644
index 00000000..de206173
--- /dev/null
+++ b/yuyu-book/illustrations/part-p4.svg
@@ -0,0 +1 @@
+
diff --git a/yuyu-book/manifest.json b/yuyu-book/manifest.json
new file mode 100644
index 00000000..3199f1f9
--- /dev/null
+++ b/yuyu-book/manifest.json
@@ -0,0 +1,1019 @@
+{
+ "version": "0.3.1",
+ "date": "2026-10-26",
+ "author": "LinTsinghua",
+ "repository": "https://github.com/lintsinghua/claude-code-book",
+ "license": {
+ "name": "CC BY-NC-SA 4.0",
+ "url": "https://creativecommons.org/licenses/by-nc-sa/4.0/"
+ },
+ "title": {
+ "zh": "御舆:解码 Agent Harness",
+ "en": "Yù Yú: Decoding Agent Harness"
+ },
+ "subtitle": {
+ "zh": "Claude Code 架构深度剖析",
+ "en": "A Deep Architectural Analysis of Claude Code"
+ },
+ "epigraph": {
+ "zh": "一器而工聚焉者,车为多。——《周礼·考工记》",
+ "en": "Of all artifacts that bring many crafts together, the chariot calls upon the most. — Kǎo Gōng Jì"
+ },
+ "start_reading": [
+ {
+ "label": {
+ "zh": "初次阅读",
+ "en": "First visit"
+ },
+ "body": {
+ "zh": "从前言开始,再读第 1 章 → 第 2 章 → 第 4 章 → 第 15 章。",
+ "en": "Begin at the Foreword, then read Chapter 1 → 2 → 4 → 15."
+ }
+ },
+ {
+ "label": {
+ "zh": "动手构建",
+ "en": "Build an agent"
+ },
+ "body": {
+ "zh": "先读基础篇与工程实践篇;增加记忆和扩展能力时查阅核心系统篇与高级模式篇。",
+ "en": "Read Parts 1 and 4 first; consult Parts 2 and 3 when adding memory and extension capabilities."
+ }
+ },
+ {
+ "label": {
+ "zh": "按需查阅",
+ "en": "Look things up"
+ },
+ "body": {
+ "zh": "使用四份附录定位模块、工具、功能标志和术语;文末主题索引按字母给出术语的章节位置。",
+ "en": "Use the four appendices to locate modules, tools, feature flags and terms; the back-matter index maps glossary terms to chapters."
+ }
+ },
+ {
+ "label": {
+ "zh": "离线整书",
+ "en": "Offline edition"
+ },
+ "body": {
+ "zh": "本文件即离线整书:单文件 HTML,图、目录、交叉引用、来源锚点断网可用;PDF 版含书签、页码与页眉。",
+ "en": "This file is the offline edition: a single HTML with diagrams, TOC, cross-references and source anchors working without network; the PDF adds bookmarks, page numbers and running heads."
+ }
+ }
+ ],
+ "parts": [
+ {
+ "id": "p1",
+ "no": {
+ "zh": "第一部分",
+ "en": "Part 1"
+ },
+ "title": {
+ "zh": "基础篇",
+ "en": "Foundations"
+ },
+ "motto": {
+ "zh": "建立心智模型",
+ "en": "Building Mental Models"
+ },
+ "goal": {
+ "zh": "理解 Agent 编程的范式转移,建立对 Agent Harness 的整体认知框架。",
+ "en": "Understand the paradigm shift in agent programming and establish a holistic cognitive framework for the Agent Harness."
+ },
+ "units": [
+ {
+ "id": "ch01",
+ "zh": "第一部分-基础篇/01-智能体编程的新范式.md",
+ "en": "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md"
+ },
+ {
+ "id": "ch02",
+ "zh": "第一部分-基础篇/02-对话循环-Agent的心跳.md",
+ "en": "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md"
+ },
+ {
+ "id": "ch03",
+ "zh": "第一部分-基础篇/03-工具系统-Agent的双手.md",
+ "en": "en/Part-1-Foundations/03-The-Tool-System-Agent-Hands.md"
+ },
+ {
+ "id": "ch04",
+ "zh": "第一部分-基础篇/04-权限管线-Agent的护栏.md",
+ "en": "en/Part-1-Foundations/04-The-Permission-Pipeline-Agent-Guardrails.md"
+ }
+ ]
+ },
+ {
+ "id": "p2",
+ "no": {
+ "zh": "第二部分",
+ "en": "Part 2"
+ },
+ "title": {
+ "zh": "核心系统篇",
+ "en": "Core Systems"
+ },
+ "motto": {
+ "zh": "深入子系统",
+ "en": "Deep into Subsystems"
+ },
+ "goal": {
+ "zh": "拆解 Agent Harness 的四大核心子系统——配置、记忆、上下文、钩子。",
+ "en": "Dissect the four core subsystems of the harness — configuration, memory, context, and hooks."
+ },
+ "units": [
+ {
+ "id": "ch05",
+ "zh": "第二部分-核心系统篇/05-设置与配置-Agent的基因.md",
+ "en": "en/Part-2-Core-Systems/05-Settings-and-Configuration-Agent-DNA.md"
+ },
+ {
+ "id": "ch06",
+ "zh": "第二部分-核心系统篇/06-记忆系统-Agent的长期记忆.md",
+ "en": "en/Part-2-Core-Systems/06-The-Memory-System-Agent-Long-Term-Memory.md"
+ },
+ {
+ "id": "ch07",
+ "zh": "第二部分-核心系统篇/07-上下文管理-Agent的工作记忆.md",
+ "en": "en/Part-2-Core-Systems/07-Context-Management-Agent-Working-Memory.md"
+ },
+ {
+ "id": "ch08",
+ "zh": "第二部分-核心系统篇/08-钩子系统-Agent的生命周期扩展点.md",
+ "en": "en/Part-2-Core-Systems/08-The-Hook-System-Agent-Lifecycle-Extension-Points.md"
+ }
+ ]
+ },
+ {
+ "id": "p3",
+ "no": {
+ "zh": "第三部分",
+ "en": "Part 3"
+ },
+ "title": {
+ "zh": "高级模式篇",
+ "en": "Advanced Patterns"
+ },
+ "motto": {
+ "zh": "Agent 的组合与扩展",
+ "en": "Composition and Extension"
+ },
+ "goal": {
+ "zh": "探索 Agent 如何组合、编排和扩展——从子智能体到 MCP 协议桥接。",
+ "en": "Explore how agents compose, orchestrate, and extend — from sub-agents to MCP protocol bridging."
+ },
+ "units": [
+ {
+ "id": "ch09",
+ "zh": "第三部分-高级模式篇/09-子智能体与Fork模式.md",
+ "en": "en/Part-3-Advanced-Patterns/09-Sub-Agents-and-the-Fork-Pattern.md"
+ },
+ {
+ "id": "ch10",
+ "zh": "第三部分-高级模式篇/10-协调器模式-多智能体编排.md",
+ "en": "en/Part-3-Advanced-Patterns/10-The-Coordinator-Pattern-Multi-Agent-Orchestration.md"
+ },
+ {
+ "id": "ch11",
+ "zh": "第三部分-高级模式篇/11-技能系统与插件架构.md",
+ "en": "en/Part-3-Advanced-Patterns/11-The-Skill-System-and-Plugin-Architecture.md"
+ },
+ {
+ "id": "ch12",
+ "zh": "第三部分-高级模式篇/12-MCP集成与外部协议.md",
+ "en": "en/Part-3-Advanced-Patterns/12-MCP-Integration-and-External-Protocols.md"
+ }
+ ]
+ },
+ {
+ "id": "p4",
+ "no": {
+ "zh": "第四部分",
+ "en": "Part 4"
+ },
+ "title": {
+ "zh": "工程实践篇",
+ "en": "Engineering Practice"
+ },
+ "motto": {
+ "zh": "从原理到构建",
+ "en": "From Principles to Construction"
+ },
+ "goal": {
+ "zh": "性能优化的工程细节,以及从零构建一个完整 Harness 的实战路线图。",
+ "en": "Engineering details of performance, and a hands-on roadmap to building a complete Harness from scratch."
+ },
+ "units": [
+ {
+ "id": "ch13",
+ "zh": "第四部分-工程实践篇/13-流式架构与性能优化.md",
+ "en": "en/Part-4-Engineering-Practice/13-Streaming-Architecture-and-Performance-Optimization.md"
+ },
+ {
+ "id": "ch14",
+ "zh": "第四部分-工程实践篇/14-Plan模式与结构化工作流.md",
+ "en": "en/Part-4-Engineering-Practice/14-Plan-Mode-and-Structured-Workflows.md"
+ },
+ {
+ "id": "ch15",
+ "zh": "第四部分-工程实践篇/15-构建你自己的Agent-Harness.md",
+ "en": "en/Part-4-Engineering-Practice/15-Building-Your-Own-Agent-Harness.md"
+ }
+ ]
+ }
+ ],
+ "front": {
+ "id": "front",
+ "zh": "00-前言.md",
+ "en": "en/00-Foreword.md"
+ },
+ "appendices": [
+ {
+ "id": "apxa",
+ "zh": "附录/A-源码导航地图.md",
+ "en": "en/Appendices/A-Architecture-Navigation-Map.md"
+ },
+ {
+ "id": "apxb",
+ "zh": "附录/B-工具完整清单.md",
+ "en": "en/Appendices/B-Complete-Tool-Inventory.md"
+ },
+ {
+ "id": "apxc",
+ "zh": "附录/C-功能标志速查表.md",
+ "en": "en/Appendices/C-Feature-Flag-Reference.md"
+ },
+ {
+ "id": "apxd",
+ "zh": "附录/D-术语表.md",
+ "en": "en/Appendices/D-Glossary.md"
+ }
+ ],
+ "guides": {
+ "front": {
+ "question": {
+ "zh": "为什么调用 LLM 的聊天机器人远远不够,Agent 需要 Harness 这一层工程基础设施?",
+ "en": "Why does an agent need a runtime layer—an Agent Harness—rather than a thin wrapper around the LLM API?"
+ },
+ "deliverable": {
+ "zh": "一张全书四部分知识地图,加一条匹配自身角色(架构师/工程师/开发者/研究者)的阅读路径。",
+ "en": "A map of the book's four parts and a reading path matched to your role—architect, senior engineer, app developer, or researcher."
+ },
+ "boundary": {
+ "zh": "不展开任何子系统的实现细节,也不提供 API 教程或技巧清单;架构拆解从第 1 章开始。",
+ "en": "No subsystem implementation details, no API how-to or tips list; the architecture walkthrough starts in Chapter 1."
+ },
+ "sources": [
+ "cc-changelog",
+ "docs-how-it-works",
+ "docs-best-practices",
+ "gh-book-repo",
+ "ld-topic-1870593"
+ ],
+ "plain": {
+ "zh": "模型会动手后,缺的是一层管住工具、权限和状态的外壳;这本书按公开行为推演它",
+ "en": "acting models need a shell for tools, permissions and state; the book deduces it from public behavior"
+ },
+ "analogy": {
+ "zh": "像医院接诊:再能诊断,也得有分诊台、处方签章和病历本才敢发药",
+ "en": "a hospital: sharp diagnoses still need triage, a signed script and a chart before anyone gets medicine"
+ },
+ "plain_evidence": [
+ "00-前言.md:93",
+ "00-前言.md:95",
+ "00-前言.md:143",
+ "00-前言.md:197",
+ "en/00-Foreword.md:64",
+ "en/00-Foreword.md:66",
+ "en/00-Foreword.md:114",
+ "en/00-Foreword.md:168"
+ ]
+ },
+ "ch01": {
+ "question": {
+ "zh": "从 Chatbot 到自主智能体的范式转移中,Claude Code 的架构骨架与五大设计原则是什么?",
+ "en": "What shifts AI programming from chatbot to autonomous agent, and what architectural skeleton and design principles does Claude Code embody?"
+ },
+ "deliverable": {
+ "zh": "一张五模块分层架构图,并能独立完成安装与 /doctor 首次诊断。",
+ "en": "A layered diagram of the five core modules and a working /doctor diagnostic run you can perform unaided."
+ },
+ "boundary": {
+ "zh": "只做全景导览;对话循环、工具系统、权限管线的内部实现分别见第 2、3、4 章。",
+ "en": "A panoramic tour only; loop internals, tool contracts, and permission stages are detailed in Chapters 2, 3, and 4."
+ },
+ "sources": [
+ "docs-how-it-works",
+ "blog-building-effective-agents",
+ "docs-best-practices",
+ "cc-release-v2-1-310"
+ ],
+ "plain": {
+ "zh": "模型动手后的门槛在工程侧:权限、状态、恢复谁来管?答案是一层运行时加五条原则",
+ "en": "the hard part moves to engineering: permissions, state, recovery - one runtime and five principles handle it"
+ },
+ "analogy": {
+ "zh": "像装修:电器再好也得先布线、装漏电保护、列回路清单,不能把插头直接拧进插座",
+ "en": "a renovation: before any appliance you run wiring, fit a leakage breaker and label every circuit"
+ },
+ "plain_evidence": [
+ "第一部分-基础篇/01-智能体编程的新范式.md:52",
+ "第一部分-基础篇/01-智能体编程的新范式.md:59",
+ "第一部分-基础篇/01-智能体编程的新范式.md:115",
+ "第一部分-基础篇/01-智能体编程的新范式.md:286",
+ "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md:52",
+ "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md:59",
+ "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md:115",
+ "en/Part-1-Foundations/01-The-New-Paradigm-of-Agent-Programming.md:286"
+ ]
+ },
+ "ch02": {
+ "question": {
+ "zh": "对话主循环为何选异步生成器?一次 Turn 如何从请求流转到终止?",
+ "en": "Why is the dialog loop an async generator, and how does one turn flow from request to termination?"
+ },
+ "deliverable": {
+ "zh": "一张 Turn 生命周期与状态转换图,能独立实现最小 AsyncGenerator 循环。",
+ "en": "A turn lifecycle and state-transition diagram, plus a minimal async-generator loop you can implement yourself."
+ },
+ "boundary": {
+ "zh": "只讲循环本身;四级压缩管线细节见第 7 章,工具编排见第 3 章,权限见第 4 章。",
+ "en": "Covers the loop only: compression pipeline internals belong to Chapter 7, tool orchestration to Chapter 3, permissions to Chapter 4."
+ },
+ "sources": [
+ "docs-how-it-works",
+ "docs-interactive-mode",
+ "docs-glossary",
+ "cc-changelog"
+ ],
+ "plain": {
+ "zh": "一轮问模型、跑工具、回填的循环:能暂停能取消,续跑七条路,退出十种因",
+ "en": "ask model, run tools, backfill: a pausable cancellable loop with seven continue paths and ten exit reasons"
+ },
+ "analogy": {
+ "zh": "像收发室:登记、寄出、等回执,没回来就再问一次;叫停时必写明停在哪一步",
+ "en": "a mailroom: log, send, await the receipt, ask again if none comes; every halt names where it stopped"
+ },
+ "plain_evidence": [
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:18",
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:141",
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:251",
+ "第一部分-基础篇/02-对话循环-Agent的心跳.md:390",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:18",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:141",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:251",
+ "en/Part-1-Foundations/02-The-Dialog-Loop-Heartbeat-of-an-Agent.md:390"
+ ]
+ },
+ "ch03": {
+ "question": {
+ "zh": "45 个以上工具如何靠同一份类型契约注册、发现、并发调度,而不改动引擎代码?",
+ "en": "How do 45-plus tools share one type contract for registration, discovery, and concurrent scheduling without touching the engine?"
+ },
+ "deliverable": {
+ "zh": "一张并发分区批次图,能用 buildTool 独立实现一个自定义工具。",
+ "en": "A batch-partition diagram for concurrent scheduling, and one custom tool built independently with buildTool."
+ },
+ "boundary": {
+ "zh": "覆盖工具契约与编排引擎;管线内的权限判定见第 4 章,MCP 外部工具见第 12 章。",
+ "en": "Stops at the tool contract and orchestrator; permission decisions inside the pipeline are Chapter 4, MCP-sourced tools are Chapter 12."
+ },
+ "sources": [
+ "docs-tools-reference",
+ "blog-writing-tools-for-agents",
+ "docs-permissions",
+ "cc-changelog"
+ ],
+ "plain": {
+ "zh": "几十个工具共用一份五要素契约:只读的成批跑,改东西的单独排队,边听指令边开工",
+ "en": "tools share a five-part contract: reads batch together, writes queue alone, work starts mid-reply"
+ },
+ "analogy": {
+ "zh": "像后厨出单:洗切可以一起上,动火的只能一个人来,做好也得按下单顺序报号",
+ "en": "a kitchen: chopping runs in parallel, only one cook may touch the flame, dishes get called in order"
+ },
+ "plain_evidence": [
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:34",
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:253",
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:317",
+ "第一部分-基础篇/03-工具系统-Agent的双手.md:344",
+ "en/Part-1-Foundations/03-The-Tool-System-Agent-Hands.md:34",
+ "en/Part-1-Foundations/03-The-Tool-System-Agent-Hands.md:253",
+ "en/Part-1-Foundations/03-The-Tool-System-Agent-Hands.md:317",
+ "en/Part-1-Foundations/03-The-Tool-System-Agent-Hands.md:344"
+ ]
+ },
+ "ch04": {
+ "question": {
+ "zh": "一次工具调用如何穿过四阶段权限管线,在自主效率与安全控制之间取得平衡?",
+ "en": "How does a tool call traverse the four-stage pipeline, and how is autonomy balanced against safety?"
+ },
+ "deliverable": {
+ "zh": "一张四阶段决策流程图,能独立编写项目级 allow/deny 权限配置。",
+ "en": "A four-stage decision-flow diagram and a project-level allow/deny ruleset you can author from scratch."
+ },
+ "boundary": {
+ "zh": "只覆盖权限判定;生命周期钩子扩展点见第 8 章,配置分层与合并见第 5 章。",
+ "en": "Permission evaluation only; lifecycle hooks are Chapter 8, and settings layering and merge rules are Chapter 5."
+ },
+ "sources": [
+ "docs-permissions",
+ "docs-permission-modes",
+ "docs-env-vars",
+ "cc-issues",
+ "cc-changelog"
+ ],
+ "plain": {
+ "zh": "工具请求要过四道关:验格式、查规则、工具自审、人点头;显式拒绝压过一切放行",
+ "en": "every call clears four gates: schema, rules, tool self-check, human nod - an explicit deny outranks any allow"
+ },
+ "analogy": {
+ "zh": "像报销审批:验票、查黑名单、主管复核、老板签字;写明不予报销的,谁签都没用",
+ "en": "expense approval: receipt check, blocklist lookup, manager review, boss signs - a written denial ends it all"
+ },
+ "plain_evidence": [
+ "第一部分-基础篇/04-权限管线-Agent的护栏.md:19",
+ "第一部分-基础篇/04-权限管线-Agent的护栏.md:121",
+ "第一部分-基础篇/04-权限管线-Agent的护栏.md:138",
+ "第一部分-基础篇/04-权限管线-Agent的护栏.md:209",
+ "en/Part-1-Foundations/04-The-Permission-Pipeline-Agent-Guardrails.md:19",
+ "en/Part-1-Foundations/04-The-Permission-Pipeline-Agent-Guardrails.md:121",
+ "en/Part-1-Foundations/04-The-Permission-Pipeline-Agent-Guardrails.md:138",
+ "en/Part-1-Foundations/04-The-Permission-Pipeline-Agent-Guardrails.md:209"
+ ]
+ },
+ "ch05": {
+ "question": {
+ "zh": "六层配置源按什么优先级合并成 Agent 的“基因”,安全边界又划在哪里?",
+ "en": "How do six configuration sources merge into the agent's operating defaults, and where are the trust boundaries?"
+ },
+ "deliverable": {
+ "zh": "一张六层优先级与合并规则对照表,能独立设计团队分层配置策略。",
+ "en": "A priority-and-merge table for the six layers, plus a tiered settings strategy you can design for a team."
+ },
+ "boundary": {
+ "zh": "聚焦配置加载、功能开关与状态存储;权限规则语义见第 4 章,钩子配置项见第 8 章。",
+ "en": "Covers loading, merging, flags, and state storage; permission rule semantics stay in Chapter 4, hook options in Chapter 8."
+ },
+ "sources": [
+ "docs-settings",
+ "docs-env-vars",
+ "npm-claude-code",
+ "cc-changelog"
+ ],
+ "plain": {
+ "zh": "六层配置层层覆盖决定它怎么行动;外来仓库改不动安全项,运行时状态只换不改",
+ "en": "six config layers override in order; a foreign repo can't set security keys, and state is swapped not edited"
+ },
+ "analogy": {
+ "zh": "像办公室公告栏:新通知压在旧通知上面,但权限清单只增不减,消防通道归物业管",
+ "en": "an office notice board: new memos cover old ones, the access list only grows, fire exits are the landlord's"
+ },
+ "plain_evidence": [
+ "第二部分-核心系统篇/05-设置与配置-Agent的基因.md:23",
+ "第二部分-核心系统篇/05-设置与配置-Agent的基因.md:76",
+ "第二部分-核心系统篇/05-设置与配置-Agent的基因.md:223",
+ "第二部分-核心系统篇/05-设置与配置-Agent的基因.md:413",
+ "en/Part-2-Core-Systems/05-Settings-and-Configuration-Agent-DNA.md:23",
+ "en/Part-2-Core-Systems/05-Settings-and-Configuration-Agent-DNA.md:76",
+ "en/Part-2-Core-Systems/05-Settings-and-Configuration-Agent-DNA.md:223",
+ "en/Part-2-Core-Systems/05-Settings-and-Configuration-Agent-DNA.md:413"
+ ]
+ },
+ "ch06": {
+ "question": {
+ "zh": "哪些信息值得跨会话长期保存?后台提取又如何在提示缓存约束下进行?",
+ "en": "What deserves durable storage across sessions, and how does background extraction run within prompt-cache limits?"
+ },
+ "deliverable": {
+ "zh": "一份四类型分类判据速查表,能独立写出合规的 frontmatter 记忆文件。",
+ "en": "A four-type decision checklist and correctly formatted memory files you can write unaided."
+ },
+ "boundary": {
+ "zh": "只管跨会话的长期记忆;会话内的工作记忆与压缩见第 7 章,配置信任边界见第 5 章。",
+ "en": "Long-term memory only; in-session working memory and compaction are Chapter 7, settings trust boundaries are Chapter 5."
+ },
+ "sources": [
+ "docs-memory",
+ "blog-context-engineering",
+ "docs-glossary",
+ "cc-changelog"
+ ],
+ "plain": {
+ "zh": "跨会话只记代码里读不出的四件事:你是谁、你纠正过什么、为何这么定、链接在哪",
+ "en": "it keeps only what code can't reveal: who you are, what you corrected, why it was decided, where links live"
+ },
+ "analogy": {
+ "zh": "像图书馆目录柜:只登记书在哪层架,正文不抄进卡片;真要引用还得走回架子核对",
+ "en": "a card catalog: it notes which shelf, never copies the text, and you still walk over to check the book"
+ },
+ "plain_evidence": [
+ "第二部分-核心系统篇/06-记忆系统-Agent的长期记忆.md:7",
+ "第二部分-核心系统篇/06-记忆系统-Agent的长期记忆.md:17",
+ "第二部分-核心系统篇/06-记忆系统-Agent的长期记忆.md:232",
+ "第二部分-核心系统篇/06-记忆系统-Agent的长期记忆.md:540",
+ "en/Part-2-Core-Systems/06-The-Memory-System-Agent-Long-Term-Memory.md:7",
+ "en/Part-2-Core-Systems/06-The-Memory-System-Agent-Long-Term-Memory.md:17",
+ "en/Part-2-Core-Systems/06-The-Memory-System-Agent-Long-Term-Memory.md:232",
+ "en/Part-2-Core-Systems/06-The-Memory-System-Agent-Long-Term-Memory.md:540"
+ ]
+ },
+ "ch07": {
+ "question": {
+ "zh": "上下文逼近窗口上限时,四级压缩按何顺序介入,各自付出多少成本?",
+ "en": "As the window fills, in what order do the four compression levels intervene, and what does each cost?"
+ },
+ "deliverable": {
+ "zh": "一张令牌阈值与四级压缩成本图,能自行算出有效窗口并选定压缩策略。",
+ "en": "A token-threshold and compression-cost map, plus the arithmetic to size an effective window yourself."
+ },
+ "boundary": {
+ "zh": "聚焦单会话内的窗口与预算;持久记忆见第 6 章,PreCompact 钩子见第 8 章,Fork 子代理见第 9 章。",
+ "en": "Working memory within one session; durable memory is Chapter 6, PreCompact hooks Chapter 8, forked sub-agents Chapter 9."
+ },
+ "sources": [
+ "docs-context-window",
+ "blog-context-engineering",
+ "docs-memory",
+ "docs-prompt-caching",
+ "cc-changelog"
+ ],
+ "plain": {
+ "zh": "白板写满就得擦:腾地方从轻到重分四级,压缩前先留好下笔的余量,连败三次就收手",
+ "en": "when the board fills, erase in four graded steps, save room before summarizing, quit after three failures"
+ },
+ "analogy": {
+ "zh": "像库房腾货架:先并零箱,再压缩打包,实在装不下才把旧货誊成一页台账贴门口",
+ "en": "clearing a crowded shelf: consolidate loose boxes, vacuum-pack, and only then write old stock onto one ledger"
+ },
+ "plain_evidence": [
+ "第二部分-核心系统篇/07-上下文管理-Agent的工作记忆.md:30",
+ "第二部分-核心系统篇/07-上下文管理-Agent的工作记忆.md:77",
+ "第二部分-核心系统篇/07-上下文管理-Agent的工作记忆.md:120",
+ "第二部分-核心系统篇/07-上下文管理-Agent的工作记忆.md:282",
+ "en/Part-2-Core-Systems/07-Context-Management-Agent-Working-Memory.md:30",
+ "en/Part-2-Core-Systems/07-Context-Management-Agent-Working-Memory.md:77",
+ "en/Part-2-Core-Systems/07-Context-Management-Agent-Working-Memory.md:120",
+ "en/Part-2-Core-Systems/07-Context-Management-Agent-Working-Memory.md:282"
+ ]
+ },
+ "ch08": {
+ "question": {
+ "zh": "26 个生命周期事件上,五种钩子类型如何观察、拦截并改写 Agent 的行为?",
+ "en": "Across 26 lifecycle events, how do five hook types observe, intercept, and reshape agent behavior?"
+ },
+ "deliverable": {
+ "zh": "一份事件×退出码×响应字段速查表,能独立写出可上线的 PreToolUse 钩子。",
+ "en": "An event/exit-code/response-field cheat sheet and a working PreToolUse hook you can ship."
+ },
+ "boundary": {
+ "zh": "讲扩展点本身;权限管线各阶段顺序见第 4 章,钩子配置的六层合并规则见第 5 章。",
+ "en": "The extension points themselves; permission-stage ordering stays in Chapter 4, hook config merge rules in Chapter 5."
+ },
+ "sources": [
+ "docs-hooks",
+ "docs-permissions",
+ "cc-changelog",
+ "cc-issues"
+ ],
+ "plain": {
+ "zh": "不改源码就能插手 Agent 的关键节点:五种钩子挂在 26 个生命周期事件上,能拦、能改写输入、能补上下文",
+ "en": "five hook kinds hang on 26 lifecycle events: watch, block, rewrite tool input, add context - no source edits"
+ },
+ "analogy": {
+ "zh": "像小区门禁:保安不盖楼,但每次进出都能拦下、改道或在登记本上补一句",
+ "en": "an estate gatehouse: it builds nothing, yet stops, redirects or notes every comings and goings"
+ },
+ "plain_evidence": [
+ "第二部分-核心系统篇/08-钩子系统-Agent的生命周期扩展点.md:15",
+ "第二部分-核心系统篇/08-钩子系统-Agent的生命周期扩展点.md:53",
+ "第二部分-核心系统篇/08-钩子系统-Agent的生命周期扩展点.md:211",
+ "第二部分-核心系统篇/08-钩子系统-Agent的生命周期扩展点.md:655",
+ "en/Part-2-Core-Systems/08-The-Hook-System-Agent-Lifecycle-Extension-Points.md:15",
+ "en/Part-2-Core-Systems/08-The-Hook-System-Agent-Lifecycle-Extension-Points.md:53",
+ "en/Part-2-Core-Systems/08-The-Hook-System-Agent-Lifecycle-Extension-Points.md:211",
+ "en/Part-2-Core-Systems/08-The-Hook-System-Agent-Lifecycle-Extension-Points.md:655"
+ ]
+ },
+ "ch09": {
+ "question": {
+ "zh": "一次对话装不下的大任务,Claude Code 怎样拆给子智能体,又不让 token 与缓存失控?",
+ "en": "When one conversation cannot hold the task, how does Claude Code split work into subagents without letting tokens or the prompt cache run away?"
+ },
+ "deliverable": {
+ "zh": "一张 AgentTool 同步/异步/Fork 三条路径的分流图,加上一份带工具白名单与 maxTurns 的自定义智能体定义。",
+ "en": "A map of AgentTool's sync, async and Fork paths, plus a custom agent definition with a tool allowlist, maxTurns and cache-prefix token math."
+ },
+ "boundary": {
+ "zh": "只讲委派与并行机制;多智能体显式编排和失败再分配见第10章,技能与插件注入见第11章。",
+ "en": "Delegation mechanics only; explicit multi-agent orchestration and reassignment are Chapter 10, skill and plugin injection is Chapter 11."
+ },
+ "sources": [
+ "docs-sub-agents",
+ "blog-multi-agent-research",
+ "docs-agent-sdk-overview",
+ "gh-agent-sdk-python",
+ "cc-changelog"
+ ],
+ "plain": {
+ "zh": "把活交给专职子智能体:内置与自定义智能体怎么定义,Fork 靠字节级继承父上下文并行且不浪费 token",
+ "en": "hand work to specialist sub-agents; Fork runs them in parallel by reusing the parent's exact cached prefix"
+ },
+ "analogy": {
+ "zh": "像复印同一份会议底稿:每人拿一份完全一样的原件各写各结论,不必重新打印",
+ "en": "one meeting brief photocopied for the table: each person annotates a copy, nobody reprints the original"
+ },
+ "plain_evidence": [
+ "第三部分-高级模式篇/09-子智能体与Fork模式.md:9",
+ "第三部分-高级模式篇/09-子智能体与Fork模式.md:226",
+ "第三部分-高级模式篇/09-子智能体与Fork模式.md:253",
+ "第三部分-高级模式篇/09-子智能体与Fork模式.md:343",
+ "en/Part-3-Advanced-Patterns/09-Sub-Agents-and-the-Fork-Pattern.md:9",
+ "en/Part-3-Advanced-Patterns/09-Sub-Agents-and-the-Fork-Pattern.md:226",
+ "en/Part-3-Advanced-Patterns/09-Sub-Agents-and-the-Fork-Pattern.md:253",
+ "en/Part-3-Advanced-Patterns/09-Sub-Agents-and-the-Fork-Pattern.md:343"
+ ]
+ },
+ "ch10": {
+ "question": {
+ "zh": "当子任务彼此依赖、发现需要共享、工人还会失败时,谁来当那个项目经理?",
+ "en": "Once subtasks depend on each other, discoveries must be shared, and workers fail mid-run, who plays the project manager?"
+ },
+ "deliverable": {
+ "zh": "一套 Coordinator 模式启用与工作者工具池的选型流程,含四阶段工作流和 Scratchpad 共享目录的落地方案。",
+ "en": "A decision flow for enabling Coordinator mode and sizing worker tool pools, with the four-phase workflow and a Scratchpad sharing plan."
+ },
+ "boundary": {
+ "zh": "不复推 Fork 的字节级缓存前缀(第9章),也不涉及技能与 MCP 扩展层(第11、12章)。",
+ "en": "Does not re-derive Fork's byte-level cache prefix (Chapter 9), nor the skill and MCP extension layers (Chapters 11 and 12)."
+ },
+ "sources": [
+ "docs-agent-teams",
+ "blog-multi-agent-research",
+ "docs-sub-agents",
+ "gh-agent-sdk-typescript",
+ "cc-changelog"
+ ],
+ "plain": {
+ "zh": "一个脑子忙不过来时:协调者只派活不干活,发消息、共用草稿区,还兜住worker失败和半成品",
+ "en": "when one agent isn't enough: a lead assigns but never executes, absorbing failures and half-finished work"
+ },
+ "analogy": {
+ "zh": "像酒席总厨:自己从不颠勺,各灶领单,一张备料单大家看,谁倒下了马上补位",
+ "en": "a banquet head chef: no pan himself, tickets out to stations, one prep list, covers whoever drops out"
+ },
+ "plain_evidence": [
+ "第三部分-高级模式篇/10-协调器模式-多智能体编排.md:9",
+ "第三部分-高级模式篇/10-协调器模式-多智能体编排.md:325",
+ "第三部分-高级模式篇/10-协调器模式-多智能体编排.md:543",
+ "第三部分-高级模式篇/10-协调器模式-多智能体编排.md:587",
+ "en/Part-3-Advanced-Patterns/10-The-Coordinator-Pattern-Multi-Agent-Orchestration.md:9",
+ "en/Part-3-Advanced-Patterns/10-The-Coordinator-Pattern-Multi-Agent-Orchestration.md:325",
+ "en/Part-3-Advanced-Patterns/10-The-Coordinator-Pattern-Multi-Agent-Orchestration.md:544",
+ "en/Part-3-Advanced-Patterns/10-The-Coordinator-Pattern-Multi-Agent-Orchestration.md:588"
+ ]
+ },
+ "ch11": {
+ "question": {
+ "zh": "怎样把一段反复粘贴的提示词,变成可分发、可覆盖、可控权限的能力包?",
+ "en": "How does a prompt you keep retyping become a capability package that can be distributed, overridden, and permission-scoped?"
+ },
+ "deliverable": {
+ "zh": "一个带 frontmatter、参数替换与路径触发的 SKILL.md,加一张 managed 到 bundled 的五层覆盖图。",
+ "en": "A SKILL.md with frontmatter, argument substitution and path triggers, plus a layering map from managed through user, project, plugin to bundled."
+ },
+ "boundary": {
+ "zh": "只到技能与插件的加载执行;MCP 传输与权限归第12章,工作流编排与定时调度归第14章。",
+ "en": "Stops at skill and plugin loading and execution; MCP transports belong to Chapter 12, workflow orchestration and scheduling to Chapter 14."
+ },
+ "sources": [
+ "docs-skills",
+ "docs-plugins",
+ "docs-env-vars",
+ "cc-changelog"
+ ],
+ "plain": {
+ "zh": "把反复要交代的话写成 Markdown 技能:五个来源分层覆盖、按路径动态发现,插件再整包带进来",
+ "en": "turn repeated instructions into Markdown skills: tiered sources override each other, plugins ship whole packs"
+ },
+ "analogy": {
+ "zh": "像墙上的菜谱卡:总部、本店、自家各一叠,同名听级别高的,要用时念名字抽一张",
+ "en": "a wall of recipe cards: HQ, house and home stacks; same name defers upward; call it out and pull one"
+ },
+ "plain_evidence": [
+ "第三部分-高级模式篇/11-技能系统与插件架构.md:17",
+ "第三部分-高级模式篇/11-技能系统与插件架构.md:302",
+ "第三部分-高级模式篇/11-技能系统与插件架构.md:413",
+ "第三部分-高级模式篇/11-技能系统与插件架构.md:512",
+ "en/Part-3-Advanced-Patterns/11-The-Skill-System-and-Plugin-Architecture.md:17",
+ "en/Part-3-Advanced-Patterns/11-The-Skill-System-and-Plugin-Architecture.md:302",
+ "en/Part-3-Advanced-Patterns/11-The-Skill-System-and-Plugin-Architecture.md:413",
+ "en/Part-3-Advanced-Patterns/11-The-Skill-System-and-Plugin-Architecture.md:513"
+ ]
+ },
+ "ch12": {
+ "question": {
+ "zh": "当工具、资源或 IDE 都在 Claude Code 之外,怎样安全接进来并管住权限?",
+ "en": "When the tools, resources, or the IDE itself live outside Claude Code, how are they wired in safely and kept under permission control?"
+ },
+ "deliverable": {
+ "zh": "一份可用的 .mcp.json,一张八类连接的选型对照表,以及 mcp__server__tool 命名贯穿四层门控的排查路径。",
+ "en": "A working .mcp.json, a selection table across the eight connection types, and a trace of mcp__server__tool naming through the four permission gates."
+ },
+ "boundary": {
+ "zh": "工具接口本体见第3章,权限管线内部机制见第4章;本章只讲 MCP 如何接入这两套系统。",
+ "en": "The tool interface itself (Chapter 3) and the permission pipeline internals (Chapter 4) are assumed, not re-explained; only MCP's integration is."
+ },
+ "sources": [
+ "docs-mcp",
+ "mcp-spec-2026-07-28",
+ "docs-plugins",
+ "blog-writing-tools-for-agents",
+ "cc-changelog"
+ ],
+ "plain": {
+ "zh": "接外部工具不再一家一个适配:连接配置怎么分八类、工具改名映射、逐层审批,外加 IDE 双向桥",
+ "en": "one protocol replaces per-tool adapters: connection configs, tool naming, layered approvals, an IDE bridge"
+ },
+ "analogy": {
+ "zh": "像小区快递驿站:任何商家只送一次就能被取,货架按楼栋分格贴名,取件先验码",
+ "en": "a shared parcel room: any courier drops once, shelves sorted and labelled by building, code scanned first"
+ },
+ "plain_evidence": [
+ "第三部分-高级模式篇/12-MCP集成与外部协议.md:64",
+ "第三部分-高级模式篇/12-MCP集成与外部协议.md:100",
+ "第三部分-高级模式篇/12-MCP集成与外部协议.md:283",
+ "第三部分-高级模式篇/12-MCP集成与外部协议.md:672",
+ "en/Part-3-Advanced-Patterns/12-MCP-Integration-and-External-Protocols.md:64",
+ "en/Part-3-Advanced-Patterns/12-MCP-Integration-and-External-Protocols.md:100",
+ "en/Part-3-Advanced-Patterns/12-MCP-Integration-and-External-Protocols.md:283",
+ "en/Part-3-Advanced-Patterns/12-MCP-Integration-and-External-Protocols.md:672"
+ ]
+ },
+ "ch13": {
+ "question": {
+ "zh": "模型还在吐字,工具为何已能开跑?延迟、并发与 token 成本之间怎么权衡?",
+ "en": "Why can tools start running while the model is still talking, and how are latency, concurrency, and token cost traded off against each other?"
+ },
+ "deliverable": {
+ "zh": "一份性能排查清单:流式管线、并/串行规则、启动并行预取、用量累加与缓存命中率的计算方法。",
+ "en": "A performance checklist covering the QueryEngine stream, the executor's parallel-versus-serial rules, startup prefetch, usage accounting, and cache-hit math."
+ },
+ "boundary": {
+ "zh": "不重讲对话循环(第2章)与压缩策略(第7章);Fork 的字节级继承细节以第9章为主。",
+ "en": "The main loop is Chapter 2 and compaction is Chapter 7; Fork's byte-exact inheritance is detailed in Chapter 9, summarized here."
+ },
+ "sources": [
+ "docs-prompt-caching",
+ "docs-agent-sdk-overview",
+ "docs-context-window",
+ "cc-release-v2-1-310",
+ "npm-claude-code"
+ ],
+ "plain": {
+ "zh": "别干等:token 还在流就开工,工具该并行的并行、启动活儿同时跑,成本和缓存一路盯(数字多为示意)",
+ "en": "act while tokens arrive: overlap tool runs and startup, watch cost and cache breaks (ms figures illustrative)"
+ },
+ "analogy": {
+ "zh": "像跑堂接菜:厨房喊一道先端一道,上桌仍按点单顺序;一道洒了,同批的先停",
+ "en": "a waiter carrying each dish as it's called, serving still in order, halting the whole batch if one spills"
+ },
+ "plain_evidence": [
+ "第四部分-工程实践篇/13-流式架构与性能优化.md:132",
+ "第四部分-工程实践篇/13-流式架构与性能优化.md:280",
+ "第四部分-工程实践篇/13-流式架构与性能优化.md:294",
+ "第四部分-工程实践篇/13-流式架构与性能优化.md:479",
+ "en/Part-4-Engineering-Practice/13-Streaming-Architecture-and-Performance-Optimization.md:132",
+ "en/Part-4-Engineering-Practice/13-Streaming-Architecture-and-Performance-Optimization.md:280",
+ "en/Part-4-Engineering-Practice/13-Streaming-Architecture-and-Performance-Optimization.md:294",
+ "en/Part-4-Engineering-Practice/13-Streaming-Architecture-and-Performance-Optimization.md:482"
+ ]
+ },
+ "ch14": {
+ "question": {
+ "zh": "如何让 Agent 先想清楚再动手,并把「想清楚」变成可读回、可审批、可定时的资产?",
+ "en": "How do you make an agent think before acting, and turn that thinking into an artifact that can be re-read, approved, and scheduled?"
+ },
+ "deliverable": {
+ "zh": "一条 EnterPlanMode 到计划文件再到 ExitPlanMode 审批的完整链路,加 Cron 与远程触发的选型建议。",
+ "en": "The full EnterPlanMode-to-plan-file-to-ExitPlanMode approval chain, plus a selection guide for Cron, RemoteTrigger, workflow scripts, and SleepTool."
+ },
+ "boundary": {
+ "zh": "权限模式的实现底座在第4章,验证智能体的对抗提示在第9章,本章不展开。",
+ "en": "Permission-mode plumbing lives in Chapter 4 and the verification agent's adversarial prompting in Chapter 9; neither is developed here."
+ },
+ "sources": [
+ "docs-permission-modes",
+ "docs-interactive-mode",
+ "docs-tools-reference",
+ "cc-changelog"
+ ],
+ "plain": {
+ "zh": "先只读想清楚、批准了再动手改:计划能校验能续存,还能接上定时触发和后台长跑",
+ "en": "think read-only before writing: an approvable, recoverable plan, plus cron triggers and background runs"
+ },
+ "analogy": {
+ "zh": "像搬家:先列清单估价,老板签字才叫车;清单收进抽屉,下次接着用",
+ "en": "moving day: list and price first, the boss signs before a truck is called, the list is filed for reuse"
+ },
+ "plain_evidence": [
+ "第四部分-工程实践篇/14-Plan模式与结构化工作流.md:13",
+ "第四部分-工程实践篇/14-Plan模式与结构化工作流.md:17",
+ "第四部分-工程实践篇/14-Plan模式与结构化工作流.md:422",
+ "第四部分-工程实践篇/14-Plan模式与结构化工作流.md:661",
+ "en/Part-4-Engineering-Practice/14-Plan-Mode-and-Structured-Workflows.md:13",
+ "en/Part-4-Engineering-Practice/14-Plan-Mode-and-Structured-Workflows.md:17",
+ "en/Part-4-Engineering-Practice/14-Plan-Mode-and-Structured-Workflows.md:423",
+ "en/Part-4-Engineering-Practice/14-Plan-Mode-and-Structured-Workflows.md:662"
+ ]
+ },
+ "ch15": {
+ "question": {
+ "zh": "抛开 Claude Code 从零起步,一个最小可用的 Agent Harness 需要哪几根承重梁,按什么顺序搭?",
+ "en": "Strip Claude Code away: which load-bearing parts does a minimal working agent harness need, and in what order would you build them?"
+ },
+ "deliverable": {
+ "zh": "六步路线图与可运行的最小 Harness:对话循环、buildTool、四阶段权限、压缩、记忆、钩子,附生产指标清单。",
+ "en": "A six-step roadmap to a runnable minimal harness — loop, buildTool factory, four-stage permission pipeline, compaction, memory, hooks — plus production metrics."
+ },
+ "boundary": {
+ "zh": "只提炼可迁移的取舍,各系统源码细节仍见第2—12章;不覆盖特定平台的部署运维。",
+ "en": "Recaps transferable principles only; per-system source detail stays in Chapters 2-12, and platform-specific deployment operations are out of scope."
+ },
+ "sources": [
+ "docs-headless",
+ "docs-cli-reference",
+ "docs-agent-sdk-overview",
+ "gh-agent-sdk-python",
+ "gh-agent-sdk-typescript"
+ ],
+ "plain": {
+ "zh": "全书收口成一张施工图:按六步搭出最小可跑 Harness(代码是教学示例),再谈架构教训与生产化",
+ "en": "capstone: six steps to a minimal harness; the code is a teaching example, then production concerns"
+ },
+ "analogy": {
+ "zh": "像照邻居厨房自己搭灶:六样大致按顺序来,中间反复回去改,最后能自己开火",
+ "en": "copying a neighbour's kitchen to build your own: six parts in rough order, fixing earlier ones as you learn"
+ },
+ "plain_evidence": [
+ "第四部分-工程实践篇/15-构建你自己的Agent-Harness.md:62",
+ "第四部分-工程实践篇/15-构建你自己的Agent-Harness.md:167",
+ "第四部分-工程实践篇/15-构建你自己的Agent-Harness.md:186",
+ "第四部分-工程实践篇/15-构建你自己的Agent-Harness.md:503",
+ "en/Part-4-Engineering-Practice/15-Building-Your-Own-Agent-Harness.md:62",
+ "en/Part-4-Engineering-Practice/15-Building-Your-Own-Agent-Harness.md:167",
+ "en/Part-4-Engineering-Practice/15-Building-Your-Own-Agent-Harness.md:186",
+ "en/Part-4-Engineering-Practice/15-Building-Your-Own-Agent-Harness.md:511"
+ ]
+ },
+ "apxa": {
+ "question": {
+ "zh": "要改一个不熟的功能:它归哪个模块、上下游依赖谁、数据实际怎么流过?",
+ "en": "You must change an unfamiliar feature: which module owns it, what does it depend on, and how does the data actually flow through?"
+ },
+ "deliverable": {
+ "zh": "一张模块索引表(职责与关键数据结构)、依赖关系图、数据流路径速查与接口契约概述,每条回指正文章节。",
+ "en": "A module index with responsibilities and key data structures, a dependency graph, dataflow path diagrams, contract summaries, and chapter pointers."
+ },
+ "boundary": {
+ "zh": "只做导航与回指,机制推导以正文各章为准;模块划分与命名随版本演进,不保证恒定。",
+ "en": "A conceptual map, not a mechanism derivation — the chapters carry the reasoning; module names and boundaries may shift between releases."
+ },
+ "sources": [
+ "cc-changelog",
+ "cc-issues",
+ "gh-book-repo",
+ "docs-settings",
+ "ld-topic-1871393"
+ ],
+ "plain": {
+ "zh": "用法:这是概念性地图不是文件清单——A.1 查模块归谁、看哪章,A.3 顺着一条数据流走一遍",
+ "en": "how to use: a conceptual map, not a file list - A.1 for who owns what and which chapter, A.3 to trace a flow"
+ },
+ "analogy": {
+ "zh": "像物业的水电管线总图:敲墙前先查这根管子归谁、水从哪进往哪出",
+ "en": "the building's utility plan: before hammering, check whose pipe it is and where water enters and exits"
+ },
+ "plain_evidence": [
+ "附录/A-源码导航地图.md:3",
+ "附录/A-源码导航地图.md:6",
+ "附录/A-源码导航地图.md:14",
+ "附录/A-源码导航地图.md:221",
+ "en/Appendices/A-Architecture-Navigation-Map.md:3",
+ "en/Appendices/A-Architecture-Navigation-Map.md:6",
+ "en/Appendices/A-Architecture-Navigation-Map.md:14",
+ "en/Appendices/A-Architecture-Navigation-Map.md:221"
+ ]
+ },
+ "apxb": {
+ "question": {
+ "zh": "想确认某工具是否存在,或它是只读、破坏性、可并行,还是被标志关在门外?",
+ "en": "Checking whether a tool exists at all, or whether it is read-only, destructive, concurrency-safe, or locked behind a feature flag?"
+ },
+ "deliverable": {
+ "zh": "十二类工具的属性对照(readOnly/destructive/concurrencySafe)、启用条件、注册默认值与延迟/消耗画像。",
+ "en": "Answers on every registered tool across twelve categories: three permission flags, enablement conditions, buildTool defaults, and rough latency and token profiles."
+ },
+ "boundary": {
+ "zh": "只登记属性与分类,不解释工具实现(第3章)与权限判定流程(第4章);清单随版本增删。",
+ "en": "Registers attributes only; tool implementation is Chapter 3 and permission decisions are Chapter 4. The roster grows and shrinks with each release."
+ },
+ "sources": [
+ "docs-tools-reference",
+ "docs-permissions",
+ "cc-changelog"
+ ],
+ "plain": {
+ "zh": "用法:拿不准某个工具时按分类查——能不能并行、算不算破坏性、要开哪个标志、和谁搭配用",
+ "en": "how to use: look a tool up by category - destructive, parallel-safe, which flag gates it, what it pairs with"
+ },
+ "analogy": {
+ "zh": "像五金店零件柜:每格标签写着能不能随手拿、刃口利不利、要不要店员开钥匙",
+ "en": "a hardware-shop parts cabinet: each bin's tag says grab-and-go, blade sharp, or needs the clerk's key"
+ },
+ "plain_evidence": [
+ "附录/B-工具完整清单.md:3",
+ "附录/B-工具完整清单.md:5",
+ "附录/B-工具完整清单.md:255",
+ "附录/B-工具完整清单.md:378",
+ "en/Appendices/B-Complete-Tool-Inventory.md:3",
+ "en/Appendices/B-Complete-Tool-Inventory.md:5",
+ "en/Appendices/B-Complete-Tool-Inventory.md:255",
+ "en/Appendices/B-Complete-Tool-Inventory.md:378"
+ ]
+ },
+ "apxc": {
+ "question": {
+ "zh": "某个能力在你的构建里不见了——是编译时没打进去,还是运行时门控没放行?",
+ "en": "A capability is missing from your build: was it never compiled in, or is it compiled in but still gated off at runtime?"
+ },
+ "deliverable": {
+ "zh": "每个标志的类型、作用、影响范围、前置依赖与章节指引,外加典型场景的标志组合建议与依赖总图。",
+ "en": "Flag type, effect, blast radius, prerequisites, and chapter pointer for each flag, grouped in twelve categories, with recommended combinations per scenario."
+ },
+ "boundary": {
+ "zh": "只管功能标志;settings 层级与环境变量见第5章,标志解锁的工具见附录B;名称与数量非版本恒定。",
+ "en": "Flags only — settings tiers and environment variables are Chapter 5, unlocked tools are Appendix B. Flag names and counts are not stable across versions."
+ },
+ "sources": [
+ "docs-env-vars",
+ "docs-cli-reference",
+ "cc-release-v2-1-310",
+ "docs-settings"
+ ],
+ "plain": {
+ "zh": "用法:想知道某功能为什么不存在就查这表——标志管什么、依赖谁、影响哪些工具;数目随版本会变",
+ "en": "how to use: feature missing? look up what a flag gates, its dependencies and tools; counts drift by version"
+ },
+ "analogy": {
+ "zh": "像配电箱上的回路表:哪个闸管哪盏灯、跳了会连带断哪一路,看清了才敢伸手",
+ "en": "the breaker panel's circuit label: which switch feeds which light, and what else goes dark when one trips"
+ },
+ "plain_evidence": [
+ "附录/C-功能标志速查表.md:3",
+ "附录/C-功能标志速查表.md:9",
+ "附录/C-功能标志速查表.md:321",
+ "附录/C-功能标志速查表.md:325",
+ "en/Appendices/C-Feature-Flag-Reference.md:3",
+ "en/Appendices/C-Feature-Flag-Reference.md:9",
+ "en/Appendices/C-Feature-Flag-Reference.md:321",
+ "en/Appendices/C-Feature-Flag-Reference.md:325"
+ ]
+ },
+ "apxd": {
+ "question": {
+ "zh": "读到一半对某个名词失焦,或要确认本书中英译名是否统一、该词在哪章首次展开?",
+ "en": "Halfway through a chapter a term slips out of focus, or you need the book's canonical English-Chinese rendering and where it is first discussed?"
+ },
+ "deliverable": {
+ "zh": "约百条术语的英文原名、推荐中译、简明定义、相关术语与首现章节,另附核心概念关系图。",
+ "en": "For roughly a hundred core terms: original English name, recommended Chinese translation, concise definition, related terms, and chapter of first appearance."
+ },
+ "boundary": {
+ "zh": "只给定义与交叉引用,讲解在正文各章;译名为本书约定,词条随版本增删,不保证恒定。",
+ "en": "Defines, never teaches — the chapters do that. Translations are this book's convention, and entries keep changing as releases add features."
+ },
+ "sources": [
+ "docs-glossary",
+ "docs-how-it-works",
+ "blog-building-effective-agents"
+ ],
+ "plain": {
+ "zh": "用法:读到生词按字母查——一条给出英文原名、中文译名、短定义、相关词,和正文首次讨论的章节",
+ "en": "how to use: hit an unfamiliar term, sort by letter for name, translation, short definition and the chapter"
+ },
+ "analogy": {
+ "zh": "像夹在书末的中英对照小词典:先给译名和一句话解释,再告诉你哪一章细说",
+ "en": "the bilingual dictionary bound in at the back: the translation and a one-line gloss first, then which chapter goes on"
+ },
+ "plain_evidence": [
+ "附录/D-术语表.md:3",
+ "附录/D-术语表.md:5",
+ "附录/D-术语表.md:10",
+ "en/Appendices/D-Glossary.md:3",
+ "en/Appendices/D-Glossary.md:5",
+ "en/Appendices/D-Glossary.md:10"
+ ]
+ }
+ }
+}
diff --git a/yuyu-book/reading-paths.json b/yuyu-book/reading-paths.json
new file mode 100644
index 00000000..6d4945bc
--- /dev/null
+++ b/yuyu-book/reading-paths.json
@@ -0,0 +1,134 @@
+{
+ "ch01": [
+ "start",
+ "start",
+ "core",
+ "core",
+ "deep",
+ "core"
+ ],
+ "ch02": [
+ "start",
+ "core",
+ "deep",
+ "core",
+ "deep",
+ "core"
+ ],
+ "ch03": [
+ "start",
+ "core",
+ "deep",
+ "core",
+ "deep",
+ "core"
+ ],
+ "ch04": [
+ "start",
+ "core",
+ "core",
+ "deep",
+ "core",
+ "deep",
+ "core"
+ ],
+ "ch05": [
+ "start",
+ "core",
+ "core",
+ "deep",
+ "deep",
+ "core"
+ ],
+ "ch06": [
+ "start",
+ "core",
+ "core",
+ "deep",
+ "deep",
+ "core"
+ ],
+ "ch07": [
+ "start",
+ "core",
+ "core",
+ "deep",
+ "core",
+ "deep",
+ "core"
+ ],
+ "ch08": [
+ "start",
+ "core",
+ "core",
+ "deep",
+ "core",
+ "deep",
+ "core"
+ ],
+ "ch09": [
+ "start",
+ "core",
+ "core",
+ "core",
+ "deep",
+ "deep",
+ "deep",
+ "core"
+ ],
+ "ch10": [
+ "start",
+ "deep",
+ "core",
+ "core",
+ "core",
+ "deep",
+ "core",
+ "deep",
+ "core"
+ ],
+ "ch11": [
+ "start",
+ "core",
+ "core",
+ "deep",
+ "core",
+ "deep",
+ "core"
+ ],
+ "ch12": [
+ "start",
+ "core",
+ "core",
+ "deep",
+ "deep",
+ "core"
+ ],
+ "ch13": [
+ "start",
+ "core",
+ "deep",
+ "core",
+ "deep",
+ "deep",
+ "core"
+ ],
+ "ch14": [
+ "start",
+ "core",
+ "core",
+ "core",
+ "deep",
+ "deep",
+ "core"
+ ],
+ "ch15": [
+ "start",
+ "deep",
+ "core",
+ "deep",
+ "core",
+ "deep",
+ "core"
+ ]
+}
\ No newline at end of file
diff --git a/yuyu-book/requirements.txt b/yuyu-book/requirements.txt
new file mode 100644
index 00000000..4a88bf93
--- /dev/null
+++ b/yuyu-book/requirements.txt
@@ -0,0 +1,6 @@
+Markdown>=3.7,<4
+Pygments>=2.18,<3
+playwright>=1.45,<2
+PyMuPDF>=1.24,<2
+fonttools>=4.50,<5 # subsetting the CJK running-head face
+Pillow>=10.4,<13
diff --git a/yuyu-book/revisions.json b/yuyu-book/revisions.json
new file mode 100644
index 00000000..bfeacf22
--- /dev/null
+++ b/yuyu-book/revisions.json
@@ -0,0 +1,94 @@
+{
+ "entries": [
+ {
+ "version": "0.3.1",
+ "date": "2026-10-26",
+ "zh": [
+ "扉页改为自绘:一辆四马单辕、圆盖三十辐的古车白描矢量图,取代原先从仓库封面位图裁切重染的做法。构建器从此不派生也不写入任何位图,纸面同时撤掉那层会把图版光栅化的滤镜;中文版整书 HTML 由 5.6 MB 降到 5.2 MB,PDF 由 16.1 MB 降到 15.7 MB。",
+ "章首页的罗盘回到三卡一行:A4 版心只有 665px,永远低于窄屏断点,于是三张卡在纸上竖成一列、章首半页空着。现在纸上与屏幕同为三列,罗盘连同新词面板一页收齐。",
+ "标题不再自我重复:前言的眉题与它自己的大标题同字,改为不渲染眉题。",
+ "措辞按单元类型走:罗盘三张卡原先一律写「本章」,而前言讲的是全书、附录不是章;新词面板同样分「开篇新词 / 本章新词 / 本附录新词」三种称呼。",
+ "手绘的线路进了正文:每章章眉下多一条走线——焊点、支线、两处抬弯,由单元 id 定种,20 章没有两条相同,重建时又一根根回到原处;四类提示块的 ✦ ⚠ ↗ ◎ ※ 字体符号换成手写墨记(中文版 74 处);小节之间的 ❖ 改为一对手绘焊盘夹一段耦合线(111 处)。都是 currentColor 的内联矢量,屏幕、暗色与纸面共用一份。",
+ "英文版不再中英夹杂:来源页每一行的读取快照、许可声明与用途说明都是本书自己写的中文,此前整段照登在英文页上。现在 35 条来源的 title / authority / snapshot / license / scope 一律配英文镜像字段,渲染按语言取值;两条社区帖的中文标题也给了英译,原文仍可凭 URL 与帖子号回溯。门禁随之收紧:镜像缺失、或镜像里仍带汉字,构建直接失败。",
+ "标点按语言走:英文版的提示块标签与「Source anchors」后面挂的是全角冒号,整书 142 处;现在冒号随语言切换,英文版归零。新增一条测试,断言英文来源页与罗盘里不出现汉字。",
+ "补上一批从未运行的测试:tests/test_pages.py 的共享基类 Config 没有继承 TestCase,封面、版权页、罗盘、目录与来源页的 22 个用例被 unittest 静默跳过。改成 TestCase 后它们真的在跑了,整套 303 个用例全绿;英文版整书 PDF 为 488 页 / 10.8 MB。",
+ "来源页不再把一条来源切成两半:每条登记是一枚带边框的卡片,跨页断开就成了两个破框,现在整行 break-inside:avoid,版式契约里也补了这条断言。",
+ "分部过渡页多了一条自绘的路:部目标与章表之间横卧一条手绘道路——起点一枚辐轮,每章一枚站圈,站间一块里程石,末站再套一道光环收束这段行程;由部 id 定种,四个部(4/4/4/3 章)画的便不是同一条。章末也补上了车辙:两轮压出的双线一路延伸,末尾散成几段短划,一章原本戛然留白的地方从此有了落笔处。两者同为 currentColor 的内联矢量、不带任何 id,重建时分毫不差;页面测试与版式契约各补了断言。",
+ "纸上两处收口:分部过渡页原先顶着上一章的页眉——PDF 的页眉分区表故意跳过了部页,于是「第一部分」那一页右上印的是「前言」;现在每个书签都开自己的一页眉,这张表也从构建流程里拆成了可测的 `pdf_meta()`。主题索引里 COWORKER_TYPE_TELEMETRY 这类长标识符会把点引线挤成一小截,现在术语可断行、引线有 1.2rem 保底,版式契约各补一条。",
+ "全书多了一张线路图:目录之后自成一页,把二十个单元画成一条路——前言是路头的辐轮,轮下标 00;十五章是四段蛇形路上依次排开的站圈;附录走虚线,是停在路边的岔线,末尾一枚同心圆收车。图形由种子定形,重建分毫不差;图下的列表把同一条路线用文字再走一遍并带链接,屏幕阅读器与搜索都够得着(图本身 aria-hidden)。纸上为守住一页版心隐去每段目标句——分部过渡页本就用大字重述它——并把铜色重染为深铜。侧栏目录也多了一枚带轮的入口。",
+ "修掉一处没人看见的空栏:宽屏规则 nav.rail{display:block} 写在 nav.rail{display:none} 之前,同特异度后写者胜,于是 1360px 以上的屏幕为侧栏预留了 17.5rem,却什么都不画,正文边上一条白。顺序调正,侧栏回来了;版式契约补一条断言,规定「显示」必须写在「隐藏」之后。",
+ "白话浮窗不再被裁掉:浮窗原先只在 44rem(704px)以下退回视口底部的说明条,可一个坐在 46rem 行末的术语,浮窗还要向右再伸出 10.5rem——720px 的屏幕上整整 100px 悬在页外,整份文档因此多出一条横向滚动,读者得往右挪才看得懂自己悬停的东西。断点改为算出来的:版心 + 浮窗 = 67rem。另一处更隐蔽——宽表自带横向滚动,会把画在它里面的浮窗一并裁掉,实测 1360px 以上有 13 张表的浮窗整块落在可见单元格之外(悬停什么也没有),其中一张还凭空多出 78px 的假滚动。表格里的术语从此在任何宽度都用底部说明条。两条各补一条 CSS 契约测试,其中一条断言两种触发共用同一份几何,改一处不会忘了改另一处。",
+ "纸边外那一列没人认领:宽表在 390px 屏幕上向右伸出 493px,而浏览器在被拖动之前不画滚动条,读者看到的是一列被切掉一半——以为那就是最后一列。外壳现在带真正的 tabindex=\"0\",图版框早就有的那根细铜色滚动条也分它一份(原先 CSS 里写的 tab-index:0 根本不是个属性,从来没生效过);代码块同理,最长的一行伸出 1555px,键盘此前完全够不着。实测按 14 次右方向键,能把 471px 的隐藏部分一路走到底,铜色描边落在板内不被裁掉。中英两版按 390 / 720 / 1360px 重扫全书:越界盒子 0,无人认领的横向滚动 0。",
+ "长名字终于有地方可以断了:中文正文是两端对齐的,下一根词如果整根放不下,这一行就会被拉成字距空洞。桌面宽度上量到 56 个各自占掉 30% 以上行宽、且完全无从下刀的 ``/``,最狠的 `getFeatureValue_CACHED_MAY_BE_STALE` 在 390px 上独占 96% 行宽。CSS 修不了——对照实验里 `line-break:normal` 一点没变,`overflow-wrap:anywhere` 只让四个样本中的一个多断了一次,而显式 `` 把它们拆成 7 到 13 段:`overflow-wrap` 既不制造“优先断点”,也不参与 min-content 计算,所以纸面上它连早先附录 B 那张被顶出版心的表都没真正救回来。新加的 `soften_long_names()` 是全书最后一趟(术语匹配与目录都得按原文读,不能被切开),只看 ≥12 字符的连续串,且只在 `_`、`/` 与 camelCase 边界下刀,`
',
+ "", 1)
+ self.assertRejects(html, "compass cards", n_units=2)
+
+ def test_unbalanced_close_tag(self):
+ self.assertRejects(page(extra=""), "unbalanced closing tags")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/yuyu-book/tests/test_build_contracts.py b/yuyu-book/tests/test_build_contracts.py
new file mode 100644
index 00000000..02dc31fc
--- /dev/null
+++ b/yuyu-book/tests/test_build_contracts.py
@@ -0,0 +1,454 @@
+"""Typesetting and delivery contracts: tokens, contrast, print rules, and the
+end-to-end no-browser build."""
+import re
+import subprocess
+import sys
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from yuyu_book.core import BOOK_DIR, REPO_ROOT # noqa: E402
+from yuyu_book.mermaid import BOOK_FAMILIES # noqa: E402
+from yuyu_book.style import CSS # noqa: E402
+
+MIN_RATIO = 4.5 # WCAG 2.1 AA for body-size text
+
+
+def _tokens(block: str) -> dict[str, str]:
+ return {k: v for k, v in re.findall(r"(--[\w-]+):\s*(#[0-9A-Fa-f]{6})", block)}
+
+
+def _luminance(hex_: str) -> float:
+ def chan(i):
+ c = int(hex_.lstrip("#")[i:i + 2], 16) / 255
+ return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
+ r, g, b = (chan(i) for i in (0, 2, 4))
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
+
+
+def _ratio(fg: str, bg: str) -> float:
+ a, b = sorted([_luminance(fg), _luminance(bg)], reverse=True)
+ return (a + 0.05) / (b + 0.05)
+
+
+LIGHT = _tokens(CSS.split("@media (prefers-color-scheme:dark)")[0])
+DARK = _tokens(re.search(r"body:not\(\.force-light\)\{(.*?)\}", CSS, re.S).group(1))
+
+
+def _rule(selector: str) -> str:
+ m = re.search(re.escape(selector) + r"\s*\{([^}]*)\}", CSS)
+ return m.group(1) if m else ""
+
+
+class Palette(unittest.TestCase):
+ def test_the_book_defines_its_own_token_set(self):
+ for key in ("paper", "paper-2", "paper-3", "card", "ink", "ink-2",
+ "ink-3", "line", "line-2", "bronze", "bronze-2", "bronze-3",
+ "cinnabar", "jade", "sky", "code-bg", "code-fg", "serif",
+ "kai", "sans", "mono", "measure"):
+ self.assertIn(f"--{key}", CSS, key)
+
+ def test_dark_mode_overrides_the_surface_and_ink_tokens(self):
+ for key in ("--paper", "--card", "--ink", "--ink-2", "--ink-3",
+ "--bronze", "--cinnabar", "--code-bg"):
+ self.assertIn(key, DARK, f"{key} has no dark override")
+
+ def test_body_text_meets_wcag_aa_on_every_surface(self):
+ inks = ("--ink", "--ink-2", "--ink-3")
+ for mode, tokens in (("light", LIGHT), ("dark", DARK)):
+ for ink in inks:
+ for surface in ("--paper", "--card", "--paper-2", "--paper-3"):
+ fg, bg = tokens.get(ink), tokens.get(surface)
+ self.assertTrue(fg and bg, f"{mode}: missing {ink or surface}")
+ ratio = _ratio(fg, bg)
+ self.assertGreaterEqual(
+ round(ratio, 2), MIN_RATIO,
+ f"{mode}: {ink} on {surface} is {ratio:.2f}")
+
+ def test_accent_colours_clear_aa_on_the_page(self):
+ for mode, tokens in (("light", LIGHT), ("dark", DARK)):
+ for ink in ("--bronze", "--bronze-3", "--cinnabar", "--jade", "--sky"):
+ ratio = _ratio(tokens[ink], tokens["--paper"])
+ self.assertGreaterEqual(round(ratio, 2), MIN_RATIO,
+ f"{mode}: {ink} is {ratio:.2f} on paper")
+
+ def test_diagram_families_keep_their_labels_readable(self):
+ """Re-inked plates are the one place a colour pair is invented in Python
+ rather than CSS, so the AA gate has to reach it too."""
+ for name, (fill, _stroke, text) in BOOK_FAMILIES.items():
+ ratio = _ratio(text, fill)
+ self.assertGreaterEqual(round(ratio, 2), MIN_RATIO,
+ f"diagram family {name} is {ratio:.2f}")
+
+ def test_tertiary_ink_is_not_reused_for_decorative_rules_only(self):
+ """--ink-3 carries figure captions, folios and colophon copy, so it
+ must clear AA on the page background in both modes."""
+ self.assertGreaterEqual(round(_ratio(LIGHT["--ink-3"], LIGHT["--paper"]), 2),
+ MIN_RATIO)
+ self.assertGreaterEqual(round(_ratio(DARK["--ink-3"], DARK["--paper"]), 2),
+ MIN_RATIO)
+
+ def test_gold_is_reserved_for_ornament_not_small_copy(self):
+ self.assertLess(_ratio(LIGHT["--bronze-2"], LIGHT["--paper"]), MIN_RATIO,
+ "bronze-2 is intentionally decorative")
+ for selector in (".toc-n", ".reg-no", ".rk-n", ".fig-no", ".cc-src-t",
+ ".pd-no", ".page-ref", ".fig-cap"):
+ self.assertNotIn("bronze-2", _rule(selector),
+ f"{selector} sets small copy in gold")
+
+
+class Typography(unittest.TestCase):
+ def test_serif_for_prose_sans_for_furniture(self):
+ self.assertIn("font:400 17px/1.8 var(--serif)", CSS)
+ self.assertIn("h1,h2,h3,h4{font-family:var(--sans)", CSS)
+
+ def test_cjk_prose_is_justified_with_strict_line_breaking(self):
+ self.assertIn(".chapter-body p,.chapter-body li{text-align:justify}", CSS)
+ self.assertIn("line-break:strict", CSS)
+ self.assertIn("hyphens:auto", CSS)
+
+ def test_a_standalone_block_keeps_the_alignment_its_container_set(self):
+ # the epigraph is a right-aligned block; the prose rule used to win on
+ # its paragraphs, so a two-line English quote justified a river under a
+ # short last line instead of hanging flush right like the rest of it.
+ self.assertIn(".chapter-body blockquote p{text-align:inherit}", CSS)
+
+ def test_a_source_chip_shows_its_whole_label(self):
+ # chips were capped with an ellipsis, which cut
+ # "Building Effective Agents(Anthropic 工程博客,2024-12-19)" mid-date;
+ # the chip row already wraps, so the label may simply get taller.
+ chip = _rule(".source-chip")
+ self.assertTrue(chip, "no .source-chip rule")
+ self.assertNotIn("text-overflow", chip)
+ self.assertNotIn("white-space:nowrap", chip)
+
+ def test_no_web_font_is_downloaded(self):
+ self.assertNotIn("@font-face", CSS)
+ self.assertNotIn("@import", CSS)
+ for url in re.findall(r"url\(([^)]*)\)", CSS):
+ self.assertNotIn("http", url, url)
+
+ def test_every_component_the_audit_counts_has_a_style(self):
+ for cls in ("chapter", "unit", "appendix", "compass", "compass-card",
+ "callout", "callout-tip", "callout-warning", "callout-info",
+ "callout-concept", "diagram", "diagram-frame", "table-scroll",
+ "codehilite", "page-ref", "xref", "source-chip", "rail",
+ "part-divider", "front-page", "colophon", "toc-group",
+ "fig-hint"):
+ self.assertIn(cls, CSS, cls)
+
+
+class ColumnWidths(unittest.TestCase):
+ """Two widths, one column: sentences stay at the reading measure while
+ plates, tables and code take the sheet. A build that quietly drops either
+ half turns into a wall of 100-character lines or a page of thumbnails."""
+
+ @staticmethod
+ def _var(name: str) -> float:
+ m = re.search(rf"--{name}:(\d+(?:\.\d+)?)rem", CSS)
+ assert m, f"--{name} is not a rem value"
+ return float(m.group(1)) * 16
+
+ @staticmethod
+ def _capped_selectors() -> list[str]:
+ block = re.search(r"^\.chapter-body>p,", CSS, re.M)
+ assert block, "the prose cap rule is missing"
+ head = CSS[block.start(): CSS.index("{", block.start())]
+ return [s.strip() for s in head.split(",")]
+
+ def test_the_measure_is_a_reading_column_and_the_furniture_is_wider(self):
+ measure, furniture = self._var("measure"), self._var("furniture")
+ self.assertGreaterEqual(measure, 40 * 16, "a 40em column is too narrow")
+ self.assertLessEqual(measure, 46 * 16, "sentences run past a comfortable line")
+ self.assertGreater(furniture, measure, "the furniture column is redundant")
+ self.assertLessEqual(furniture, 66 * 16, "code lines would need scrolling")
+
+ def test_a_wide_sheet_gives_the_furniture_its_own_width(self):
+ block = CSS[CSS.index("@media (min-width:1360px)"):]
+ self.assertIn(".sheet{max-width:var(--furniture)}", block)
+
+ def test_prose_blocks_are_capped_and_centred(self):
+ body = CSS[CSS.index(".chapter-body>p,.chapter-body>ul"):]
+ body = body[: body.index("}") + 1]
+ self.assertIn("max-width:var(--measure)", body)
+ # centred, because a column pinned to the left of a wide sheet leaves a
+ # dead gutter the reader reads as a broken page
+ self.assertIn("margin-inline:auto", body)
+ for sel in (".chapter-body>p", ".chapter-body .callout", ".chapter-notes"):
+ self.assertIn(sel, self._capped_selectors(), sel)
+
+ def test_the_chapter_furniture_keeps_the_sheet_width(self):
+ # an inset lead band sitting over full-width compass cards reads as a
+ # misaligned page, not as a hierarchy
+ capped = " ".join(self._capped_selectors())
+ for cls in (".lead-band", ".new-terms", ".compass"):
+ self.assertNotIn(cls, capped, f"{cls} should span the sheet")
+
+ def test_the_wide_blocks_are_left_out_of_the_cap(self):
+ capped = " ".join(self._capped_selectors())
+ for cls in ("figure.diagram", ".table-scroll", ".codehilite"):
+ self.assertNotIn(cls, capped, f"{cls} should use the full sheet width")
+
+ def test_latin_gets_a_shorter_measure_than_cjk(self):
+ """46rem of Chinese is ~43 characters; the same box in 17px Palatino is
+ a 90-character line, which is the classic 'no one can track the return'
+ failure. English overrides the token instead of the font size."""
+ m = re.search(r"body\.lang-en\{--measure:(\d+(?:\.\d+)?)rem", CSS)
+ self.assertTrue(m, "the English column does not set its own measure")
+ en = float(m.group(1)) * 16
+ self.assertLess(en, self._var("measure"), "English is not narrower")
+ # ~8.2px per character at 17px Palatino; 80 characters is already long
+ self.assertLessEqual(round(en / 8.2), 80)
+
+ def test_the_gloss_bubble_is_demoted_before_it_can_hang_off_screen(self):
+ """A centred bubble only fits beside the column if the screen can hold
+ the whole measure plus the bubble on the side the term sits at. The
+ worst case is a term at the very end of the 46rem column, so the float
+ becomes a bottom caption below measure + bubble width -- a 720px screen
+ used to clip 100px of it and make the document scroll sideways."""
+ bubble = re.search(
+ r"\.gloss-pop\{[^}]*max-width:min\((\d+(?:\.\d+)?)rem", CSS)
+ assert bubble, "the gloss bubble has no rem cap"
+ cap = re.search(
+ r"@media \(max-width:([\d.]+)rem\)\s*\{\s*\.gloss-pop\{"
+ r"position:fixed", CSS)
+ assert cap, "the bubble is never demoted to a caption"
+ self.assertGreaterEqual(
+ float(cap.group(1)) * 16,
+ self._var("measure") + float(bubble.group(1)) * 16,
+ "a term at the end of the measure overflows the viewport")
+ # pinned to the viewport rather than to the term: an absolutely placed
+ # caption would be written off the bottom of the page the reader left
+ demo = CSS[cap.start():]
+ demo = demo[: demo.index("}") + 1]
+ self.assertIn("left:50%", demo)
+ self.assertIn("92vw", demo)
+
+ def test_a_bubble_inside_a_scrolling_sheet_is_pinned(self):
+ # a table scrolls sideways and clips everything painted inside it, so a
+ # floating bubble whose term sits near the sheet edge hovers into empty
+ # space; at 1360px 13 of the book's tables hid their bubbles outright
+ sheet = re.search(r"\.table-scroll \.gloss-pop\{([^}]*)\}", CSS)
+ assert sheet, "table bubbles still hang inside the box that clips them"
+ self.assertIn("position:fixed", sheet.group(1))
+ cap = re.search(r"@media \(max-width:([\d.]+)rem\)\s*\{\s*\.gloss-pop\{"
+ r"([^}]*)\}", CSS)
+ assert cap, "the narrow-screen caption rule moved"
+ # one caption, two triggers: if the two drift apart the bubble jumps as
+ # the reader resizes the window
+ self.assertEqual(" ".join(cap.group(2).split()),
+ " ".join(sheet.group(1).split()))
+
+ def test_the_cap_stands_down_on_paper(self):
+ # @page margins are 17mm each side of 210mm; rem is the 16px root, so a
+ # printed measure wider than the page box means paper is unaffected
+ box_px = (210 - 2 * 17) / 25.4 * 96
+ m = re.search(r"@page\{size:A4;margin:21mm (\d+)mm", CSS)
+ self.assertTrue(m, "@page margins moved out from under this test")
+ print_block = CSS[CSS.index("@media print{"):]
+ pm = re.search(r"body,body\.lang-en\{--measure:(\d+(?:\.\d+)?)rem",
+ print_block)
+ self.assertTrue(pm, "paper does not release the reading-column cap")
+ self.assertGreaterEqual(float(pm.group(1)) * 16, box_px)
+ for name in ("--measure", "--furniture"):
+ self.assertLess(self._var(name[2:]), box_px * 1.6,
+ f"{name} is wider than a page for no reason")
+
+
+class PrintContract(unittest.TestCase):
+ def test_print_block_exists_with_a4_geometry(self):
+ self.assertIn("@media print{", CSS)
+ self.assertIn("@page{size:A4", CSS)
+
+ def test_units_and_parts_start_new_pages(self):
+ self.assertIn("article.chapter,article.unit{", CSS)
+ self.assertIn("break-before:page", CSS)
+ self.assertIn("section.front-page{break-before:page}", CSS)
+ self.assertIn(".part-divider", CSS)
+
+ def test_orphan_and_widow_control(self):
+ self.assertIn("p{orphans:2;widows:2}", CSS)
+ self.assertIn("h1,h2,h3{break-after:avoid}", CSS)
+ self.assertIn(".toc-group{break-inside:avoid}", CSS)
+ self.assertIn("figure.diagram", CSS)
+ # a registry row is a bordered card, so a page break may not cut it
+ self.assertRegex(CSS, r"\.reg-list li\{[^}]*break-inside:avoid")
+
+ def test_rail_disappears_on_paper_and_narrow_screens_use_the_column(self):
+ self.assertIn("nav.rail,.no-print{display:none!important}", CSS)
+ self.assertRegex(CSS, r"@media \(min-width:1360px\)")
+
+ def test_the_wide_screen_rail_rule_outranks_its_own_default(self):
+ """`nav.rail{display:none}` and the media query's `nav.rail{display:block}`
+ are equally specific, so whichever is written last wins. With the query
+ first, every screen 1360px and up reserved a 17.5rem gutter and left it
+ empty — a rail the reader never sees."""
+ hidden = CSS.index("nav.rail{display:none")
+ shown = CSS.index("nav.rail{display:block}")
+ self.assertLess(hidden, shown)
+
+ def test_an_index_row_never_loses_its_leader(self):
+ """A long identifier used to squeeze the dotted leader down to a stub.
+ The term breaks anywhere rather than eat the dots, so every entry still
+ reads as term — leader — page."""
+ self.assertRegex(CSS, r"\.idx-term\{[^}]*overflow-wrap:anywhere")
+ self.assertRegex(CSS, r"\.idx-row \.leader\{[^}]*min-width")
+
+
+class RunningHeads(unittest.TestCase):
+ def test_every_bookmarked_section_opens_its_own_head(self):
+ """The PDF pass reads its section map out of the bookmarks. Part ids
+ were once filtered out, which left each divider page wearing the head
+ of the chapter that ended before it."""
+ import build
+ from yuyu_book.core import load_configs
+ manifest, _ = load_configs()
+ outline = [(1, p["no"]["zh"] + " · " + p["title"]["zh"], p["id"])
+ for p in manifest["parts"]]
+ outline.append((2, "第 1 章", "ch01"))
+ meta = build.pdf_meta(manifest, outline, [], "zh")
+ self.assertEqual([a for a, _label in meta["heads"]],
+ [a for _lvl, _t, a in outline])
+ self.assertEqual(meta["lang"], "zh")
+ self.assertTrue(meta["title"])
+
+ def test_the_screen_floor_for_wide_plates_is_dropped_on_paper(self):
+ # render.py pins a min-width onto plates too wide to read at column
+ # width; on A4 that must give back the whole page width or the
+ # overflow-x frame would clip the drawing.
+ print_block = CSS[CSS.index("@media print{"):]
+ self.assertIn("min-width:0!important", print_block)
+ self.assertIn(".fig-hint{display:none}", print_block)
+ # ...and the cap is a per-plate variable, so a tall plate can stand up
+ self.assertIn("max-height:var(--plate-cap,150mm)", print_block)
+
+ def test_a_wide_plate_gets_a_landscape_sheet_of_its_own(self):
+ print_block = CSS[CSS.index("@media print{"):]
+ self.assertIn("@page plate-landscape{size:A4 landscape", print_block)
+ rule = re.search(r"figure\.landscape\{[^}]*\}", print_block).group(0)
+ self.assertIn("break-before:page", rule)
+ # the named page already ends the sheet; asking for a break afterwards
+ # left one empty landscape page behind every fold-out
+ self.assertNotIn("break-after", rule)
+
+ def test_a_capped_plate_is_printed_without_its_screen_frame(self):
+ # the height cap makes a plate narrower than the column, so a bordered
+ # frame would print as a wide empty box around a small drawing. The box
+ # stands down on paper; it may NOT shrink-to-fit instead, because every
+ # plate root is width="100%" and a fit-content frame collapses it.
+ print_block = CSS[CSS.index("@media print{"):]
+ self.assertIn(".diagram-frame{background:none;border:0", print_block)
+ self.assertNotIn("fit-content", print_block)
+ self.assertIn(".diagram-frame::before,.diagram-frame::after{display:none}",
+ print_block)
+
+ def test_part_and_unit_openers_own_their_whole_sheet(self):
+ # a part plate taller than the text column drops its last chapter onto
+ # the next sheet as a page of one line.
+ print_block = CSS[CSS.index("@media print{"):]
+ self.assertIn(".part-divider{padding:26mm 0 24mm}", print_block)
+ self.assertIn(".tier-legend{break-inside:avoid", print_block)
+ self.assertIn(".tl-list{display:grid;grid-template-columns:repeat(2",
+ print_block)
+ self.assertIn(".route-parts li{padding:.3rem 0}", print_block)
+
+ def test_wide_tables_wrap_on_paper_instead_of_losing_a_column(self):
+ # .table-scroll is a screen device; on paper its overflow-x would
+ # silently cut the last column of the appendix flag/glossary tables.
+ print_block = CSS[CSS.index("@media print{"):]
+ self.assertIn("th{white-space:normal;word-break:keep-all;overflow-wrap:break-word}",
+ print_block)
+ self.assertIn("table td{overflow-wrap:break-word}", print_block)
+ # `anywhere` feeds min-content width, which is exactly what let the
+ # layout squeeze a column below the width of one word.
+ self.assertNotIn("overflow-wrap:anywhere", print_block)
+ self.assertRegex(CSS, r"@media \(max-width:860px\)\{\.compass")
+
+ def test_a_cut_off_table_advertises_the_rest_on_screen(self):
+ # a phone paints no scrollbar until the reader drags it, so an appendix
+ # table running 493px past a 390px screen read as a complete table with
+ # one narrow last column. Naming the bar keeps it in layout.
+ at = CSS.index(".table-scroll{overflow-x:auto")
+ base = CSS[at: CSS.index("}", at)]
+ self.assertIn("scrollbar-width:thin", base)
+ self.assertIn("scrollbar-color:", base)
+ self.assertRegex(CSS, r"\.table-scroll::-webkit-scrollbar\{height:\d+px\}")
+ # `tab-index` is not a CSS property at all; the focus ring under it only
+ # works because render.py writes a real tabindex on the wrapper
+ self.assertNotIn("tab-index", CSS)
+ # code plates get the same stop, and their ring has to sit inside the
+ # frame because the frame is what clips it
+ self.assertRegex(CSS, r"\.codehilite pre:focus-visible\{outline:2px solid "
+ r"var\(--bronze-2\);outline-offset:-2px")
+
+ def test_the_plate_hint_wraps_instead_of_leaving_the_page_on_phones(self):
+ # the hint chip is nowrap and the caption is a centered flex row, so on
+ # a 380px screen the row overflows sideways unless it may wrap.
+ self.assertIn("@media (max-width:44rem){figcaption{flex-wrap:wrap", CSS)
+
+ def test_the_printed_cover_frame_is_the_page_not_a_card_at_the_top(self):
+ print_block = CSS[CSS.index("@media print{"):]
+ self.assertIn(".front-page.cover{min-height:250mm", print_block)
+ self.assertIn(".cover-inner{margin:0;width:100%", print_block)
+
+ def test_the_printed_subject_index_keeps_two_columns(self):
+ print_block = CSS[CSS.index("@media print{"):]
+ self.assertIn(".idx-cols{columns:2", print_block)
+
+ def test_a_unit_closes_on_the_page_it_ends_on(self):
+ print_block = CSS[CSS.index("@media print{"):]
+ # break-before:avoid is what the ruts used to carry, and Chromium does
+ # not implement it -- the keep has to sit on the wrapper instead
+ self.assertIn(".ch-close{break-inside:avoid}", print_block)
+ self.assertRegex(print_block, r"\.ch-rut\{[^}]*margin-top:[0-9.]+rem")
+ self.assertIn(".chapter-body .ch-close",
+ CSS[:CSS.index("@media print{")])
+
+ def test_no_unit_padding_spills_onto_a_sheet_of_its_own(self):
+ print_block = CSS[CSS.index("@media print{"):]
+ # 2.2rem of screen padding under a line that ends 15pt from the box edge
+ # buys an empty page: Chromium keeps the sheet to paint the padding, and
+ # the next unit's forced break makes it permanent.
+ self.assertIn("article.chapter,article.unit{padding-bottom:0}",
+ print_block)
+
+ def test_pdf_bookmarks_are_driven_by_css_not_injected_markers(self):
+ self.assertIn("bookmark-level:1", CSS)
+ self.assertIn("bookmark-label:content()", CSS)
+ self.assertIn("bookmark-level:none", CSS)
+
+
+class Deliverables(unittest.TestCase):
+ def test_requirements_pin_the_tools_the_build_needs(self):
+ text = (BOOK_DIR / "requirements.txt").read_text(encoding="utf-8")
+ for pkg in ("Markdown", "Pygments", "playwright", "PyMuPDF",
+ "Pillow", "fonttools"):
+ self.assertIn(pkg, text, pkg)
+
+ def test_package_json_pins_the_mermaid_version_used(self):
+ pkg = (REPO_ROOT / "package.json").read_text(encoding="utf-8")
+ self.assertRegex(pkg, r'"mermaid"\s*:\s*"[^"]+"')
+
+ def test_repository_docs_exist(self):
+ for name in ("CREDITS.md", "STRUCTURE.md", "CONTRIBUTING.md", "LICENSE"):
+ self.assertTrue((REPO_ROOT / name).exists(), name)
+ self.assertGreater((REPO_ROOT / name).stat().st_size, 200, name)
+
+
+class EndToEndNoBrowser(unittest.TestCase):
+ """The whole pipeline, with diagrams degraded to source listings: this is
+ the assertion that a machine without Edge still produces a legal book."""
+
+ def test_check_mode_passes_for_both_languages(self):
+ proc = subprocess.run(
+ [sys.executable, str(BOOK_DIR / "build.py"), "--check",
+ "--mermaid=source"],
+ cwd=str(REPO_ROOT), capture_output=True, text=True, encoding="utf-8")
+ self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
+ self.assertIn("CHECK OK", proc.stdout)
+ self.assertIn("20 units", proc.stdout)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/yuyu-book/tests/test_crossrefs.py b/yuyu-book/tests/test_crossrefs.py
new file mode 100644
index 00000000..be640a13
--- /dev/null
+++ b/yuyu-book/tests/test_crossrefs.py
@@ -0,0 +1,117 @@
+"""Cross-reference autolinking: 第N章 / Chapter N become clickable."""
+import re
+import sys
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from yuyu_book import render # noqa: E402
+from yuyu_book.core import (REPO_ROOT, expected_ids, load_configs, load_unit, # noqa: E402
+ unit_stream)
+
+MAP = {i: i for i in expected_ids(load_configs()[0])}
+
+
+def link(text, current="ch01", lang="zh"):
+ return render.RefLinker(MAP, current, lang).transform(text)
+
+
+class ChineseReferences(unittest.TestCase):
+ def test_plain_mention_becomes_a_link(self):
+ out = link("
这一机制在第 3 章展开。
")
+ self.assertIn('第 3 章', out)
+
+ def test_space_variants_all_match(self):
+ for text in ("第3章", "第 3 章", "第 3 章"):
+ self.assertIn('href="#ch03"', link(f"
", lang="en"))
+
+ def test_plurals_and_prose_without_a_number_stay_plain(self):
+ for text in ("Chapters 3 and 4", "the chapter explains"):
+ self.assertNotIn("xref", link(f"
", lang="en"))
+
+
+class WholeBookCrossReferences(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ manifest, _sources = load_configs()
+ cls.html = {}
+ for lang in ("zh", "en"):
+ # render every unit the way the book does, so the assertion is
+ # about real prose rather than a fixture
+ parts = []
+ for unit_def, kind, _part in unit_stream(manifest):
+ u = load_unit(unit_def, kind, lang, REPO_ROOT)
+ diagrams = {b.uid: ("图", "") for b in u.blocks}
+ parts.append(render.render_unit(u, MAP, diagrams=diagrams)["html"])
+ cls.html[lang] = "\n".join(parts)
+ cls.manifest = manifest
+
+ def test_every_generated_href_resolves_to_a_real_unit(self):
+ for lang, doc in self.html.items():
+ for href in re.findall(r'") for b in u.blocks}
+ off = render.render_unit(u, MAP, diagrams=diagrams, autolink=False)["html"]
+ on = render.render_unit(u, MAP, diagrams=diagrams)["html"]
+ self.assertLess(off.count('class="xref"'), on.count('class="xref"'))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/yuyu-book/tests/test_manifest.py b/yuyu-book/tests/test_manifest.py
new file mode 100644
index 00000000..779ce6f3
--- /dev/null
+++ b/yuyu-book/tests/test_manifest.py
@@ -0,0 +1,274 @@
+"""The shipped config must describe the book that is actually on disk.
+
+Everything structural (book order, anchors, compass text, source anchors) is
+derived from manifest.json; this module locks the manifest to the repository:
+paths exist, nothing on disk is unregistered, ids stay in part order, and
+validate_configs accepts the real files (and rejects the classic mistakes).
+"""
+import copy
+import re
+import sys
+import unittest
+from pathlib import Path
+
+if not __package__: # direct run: see tests/__init__.py for the real bootstrap
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from yuyu_book.core import (GUIDE_FIELDS, REPO_ROOT, BuildError, expected_ids,
+ load_configs, load_unit, unit_stream,
+ validate_configs)
+
+ZH_DIR_RX = re.compile(r"^(第[一二三四五六七八九十]+部分-.+|附录)$")
+EN_DIR_RX = re.compile(r"^(Part-\d-.+|Appendices)$")
+
+
+def disk_markdown(lang: str) -> set[str]:
+ """Every book markdown file on disk for one language.
+
+ The builder has no discovery of its own (the manifest is the registry), so
+ this walks the part/appendix directories — plus the foreword — and returns
+ repo-relative posix paths. A new part directory is picked up here, which is
+ exactly how an unregistered chapter would slip into the book.
+ """
+ if lang == "zh":
+ dirs = [p for p in REPO_ROOT.iterdir()
+ if p.is_dir() and ZH_DIR_RX.match(p.name)]
+ roots = [(p, p) for p in dirs]
+ foreword = [p for p in REPO_ROOT.glob("00-*.md")]
+ else:
+ en = REPO_ROOT / "en"
+ dirs = [p for p in en.iterdir() if p.is_dir() and EN_DIR_RX.match(p.name)]
+ roots = [(en, p) for p in dirs]
+ foreword = list(en.glob("00-*.md"))
+ found = {p.relative_to(REPO_ROOT).as_posix() for _r, d in roots
+ for p in d.glob("*.md")}
+ found |= {p.relative_to(REPO_ROOT).as_posix() for p in foreword}
+ return found
+
+
+class ShippedConfigIsSelfConsistent(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ # load_configs() runs validate_configs() and raises on any drift, so
+ # reaching the tests at all is the "real files are accepted" proof.
+ cls.manifest, cls.sources = load_configs()
+
+ def test_validate_configs_accepts_the_shipped_files(self):
+ validate_configs(self.manifest, self.sources)
+ self.assertEqual(self.manifest["front"]["id"], "front")
+ self.assertGreaterEqual(len(self.sources), 30)
+
+ def test_every_manifest_path_exists_on_disk_in_both_languages(self):
+ for unit_def, _kind, _part in unit_stream(self.manifest):
+ for lang in ("zh", "en"):
+ rel = unit_def[lang]
+ path = REPO_ROOT / rel
+ self.assertTrue(path.is_file(), f"{unit_def['id']}.{lang}: {rel}")
+ self.assertTrue(path.read_text(encoding="utf-8").lstrip(),
+ f"{unit_def['id']}.{lang}: empty file {rel}")
+
+ def test_no_markdown_on_disk_is_unregistered(self):
+ registered = {u[lang] for lang in ("zh", "en")
+ for u, _k, _p in unit_stream(self.manifest)}
+ for lang in ("zh", "en"):
+ on_disk = disk_markdown(lang)
+ self.assertTrue(on_disk, f"no {lang} book markdown found")
+ self.assertEqual(sorted(on_disk), sorted(registered & on_disk),
+ f"unregistered {lang} markdown")
+
+ def test_expected_ids_follow_part_order(self):
+ m = self.manifest
+ manual = ([m["front"]["id"]]
+ + [u["id"] for p in m["parts"] for u in p["units"]]
+ + [a["id"] for a in m["appendices"]])
+ self.assertEqual(expected_ids(m), manual)
+
+ def test_book_order_is_foreword_then_15_chapters_then_four_appendices(self):
+ ids = expected_ids(self.manifest)
+ self.assertEqual(len(ids), 20)
+ self.assertEqual(ids[0], "front")
+ self.assertEqual(ids[1:16], [f"ch{n:02d}" for n in range(1, 16)])
+ self.assertEqual(ids[16:], ["apxa", "apxb", "apxc", "apxd"])
+
+ def test_unit_stream_matches_expected_ids_and_kinds(self):
+ stream = [(u["id"], k) for u, k, _p in unit_stream(self.manifest)]
+ self.assertEqual([i for i, _k in stream], expected_ids(self.manifest))
+ kinds = {i: k for i, k in stream}
+ self.assertEqual(kinds["front"], "front")
+ self.assertEqual(kinds["ch07"], "chapter")
+ self.assertEqual(kinds["apxd"], "appendix")
+
+ def test_parts_cover_every_chapter_and_no_appendix(self):
+ apx = {a["id"] for a in self.manifest["appendices"]}
+ for part in self.manifest["parts"]:
+ self.assertTrue(part["units"])
+ for unit_def in part["units"]:
+ self.assertNotIn(unit_def["id"], apx)
+ self.assertRegex(unit_def["id"], r"^ch\d{2}$")
+
+ def test_every_unit_has_a_non_empty_compass_in_both_languages(self):
+ guides = self.manifest["guides"]
+ self.assertEqual(sorted(guides), sorted(expected_ids(self.manifest)))
+ for uid in expected_ids(self.manifest):
+ for field in GUIDE_FIELDS:
+ entry = guides[uid].get(field)
+ self.assertIsInstance(entry, dict, f"{uid}.{field}")
+ for lang in ("zh", "en"):
+ self.assertTrue((entry.get(lang) or "").strip(),
+ f"{uid}.{field}.{lang} is empty")
+
+ def test_compass_fields_are_single_sentences_not_paragraphs(self):
+ # the compass card is a fixed-width sidebar; the real data tops out at
+ # 163 chars (apxb.deliverable), so 200 is headroom without prose
+ for uid, guide in self.manifest["guides"].items():
+ for field in GUIDE_FIELDS:
+ for lang in ("zh", "en"):
+ text = guide[field][lang]
+ self.assertNotIn("\n", text, f"{uid}.{field}.{lang}")
+ self.assertLessEqual(len(text), 200, f"{uid}.{field}.{lang}")
+
+ def test_every_guide_source_id_resolves_in_sources_json(self):
+ for uid, guide in self.manifest["guides"].items():
+ self.assertTrue(guide.get("sources"), f"{uid} has no source anchors")
+ for sid in guide["sources"]:
+ self.assertIn(sid, self.sources, f"{uid} -> unknown source {sid}")
+
+ def test_part_titles_mottos_and_goals_exist_in_both_languages(self):
+ for part in self.manifest["parts"]:
+ for field in ("no", "title", "motto", "goal"):
+ for lang in ("zh", "en"):
+ self.assertTrue((part[field][lang] or "").strip(),
+ f"{part['id']}.{field}.{lang} is empty")
+
+ def test_top_level_book_metadata_is_present(self):
+ for key in ("title", "subtitle", "epigraph"):
+ for lang in ("zh", "en"):
+ self.assertTrue(self.manifest[key][lang].strip(), key)
+ self.assertTrue(self.manifest["license"]["url"].startswith("https://"))
+ self.assertRegex(self.manifest["version"], r"^\d+\.\d+\.\d+$")
+ self.assertRegex(self.manifest["date"], r"^\d{4}-\d{2}-\d{2}$")
+
+
+class DerivedUnitLabels(unittest.TestCase):
+ """The ids/labels the TOC, PDF bookmarks and folios are built from."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls.manifest, _ = load_configs()
+ cls.by_id = {u["id"]: u for u, _k, _p in unit_stream(cls.manifest)}
+
+ def unit(self, uid, lang):
+ kind = ("front" if uid == "front"
+ else "appendix" if uid.startswith("apx") else "chapter")
+ return load_unit(self.by_id[uid], kind, lang, REPO_ROOT)
+
+ def test_zh_display_uses_the_ideographic_space(self):
+ u = self.unit("ch02", "zh")
+ self.assertEqual(u.no, "第 2 章")
+ self.assertEqual(u.display, f"第 2 章 {u.title}")
+ self.assertEqual(u.eyebrow, "第 2 章")
+
+ def test_en_display_uses_a_colon(self):
+ u = self.unit("ch02", "en")
+ self.assertEqual(u.no, "Chapter 2")
+ self.assertEqual(u.display, f"Chapter 2: {u.title}")
+
+ def test_no_plain_is_a_two_digit_gutter_label(self):
+ self.assertEqual(self.unit("ch01", "zh").no_plain, "01")
+ self.assertEqual(self.unit("ch15", "zh").no_plain, "15")
+ self.assertEqual(self.unit("ch09", "en").no_plain, "09")
+
+ def test_no_plain_for_appendices_is_the_letter(self):
+ self.assertEqual(self.unit("apxa", "zh").no_plain, "A")
+ self.assertEqual(self.unit("apxd", "en").no_plain, "D")
+
+ def test_foreword_has_no_number_and_a_localised_eyebrow(self):
+ zh = self.unit("front", "zh")
+ en = self.unit("front", "en")
+ self.assertEqual(zh.no, "")
+ self.assertEqual(zh.display, zh.title)
+ self.assertEqual(zh.eyebrow, "前言")
+ self.assertEqual(en.eyebrow, "Foreword")
+
+ def test_anchor_is_the_unit_id_for_every_unit(self):
+ for uid in ("front", "ch07", "apxc"):
+ for lang in ("zh", "en"):
+ self.assertEqual(self.unit(uid, lang).anchor, uid)
+
+ def test_unit_id_is_unique_across_the_book(self):
+ ids = expected_ids(self.manifest)
+ self.assertEqual(len(ids), len(set(ids)))
+
+
+class ConfigValidationRejects(unittest.TestCase):
+ """Negative cases over an in-memory copy of the real config."""
+
+ @classmethod
+ def setUpClass(cls):
+ cls.manifest, cls.sources = load_configs()
+
+ def check(self, manifest, sources=None, message=""):
+ with self.assertRaises(BuildError) as ctx:
+ validate_configs(manifest, sources or self.sources)
+ self.assertIn(message, str(ctx.exception))
+
+ def test_missing_guide_for_a_unit(self):
+ m = copy.deepcopy(self.manifest)
+ del m["guides"]["ch07"]
+ self.check(m, message="guides missing for: ch07")
+
+ def test_guide_for_a_unit_that_does_not_exist(self):
+ m = copy.deepcopy(self.manifest)
+ m["guides"]["ch99"] = copy.deepcopy(m["guides"]["ch07"])
+ self.check(m, message="guides for unknown units: ch99")
+
+ def test_guide_pointing_at_an_unknown_source_id(self):
+ m = copy.deepcopy(self.manifest)
+ m["guides"]["ch07"]["sources"] = ["does-not-exist"]
+ self.check(m, message="unknown source id 'does-not-exist'")
+
+ def test_unit_file_missing_from_disk(self):
+ m = copy.deepcopy(self.manifest)
+ m["parts"][1]["units"][2]["en"] = "en/Part-2-Core-Systems/99-Ghost.md"
+ self.check(m, message="unit ch07: missing en file")
+
+ def test_empty_compass_field_in_one_language(self):
+ m = copy.deepcopy(self.manifest)
+ m["guides"]["ch07"]["deliverable"]["en"] = " "
+ self.check(m, message="guide ch07.deliverable.en is empty")
+
+ def test_duplicate_unit_ids(self):
+ m = copy.deepcopy(self.manifest)
+ m["parts"][0]["units"].append(copy.deepcopy(m["appendices"][0]))
+ self.check(m, message="duplicate unit ids")
+
+ def test_source_missing_a_required_field(self):
+ s = copy.deepcopy(self.sources)
+ del s["cc-changelog"]["license"]
+ self.check(self.manifest, sources=s,
+ message="source cc-changelog: field license missing/empty")
+
+ def test_source_url_that_is_not_https(self):
+ s = copy.deepcopy(self.sources)
+ s["cc-changelog"]["url"] = "http://github.com/anthropics/claude-code"
+ self.check(self.manifest, sources=s, message="source cc-changelog: url must be https")
+
+ def test_github_source_without_a_pin(self):
+ s = copy.deepcopy(self.sources)
+ s["gh-book-repo"]["pinned"] = {}
+ self.check(self.manifest, sources=s,
+ message="source gh-book-repo: github refs must pin tag or commit")
+
+ def test_community_source_claiming_normative_status(self):
+ s = copy.deepcopy(self.sources)
+ s["ld-topic-1870593"]["normative"] = True
+ self.check(self.manifest, sources=s,
+ message="source ld-topic-1870593: community refs need")
+
+ def test_the_unmutated_copy_still_passes(self):
+ validate_configs(copy.deepcopy(self.manifest),
+ copy.deepcopy(self.sources))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/yuyu-book/tests/test_mermaid.py b/yuyu-book/tests/test_mermaid.py
new file mode 100644
index 00000000..956ae94a
--- /dev/null
+++ b/yuyu-book/tests/test_mermaid.py
@@ -0,0 +1,472 @@
+"""The fence-aware scanner behind all 314 diagram plates (157 per language).
+
+Markdown is the read-only source of truth, so the only thing that may cut a
+document into structure is a real (unfenced) line. extract_mermaid also has to
+leave the document balanced: a closing fence re-emitted after the placeholder
+unmatched every following fence and swallowed whole chapters into a
, so
+"no fence residue, one placeholder per block, placeholder alone on its line"
+are pinned here together with the caption/cache/fallback contracts.
+"""
+import re
+import sys
+import unittest
+from pathlib import Path
+
+if not __package__: # direct run: see tests/__init__.py for the real bootstrap
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+from yuyu_book import mermaid as mm
+from yuyu_book import render
+from yuyu_book.core import (REPO_ROOT, BuildError, Unit, expected_ids,
+ extract_mermaid, load_configs, load_unit,
+ normalize_h1, read_text, unit_stream)
+
+PLACEHOLDER = re.compile(r"@@MM:[0-9a-f]{12}@@")
+FENCE_LINE = re.compile(r"^\s*`{3,}")
+
+CH11 = "第三部分-高级模式篇/11-技能系统与插件架构.md"
+APXB = "附录/B-工具完整清单.md"
+
+
+def pipeline(text, lang="zh", file="t.md"):
+ """What load_unit does to one file, exposed for inline fixtures."""
+ _no, _title, body = normalize_h1(text, lang, file)
+ return extract_mermaid(body, file)
+
+
+class FenceAwareExtraction(unittest.TestCase):
+ def test_hash_inside_a_fence_is_never_document_structure(self):
+ text = ("# 第 1 章 标题\n\n正文。\n\n"
+ "```bash\n# 这是注释\n# 也是注释\nls\n```\n\n尾部。\n")
+ no, title, _body = normalize_h1(text, "zh", "t.md")
+ self.assertEqual((no, title), ("第 1 章", "标题"))
+ body, blocks, langs = pipeline(text)
+ self.assertEqual(blocks, [])
+ self.assertEqual(langs, ["bash"])
+ self.assertIn("# 这是注释", body)
+ self.assertIsNone(PLACEHOLDER.search(body))
+
+ def test_a_fence_never_opens_a_heading_for_the_scanner(self):
+ # strip the real H1: the four `# ` lines in ch11 are all inside fenced
+ # samples, so a fence-aware scanner must find NO heading at all
+ text = read_text(REPO_ROOT / CH11)
+ body = "\n".join(text.split("\n")[1:])
+ with self.assertRaises(BuildError) as ctx:
+ normalize_h1(body, "zh", CH11)
+ self.assertIn("no top-level H1", str(ctx.exception))
+
+ def test_chapter_11_regression_keeps_its_four_fenced_comments(self):
+ unit_def, kind = next((u, k) for u, k, _p in unit_stream(load_configs()[0])
+ if u["id"] == "ch11")
+ u = load_unit(unit_def, kind, "zh", REPO_ROOT)
+ self.assertEqual(u.no, "第 11 章")
+ self.assertEqual(u.title, "技能系统与插件架构")
+ self.assertEqual(len(u.blocks), 7)
+ for comment in ("# 技能正文", "# API 端点生成器", "# SKILL.md", "# 技能内容"):
+ self.assertIn(f"{comment}\n", u.md, comment)
+
+ def test_longer_outer_fence_contains_a_shorter_inner_fence(self):
+ text = ("# 第 1 章 标题\n\n示例:\n\n"
+ "````markdown\n```mermaid\ngraph LR\nA-->B\n```\n````\n\n"
+ "真图:\n\n```mermaid\nA-->B\n```\n")
+ body, blocks, langs = pipeline(text)
+ self.assertEqual(langs[0], "markdown") # the example is code
+ self.assertEqual(len(blocks), 1) # only the real diagram
+ self.assertEqual(body.count("```mermaid"), 1) # the example stays literal
+ self.assertIn("graph LR", body)
+
+ def test_tilde_fence_and_backtick_fence_do_not_cross_close(self):
+ text = ("# 第 1 章 标题\n\n~~~\n```not-a-fence\n~~~\n\n"
+ "```mermaid\nA-->B\n```\n")
+ body, blocks, langs = pipeline(text)
+ self.assertEqual(len(blocks), 1)
+ self.assertEqual(langs, [""])
+ self.assertIn("```not-a-fence", body)
+
+ def test_unclosed_fence_raises_and_names_the_file(self):
+ with self.assertRaises(BuildError) as ctx:
+ pipeline("# 第 1 章 标题\n\n```mermaid\nA-->B\n")
+ self.assertIn("t.md: unclosed code fence at line", str(ctx.exception))
+
+ def test_unclosed_plain_fence_names_the_line_it_opened_on(self):
+ with self.assertRaises(BuildError) as ctx:
+ pipeline("# 第 1 章 标题\n\n```python\nx = 1\n")
+ self.assertIn("unclosed code fence at line 3", str(ctx.exception))
+
+ def test_block_line_numbers_are_lines_of_the_markdown_file(self):
+ _body, blocks, _langs = pipeline("# 第 1 章 标题\n\n```mermaid\nA-->B\n```\n")
+ self.assertEqual(blocks[0].line, 3)
+
+ def test_two_blocks_get_distinct_stable_uids(self):
+ text = ("# 第 1 章 标题\n\n```mermaid\nA-->B\n```\n\n"
+ "```mermaid\nA-->B\n```\n")
+ _body, first, _l = pipeline(text)
+ body2, second, _l = pipeline(text)
+ self.assertEqual([b.uid for b in first], [b.uid for b in second])
+ self.assertEqual(len({b.uid for b in first}), 2) # same src, two places
+ self.assertEqual(len({b.src for b in first}), 1)
+ self.assertEqual(first[0].file, "t.md")
+ self.assertEqual([b.line for b in first], [3, 7])
+
+ def test_uid_moves_with_the_diagram_but_the_svg_cache_does_not(self):
+ same_src = mm.MermaidBlock("x", "graph LR\nA-->B", "ch01.md", 10)
+ moved = mm.MermaidBlock("y", "graph LR\nA-->B", "ch02.md", 99)
+ self.assertNotEqual(same_src.uid, moved.uid)
+ self.assertEqual(mm._cache_key(same_src.src, "11.17.2", "edge"),
+ mm._cache_key(moved.src, "11.17.2", "edge"))
+
+ def test_placeholder_always_stands_alone_on_its_own_line(self):
+ text = "# 第 1 章 标题\n\n段落紧贴图:\n```mermaid\nA-->B\n```\n紧跟的段落。\n"
+ body, blocks, _langs = pipeline(text)
+ uid = blocks[0].uid
+ self.assertIn(f"\n\n@@MM:{uid}@@\n\n", body)
+ for line in body.split("\n"):
+ if "@@MM:" in line:
+ self.assertEqual(line.strip(), f"@@MM:{uid}@@")
+
+ def test_no_closing_fence_is_re_emitted_after_the_placeholder(self):
+ # the catastrophic case: an extra ``` after the placeholder re-opened a
+ # fence and swallowed every following paragraph into a
"))
+ self.assertIn("A -->|x<y & "q"| B", html)
+ self.assertNotIn("B", "ch01.md", i)
+ for i in range(5)]
+ out = mm.source_fallback(blocks, {})
+ self.assertEqual(sorted(out), sorted(b.uid for b in blocks))
+ self.assertEqual(out["000000000000"][0], "") # caption-less is fine
+
+
+class AuthorColoursReInked(unittest.TestCase):
+ """The ~900 classDef/style hexes the manuscript brings to the 314 plates.
+
+ Mermaid's ``theme: base`` only reaches nodes the author left alone, so an
+ untreated Material-blue box sits in the middle of a bronze-and-parchment
+ book and reads as a screenshot from another product. These tests pin the
+ build-time re-inking: role preserved, palette replaced, and nothing outside
+ a directive line touched.
+ """
+
+ def reink(self, decls: str) -> str:
+ return mm.reink_colors(f"graph LR\nclassDef x {decls}").splitlines()[1]
+
+ def test_hue_decides_the_family_not_the_exact_hex(self):
+ cases = {
+ "fill:#eff6ff,stroke:#3b82f6,color:#1e3a8a": mm.BOOK_FAMILIES["info"],
+ "fill:#e8f4f8,stroke:#2196F3,color:#1565C0": mm.BOOK_FAMILIES["info"],
+ "fill:#d4edda,stroke:#27ae60,color:#1b5e20": mm.BOOK_FAMILIES["good"],
+ "fill:#fff3cd,stroke:#f39c12,color:#664d03": mm.BOOK_FAMILIES["warn"],
+ "fill:#f8d7da,stroke:#c0392b,color:#842029": mm.BOOK_FAMILIES["danger"],
+ "fill:#f3e5f5,stroke:#7b1fa2,color:#4a148c": mm.BOOK_FAMILIES["plum"],
+ }
+ for decls, (fill, stroke, text) in cases.items():
+ with self.subTest(decls=decls):
+ out = self.reink(decls)
+ self.assertIn(f"fill:{fill}", out)
+ self.assertIn(f"stroke:{stroke}", out)
+ self.assertIn(f"color:{text}", out)
+
+ def test_the_fill_decides_the_family_when_the_stroke_disagrees(self):
+ """A pale-blue box with a purple border is a blue box; the border is
+ the weaker signal and both halves still have to come from one family."""
+ out = self.reink("fill:#e3f2fd,stroke:#7b1fa2,color:#4a148c")
+ fill, stroke, text = mm.BOOK_FAMILIES["info"]
+ self.assertEqual(out, f"classDef x fill:{fill},stroke:{stroke},"
+ f"color:{text}")
+
+ def test_a_diagram_that_distinguishes_two_groups_still_does(self):
+ block = ("classDef core fill:#4a90d9,stroke:#2c5f8a,color:#fff\n"
+ "classDef tool fill:#8fbc8f,stroke:#5a8a5a,color:#fff")
+ out = mm.reink_colors(block)
+ self.assertIn(mm.BOOK_FAMILIES["info"][0], out)
+ self.assertIn(mm.BOOK_FAMILIES["good"][0], out)
+
+ def test_a_mid_tone_fill_becomes_its_hue_tint_not_a_dark_box(self):
+ """The manuscript's darkest fills are #1abc9c / #27ae60; they read as
+ teal and green here, and the white labels they carried stay legible."""
+ for hexc, family in (("#27ae60", "good"), ("#1abc9c", "good"),
+ ("#0d47a1", "info")):
+ with self.subTest(hexc=hexc):
+ fill, _, text = mm.BOOK_FAMILIES[family]
+ out = self.reink(f"fill:{hexc},stroke:#333,color:#fff")
+ self.assertIn(f"fill:{fill}", out)
+ self.assertIn(f"color:{text}", out)
+
+ def test_properties_without_a_colour_are_left_alone(self):
+ out = self.reink("fill:#2196F3,stroke:#333,stroke-width:2px,"
+ "font-weight:bold")
+ self.assertIn("stroke-width:2px", out)
+ self.assertIn("font-weight:bold", out)
+
+ def test_only_directive_lines_are_rewritten(self):
+ src = ("sequenceDiagram\n"
+ " Keychain-->>Server: hit #1 (32ms) fill:#000\n"
+ ' A["style: keep this fill:#e8f4f8 text"]\n'
+ " style A fill:#2196F3,stroke:#333")
+ out = mm.reink_colors(src)
+ self.assertIn("hit #1 (32ms) fill:#000", out)
+ self.assertIn('A["style: keep this fill:#e8f4f8 text"]', out)
+ self.assertIn(f"style A fill:{mm.BOOK_FAMILIES['info'][0]}", out)
+
+ def test_reinking_is_idempotent(self):
+ src = ("classDef a fill:#27ae60,stroke:#1b5e20,color:#fff\n"
+ "style B fill:#ffcdd2,stroke:#d32f2f")
+ once = mm.reink_colors(src)
+ self.assertEqual(once, mm.reink_colors(once))
+
+ def test_no_shipped_directive_keeps_an_off_book_hex(self):
+ allowed = {c.lower() for fam in mm.BOOK_FAMILIES.values() for c in fam}
+ checked = 0
+ for path in sorted(REPO_ROOT.glob("**/*.md")):
+ if "node_modules" in path.parts:
+ continue
+ for block in re.findall(r"```mermaid\n(.*?)```",
+ read_text(path), re.S):
+ for line in mm.reink_colors(block).splitlines():
+ if not re.match(r"\s*(classDef|style)\s", line):
+ continue
+ for prop, hexc in re.findall(
+ r"\b(fill|stroke|color)\s*:\s*(#[0-9a-fA-F]{3,8})",
+ line):
+ with self.subTest(path=path.name, prop=prop):
+ self.assertIn(hexc.lower(), allowed)
+ checked += 1
+ self.assertGreater(checked, 2000)
+
+
+class FigureMarkup(unittest.TestCase):
+ def test_zh_and_en_caption_words_and_figure_id(self):
+ zh = render.mermaid_figure("ab" * 6, "", "7-1", "循环", "zh")
+ en = render.mermaid_figure("ab" * 6, "", "7-1", "loop", "en")
+ self.assertIn('', zh)
+ self.assertIn('图 7-1', zh)
+ self.assertIn('Figure 7-1', en)
+ self.assertIn('
'
+ for p in manifest["parts"])
+ return (f''
+ f'
{esc(ui("how", lang))}
'
+ f'
{cards}
'
+ f'
{parts}
{tier_legend(paths, lang)}')
+
+
+def route_map_page(manifest: dict, lang: str, nos: dict[str, str]) -> str:
+ """The whole book as one road, for the reader who wants to know how far it
+ goes before opening it. The drawing is the ornament; the list under it is
+ the thing itself, because the numbers written inside an `aria-hidden`
+ picture are unreachable to a screen reader and carry no link."""
+ from .style import itinerary, key_mark
+
+ front = manifest["front"]["id"]
+ front_stop = (front, nos.get(front, ""))
+ side = [(a["id"], nos.get(a["id"], "")) for a in manifest["appendices"]]
+ siding = f'{ui("appendix", lang)} · {ui("rm_side", lang)}'
+ legs = [(f'{p["no"][lang]} · {p["title"][lang]}',
+ [(u["id"], nos.get(u["id"], "")) for u in p["units"]])
+ for p in manifest["parts"]]
+ # the foreword is the wheel at the head of the road rather than a ring on
+ # it: it is where the team harnesses up, not a station you travel to
+ rows = [(front, ui("front", lang), [front_stop], ui("rm_why_front", lang))]
+ rows += [(p["id"], head, stops, p["goal"][lang])
+ for p, (head, stops) in zip(manifest["parts"], legs)]
+ rows.append((side[0][0], siding, side, ui("rm_why_apx", lang)))
+
+ def li(anchor: str, head: str, stops: list[tuple[str, str]], why: str) -> str:
+ numbers = " ".join(n for _a, n in stops if n)
+ return (f'
'
+ for kind, label in (("wheel", "rm_key_wheel"),
+ ("ring", "rm_key_ring"),
+ ("siding", "rm_key_siding")))
+ map_svg = itinerary("route",
+ [(esc(head), stops) for head, stops in legs]
+ + [(esc(siding), side)],
+ start_label=esc(nos.get(front, "")))
+ return (f''
+ f'
{esc(ui("route_map", lang))}
'
+ f'
{esc(ui("route_map_lead", lang))}
'
+ f'{map_svg}'
+ f'
{keys}
'
+ f'
{"".join(li(*r) for r in rows)}
')
+
+
+def tier_legend(paths: dict | None, lang: str) -> str:
+ """How many sections sit at each level, so a reader can size the shortcut."""
+ if not paths:
+ return ""
+ from .plain import TIER_DESC, TIER_LABELS, TIER_ORDER
+
+ counts = {t: sum(1 for tiers in paths.values()
+ for x in tiers if x == t) for t in TIER_ORDER}
+ rows = "".join(
+ f'
')
+
+
+def lead_band(guide: dict, lang: str) -> str:
+ """The one plain-language device that reads the same on paper and screen."""
+ plain = (guide.get("plain") or {}).get(lang) or ""
+ if not plain:
+ return ""
+ analogy = (guide.get("analogy") or {}).get(lang) or ""
+ a = (f'
')
+
+
+def new_terms_panel(hits: list[dict], lang: str, kind: str = "") -> str:
+ """Paper has no hover, so the dashed terms a reader meets in this unit are
+ restated here in document order — the marks and this list are the same
+ set, which the build asserts."""
+ if not hits:
+ return ""
+ title = ui("new_terms" if kind in ("", "chapter") else f"new_terms_{kind}",
+ lang)
+ rows = "".join(
+ f'
{esc(h["term"])}'
+ f'{esc(h["plain"])}
'
+ for h in hits)
+ return (f'')
+
+
+def compass(unit, guide: dict, sources: dict, lang: str) -> str:
+ # the labels say 本章, which is true of a chapter only: the foreword opens
+ # the whole book and the appendices are not chapters
+ scope = {"front": "全书", "appendix": "本附录"}.get(getattr(unit, "kind", ""), "")
+
+ def tag(label: str) -> str:
+ text = ui(label, lang)
+ return scope + text[2:] if scope and text.startswith("本章") else text
+
+ cards = "".join(
+ f'
'
+ f'
{esc(tag(label))}
'
+ f'
{esc(guide[key][lang])}
'
+ for key, label in (("question", "compass_q"),
+ ("deliverable", "compass_d"),
+ ("boundary", "compass_b")))
+ chips = ""
+ if guide.get("sources"):
+ names = {sid: src_field(sources.get(sid, {}), "title", lang) or sid
+ for sid in guide["sources"]}
+ chips = ('
'
+ f'{esc(ui("compass_src", lang))}{COLON[lang]}' +
+ "".join(
+ f''
+ f'{esc(names[sid])}'
+ for sid in guide["sources"]) + "
")
+ return (f'
{cards}{chips}
')
+
+
+def src_field(src: dict, key: str, lang: str) -> str:
+ """Registry text in the reader's language. Anything annotated by us carries
+ a `__en` mirror when the base text is Chinese; third-party names are as
+ published, so a Chinese forum thread keeps the title it was posted under."""
+ return str(src.get(f"{key}_{lang}") or src.get(key, ""))
+
+
+def part_divider(part: dict, lang: str, idx: int,
+ rows: list[tuple[str, str, str]]) -> str:
+ from .style import route_map, wheel
+ from . import art
+ items = "".join(
+ f'
'
+ for anchor, no, label in rows)
+ return (f''
+ f'{wheel(360)}'
+ f'
{art.headpiece(part["id"])}'
+ f'
{esc(part["no"][lang])}
'
+ f'
{esc(part["title"][lang])}
'
+ f'
{wheel(24)}
'
+ f'
{esc(part["motto"][lang])}
'
+ f'
{esc(part["goal"][lang])}
'
+ f'{route_map(part["id"], len(rows))}'
+ f'{items}'
+ f'
')
+
+
+_ROMAN = ["", "Ⅰ", "Ⅱ", "Ⅲ", "Ⅳ", "Ⅴ", "Ⅵ", "Ⅶ", "Ⅷ"]
+
+
+def toc_html(entries: list[dict], lang: str) -> str:
+ """entries: {'kind': 'part'|'row'|'hr', 'anchor', 'no', 'title'}.
+ Parts link to their divider page; rows carry a page-ref placeholder
+ that the PDF pass stamps with the printed folio. Each part and its rows
+ form one group, so a heading never strands itself on the previous page."""
+ chunks: list[list[str]] = [[]]
+
+ def close() -> None:
+ if any(c.strip() for c in chunks[-1]):
+ chunks.append([])
+
+ for e in entries:
+ kind = e["kind"]
+ if kind in ("part", "hr"):
+ close()
+ if kind == "part":
+ no = (f'{esc(e["no"])}'
+ if e["no"] else "")
+ chunks[-1].append(
+ f'{no}'
+ f'{esc(e["title"])}'
+ f'')
+ elif kind == "hr":
+ chunks[-1].append('')
+ else:
+ chunks[-1].append(
+ f''
+ f'{esc(e["no"])}'
+ f'{esc(e["title"])}'
+ f''
+ f'')
+ groups = "".join(f'
{"".join(c)}
'
+ for c in chunks if any(x.strip() for x in c))
+ return (f''
+ f'
")
+ parts.append("")
+ return "".join(parts)
+
+
+def subject_index_page(entries: list[dict], lang: str) -> str:
+ letters: dict[str, list] = {}
+ for e in entries:
+ letters.setdefault(e["letter"], []).append(e)
+ parts = [f''
+ f'
{esc(ui("index_page", lang))}
'
+ f'
']
+ for letter in sorted(letters):
+ parts.append(f'
{letter}
'
+ f'
')
+ for e in letters[letter]:
+ # a 23-character feature flag has no space to break at, and the row
+ # would spend its dotted leader to fit it; `` after each
+ # underscore gives the name a place to end instead, and is used only
+ # when the entry genuinely cannot fit on one line
+ term = esc(e[lang]["term"]).replace("_", "_")
+ refs = "".join(
+ f'{esc(r["label"])}'
+ for r in e[lang]["refs"])
+ parts.append(f'
{term}'
+ f''
+ f'{refs}'
+ f'
')
+ parts.append("
")
+ parts.append("
")
+ return "".join(parts)
diff --git a/yuyu-book/yuyu_book/pdf.py b/yuyu-book/yuyu_book/pdf.py
new file mode 100644
index 00000000..fa94d479
--- /dev/null
+++ b/yuyu-book/yuyu_book/pdf.py
@@ -0,0 +1,250 @@
+"""PDF pipeline: headless print of the styled HTML, then a PyMuPDF post-pass.
+
+Chromium turns every in-document link into a named destination, so the
+printed PDF already knows that #ch09 lives on page 81. The post-pass reads
+those destinations instead of injecting marker text: it builds the bookmark
+tree (meta["outline"]), the running heads (meta["heads"]), the footer folios
+and the printed contents numbers (meta["toc_rows"] inside the contents
+pages). Number glyphs use built-in Helvetica; CJK heads need a local system
+font, subsetted before save, and degrade to a plain rule if none is there.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+import fitz # PyMuPDF
+
+from .core import BuildError
+
+CJK_FONTS = ["C:/Windows/Fonts/msyh.ttc", "C:/Windows/Fonts/simsun.ttc",
+ "C:/Windows/Fonts/simhei.ttf",
+ # the running head is drawn with a real face; on a runner without
+ # any of these the Chinese heads/folios would be dropped silently
+ "/System/Library/Fonts/PingFang.ttc",
+ "/System/Library/Fonts/Supplemental/Songti.ttc",
+ "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
+ "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
+ "/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc"]
+MARGIN = 48.0
+INK = (0.42, 0.36, 0.26)
+RULE = (0.82, 0.76, 0.62)
+
+
+def _launch(p, browser: str | None):
+ if browser:
+ try:
+ return p.chromium.launch(executable_path=browser)
+ except Exception:
+ pass
+ for kind in ("msedge", "chrome", None):
+ try:
+ return (p.chromium.launch(channel=kind) if kind
+ else p.chromium.launch())
+ except Exception:
+ continue
+ raise BuildError("no Chromium/Edge available for PDF")
+
+
+def _print_pdf(html_path: Path, raw: Path, browser: str | None) -> None:
+ try:
+ from playwright.sync_api import sync_playwright
+ except ImportError as e:
+ raise BuildError("playwright required for PDF") from e
+ with sync_playwright() as p:
+ launched = _launch(p, browser)
+ page = launched.new_page(viewport={"width": 1280, "height": 900})
+ page.goto(html_path.resolve().as_uri(), wait_until="load")
+ try:
+ page.evaluate("() => document.fonts.ready.then(() => true)")
+ except Exception:
+ pass
+ page.emulate_media(media="print")
+ page.pdf(path=str(raw), format="A4", print_background=True,
+ prefer_css_page_size=True, margin={"top": "0", "bottom": "0",
+ "left": "0", "right": "0"})
+ launched.close()
+
+
+def _destinations(doc, anchors: list[str]) -> dict[str, int]:
+ """Zero-based page per anchor, read from the link annotations Chromium
+ wrote for the in-book cross references (its target page is 0-based)."""
+ want = set(anchors)
+ found: dict[str, int] = {}
+ for pno in range(doc.page_count):
+ if want <= set(found):
+ break
+ for link in doc[pno].get_links():
+ name, page = link.get("nameddest"), link.get("page")
+ if name in want and isinstance(page, int) and 0 <= page < doc.page_count:
+ found.setdefault(name, page)
+ return found
+
+
+def _fit(text: str, width: float, size: float, measure) -> str:
+ if measure(text) <= width:
+ return text
+ trimmed = text
+ while trimmed and measure(trimmed + "…") > width:
+ trimmed = trimmed[:-1]
+ return (trimmed + "…") if trimmed else ""
+
+
+class _Head:
+ """Running-head/folio ink: Helvetica for digits, a local system font for
+ CJK. Widths come from the real face, so nothing is silently clipped."""
+
+ def __init__(self, fontfile: str | None):
+ self.fontfile = fontfile
+ self.face = None
+ if fontfile:
+ try:
+ self.face = fitz.Font(fontfile=fontfile)
+ except Exception:
+ self.fontfile = None
+
+ def width(self, text: str, size: float) -> float:
+ if self.face:
+ return self.face.text_length(text, fontsize=size)
+ return fitz.get_text_length(text, fontname="helv", fontsize=size)
+
+ def draw(self, page, x: float, y: float, text: str, size: float,
+ color: tuple) -> None:
+ if self.face:
+ page.insert_text((x, y), text, fontsize=size, fontname="yuyu-head",
+ fontfile=self.fontfile, color=color)
+ else:
+ page.insert_text((x, y), text.encode("latin-1", "ignore").decode(),
+ fontsize=size, fontname="helv", color=color)
+
+
+def _folio(head: _Head, page, n: int, total: int) -> None:
+ """Centred footer folio: n / total, flanked by short hairlines."""
+ text, size = f"{n} / {total}", 8.2
+ rect = page.rect
+ w = head.width(text, size)
+ cx, y = rect.width / 2, rect.height - 26
+ head.draw(page, cx - w / 2, y, text, size, INK)
+ shape = page.new_shape()
+ shape.draw_line((cx - w / 2 - 26, y - 3), (cx - w / 2 - 9, y - 3))
+ shape.draw_line((cx + w / 2 + 9, y - 3), (cx + w / 2 + 26, y - 3))
+ shape.finish(color=RULE, width=0.5)
+ shape.commit()
+
+
+def _running_head(head: _Head, page, text: str, side: str) -> None:
+ size = 8.0
+ rect = page.rect
+ text = _fit(text, (rect.width - 2 * MARGIN) / 2, size,
+ lambda t: head.width(t, size))
+ if not text:
+ return
+ w = head.width(text, size)
+ x = MARGIN if side == "left" else rect.width - MARGIN - w
+ head.draw(page, x, 40, text, size, INK)
+ shape = page.new_shape()
+ shape.draw_line((MARGIN, 46), (rect.width - MARGIN, 46))
+ shape.finish(color=RULE, width=0.5)
+ shape.commit()
+
+
+def build_pdf(html_path: Path, meta: dict, browser: str | None = None) -> Path:
+ raw = html_path.with_name(html_path.stem + ".raw.pdf")
+ out = html_path.with_suffix(".pdf")
+ _print_pdf(html_path, raw, browser)
+ doc = fitz.open(raw)
+ n = doc.page_count
+
+ anchors = [a for (_lvl, _t, a) in meta["outline"]]
+ anchors += list(meta["toc_rows"]) + ["front"]
+ dest = _destinations(doc, anchors)
+ missing = sorted({a for a in anchors if a not in dest})
+ if missing:
+ doc.close()
+ raise BuildError(f"pdf: no destination found for {missing[:8]}")
+
+ doc.set_toc([[lvl, label, dest[a] + 1]
+ for lvl, label, a in meta["outline"]])
+
+ fontfile = next((f for f in CJK_FONTS if Path(f).exists()), None)
+ if fontfile is None and meta.get("lang") == "zh":
+ doc.close()
+ raise BuildError(
+ "pdf: no CJK face available, so the running heads and folios "
+ f"would lose every Chinese glyph (searched: {', '.join(CJK_FONTS)})")
+ head = _Head(fontfile)
+ book = meta["title"]
+ starts = sorted((dest[a], label) for a, label in meta["heads"])
+
+ def head_for(pno: int) -> str:
+ cur = ""
+ for page_start, label in starts:
+ if page_start <= pno:
+ cur = label
+ else:
+ break
+ return cur
+
+ first_body = dest["front"]
+ for pno in range(1, n): # the cover stays clean
+ page = doc[pno]
+ _folio(head, page, pno + 1, n)
+ if pno < first_body:
+ continue
+ if pno % 2 == 0: # recto: chapter, outer edge
+ _running_head(head, page, head_for(pno) or book, "right")
+ else: # verso: book title
+ _running_head(head, page, book, "left")
+
+ rows = set(meta["toc_rows"])
+ # the contents is the last section before the body, so its first page is
+ # the last match for the heading title; rows then run to that page's end
+ hits = [pno for pno in range(dest["front"])
+ if doc[pno].search_for(meta["toc_title"])]
+ if not hits:
+ doc.close()
+ raise BuildError("pdf: contents pages not found")
+ # every contents row is a full-width flex link, so a narrow rect with the
+ # same destination (a compass chip) can never be mistaken for one
+ min_row = doc[0].rect.width * 0.5
+ best: dict[str, tuple[int, fitz.Rect]] = {}
+ for pno in range(hits[-1], dest["front"]):
+ for link in doc[pno].get_links():
+ anchor = link.get("nameddest")
+ if anchor not in rows:
+ continue
+ r = fitz.Rect(link["from"])
+ if r.width < min_row:
+ continue
+ # a row pushed to the next page leaves a clipped sliver behind;
+ # the row actually printed is the tallest fragment
+ if anchor not in best or r.height > best[anchor][1].height:
+ best[anchor] = (pno, r)
+ for anchor, (pno, r) in best.items():
+ label = str(dest[anchor] + 1)
+ w = fitz.get_text_length(label, fontname="helv", fontsize=8.2)
+ doc[pno].insert_text((r.x1 - w, r.y1 - 7), label, fontsize=8.2,
+ fontname="helv", color=(0.36, 0.32, 0.24))
+ if len(best) < len(rows):
+ missed = sorted(rows - set(best))
+ doc.close()
+ raise BuildError(f"pdf: stamped only {len(best)} of {len(rows)} "
+ f"contents rows (missing {missed[:8]})")
+
+ doc.set_metadata({
+ "title": meta["title"], "author": meta["author"],
+ "subject": meta.get("subtitle", ""),
+ "keywords": meta.get("keywords", ""),
+ "creator": "yuyu-book", "producer": "yuyu-book"})
+ doc.subset_fonts() # the running-head face ships once
+ try:
+ doc.save(out, garbage=3, deflate=True)
+ except Exception as exc:
+ # Windows refuses to replace a PDF that is open in a viewer or held by
+ # another process; the raw print file stays behind for a re-run.
+ doc.close()
+ raise BuildError(
+ f"pdf: cannot replace {out.name}: {exc}. Close it in any viewer "
+ f"or checker, then re-run the build.") from exc
+ doc.close()
+ raw.unlink(missing_ok=True)
+ return out
diff --git a/yuyu-book/yuyu_book/plain.py b/yuyu-book/yuyu_book/plain.py
new file mode 100644
index 00000000..12d44f11
--- /dev/null
+++ b/yuyu-book/yuyu_book/plain.py
@@ -0,0 +1,340 @@
+"""The plain-language layer (通俗层): term glosses, lead bands, reading tiers.
+
+Nothing here touches the manuscript. glosses.json and reading-paths.json are
+the only inputs, and both stay optional while they are being authored — an
+absent file leaves the book exactly as it was.
+"""
+from __future__ import annotations
+
+import html
+import re
+
+from .core import (BOOK_DIR, FENCE_RE, REPO_ROOT, SOURCE_ID_RE, BuildError,
+ load_json, read_text)
+
+TIER_ORDER = ("start", "core", "deep")
+TIER_LABELS = {"start": {"zh": "起步", "en": "Start"},
+ "core": {"zh": "主干", "en": "Core"},
+ "deep": {"zh": "进阶", "en": "Advanced"}}
+TIER_DESC = {
+ "start": {"zh": "不懂背景也能读,先把全局看明白",
+ "en": "Readable with no background; it builds the big picture"},
+ "core": {"zh": "要理解这个子系统就必须读",
+ "en": "Required to understand this subsystem"},
+ "deep": {"zh": "源码级细节,动手实现时再回来",
+ "en": "Source-level detail; come back when you build one"},
+}
+
+# the paper edition has no hover, so the chapter panel is the only carrier;
+# past this many new terms in one chapter the panel stops being a help and
+# becomes a wall of text, so the build asks for the registry to be trimmed.
+MAX_PANEL = 22
+LIMITS = {"plain": {"zh": 40, "en": 130}, "analogy": {"zh": 44, "en": 130}}
+LEAD_LIMITS = {"plain": {"zh": 60, "en": 170}, "analogy": {"zh": 52, "en": 160}}
+
+# annotating prose only: no links, code, headings, plates, sup/fn markers or
+# already-built components (every component we emit lives in a )
+_SKIP_GLOSS = {"a", "code", "pre", "svg", "style", "sup", "sub", "figure",
+ "h1", "h2", "h3", "h4", "span", "kbd", "samp", "var"}
+_ASCII_FORM = re.compile(r"[A-Za-z]")
+_H2_RE = re.compile(r'
((?:(?!?h2\b).)*)
', re.S)
+
+
+def load_plain():
+ """(glosses, reading paths) — {} when the layer has not been authored yet."""
+ out = []
+ for name in ("glosses.json", "reading-paths.json"):
+ p = BOOK_DIR / name
+ out.append(load_json(p) if p.exists() else {})
+ return out[0], out[1]
+
+
+def prose_lines(text: str) -> dict[int, str]:
+ """1-based line numbers that sit outside every code fence."""
+ keep, fence = {}, None
+ for n, line in enumerate(text.split("\n"), 1):
+ m = FENCE_RE.match(line)
+ if m:
+ token, info = m.group(1), m.group(2).strip()
+ if fence is None:
+ fence = token
+ elif (token[0] == fence[0] and len(token) >= len(fence)
+ and not info):
+ fence = None
+ continue
+ if fence is None:
+ keep[n] = line
+ return keep
+
+
+def validate_plain(glosses: dict, paths: dict, manifest: dict) -> None:
+ """Every gloss must be provably present in the prose it claims to explain.
+
+ An evidence pointer is checked against the real file: the line has to sit
+ outside a code fence and contain one of the registered surface forms, so a
+ invented term or a form that never occurs cannot enter the registry.
+ """
+ from .core import unit_stream
+
+ errors: list[str] = []
+ guides = manifest.get("guides", {})
+ if not glosses and not paths and \
+ not any((g.get("plain") for g in guides.values())):
+ return # layer not authored yet: build the book as it stands
+ cache: dict[str, dict[int, str]] = {}
+ unit_ids = {u["id"] for u, _k, _p in unit_stream(manifest)}
+
+ def prose(ref: str, who: str,
+ forms_by_lang: dict[str, list[str]] | None = None) -> str | None:
+ """The language a verified `file:line` points at, or None if it is not
+ real prose. A pointer into a code fence proves nothing about the book's
+ claims, so every plain-language sentence needs prose behind it."""
+ m = re.match(r"^(.+):(\d+)$", str(ref))
+ if not m:
+ errors.append(f"{who}: evidence {ref!r} is not file:line")
+ return None
+ rel, lineno = m.group(1), int(m.group(2))
+ lang = "en" if rel.replace("\\", "/").startswith("en/") else "zh"
+ if rel not in cache:
+ path = REPO_ROOT / rel
+ if not path.exists():
+ errors.append(f"{who}: evidence file {rel} missing")
+ return None
+ cache[rel] = prose_lines(read_text(path))
+ line = cache[rel].get(lineno)
+ if line is None or not line.strip():
+ errors.append(f"{who}: evidence {ref} is inside a code fence, "
+ "blank, or past the end of the file")
+ return None
+ if forms_by_lang is not None:
+ want = forms_by_lang.get(lang) or []
+ if not any(f in line for f in want):
+ errors.append(f"{who}: evidence {ref} does not contain any "
+ f"registered form")
+ return None
+ return lang
+
+ for gid, g in glosses.items():
+ if not SOURCE_ID_RE.match(gid):
+ errors.append(f"gloss {gid!r} id not [a-z0-9-]+")
+ for field in ("term", "forms", "plain"):
+ entry = g.get(field) or {}
+ for lang in ("zh", "en"):
+ val = entry.get(lang)
+ if field == "forms":
+ if not val:
+ errors.append(f"gloss {gid}: forms.{lang} empty")
+ elif not (val or "").strip():
+ errors.append(f"gloss {gid}: {field}.{lang} empty")
+ for field, lim in LIMITS.items():
+ if field == "plain":
+ continue
+ for lang in ("zh", "en"):
+ val = (g.get("analogy") or {}).get(lang)
+ if val and len(val) > lim[lang]:
+ errors.append(f"gloss {gid}: analogy.{lang} is "
+ f"{len(val)} chars (max {lim[lang]})")
+ for lang in ("zh", "en"):
+ limit = LIMITS["plain"][lang]
+ val = (g.get("plain") or {}).get(lang) or ""
+ if len(val) > limit:
+ errors.append(f"gloss {gid}: plain.{lang} is {len(val)} "
+ f"chars (max {limit})")
+ pr = g.get("priority")
+ if pr not in ("core", "extra"):
+ errors.append(f"gloss {gid}: priority must be core|extra")
+ forms = g.get("forms") or {}
+ ev = g.get("evidence") or []
+ if not ev:
+ errors.append(f"gloss {gid}: no evidence (file:line)")
+ langs_seen = set()
+ for ref in ev:
+ lang = prose(ref, f"gloss {gid}", forms)
+ if lang:
+ langs_seen.add(lang)
+ if not langs_seen:
+ errors.append(f"gloss {gid}: no usable evidence")
+ elif pr == "core" and langs_seen != {"zh", "en"}:
+ errors.append(f"gloss {gid}: core glosses need evidence in both "
+ f"languages (found {sorted(langs_seen)})")
+
+ chapters = {u["id"] for u, k, _p in unit_stream(manifest) if k == "chapter"}
+ for uid, tiers in paths.items():
+ if uid not in unit_ids:
+ errors.append(f"reading-paths: unknown unit {uid}")
+ if not isinstance(tiers, list) or not tiers:
+ errors.append(f"reading-paths {uid}: must be a non-empty list")
+ continue
+ bad = [t for t in tiers if t not in TIER_ORDER]
+ if bad:
+ errors.append(f"reading-paths {uid}: unknown tier(s) {bad}")
+ for uid in chapters - set(paths):
+ errors.append(f"reading-paths: chapter {uid} has no tiers")
+
+ # the lead band is the one plain-language device that works identically on
+ # screen and on paper, so every unit has to carry it once the layer exists
+ guides = manifest.get("guides", {})
+ for uid in sorted(unit_ids):
+ guide = guides.get(uid) or {}
+ for field, lim in LEAD_LIMITS.items():
+ entry = guide.get(field)
+ if entry is None:
+ if field == "plain":
+ errors.append(f"guide {uid}: plain is missing")
+ continue
+ for lang in ("zh", "en"):
+ val = (entry.get(lang) or "").strip()
+ if not val:
+ errors.append(f"guide {uid}.{field}.{lang} is empty")
+ elif len(val) > lim[lang]:
+ errors.append(f"guide {uid}.{field}.{lang} is "
+ f"{len(val)} chars (max {lim[lang]})")
+ # a summary sentence is a claim about the chapter, so it needs the same
+ # proof as a gloss: real prose lines, in both languages, since both
+ # editions print the band
+ ev = guide.get("plain_evidence") or []
+ if not ev:
+ errors.append(f"guide {uid}: plain_evidence is missing")
+ continue
+ seen = {lang for lang in (prose(ref, f"guide {uid}") for ref in ev)
+ if lang}
+ if seen != {"zh", "en"}:
+ errors.append(f"guide {uid}: plain_evidence must point at prose in "
+ f"both languages (found {sorted(seen)})")
+ if errors:
+ raise BuildError("plain layer invalid:\n " + "\n ".join(errors))
+
+
+def marks(g: dict) -> bool:
+ """Whether a term gets dashed in the prose. `extra` terms stay in the
+ registry — searchable in appendix D, hoverable where they do occur — but
+ are not marked, because a chapter whose every noun is annotated turns help
+ into noise. `"mark": true` opts one back in deliberately."""
+ return g.get("mark", g.get("priority") == "core")
+
+
+class Matcher:
+ """Longest-form-first alternation over every markable surface form."""
+
+ def __init__(self, glosses: dict, lang: str):
+ pairs: list[tuple[str, str]] = []
+ for gid, g in glosses.items():
+ if not marks(g):
+ continue
+ for form in (g.get("forms") or {}).get(lang) or []:
+ pairs.append((form, gid))
+ pairs.sort(key=lambda p: (-len(p[0]), p[0]))
+ self.pairs = pairs
+ alts = []
+ for form, _gid in pairs:
+ esc = re.escape(form)
+ if _ASCII_FORM.match(form):
+ esc = rf"(? tuple[str, list[dict]]:
+ """Wrap the first occurrence of each term in this unit and return the
+ hits in document order, so the chapter panel lists exactly the terms
+ the prose actually marked."""
+ if self.rx is None:
+ return doc, []
+ from . import render
+
+ gid_of = [gid for _f, gid in self.pairs]
+ seen: set[str] = set()
+ hits: list[dict] = []
+ out: list[str] = []
+ for kind, text, blocked in render.iter_doc(doc, _SKIP_GLOSS):
+ if kind == "tag" or blocked:
+ out.append(text)
+ continue
+
+ def repl(m):
+ gid = gid_of[m.lastindex - 1]
+ if gid in seen:
+ return m.group(0)
+ seen.add(gid)
+ used.add(gid)
+ g = glosses[gid]
+ hits.append({"id": gid, "form": m.group(0),
+ "term": g["term"][lang],
+ "plain": g["plain"][lang],
+ "analogy": (g.get("analogy") or {}).get(lang, ""),
+ "priority": g.get("priority", "extra")})
+ return f'' \
+ f'{m.group(0)}{_pop(g, lang)}'
+
+ out.append(self.rx.sub(repl, text))
+ return "".join(out), hits
+
+
+def _pop(g: dict, lang: str) -> str:
+ ana = (g.get("analogy") or {}).get(lang)
+ return (''
+ f'{html.escape(g["term"][lang])}'
+ f'{html.escape(g["plain"][lang])}'
+ + (f'{html.escape(ana)}' if ana
+ else "") + "")
+
+
+def stamp_tiers(doc: str, tiers: list[str], lang: str, who: str = "") -> str:
+ """Append a level chip to each H2, in document order. The list is
+ positional, so it has to be exactly as long as the chapter's real heading
+ count — a heading added, dropped or swallowed by a code fence shifts every
+ chip after it onto the wrong section, which is worse than no chips."""
+ if not tiers:
+ return doc
+ heads = _H2_RE.findall(doc)
+ if len(heads) != len(tiers):
+ raise BuildError(
+ f"{who}: reading-paths lists {len(tiers)} sections but the unit "
+ f"renders {len(heads)}")
+ state = [0]
+
+ def repl(m):
+ i = state[0]
+ state[0] += 1
+ tier = tiers[i]
+ return (f'
")
+
+
+def ink_rules(doc: str) -> str:
+ """A `---` in the manuscript is a scene change. Typeset as a drawn coupling
+ between two hairlines instead of a diamond out of the font."""
+ return _HR_RE.sub(f'
{knot()}
',
+ doc)
+
+
+# --------------------------------------------------------------- links ----
+
+_EXT_LINK_RE = re.compile(r']*)?>(.*?)', re.S)
+
+
+def process_links(doc: str, external: list[str]) -> str:
+ """External links get a superscript footnote marker; every chapter ends
+ with the full URL list so the printed book keeps all references."""
+ counter: dict[str, int] = {}
+
+ def repl(m):
+ url, attrs, label = m.group(1), m.group(2) or "", m.group(3)
+ if "class=" in attrs or 'rel="' in attrs or "{label}'
+ f'[{num}]')
+ return _EXT_LINK_RE.sub(repl, doc)
+
+
+def footnotes_html(external: list[str], lang: str) -> str:
+ if not external:
+ return ""
+ # the list closes whichever unit it sits under — the foreword and the
+ # appendices cite too, and are not chapters
+ title = "引用链接" if lang == "zh" else "Cited links"
+ items = "".join(
+ f'
{t}'
+ return _TABLE_RE.sub(repl, doc)
+
+
+# ------------------------------------------------------------- name breaks --
+
+_LONG_RUN = re.compile(r"\S{12,}")
+_BREAK_AFTER = re.compile(r"[_/]+")
+_CAMEL = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
+_NAME_BREAK_SKIP = {"svg", "style", "script", "textarea", "pre",
+ "h1", "h2", "h3", "h4", "h5", "h6"}
+
+
+def _break_offsets(run: str) -> list[int]:
+ """Character indices inside `run` where a line may break without damage."""
+ cuts = {m.end() for m in _BREAK_AFTER.finditer(run)}
+ cuts |= {m.start() for m in _CAMEL.finditer(run)}
+ return sorted(c for c in cuts if 0 < c < len(run))
+
+
+def soften_long_names(doc: str) -> str:
+ """Give `FLAG_NAME_WITH_UNDERSCORES`, `skipDangerousMode…` and
+ `~/.claude/settings.json` a place to break.
+
+ Justified prose stretches badly when the next token cannot be cut: at
+ desktop width 56 distinct inline tokens took more than 30% of a line on
+ their own, and `getFeatureValue_CACHED_MAY_BE_STALE` filled 96% of a
+ 390px line. `overflow-wrap` does not create a *preferred* break and does
+ not feed min-content width either, so it fixes neither the rivers on
+ screen nor the table that ran past the A4 page box; an explicit ``
+ does, and is only used when the name genuinely cannot fit.
+
+ Only runs of twelve characters or more are even examined, and a run is
+ left alone if it holds no separator or case boundary, so ordinary English
+ words are never cut. Entities are copied through untouched -- the offsets
+ are computed against the source text, and `&` is not an identifier.
+ """
+ def repl(m):
+ run = m.group(0)
+ if "&" in run:
+ return run
+ cuts = _break_offsets(run)
+ if not cuts:
+ return run
+ out, prev = [], 0
+ for c in cuts:
+ out.append(run[prev:c])
+ out.append("")
+ prev = c
+ out.append(run[prev:])
+ return "".join(out)
+
+ out = []
+ for kind, text, blocked in iter_doc(doc, _NAME_BREAK_SKIP):
+ out.append(text if kind == "tag" or blocked else _LONG_RUN.sub(repl, text))
+ return "".join(out)
+
+
+# ------------------------------------------------------------- unit close --
+
+_CLOSER_TAGS = {"p", "ul", "ol", "aside", "blockquote", "div"}
+_CLOSER_HEAVY = ("
str:
+ """Wrap the block a unit ends on together with the ruts that close it.
+
+ Chromium honours `break-inside: avoid` but not `break-before: avoid`, so a
+ rut that no longer fitted on the last page landed alone at the top of a
+ fresh sheet -- and since the next unit opens with a forced break, that sheet
+ stayed empty: three of them in the Chinese PDF, two in the English. Glued to
+ the block it closes, the pair moves together and the page ends short instead
+ of vanishing.
+
+ Only a light tail is wrapped: a plate or a wide table is its own
+ keep-together unit already, and anything tall enough to be one of those
+ cannot be moved at all, so gluing to it would only buy a hole.
+ """
+ stack: list[tuple[str, int]] = []
+ last: tuple[str, int, int] | None = None
+ for m in _TAG_RE.finditer(doc):
+ name = m.group(2).lower()
+ if name in _VOID_TAGS or m.group(3):
+ continue
+ if m.group(1):
+ if not stack or name not in [n for n, _ in stack]:
+ continue
+ while stack[-1][0] != name:
+ stack.pop()
+ opened, start = stack.pop()
+ if not stack:
+ last = (opened, start, m.end())
+ else:
+ stack.append((name, m.start()))
+ if last is None or doc[last[2]:].strip():
+ return doc + ornament
+ name, start, end = last
+ block = doc[start:end]
+ if name not in _CLOSER_TAGS or any(t in block for t in _CLOSER_HEAVY):
+ return doc + ornament
+ if len(re.sub(r"<[^>]+>", "", block)) > 900 or block.count("\n") > 30:
+ return doc + ornament
+ return doc[:start] + f'
')
+
+
+def stamp_code_langs(doc: str, langs: list[str], file: str) -> str:
+ """Label each highlighted block with the language it was fenced as, so
+ the stylesheet can print a corner chip. Position-matched, so a drift
+ between source fences and rendered blocks must fail loudly.
+
+ The pass also gives each block's scroller a tab stop: a long line runs
+ past the column and the block, unlike a link, is otherwise unreachable
+ without a mouse."""
+ left = list(langs)
+ found = len(_CODE_DIV_RE.findall(doc))
+ if found != len(left):
+ raise BuildError(f"{file}: {found} rendered code blocks but "
+ f"{len(left)} source fences")
+
+ def repl(m):
+ lang = left.pop(0)
+ if not lang:
+ return m.group(0)
+ return f'
|@@MM:([0-9a-f]{12})@@")
+
+# Mermaid lays a plate out in its own units and draws its labels at 13px,
+# so a 1500-unit map inside the 635px reading column drops those labels to
+# 5px — a texture, not a diagram. Hold the genuinely oversized plates above
+# a readable scale and let .diagram-frame scroll sideways (2600px caps the
+# drag at ~4 screens for the biggest dependency maps). @media print drops
+# the floor again so the PDF keeps the whole plate on one A4 page.
+_FLOOR_FROM = 1150.0
+_FLOOR_RATIO = 0.85
+_FLOOR_CAP = 2600.0
+_VIEWBOX_RE = re.compile(r'viewBox="0 0 ([0-9.]+) ([0-9.]+)"')
+_SVG_ROOT_RE = re.compile(r"]*>")
+
+
+def floor_wide_svg(svg: str) -> tuple[str, str | None]:
+ """Return (svg with a min-width floor, that floor in px or None).
+
+ Only rendered plates are floored: --mermaid=source hands over a
,
+ which already wraps.
+ """
+ tag = _SVG_ROOT_RE.match(svg)
+ if not tag:
+ return svg, None
+ head = tag.group(0)
+ vb = _VIEWBOX_RE.search(head)
+ if not vb:
+ return svg, None
+ vw = float(vb.group(1))
+ if vw < _FLOOR_FROM:
+ return svg, None
+ floor = f"{round(min(vw * _FLOOR_RATIO, _FLOOR_CAP))}px"
+ if " style=" in head:
+ new = head.replace('style="', f'style="min-width:{floor};', 1)
+ else:
+ new = head[:4] + f' style="min-width:{floor};"' + head[4:]
+ return svg[:tag.start()] + new + svg[tag.end():], floor
+
+
+# A plate's own labels are what has to survive the page. An A4 text block here
+# is 665x926 CSS px, and the 150mm default cap is a compromise for wide plates;
+# a tall one — a stacked layer map, a long decision chain — is shrunk by it into
+# a 4pt texture even though the column has room below it. So a plate that could
+# reach a legible scale by standing taller is given the room it needs, up to a
+# full column less its caption.
+_PRINT_COL_PX = 665.0
+_PRINT_CAP_PX = 567.0 # 150mm, style.py's default
+_PRINT_MAX_PX = 926.0 # 245mm
+_LAND_COL_PX = 993.0 # A4 landscape text block: 297mm - 2x17mm
+_LAND_MAX_PX = 567.0 # 150mm — the sheet's own cap, see mermaid_figure:
+ # 170mm of landscape block minus the caption row
+_MIN_PLATE_PT = 6.0 # what a plate must reach to stay portrait
+_LAND_MIN_PLATE_PT = 5.0 # a turned sheet is held to a slightly lower bar:
+ # it costs a whole sheet, but reads at arm's length
+_PX_PER_MM = 96 / 25.4
+
+
+def _plate_metrics(svg: str) -> tuple[float, float, float] | None:
+ tag = _SVG_ROOT_RE.match(svg)
+ if not tag:
+ return None
+ vb = _VIEWBOX_RE.search(tag.group(0))
+ if not vb:
+ return None
+ fonts = [float(x) for x in re.findall(r"font-size:\s*([0-9.]+)px", svg)]
+ return (float(vb.group(1)), float(vb.group(2)),
+ max(fonts) if fonts else 16.0)
+
+
+def plate_cap(svg: str) -> str | None:
+ """Print height cap in mm for this plate, or None for the default."""
+ m = _plate_metrics(svg)
+ if not m:
+ return None
+ w, h, base = m
+ need = _MIN_PLATE_PT / (base * 0.75) # 1px = 0.75pt
+ if w * need <= _PRINT_COL_PX and _PRINT_CAP_PX < h * need <= _PRINT_MAX_PX:
+ return f"{h * need / _PX_PER_MM:.0f}mm"
+ return None
+
+
+def plate_landscape(svg: str) -> bool:
+ """True when turning the sheet is what makes the plate readable.
+
+ A wide band is bound by the column, not by the cap, so standing taller
+ cannot save it; a landscape sheet buys 50% more width. That buys a whole
+ mostly-empty sheet, though, so it is spent only where it lands the plate
+ above a readable size rather than merely moving the unreadability around."""
+ m = _plate_metrics(svg)
+ if not m:
+ return False
+ w, h, base = m
+ here = base * min(_PRINT_COL_PX / w, _PRINT_CAP_PX / h) * 0.75
+ turned = base * min(_LAND_COL_PX / w, _LAND_MAX_PX / h) * 0.75
+ return here < _MIN_PLATE_PT and turned >= _LAND_MIN_PLATE_PT \
+ and turned > here * 1.25
+
+
+def mermaid_figure(uid: str, svg: str, fig_no: str, caption: str,
+ lang: str) -> str:
+ cap_word = "图" if lang == "zh" else "Figure"
+ svg, floor = floor_wide_svg(svg)
+ classes = ["diagram"]
+ if floor:
+ classes.append("wide")
+ land = plate_landscape(svg)
+ if land:
+ classes.append("landscape")
+ hint = (f'{pages.ui("plate_wide", lang)}'
+ if floor else "")
+ # A turned plate takes the sheet's default cap: the landscape block is
+ # 170mm tall, so a cap of its own would push the caption onto a second sheet.
+ cap = None if land else plate_cap(svg)
+ style = f' style="--plate-cap:{cap}"' if cap else ""
+ return (f''
+ f'
{svg}
'
+ f'{cap_word} {fig_no}'
+ f'{caption}{hint}')
+
+
+# ------------------------------------------------------------ autolink --
+
+_SKIP_TAGS = {"a", "code", "pre", "h1", "svg", "style", "sup", "figure"}
+_CURL_SKIP = {"code", "pre", "svg", "style", "kbd", "samp", "var", "textarea"}
+_VOID_TAGS = ("br", "img", "hr", "meta", "input", "wbr")
+_TAG_RE = re.compile(r"<(/?)([a-zA-Z][a-zA-Z0-9]*)[^>]*?(/)?>")
+
+
+def iter_doc(doc: str, skip_tags: set[str]):
+ """Walk rendered HTML and yield (kind, text, blocked) chunks, 'tag' being
+ markup copied through verbatim. `blocked` says an element in skip_tags
+ still owns this run, so prose annotators never reach into code, links,
+ plates or headings. Closing tags whose partner never opened (the mermaid
+ SVGs are full of them) pop back to the matching level instead of
+ corrupting the stack."""
+ stack: list[str] = []
+ pos = 0
+ while True:
+ m = _TAG_RE.search(doc, pos)
+ seg_end = m.start() if m else len(doc)
+ blocked = any(name in skip_tags for name in stack)
+ if seg_end > pos:
+ yield "text", doc[pos:seg_end], blocked
+ if not m:
+ return
+ closing, name, selfclose = m.group(1), m.group(2).lower(), m.group(3)
+ if name in _VOID_TAGS or selfclose:
+ pass
+ elif closing:
+ if name in stack:
+ while stack and stack[-1] != name:
+ stack.pop()
+ stack.pop()
+ else:
+ stack.append(name)
+ yield "tag", m.group(0), blocked
+ pos = m.end()
+
+
+class RefLinker:
+ """Wrap bare 第N章/Chapter N/附录X/Appendix X mentions in .
+ Skips subtrees of block/inline anchors; one link per target per text run."""
+
+ def __init__(self, mapping: dict[str, str], current: str, lang: str):
+ if lang == "zh":
+ self.rx = re.compile(r"第\s*([1-9]|1[0-5])\s*章|附录\s*([A-D])")
+ else:
+ self.rx = re.compile(
+ r"\bChapter\s+(\d{1,2})\b|\bAppendix\s+([A-D])\b")
+ self.map = mapping
+ self.current = current
+
+ def transform(self, doc: str) -> str:
+ out = []
+ for kind, text, blocked in iter_doc(doc, _SKIP_TAGS):
+ out.append(text if kind == "tag" or blocked
+ else self._link_text(text))
+ return "".join(out)
+
+ def _link_text(self, text: str) -> str:
+ used: set[str] = set()
+ out, last = [], 0
+ for m in self.rx.finditer(text):
+ g1, g2 = m.group(1), m.group(2)
+ aid = f"ch{int(g1):02d}" if g1 else f"apx{g2.lower()}"
+ anchor = self.map.get(aid)
+ if not anchor or aid == self.current or aid in used:
+ continue
+ used.add(aid)
+ out.append(text[last:m.start()])
+ out.append(f'{m.group(0)}')
+ last = m.end()
+ out.append(text[last:])
+ return "".join(out)
+
+
+# ---------------------------------------------------------------- ids -----
+
+_STRAY_ANGLE = re.compile(r"<[A-Za-z][A-Za-z0-9_]*"
+ r"(?:[,\s]+[A-Za-z0-9_=?<>.'’]+)+>")
+
+
+def escape_stray_angles(md_text: str) -> str:
+ """Type-parameter notation like Tool in prose is invalid HTML and
+ would be swallowed by browsers; fence/code spans are left untouched."""
+ lines, fence = [], None
+ for line in md_text.split("\n"):
+ m = re.match(r"^\s*(`{3,}|~{3,})(.*)$", line)
+ if m:
+ token, info = m.group(1), m.group(2).strip()
+ if fence is None:
+ fence = token
+ elif token[0] == fence[0] and len(token) >= len(fence) and not info:
+ fence = None
+ lines.append(line)
+ continue
+ if fence is not None:
+ lines.append(line)
+ continue
+ parts = re.split(r"(`+[^`]*`+)", line)
+ for i, seg in enumerate(parts):
+ if i % 2 == 0 and "<" in seg:
+ parts[i] = _STRAY_ANGLE.sub(
+ lambda mm: mm.group(0).replace("<", "<").replace(
+ ">", ">"), seg)
+ lines.append("".join(parts))
+ return "\n".join(lines)
+
+def prefix_ids(doc: str, pfx: str) -> str:
+ doc = re.sub(rf'\bid="(?!mm-|fig-)((?!{pfx}-)[^"]+)"',
+ rf'id="{pfx}-\1"', doc)
+ doc = re.sub(r'href="#(?!fn-)((?!{0}-)[^"]+)"'.format(pfx),
+ rf'href="#{pfx}-\1"', doc)
+ doc = doc.replace('href="#fn-', f'href="#{pfx}-fn-')
+ return doc
+
+
+# ------------------------------------------------------------ slugify ----
+
+def book_slug(value: str, separator: str = "-") -> str:
+ s = unicodedata.normalize("NFKC", value).strip().lower()
+ s = re.sub(r"[`*_~\[\]()()「」::,,。.;;/\\]", "", s)
+ s = re.sub(r"[^\w一-鿿-]", " ", s)
+ s = re.sub(r"\s+", separator, s.strip())
+ return s or "section"
+
+
+# ------------------------------------------------------------- render ----
+
+def build_md():
+ return markdown.Markdown(extensions=[
+ "extra", "sane_lists",
+ TocExtension(slugify=book_slug, permalink=False),
+ CodeHiliteExtension(language="text", guess_lang=False, linenums=False),
+ ])
+
+
+def curlify(doc: str) -> str:
+ """Straight double quotes → typographic pairs in prose, never in code."""
+ state = [0]
+
+ def flip(_m):
+ state[0] += 1
+ return "“" if state[0] % 2 else "”"
+
+ out, skip = [], None
+ for piece in re.split(r"(<[^>]+>)", doc):
+ if piece.startswith("<") and piece.endswith(">"):
+ m = re.match(r"?([a-zA-Z][\w-]*)", piece)
+ name = m.group(1).lower() if m else ""
+ if piece.startswith(""):
+ if name == skip:
+ skip = None
+ elif skip is None and name in _CURL_SKIP:
+ skip = name
+ out.append(piece)
+ continue
+ out.append(re.sub(r'"', flip, piece) if not skip else piece)
+ return "".join(out)
+
+
+def render_unit(unit: Unit, mapping: dict[str, str], *,
+ diagrams: dict[str, tuple[str, str]],
+ autolink: bool = True, gloss=None,
+ tiers: list[str] | None = None) -> dict:
+ body = re.sub(r"\A[ \t]*>[ \t]*(English|中文)[^\n]*\n+", "", unit.md)
+ if unit.kind == "front":
+ h1 = f"# {unit.title}"
+ elif unit.lang == "zh":
+ h1 = f"# {unit.no} {unit.title}"
+ else:
+ h1 = f"# {unit.no}: {unit.title}"
+
+ md = build_md()
+ doc = curlify(md.convert(escape_stray_angles(h1 + "\n\n" + body)))
+ doc = stamp_code_langs(doc, unit.code_langs, unit.path)
+
+ external: list[str] = []
+ doc = classify_callouts(doc, unit.lang)
+ doc = ink_rules(doc)
+ doc = process_links(doc, external)
+ doc += footnotes_html(external, unit.lang)
+ doc = wrap_tables(doc)
+
+ ch_num = re.sub(r"\D", "", unit.no) or ""
+ apx_letter = unit.id[-1].upper()
+ fig_seq = [0]
+
+ def fig_no():
+ fig_seq[0] += 1
+ if unit.kind == "chapter":
+ return f"{int(ch_num)}-{fig_seq[0]}"
+ if unit.kind == "front":
+ # front matter has no number to inherit; plain digits read better
+ # than the "T" that unit.id[-1] would give 前言/front
+ return f"{fig_seq[0]}"
+ return f"{apx_letter}{fig_seq[0]}"
+
+ def mm_repl(m):
+ uid = m.group(1) or m.group(2)
+ entry = diagrams.get(uid)
+ if entry is None:
+ return f'
缺失图 {uid}
'
+ caption, svg = entry
+ return mermaid_figure(uid, svg, fig_no(), caption, unit.lang)
+ doc = _MM_PH_RE.sub(mm_repl, doc)
+
+ doc = prefix_ids(doc, unit.id)
+ if autolink:
+ doc = RefLinker(mapping, unit.id, unit.lang).transform(doc)
+
+ new_terms: list[dict] = []
+ if gloss is not None and gloss.active:
+ doc, new_terms = gloss.apply(doc, tiers, who=unit.path)
+
+ headings = [{"level": int(lvl), "id": hid,
+ "text": re.sub(r"<[^>]+>", "", txt)}
+ for lvl, hid, txt in re.findall(
+ r'(.*?)', doc)]
+ # stamped after the outline is collected: the chip belongs to the reader,
+ # not to the section's name
+ doc = plain.stamp_tiers(doc, tiers or [], unit.lang, who=unit.id)
+ # the break points go in once the whole book is assembled, so the front and
+ # back matter get them too — see build.py
+ return {"unit": unit, "html": doc, "headings": headings,
+ "external": external, "n_diagrams": fig_seq[0],
+ "new_terms": new_terms}
diff --git a/yuyu-book/yuyu_book/style.py b/yuyu-book/yuyu_book/style.py
new file mode 100644
index 00000000..f70b1a17
--- /dev/null
+++ b/yuyu-book/yuyu_book/style.py
@@ -0,0 +1,1227 @@
+"""The book's stylesheet — tokens, typography, components, print & dark mode.
+
+Design language「舆」: bronze fittings, ink-black lacquer, cinnabar accents on
+warm paper, with a chariot-wheel (辐) motif as the recurring ornament. No web
+fonts, no JS; everything must survive offline file:// viewing and A4 print.
+"""
+from __future__ import annotations
+
+import math
+import random
+
+WHEEL_SVG = """
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+_WHEEL_N = 0
+
+
+def wheel(size: int = 44) -> str:
+ """Each instance needs its own spoke id — the SVG is inlined repeatedly."""
+ global _WHEEL_N
+ _WHEEL_N += 1
+ sid = f"spoke{_WHEEL_N}"
+ return (WHEEL_SVG
+ .replace('viewBox', f'width="{size}" height="{size}" viewBox', 1)
+ .replace('id="spoke"', f'id="{sid}"')
+ .replace('href="#spoke"', f'href="#{sid}"'))
+
+
+# ------------------------------------------------------- hand-inked marks ----
+# Callouts used to open with a font glyph (✦ ⚠ ↗ ◎ ※), which is the one place in
+# the book where the typeface had to speak for the drawing. These are the same
+# five marks inked by hand in the emblems' idiom: a ring left open where the
+# brush lifted, a triangle whose corners do not meet at a perfect angle, pads
+# and stubs that route like a trace. Inline, currentColor, no ids -- a callout
+# mark is repeated some seventy times per edition.
+INK_MARKS = {
+ "tip": (''
+ ''),
+ "warning": (''
+ ''
+ ''),
+ "info": (''
+ ''),
+ "concept": (''
+ ''
+ ''),
+ "note": (''
+ ''
+ ''
+ ''),
+}
+
+
+def ink_mark(kind: str) -> str:
+ """A callout's drawn mark, or '' for a kind the book has not inked yet."""
+ body = INK_MARKS.get(kind)
+ if not body:
+ return ""
+ return (f'{body}')
+
+
+def trace(seed: str) -> str:
+ """The wire a chapter head closes on: one hand-run line with pads, a couple
+ of stubs and two bends that lift it off the baseline, like a trace routing
+ around a via. Seeded by the unit id, so no two chapters draw the same line
+ and every rebuild draws the same one."""
+ rng = random.Random(seed)
+ width, base = 240.0, 11.0
+ nodes, x = [], 5.0
+ while x < width - 5.0:
+ nodes.append((x, base + rng.uniform(-1.4, 1.4)))
+ x += rng.uniform(23.0, 41.0)
+ nodes.append((width - 5.0, base + rng.uniform(-1.4, 1.4)))
+ if len(nodes) > 4:
+ for i in rng.sample(range(1, len(nodes) - 1), 2):
+ px, _ = nodes[i]
+ nodes[i] = (px, base - rng.uniform(3.4, 4.8))
+ d = "M{:.1f} {:.1f}".format(*nodes[0])
+ for (ax, ay), (bx, by) in zip(nodes, nodes[1:]):
+ d += (f"Q{(ax + bx) / 2:.1f} {(ay + by) / 2 + rng.uniform(-.9, .9):.1f}"
+ f" {bx:.1f} {by:.1f}")
+ out = [f'']
+ # the wire always lands on a pad at its far end; the rest are wherever the
+ # seed put them
+ pads = [len(nodes) - 1] + rng.sample(range(len(nodes) - 1),
+ min(3, len(nodes) - 1))
+ for i in pads:
+ px, py = nodes[i]
+ out.append(f'')
+ if rng.random() < .6:
+ down = 1 if py <= base else -1
+ out.append(f'')
+ ex, ey = nodes[-1]
+ out.append(f'')
+ return (f'{"".join(out)}')
+
+
+def _mini_wheel(cx: float, cy: float, r: float, n: int = 8,
+ phase: float = 0.0) -> str:
+ """A spoked wheel worked out with arithmetic instead of /