#!/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'
{"".join(rows)}'
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'{esc(concept.get("textbook", ""))}
' # 源码栏 anchors = [] for a in card.get("code", []) or []: block = render_code_block(a.get("file", ""), a.get("lines", "")) anchors.append( '{esc(formal["note"])}
' # 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'{esc(ld.get("highlight",""))}
' if ld.get("highlight") else "") + "{esc(summ["path"])} · status={esc(summ["status"])}{esc(ld.get("highlight",""))}
' if ld.get("highlight") else "") + "{esc(ld["highlight"])}
相关对照卡: {" ".join(rel_links)}
' if rel_links else "") keypoint = (f'| 第 {esc(c.get("week","?"))} 周 | ' f'{esc(c.get("title",cid))}'
f' {esc(kp)} |