| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353 |
- #!/usr/bin/env python3
- """教学覆盖层 · 对照卡静态视图生成器(机制①)。
- 读 teaching/cards/*.yml,渲染成"概念 │ 源码 │ 形式化"三栏对照网页 + 索引。
- 设计原则:
- - 零额外依赖(stdlib + 仓库已有的 pyyaml);
- - 源码 span 在生成时**实时从仓库读取**(read_source_span),对照卡永不与代码脱节;
- 锚点失效(文件/行号不存在)会在页面上显式报警,而非静默腐烂;
- - live_demo 直接挂现有 run 的 trace.json,自动算统计 + 抽工具调用。
- 用法: python3 teaching/gen.py # 生成到 teaching/site/
- python3 teaching/gen.py --check # 只校验锚点,不写文件(CI 用)
- """
- from __future__ import annotations
- import html
- import json
- import re
- import sys
- from pathlib import Path
- import yaml
- HERE = Path(__file__).resolve().parent
- REPO = HERE.parent
- CARDS_DIR = HERE / "cards"
- SITE_DIR = HERE / "site"
- MODULE_NAMES = {
- "A": "A · 智能体即解释器",
- "B": "B · 状态、记忆与成本",
- "C": "C · 安全、编排与产物",
- "D": "D · 可靠性与形式化收口",
- }
- # 已知内置工具名,用于从 react-over-text 的文本 trace 里抽"被调用的工具"。
- KNOWN_TOOLS = [
- "ReadFile", "WriteFile", "EditFile", "ListFiles", "SearchContent",
- "Bash", "WebSearch", "WebFetch", "DocGen", "ChunkSplit", "KBSearch",
- "RunTests", "Git", "terminate", "done",
- ]
- # ──────────────────────────── 数据加载 ────────────────────────────
- def load_cards():
- cards = []
- for f in sorted(CARDS_DIR.glob("*.yml")):
- data = yaml.safe_load(f.read_text(encoding="utf-8"))
- data["_src"] = f.name
- cards.append(data)
- cards.sort(key=lambda c: (c.get("week", 99), c.get("id", "")))
- return cards
- def read_source_span(file_rel: str, lines: str):
- """实时从仓库读源码 span。返回 (ok, [(lineno, text)...] | error_msg)。"""
- p = REPO / file_rel
- if not p.exists():
- return False, f"锚点文件不存在: {file_rel}"
- try:
- start, end = (int(x) for x in str(lines).split("-"))
- except ValueError:
- return False, f"行号格式应为 'a-b',得到: {lines}"
- all_lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
- if start < 1 or end > len(all_lines) or start > end:
- return False, f"行号越界: {lines}(文件共 {len(all_lines)} 行)"
- return True, [(i, all_lines[i - 1]) for i in range(start, end + 1)]
- def summarize_trace(run_rel: str):
- """读 run 的 trace.json,返回统计 dict 或 None。"""
- if not run_rel:
- return None
- run = REPO / run_rel
- tp = run / "trace.json"
- if not tp.exists():
- return {"missing": True, "path": run_rel}
- try:
- events = json.loads(tp.read_text(encoding="utf-8", errors="replace"))
- except Exception as e: # noqa: BLE001
- return {"missing": True, "path": run_rel, "error": str(e)}
- if not isinstance(events, list):
- events = [events]
- tools = {}
- pat = re.compile(r"\[Step \d+\]\s*([A-Za-z_]\w*)")
- for e in events:
- text = f"{e.get('output', '')}"
- for m in pat.findall(text):
- if m in KNOWN_TOOLS:
- tools[m] = tools.get(m, 0) + 1
- # 兜底:若 [Step] 模式没抽到,扫已知工具词
- if not tools:
- blob = " ".join(str(e.get("output", "")) for e in events)
- for t in KNOWN_TOOLS:
- n = blob.count(t)
- if n:
- tools[t] = n
- cost = {}
- cp = run / "cost.json"
- if cp.exists():
- try:
- cost = json.loads(cp.read_text(encoding="utf-8"))
- except Exception: # noqa: BLE001
- cost = {}
- return {
- "missing": False,
- "path": run_rel,
- "events": len(events),
- "tokens": sum(int(e.get("tokens_used", 0) or 0) for e in events),
- "duration_ms": sum(float(e.get("duration_ms", 0) or 0) for e in events),
- "tools": sorted(tools.items(), key=lambda kv: -kv[1]),
- "status": cost.get("status", ""),
- "steps": cost.get("steps", len(events)),
- }
- # ──────────────────────────── 渲染 ────────────────────────────
- def esc(s):
- return html.escape(str(s if s is not None else ""))
- CSS = """
- :root{--fg:#1a1a1a;--bg:#fff;--mut:#666;--line:#e3e3e3;--accent:#3b5bdb;
- --codebg:#f6f8fa;--warn:#b00020;--okbg:#eef4ff;--formalbg:#f3f0ff;--conceptbg:#fbfbf9;}
- *{box-sizing:border-box}
- body{font:15px/1.65 -apple-system,"PingFang SC",Segoe UI,Roboto,sans-serif;
- color:var(--fg);background:var(--bg);margin:0;padding:0}
- a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
- .wrap{max-width:1180px;margin:0 auto;padding:28px 22px 80px}
- header.top{border-bottom:1px solid var(--line);padding-bottom:14px;margin-bottom:22px}
- header.top h1{margin:0 0 4px;font-size:20px}
- .sub{color:var(--mut);font-size:13px}
- .crumbs{font-size:13px;margin-bottom:18px}.crumbs a{color:var(--mut)}
- .badge{display:inline-block;font-size:12px;background:var(--okbg);color:var(--accent);
- border-radius:4px;padding:1px 8px;margin-right:6px}
- h2.ctitle{font-size:19px;margin:6px 0 4px}
- .keypoint{background:#fff8e6;border-left:3px solid #e6a700;padding:10px 14px;
- border-radius:4px;margin:14px 0;font-size:14px}
- .cols{display:grid;grid-template-columns:1fr 1.5fr 1fr;gap:16px;margin:22px 0}
- .col{border:1px solid var(--line);border-radius:8px;padding:14px 16px;min-width:0}
- .col h3{margin:0 0 10px;font-size:13px;letter-spacing:.04em;text-transform:uppercase;color:var(--mut)}
- .col.concept{background:var(--conceptbg)}
- .col.formal{background:var(--formalbg)}
- .col p{margin:0 0 10px;white-space:pre-wrap;font-size:14px}
- .anchor{margin:0 0 16px}
- .anchor .meta{font-size:12px;color:var(--mut);margin-bottom:4px}
- .anchor .meta code{background:var(--codebg);padding:1px 5px;border-radius:3px}
- pre{background:var(--codebg);border:1px solid var(--line);border-radius:6px;
- overflow:auto;margin:0 0 6px;padding:10px 0;font-size:12px;line-height:1.5}
- pre code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;display:block}
- .ln{display:flex}.ln .n{color:#aaa;text-align:right;width:46px;padding:0 10px;
- user-select:none;flex:none}.ln .c{white-space:pre;padding-right:14px}
- .note{font-size:12.5px;color:var(--mut);margin:0 0 14px}
- .warn{color:var(--warn);background:#fff0f0;border:1px solid #ffd0d0;border-radius:6px;
- padding:8px 12px;font-size:13px}
- .kv{font-size:13px}.kv code{background:var(--codebg);padding:1px 5px;border-radius:3px}
- .formal .term{font-family:ui-monospace,Menlo,monospace;background:#fff;border:1px solid var(--line);
- border-radius:5px;padding:8px 10px;font-size:13px;margin-bottom:8px;word-break:break-word}
- .effect{font-family:ui-monospace,Menlo,monospace;color:#7048e8;font-size:13px;margin-bottom:8px}
- .trace{border:1px solid var(--line);border-radius:8px;padding:14px 16px;margin:22px 0;background:#fafafa}
- .trace h3{margin:0 0 10px;font-size:14px}
- .stat{display:inline-block;margin:0 18px 6px 0;font-size:13px}
- .stat b{font-size:17px;color:var(--accent)}
- .toolchip{display:inline-block;background:#eef;border:1px solid #dde;border-radius:12px;
- padding:1px 9px;margin:0 6px 6px 0;font-size:12px}
- .takeaway{background:var(--okbg);border-radius:8px;padding:14px 18px;margin:22px 0;
- white-space:pre-wrap;font-size:14px}
- .rel a{margin-right:14px;font-size:13px}
- table.idx{border-collapse:collapse;width:100%;margin-top:8px}
- table.idx td{border-bottom:1px solid var(--line);padding:9px 8px;vertical-align:top}
- table.idx .wk{color:var(--mut);font-size:13px;width:64px;white-space:nowrap}
- .modhdr{margin:26px 0 4px;font-size:15px;color:var(--accent)}
- footer{margin-top:50px;color:var(--mut);font-size:12px;border-top:1px solid var(--line);padding-top:14px}
- @media(max-width:900px){.cols{grid-template-columns:1fr}}
- """
- def render_code_block(file_rel, lines):
- ok, payload = read_source_span(file_rel, lines)
- if not ok:
- return f'<div class="warn">⚠ {esc(payload)}</div>'
- rows = []
- for n, text in payload:
- rows.append(f'<div class="ln"><span class="n">{n}</span>'
- f'<span class="c">{esc(text)}</span></div>')
- return f'<pre><code>{"".join(rows)}</code></pre>'
- def render_card(card, cards_by_id):
- cid = card.get("id", "?")
- title = card.get("title", cid)
- week = card.get("week", "?")
- module = card.get("module", "?")
- concept = card.get("concept", {}) or {}
- formal = card.get("formal", {}) or {}
- # 概念栏
- concept_html = f'<p>{esc(concept.get("textbook", ""))}</p>'
- # 源码栏
- anchors = []
- for a in card.get("code", []) or []:
- block = render_code_block(a.get("file", ""), a.get("lines", ""))
- anchors.append(
- '<div class="anchor"><div class="meta">'
- f'<code>{esc(a.get("file",""))}</code> · '
- f'<b>{esc(a.get("symbol",""))}</b> · L{esc(a.get("lines",""))}'
- f'</div>{block}'
- + (f'<div class="note">{esc(a.get("note",""))}</div>' if a.get("note") else "")
- + "</div>"
- )
- code_html = "".join(anchors) or '<div class="note">(无源码锚点)</div>'
- # 形式化栏
- formal_html = ""
- if formal.get("term"):
- formal_html += f'<div class="term">{esc(formal["term"])}</div>'
- if formal.get("effect"):
- formal_html += f'<div class="effect">effect: {esc(formal["effect"])}</div>'
- if formal.get("note"):
- formal_html += f'<p>{esc(formal["note"])}</p>'
- # trace 面板
- trace_html = ""
- ld = card.get("live_demo") or {}
- summ = summarize_trace(ld.get("run", ""))
- if summ is not None:
- if summ.get("missing"):
- if ld.get("run"):
- trace_html = (f'<div class="trace"><h3>🎞 Live Demo</h3>'
- f'<div class="warn">trace 未找到: {esc(summ["path"])}</div>'
- + (f'<p class="note">{esc(ld.get("highlight",""))}</p>' if ld.get("highlight") else "")
- + "</div>")
- else:
- chips = "".join(f'<span class="toolchip">{esc(t)} ×{n}</span>'
- for t, n in summ["tools"][:10]) or '<span class="note">未从文本抽到工具调用</span>'
- trace_html = (
- '<div class="trace"><h3>🎞 Live Demo · 真实 run</h3>'
- f'<div class="note"><code>{esc(summ["path"])}</code> · status={esc(summ["status"])}</div>'
- f'<div class="stat"><b>{summ["events"]}</b> 步</div>'
- f'<div class="stat"><b>{summ["tokens"]}</b> tokens</div>'
- f'<div class="stat"><b>{summ["duration_ms"]/1000:.1f}</b> s</div>'
- f'<div style="margin-top:8px">{chips}</div>'
- + (f'<p class="note" style="margin-top:8px">{esc(ld.get("highlight",""))}</p>' if ld.get("highlight") else "")
- + "</div>"
- )
- elif ld.get("highlight"):
- trace_html = (f'<div class="trace"><h3>🎞 Live Demo</h3>'
- f'<p class="note">{esc(ld["highlight"])}</p></div>')
- # 相关卡
- rel = card.get("related", []) or []
- rel_links = []
- for r in rel:
- rc = cards_by_id.get(r)
- label = rc.get("title", r) if rc else r
- if rc:
- rel_links.append(f'<a href="{r}.html">{esc(label)}</a>')
- else:
- rel_links.append(f'<span class="note">{esc(r)}(待建)</span>')
- rel_html = (f'<p class="rel"><b>相关对照卡:</b> {" ".join(rel_links)}</p>'
- if rel_links else "")
- keypoint = (f'<div class="keypoint">🎯 {esc(concept.get("key_point",""))}</div>'
- if concept.get("key_point") else "")
- takeaway = (f'<div class="takeaway"><b>学习目标 / Takeaway</b>\n{esc(card.get("takeaway",""))}</div>'
- if card.get("takeaway") else "")
- body = f"""<!doctype html><html lang="zh"><head><meta charset="utf-8">
- <meta name="viewport" content="width=device-width,initial-scale=1">
- <title>{esc(title)}</title><style>{CSS}</style></head><body><div class="wrap">
- <div class="crumbs"><a href="index.html">← 对照卡索引</a></div>
- <header class="top">
- <span class="badge">模块 {esc(module)}</span><span class="badge">第 {esc(week)} 周</span>
- <span class="badge">{esc(cid)}</span>
- <h2 class="ctitle">{esc(title)}</h2></header>
- {keypoint}
- <div class="cols">
- <div class="col concept"><h3>📖 教科书概念</h3>{concept_html}</div>
- <div class="col code"><h3>💻 平台源码落点</h3>{code_html}</div>
- <div class="col formal"><h3>∑ 形式化对象</h3>{formal_html}</div>
- </div>
- {trace_html}
- {takeaway}
- {rel_html}
- <footer>对照卡源文件: <code>teaching/cards/{esc(card.get("_src",""))}</code> ·
- 源码 span 由 gen.py 在生成时实时读自仓库</footer>
- </div></body></html>"""
- return body
- def render_index(cards):
- by_mod = {}
- for c in cards:
- by_mod.setdefault(c.get("module", "?"), []).append(c)
- rows = []
- for mod in sorted(by_mod):
- rows.append(f'<div class="modhdr">{esc(MODULE_NAMES.get(mod, mod))}</div>')
- rows.append('<table class="idx">')
- for c in by_mod[mod]:
- cid = c.get("id")
- kp = (c.get("concept") or {}).get("key_point", "")
- rows.append(
- f'<tr><td class="wk">第 {esc(c.get("week","?"))} 周</td>'
- f'<td><a href="{esc(cid)}.html"><b>{esc(c.get("title",cid))}</b></a>'
- f'<div class="note">{esc(kp)}</div></td></tr>'
- )
- rows.append("</table>")
- body = f"""<!doctype html><html lang="zh"><head><meta charset="utf-8">
- <meta name="viewport" content="width=device-width,initial-scale=1">
- <title>对照卡索引 · 理论↔代码对应</title><style>{CSS}</style></head><body><div class="wrap">
- <header class="top"><h1>智能体:理论 ↔ 代码 对照卡</h1>
- <div class="sub">每张卡三栏对照:教科书概念 │ 平台源码落点(实时读仓库) │ 形式化对象。
- 共 {len(cards)} 张卡。</div></header>
- {"".join(rows)}
- <footer>由 <code>teaching/gen.py</code> 生成 · 卡片源: <code>teaching/cards/*.yml</code></footer>
- </div></body></html>"""
- return body
- def main():
- check_only = "--check" in sys.argv
- cards = load_cards()
- cards_by_id = {c.get("id"): c for c in cards}
- # 锚点校验(check 模式 + 正常模式都跑)
- problems = []
- for c in cards:
- for a in c.get("code", []) or []:
- ok, msg = read_source_span(a.get("file", ""), a.get("lines", ""))
- if not ok:
- problems.append(f"[{c.get('id')}] {msg}")
- if problems:
- print("⚠ 锚点问题:")
- for p in problems:
- print(" -", p)
- else:
- print(f"✓ {sum(len(c.get('code',[]) or []) for c in cards)} 个源码锚点全部有效")
- if check_only:
- sys.exit(1 if problems else 0)
- SITE_DIR.mkdir(exist_ok=True)
- (SITE_DIR / "index.html").write_text(render_index(cards), encoding="utf-8")
- for c in cards:
- (SITE_DIR / f"{c['id']}.html").write_text(
- render_card(c, cards_by_id), encoding="utf-8")
- print(f"✓ 生成 {len(cards)+1} 个页面 → {SITE_DIR.relative_to(REPO)}/")
- print(f" 打开: file://{SITE_DIR}/index.html")
- if __name__ == "__main__":
- main()
|