#!/usr/bin/env python3 """从所有项目档案生成横向对比矩阵与赛道图,回填到总纲文。 用法 ---- python3 scripts/build_matrix.py python3 scripts/build_matrix.py --stdout # 只打印,不写文件 输出的两张图 ------------ 视图 A:形态纵深(x:单点工具 → 统一工作台)× 引擎立场(y:绑定单引擎 → 多引擎中立) 视图 B:本地优先(x:强云依赖 → 完全本地)× 交付物纵深(y:只出代码 → 多模态可编辑) 阈值:项目数 <4 时**不出象限图**,改出赛道分层图。 原因:象限图的意义来自点的相对分布,只有一两个点时它传达的信息量 还不如一张分层图,却会让读者误以为已经建立了坐标系。 """ from __future__ import annotations import argparse import re import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from _common import ARTICLES, PROJECTS, load_yaml # noqa: E402 QUAD_THRESHOLD = 4 BEGIN, END = "", "" IDX_BEGIN, IDX_END = "", "" OVERVIEW_AID = "00-overview" def short_maturity(risk: dict) -> str: """成熟度在档案里是一整段判断(几十字),直接塞进表格会把列撑爆。 优先用档案给的 `maturity_level` 短标签(人工判断最准); 没有则从长文本截取第一个分句兜底。 """ r = risk or {} lv = r.get("maturity_level") if lv: return str(lv) m = (r.get("maturity") or "").strip() if not m: return "-" for sep in ("。", ";", "\n"): if sep in m: m = m.split(sep)[0] break return m[:14] + ("…" if len(m) > 14 else "") def cell(v, empty="-") -> str: if v is None or v == "" or v == [] or v == {}: return empty if isinstance(v, bool): return "是" if v else "否" if isinstance(v, list): return "、".join(str(x) for x in v) if v else empty if isinstance(v, dict): return "、".join(f"{k} {x}" for k, x in v.items()) if v else empty return str(v).replace("|", "\\|").replace("\n", " ") def load_projects() -> list[dict]: out = [] for p in sorted(PROJECTS.glob("*.yaml")): d = load_yaml(p) if d: d["_id"] = p.stem out.append(d) return out # ---------------------------------------------------------------- 矩阵 def build_tables(items: list[dict]) -> str: L = [] add = L.append add("### 业务维度\n") add("| 项目 | 定位 | 目标用户 | 变现 | 许可证 | 成色 |") add("| --- | --- | --- | --- | --- | --- |") for d in items: b = d.get("business") or {} lic = b.get("license") or {} add(f"| **{d.get('name','')}** | {cell(b.get('positioning'))} | " f"{cell(b.get('target_users'))} | {cell(b.get('monetization'))} | " f"{cell(lic.get('name'))} | {cell(lic.get('openness'))} |") add("\n### 场景维度\n") add("| 项目 | 交互形态 | 人机协作 | 交付物 |") add("| --- | --- | --- | --- |") for d in items: s = d.get("scene") or d.get("scenario") or {} add(f"| **{d.get('name','')}** | {cell(s.get('interaction_mode'))} | " f"{cell(s.get('human_in_the_loop'))} | {cell(s.get('deliverables'))} |") add("\n### 技术维度\n") add("| 项目 | 形态 | 引擎抽象 | 接入深度 | 模型绑定 | 本地优先 |") add("| --- | --- | --- | --- | --- | --- |") for d in items: t = d.get("tech") or {} ea = t.get("engine_abstraction") or {} add(f"| **{d.get('name','')}** | {cell(t.get('form_factor'))} | " f"{cell(ea.get('abstraction_layer'))} | {cell(ea.get('depth_notes'))} | " f"{cell(t.get('model_binding'))} | {cell(t.get('local_first'))} |") add("\n### 生态与风险\n") add("| 项目 | Star | Fork | Issues | 最新 release | 节奏 | 成熟度 | 数据日期 |") add("| --- | --- | --- | --- | --- | --- | --- | --- |") for d in items: m = (d.get("ecosystem") or {}).get("metrics") or {} stale = " ⚠" if m.get("stale") else "" add(f"| **{d.get('name','')}** | {cell(m.get('stars'))} | {cell(m.get('forks'))} | " f"{cell(m.get('open_issues'))} | {cell(m.get('latest_release'))} | " f"{cell(m.get('release_cadence'))} | {short_maturity(d.get('risk') or {})} | " f"{cell(m.get('data_date'))}{stale} |") # 成熟度是长文本,塞进表格会把列撑爆。表格里只放短标签, # 完整的成熟度判断单独列在表后,读者想看细节再往下读。 mats = [(d.get("name", ""), (d.get("risk") or {}).get("maturity")) for d in items] if any(v for _, v in mats): add("\n**成熟度详解**\n") for name, v in mats: if v: add(f"- **{name}**:{v}") # 宣称 vs 实测:全套矩阵里信息密度最高的一张表 rows = [] for d in items: for c in (d.get("risk") or {}).get("claim_vs_reality") or []: if isinstance(c, dict): rows.append((d.get("name", ""), c)) if rows: add("\n### 宣称 vs 实测\n") add("| 项目 | 宣称 | 实际 | 证据来源 |") add("| --- | --- | --- | --- |") for name, c in rows: add(f"| **{name}** | {cell(c.get('claim'))} | {cell(c.get('actual'))} | " f"{cell(c.get('source'))} |") add("\n### 一句话结论\n") add("| 项目 | 结论 | 适合 | 不适合 |") add("| --- | --- | --- | --- |") for d in items: v = d.get("verdict") or {} add(f"| **{d.get('name','')}** | {cell(v.get('one_liner'))} | " f"{cell(v.get('who_should_use'))} | {cell(v.get('who_should_not'))} |") return "\n".join(L) # ---------------------------------------------------------------- 文章索引 def read_h1(aid: str) -> str: """取 articles/.md 的第一个一级标题作为文章名。""" p = ARTICLES / f"{aid}.md" if not p.exists(): return aid for line in p.read_text(encoding="utf-8").lstrip().split("\n"): if line.startswith("# "): return line[2:].strip() return aid def collect_cross_articles() -> list[dict]: """跨项目文章(总纲 / 横评 / 专题)的发布元数据。 ID 落在 articles/.meta.yaml 里,由 publish_emlog.py 维护。 总纲文自身要排除——索引出现在总纲内部,自己链自己没有意义。 """ out = [] for mp in sorted(ARTICLES.glob("*.meta.yaml")): aid = mp.name[: -len(".meta.yaml")] if aid == OVERVIEW_AID: continue d = load_yaml(mp) or {} if not d.get("emlog_id"): continue out.append({ "aid": aid, "title": read_h1(aid), "emlog_id": d["emlog_id"], "url": d.get("url") or f"https://blog.apescale.com/?post={d['emlog_id']}", "last_published": d.get("last_published", ""), }) return out def article_index_section(items: list[dict]) -> str: """文章索引。 这段原来是手抄的,结果新发了横评文、索引里却没有它——手写的索引 每发一篇文章就会漂移一次。改成从两处事实源生成: 项目深度文 ← data/projects/*.yaml 的 article 块 跨项目文章 ← articles/*.meta.yaml """ L = [""] cross = collect_cross_articles() L.append("| 项目 | 一句话结论 | 深度文 |") L.append("| --- | --- | --- |") fallback = cross[0]["url"] if cross else "" for d in items: art = d.get("article") or {} url = art.get("url") or "" if url: link = f"[已发布]({url})" elif fallback: link = f"待发布(见下方横评)" else: link = "待发布" one = (d.get("verdict") or {}).get("one_liner") or "-" # 一句话结论本来就长,索引表里截断到第一个分句,避免一行几十字 for sep in ("——", "。", ";"): if sep in one: one = one.split(sep)[0] break L.append(f"| **{d.get('name','')}** | {one} | {link} |") if cross: L.append("\n**跨项目文章**\n") for a in cross: L.append(f"- [{a['title']}]({a['url']}) — {a['last_published']}") pending = [d.get("name", "") for d in items if not (d.get("article") or {}).get("url")] if pending: L.append(f"\n> 待补深度文:{'、'.join(pending)}。" f"当前可先读上方跨项目文章,其中已包含这些项目的完整分维度对比。") L.append("") return "\n".join(L) # ---------------------------------------------------------------- 图 def stack_diagram(items: list[dict]) -> str: """项目 <4 个时的赛道分层图:按交互形态把项目归到三层栈。""" layers = [ ("执行引擎层", "真正跑 agent loop 的地方", ["cli"]), ("编辑器内 Agent 层", "嵌在 IDE / 编辑器里的交互层", ["ide-plugin", "editor-fork"]), ("桌面工作台层", "跨引擎、跨项目、跨交付物的统一界面", ["desktop-app", "web-app"]), ] L = ["```text"] L.append("┌─────────────────────────────────────────────────────────┐") for name, desc, modes in layers: hits = [] for d in items: modes_ = (d.get("scenario") or {}).get("interaction_mode") or [] if any(m in modes_ for m in modes): hits.append(f"{d.get('name')}★{(d.get('ecosystem',{}).get('metrics',{}) or {}).get('stars','-')}") body = "、".join(hits) if hits else "(暂无收录)" L.append(f"│ {name:<12} {body}") L.append(f"│{'':<14}└ {desc}") if name != layers[-1][0]: L.append("│" + " " * 56 + "│") L.append("└─────────────────────────────────────────────────────────┘") L.append("```") L.append(f"\n> 当前收录 {len(items)} 个项目,不足 {QUAD_THRESHOLD} 个,此阶段只出分层图。" f"样本够了之后会自动切换为象限图。") return "\n".join(L) def quadrant_svg(items: list[dict]) -> str: """项目 >=4 个时的双视图象限图。""" W, H, PAD = 720, 420, 70 plots = [ ("视图 A · 形态纵深 × 引擎立场", "form_depth", "engine_neutral", "单点工具", "统一工作台", "绑定单引擎", "多引擎中立"), ("视图 B · 本地优先 × 交付物纵深", "local_first_score", "deliverable_depth", "强云依赖", "完全本地", "只出代码", "多模态可编辑"), ] svg = [] for i, (title, xk, yk, xlo, xhi, ylo, yhi) in enumerate(plots): ox = i * (W + 30) # max-width:100% + height:auto 不能省:SVG 带固定 width/height 属性, # 在窄屏(手机、窄主题)里会撑破容器横向溢出。有 viewBox 时浏览器 # 才能按这两个声明等比缩放。 svg.append(f'') svg.append(f'{title}') px, py, pw, ph = PAD, 55, W - PAD * 2, H - PAD - 55 svg.append(f'') # 网格 for k in range(1, 4): gx = px + pw * k / 4 gy = py + ph * k / 4 svg.append(f'') svg.append(f'') # 轴标签 svg.append(f'{xlo}') svg.append(f'{xhi}') svg.append(f'{ylo}') svg.append(f'{yhi}') # 点 for d in items: c = d.get("chart") or {} xv, yv = c.get(xk), c.get(yk) if xv is None or yv is None: continue cx = px + pw * max(0, min(10, float(xv))) / 10 cy = py + ph * (1 - max(0, min(10, float(yv))) / 10) name = d.get("name", "") svg.append(f'') # 标签默认画在点右侧,但满分点会顶到绘图区右边界(cx == px+pw), # 文字就会溢出画布被裁掉。靠右时翻到点的左侧。 # 估算文字宽度:中文按 12px、其余按 7px,够用且不依赖字体度量。 tw = sum(12 if ord(ch) > 0x2E80 else 7 for ch in name) if cx + 11 + tw > W - 4: svg.append(f'{name}') else: svg.append(f'{name}') svg.append("") return "\n".join(svg) # ---------------------------------------------------------------- 主流程 def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--stdout", action="store_true", help="只打印不写文件") args = ap.parse_args() items = load_projects() if not items: print("data/projects/ 下没有任何档案。") return 1 print(f"==> 载入 {len(items)} 个项目:{', '.join(d.get('name','') for d in items)}") matrix = build_tables(items) chart = quadrant_svg(items) if len(items) >= QUAD_THRESHOLD else stack_diagram(items) block = f"{BEGIN}\n{matrix}\n\n{chart}\n{END}" if args.stdout: print(block) return 0 path = ARTICLES / f"{OVERVIEW_AID}.md" if not path.exists(): print(f"总纲文不存在:{path}") return 1 text = path.read_text(encoding="utf-8") if BEGIN not in text or END not in text: print(f"总纲文缺少 {BEGIN} / {END} 占位标记,未回填。") return 1 text = re.sub(rf"{re.escape(BEGIN)}.*?{re.escape(END)}", lambda _: block, text, flags=re.S) # 文章索引同样回填,两个块各管一段,互不干扰 index = article_index_section(items) if IDX_BEGIN in text and IDX_END in text: text = re.sub(rf"{re.escape(IDX_BEGIN)}.*?{re.escape(IDX_END)}", lambda _: index, text, flags=re.S) else: print(f" [warn] 总纲文缺少 {IDX_BEGIN} / {IDX_END},文章索引未回填") path.write_text(text, encoding="utf-8") print(f"==> 已回填总纲文:{path}") return 0 if __name__ == "__main__": raise SystemExit(main())