gen.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353
  1. #!/usr/bin/env python3
  2. """教学覆盖层 · 对照卡静态视图生成器(机制①)。
  3. 读 teaching/cards/*.yml,渲染成"概念 │ 源码 │ 形式化"三栏对照网页 + 索引。
  4. 设计原则:
  5. - 零额外依赖(stdlib + 仓库已有的 pyyaml);
  6. - 源码 span 在生成时**实时从仓库读取**(read_source_span),对照卡永不与代码脱节;
  7. 锚点失效(文件/行号不存在)会在页面上显式报警,而非静默腐烂;
  8. - live_demo 直接挂现有 run 的 trace.json,自动算统计 + 抽工具调用。
  9. 用法: python3 teaching/gen.py # 生成到 teaching/site/
  10. python3 teaching/gen.py --check # 只校验锚点,不写文件(CI 用)
  11. """
  12. from __future__ import annotations
  13. import html
  14. import json
  15. import re
  16. import sys
  17. from pathlib import Path
  18. import yaml
  19. HERE = Path(__file__).resolve().parent
  20. REPO = HERE.parent
  21. CARDS_DIR = HERE / "cards"
  22. SITE_DIR = HERE / "site"
  23. MODULE_NAMES = {
  24. "A": "A · 智能体即解释器",
  25. "B": "B · 状态、记忆与成本",
  26. "C": "C · 安全、编排与产物",
  27. "D": "D · 可靠性与形式化收口",
  28. }
  29. # 已知内置工具名,用于从 react-over-text 的文本 trace 里抽"被调用的工具"。
  30. KNOWN_TOOLS = [
  31. "ReadFile", "WriteFile", "EditFile", "ListFiles", "SearchContent",
  32. "Bash", "WebSearch", "WebFetch", "DocGen", "ChunkSplit", "KBSearch",
  33. "RunTests", "Git", "terminate", "done",
  34. ]
  35. # ──────────────────────────── 数据加载 ────────────────────────────
  36. def load_cards():
  37. cards = []
  38. for f in sorted(CARDS_DIR.glob("*.yml")):
  39. data = yaml.safe_load(f.read_text(encoding="utf-8"))
  40. data["_src"] = f.name
  41. cards.append(data)
  42. cards.sort(key=lambda c: (c.get("week", 99), c.get("id", "")))
  43. return cards
  44. def read_source_span(file_rel: str, lines: str):
  45. """实时从仓库读源码 span。返回 (ok, [(lineno, text)...] | error_msg)。"""
  46. p = REPO / file_rel
  47. if not p.exists():
  48. return False, f"锚点文件不存在: {file_rel}"
  49. try:
  50. start, end = (int(x) for x in str(lines).split("-"))
  51. except ValueError:
  52. return False, f"行号格式应为 'a-b',得到: {lines}"
  53. all_lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
  54. if start < 1 or end > len(all_lines) or start > end:
  55. return False, f"行号越界: {lines}(文件共 {len(all_lines)} 行)"
  56. return True, [(i, all_lines[i - 1]) for i in range(start, end + 1)]
  57. def summarize_trace(run_rel: str):
  58. """读 run 的 trace.json,返回统计 dict 或 None。"""
  59. if not run_rel:
  60. return None
  61. run = REPO / run_rel
  62. tp = run / "trace.json"
  63. if not tp.exists():
  64. return {"missing": True, "path": run_rel}
  65. try:
  66. events = json.loads(tp.read_text(encoding="utf-8", errors="replace"))
  67. except Exception as e: # noqa: BLE001
  68. return {"missing": True, "path": run_rel, "error": str(e)}
  69. if not isinstance(events, list):
  70. events = [events]
  71. tools = {}
  72. pat = re.compile(r"\[Step \d+\]\s*([A-Za-z_]\w*)")
  73. for e in events:
  74. text = f"{e.get('output', '')}"
  75. for m in pat.findall(text):
  76. if m in KNOWN_TOOLS:
  77. tools[m] = tools.get(m, 0) + 1
  78. # 兜底:若 [Step] 模式没抽到,扫已知工具词
  79. if not tools:
  80. blob = " ".join(str(e.get("output", "")) for e in events)
  81. for t in KNOWN_TOOLS:
  82. n = blob.count(t)
  83. if n:
  84. tools[t] = n
  85. cost = {}
  86. cp = run / "cost.json"
  87. if cp.exists():
  88. try:
  89. cost = json.loads(cp.read_text(encoding="utf-8"))
  90. except Exception: # noqa: BLE001
  91. cost = {}
  92. return {
  93. "missing": False,
  94. "path": run_rel,
  95. "events": len(events),
  96. "tokens": sum(int(e.get("tokens_used", 0) or 0) for e in events),
  97. "duration_ms": sum(float(e.get("duration_ms", 0) or 0) for e in events),
  98. "tools": sorted(tools.items(), key=lambda kv: -kv[1]),
  99. "status": cost.get("status", ""),
  100. "steps": cost.get("steps", len(events)),
  101. }
  102. # ──────────────────────────── 渲染 ────────────────────────────
  103. def esc(s):
  104. return html.escape(str(s if s is not None else ""))
  105. CSS = """
  106. :root{--fg:#1a1a1a;--bg:#fff;--mut:#666;--line:#e3e3e3;--accent:#3b5bdb;
  107. --codebg:#f6f8fa;--warn:#b00020;--okbg:#eef4ff;--formalbg:#f3f0ff;--conceptbg:#fbfbf9;}
  108. *{box-sizing:border-box}
  109. body{font:15px/1.65 -apple-system,"PingFang SC",Segoe UI,Roboto,sans-serif;
  110. color:var(--fg);background:var(--bg);margin:0;padding:0}
  111. a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
  112. .wrap{max-width:1180px;margin:0 auto;padding:28px 22px 80px}
  113. header.top{border-bottom:1px solid var(--line);padding-bottom:14px;margin-bottom:22px}
  114. header.top h1{margin:0 0 4px;font-size:20px}
  115. .sub{color:var(--mut);font-size:13px}
  116. .crumbs{font-size:13px;margin-bottom:18px}.crumbs a{color:var(--mut)}
  117. .badge{display:inline-block;font-size:12px;background:var(--okbg);color:var(--accent);
  118. border-radius:4px;padding:1px 8px;margin-right:6px}
  119. h2.ctitle{font-size:19px;margin:6px 0 4px}
  120. .keypoint{background:#fff8e6;border-left:3px solid #e6a700;padding:10px 14px;
  121. border-radius:4px;margin:14px 0;font-size:14px}
  122. .cols{display:grid;grid-template-columns:1fr 1.5fr 1fr;gap:16px;margin:22px 0}
  123. .col{border:1px solid var(--line);border-radius:8px;padding:14px 16px;min-width:0}
  124. .col h3{margin:0 0 10px;font-size:13px;letter-spacing:.04em;text-transform:uppercase;color:var(--mut)}
  125. .col.concept{background:var(--conceptbg)}
  126. .col.formal{background:var(--formalbg)}
  127. .col p{margin:0 0 10px;white-space:pre-wrap;font-size:14px}
  128. .anchor{margin:0 0 16px}
  129. .anchor .meta{font-size:12px;color:var(--mut);margin-bottom:4px}
  130. .anchor .meta code{background:var(--codebg);padding:1px 5px;border-radius:3px}
  131. pre{background:var(--codebg);border:1px solid var(--line);border-radius:6px;
  132. overflow:auto;margin:0 0 6px;padding:10px 0;font-size:12px;line-height:1.5}
  133. pre code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;display:block}
  134. .ln{display:flex}.ln .n{color:#aaa;text-align:right;width:46px;padding:0 10px;
  135. user-select:none;flex:none}.ln .c{white-space:pre;padding-right:14px}
  136. .note{font-size:12.5px;color:var(--mut);margin:0 0 14px}
  137. .warn{color:var(--warn);background:#fff0f0;border:1px solid #ffd0d0;border-radius:6px;
  138. padding:8px 12px;font-size:13px}
  139. .kv{font-size:13px}.kv code{background:var(--codebg);padding:1px 5px;border-radius:3px}
  140. .formal .term{font-family:ui-monospace,Menlo,monospace;background:#fff;border:1px solid var(--line);
  141. border-radius:5px;padding:8px 10px;font-size:13px;margin-bottom:8px;word-break:break-word}
  142. .effect{font-family:ui-monospace,Menlo,monospace;color:#7048e8;font-size:13px;margin-bottom:8px}
  143. .trace{border:1px solid var(--line);border-radius:8px;padding:14px 16px;margin:22px 0;background:#fafafa}
  144. .trace h3{margin:0 0 10px;font-size:14px}
  145. .stat{display:inline-block;margin:0 18px 6px 0;font-size:13px}
  146. .stat b{font-size:17px;color:var(--accent)}
  147. .toolchip{display:inline-block;background:#eef;border:1px solid #dde;border-radius:12px;
  148. padding:1px 9px;margin:0 6px 6px 0;font-size:12px}
  149. .takeaway{background:var(--okbg);border-radius:8px;padding:14px 18px;margin:22px 0;
  150. white-space:pre-wrap;font-size:14px}
  151. .rel a{margin-right:14px;font-size:13px}
  152. table.idx{border-collapse:collapse;width:100%;margin-top:8px}
  153. table.idx td{border-bottom:1px solid var(--line);padding:9px 8px;vertical-align:top}
  154. table.idx .wk{color:var(--mut);font-size:13px;width:64px;white-space:nowrap}
  155. .modhdr{margin:26px 0 4px;font-size:15px;color:var(--accent)}
  156. footer{margin-top:50px;color:var(--mut);font-size:12px;border-top:1px solid var(--line);padding-top:14px}
  157. @media(max-width:900px){.cols{grid-template-columns:1fr}}
  158. """
  159. def render_code_block(file_rel, lines):
  160. ok, payload = read_source_span(file_rel, lines)
  161. if not ok:
  162. return f'<div class="warn">⚠ {esc(payload)}</div>'
  163. rows = []
  164. for n, text in payload:
  165. rows.append(f'<div class="ln"><span class="n">{n}</span>'
  166. f'<span class="c">{esc(text)}</span></div>')
  167. return f'<pre><code>{"".join(rows)}</code></pre>'
  168. def render_card(card, cards_by_id):
  169. cid = card.get("id", "?")
  170. title = card.get("title", cid)
  171. week = card.get("week", "?")
  172. module = card.get("module", "?")
  173. concept = card.get("concept", {}) or {}
  174. formal = card.get("formal", {}) or {}
  175. # 概念栏
  176. concept_html = f'<p>{esc(concept.get("textbook", ""))}</p>'
  177. # 源码栏
  178. anchors = []
  179. for a in card.get("code", []) or []:
  180. block = render_code_block(a.get("file", ""), a.get("lines", ""))
  181. anchors.append(
  182. '<div class="anchor"><div class="meta">'
  183. f'<code>{esc(a.get("file",""))}</code> · '
  184. f'<b>{esc(a.get("symbol",""))}</b> · L{esc(a.get("lines",""))}'
  185. f'</div>{block}'
  186. + (f'<div class="note">{esc(a.get("note",""))}</div>' if a.get("note") else "")
  187. + "</div>"
  188. )
  189. code_html = "".join(anchors) or '<div class="note">(无源码锚点)</div>'
  190. # 形式化栏
  191. formal_html = ""
  192. if formal.get("term"):
  193. formal_html += f'<div class="term">{esc(formal["term"])}</div>'
  194. if formal.get("effect"):
  195. formal_html += f'<div class="effect">effect: {esc(formal["effect"])}</div>'
  196. if formal.get("note"):
  197. formal_html += f'<p>{esc(formal["note"])}</p>'
  198. # trace 面板
  199. trace_html = ""
  200. ld = card.get("live_demo") or {}
  201. summ = summarize_trace(ld.get("run", ""))
  202. if summ is not None:
  203. if summ.get("missing"):
  204. if ld.get("run"):
  205. trace_html = (f'<div class="trace"><h3>🎞 Live Demo</h3>'
  206. f'<div class="warn">trace 未找到: {esc(summ["path"])}</div>'
  207. + (f'<p class="note">{esc(ld.get("highlight",""))}</p>' if ld.get("highlight") else "")
  208. + "</div>")
  209. else:
  210. chips = "".join(f'<span class="toolchip">{esc(t)} ×{n}</span>'
  211. for t, n in summ["tools"][:10]) or '<span class="note">未从文本抽到工具调用</span>'
  212. trace_html = (
  213. '<div class="trace"><h3>🎞 Live Demo · 真实 run</h3>'
  214. f'<div class="note"><code>{esc(summ["path"])}</code> · status={esc(summ["status"])}</div>'
  215. f'<div class="stat"><b>{summ["events"]}</b> 步</div>'
  216. f'<div class="stat"><b>{summ["tokens"]}</b> tokens</div>'
  217. f'<div class="stat"><b>{summ["duration_ms"]/1000:.1f}</b> s</div>'
  218. f'<div style="margin-top:8px">{chips}</div>'
  219. + (f'<p class="note" style="margin-top:8px">{esc(ld.get("highlight",""))}</p>' if ld.get("highlight") else "")
  220. + "</div>"
  221. )
  222. elif ld.get("highlight"):
  223. trace_html = (f'<div class="trace"><h3>🎞 Live Demo</h3>'
  224. f'<p class="note">{esc(ld["highlight"])}</p></div>')
  225. # 相关卡
  226. rel = card.get("related", []) or []
  227. rel_links = []
  228. for r in rel:
  229. rc = cards_by_id.get(r)
  230. label = rc.get("title", r) if rc else r
  231. if rc:
  232. rel_links.append(f'<a href="{r}.html">{esc(label)}</a>')
  233. else:
  234. rel_links.append(f'<span class="note">{esc(r)}(待建)</span>')
  235. rel_html = (f'<p class="rel"><b>相关对照卡:</b> {" ".join(rel_links)}</p>'
  236. if rel_links else "")
  237. keypoint = (f'<div class="keypoint">🎯 {esc(concept.get("key_point",""))}</div>'
  238. if concept.get("key_point") else "")
  239. takeaway = (f'<div class="takeaway"><b>学习目标 / Takeaway</b>\n{esc(card.get("takeaway",""))}</div>'
  240. if card.get("takeaway") else "")
  241. body = f"""<!doctype html><html lang="zh"><head><meta charset="utf-8">
  242. <meta name="viewport" content="width=device-width,initial-scale=1">
  243. <title>{esc(title)}</title><style>{CSS}</style></head><body><div class="wrap">
  244. <div class="crumbs"><a href="index.html">← 对照卡索引</a></div>
  245. <header class="top">
  246. <span class="badge">模块 {esc(module)}</span><span class="badge">第 {esc(week)} 周</span>
  247. <span class="badge">{esc(cid)}</span>
  248. <h2 class="ctitle">{esc(title)}</h2></header>
  249. {keypoint}
  250. <div class="cols">
  251. <div class="col concept"><h3>📖 教科书概念</h3>{concept_html}</div>
  252. <div class="col code"><h3>💻 平台源码落点</h3>{code_html}</div>
  253. <div class="col formal"><h3>∑ 形式化对象</h3>{formal_html}</div>
  254. </div>
  255. {trace_html}
  256. {takeaway}
  257. {rel_html}
  258. <footer>对照卡源文件: <code>teaching/cards/{esc(card.get("_src",""))}</code> ·
  259. 源码 span 由 gen.py 在生成时实时读自仓库</footer>
  260. </div></body></html>"""
  261. return body
  262. def render_index(cards):
  263. by_mod = {}
  264. for c in cards:
  265. by_mod.setdefault(c.get("module", "?"), []).append(c)
  266. rows = []
  267. for mod in sorted(by_mod):
  268. rows.append(f'<div class="modhdr">{esc(MODULE_NAMES.get(mod, mod))}</div>')
  269. rows.append('<table class="idx">')
  270. for c in by_mod[mod]:
  271. cid = c.get("id")
  272. kp = (c.get("concept") or {}).get("key_point", "")
  273. rows.append(
  274. f'<tr><td class="wk">第 {esc(c.get("week","?"))} 周</td>'
  275. f'<td><a href="{esc(cid)}.html"><b>{esc(c.get("title",cid))}</b></a>'
  276. f'<div class="note">{esc(kp)}</div></td></tr>'
  277. )
  278. rows.append("</table>")
  279. body = f"""<!doctype html><html lang="zh"><head><meta charset="utf-8">
  280. <meta name="viewport" content="width=device-width,initial-scale=1">
  281. <title>对照卡索引 · 理论↔代码对应</title><style>{CSS}</style></head><body><div class="wrap">
  282. <header class="top"><h1>智能体:理论 ↔ 代码 对照卡</h1>
  283. <div class="sub">每张卡三栏对照:教科书概念 │ 平台源码落点(实时读仓库) │ 形式化对象。
  284. 共 {len(cards)} 张卡。</div></header>
  285. {"".join(rows)}
  286. <footer>由 <code>teaching/gen.py</code> 生成 · 卡片源: <code>teaching/cards/*.yml</code></footer>
  287. </div></body></html>"""
  288. return body
  289. def main():
  290. check_only = "--check" in sys.argv
  291. cards = load_cards()
  292. cards_by_id = {c.get("id"): c for c in cards}
  293. # 锚点校验(check 模式 + 正常模式都跑)
  294. problems = []
  295. for c in cards:
  296. for a in c.get("code", []) or []:
  297. ok, msg = read_source_span(a.get("file", ""), a.get("lines", ""))
  298. if not ok:
  299. problems.append(f"[{c.get('id')}] {msg}")
  300. if problems:
  301. print("⚠ 锚点问题:")
  302. for p in problems:
  303. print(" -", p)
  304. else:
  305. print(f"✓ {sum(len(c.get('code',[]) or []) for c in cards)} 个源码锚点全部有效")
  306. if check_only:
  307. sys.exit(1 if problems else 0)
  308. SITE_DIR.mkdir(exist_ok=True)
  309. (SITE_DIR / "index.html").write_text(render_index(cards), encoding="utf-8")
  310. for c in cards:
  311. (SITE_DIR / f"{c['id']}.html").write_text(
  312. render_card(c, cards_by_id), encoding="utf-8")
  313. print(f"✓ 生成 {len(cards)+1} 个页面 → {SITE_DIR.relative_to(REPO)}/")
  314. print(f" 打开: file://{SITE_DIR}/index.html")
  315. if __name__ == "__main__":
  316. main()