Files
agent-radar/scripts/build_matrix.py
T
gitea-publish ed1da08f42 track: 新增 OpenClaw / QwenPaw / Tutti,样本达到 7 个
新增项目档案(五维 + claim_vs_reality):
- openclaw.yaml  388k★,GitHub 星标最多的仓库;个人助理赛道
- qwenpaw.yaml   34.7k★,OpenClaw 正面竞品;安全默认值最完整
- tutti.yaml     3.6k★,唯一做「多人 × 多 Agent」协作;技术维度基于本地源码实测

两处对既有结论的修正:
- OpenClaw 许可证自动定级为 L3/private 系误判。SPDX=NOASSERTION 是因为
  LICENSE 末尾多了一段第三方声明指引,原文主体是完整 MIT → 改判 L1/public。
  这是本系列第二个「SPDX 不可信」判例(第一个是 OpenWork 的 ee/ 目录切分许可)。
- OpenWork 巴士系数去重仅 3 人,与上一轮「工程治理最好」并不矛盾:
  流程规范与人员分散是两个独立维度,已在文章中区分。

脚本:
- build_matrix.py 象限图新增重合点沿小圆散开(SPREAD=11)+ 钳制 + 脚注。
  样本到 7 个时首次出现两组完全重合坐标,后画的点会整个盖住先画的,
  读者会以为少收录了一个项目——这种静默丢信息比图难看严重得多。
- build_matrix.py 跨项目文章索引改为按 emlog_id 升序(= 阅读顺序)。
  原先靠文件名 sorted() 排是碰运气:'-'(0x2D) < '.'(0x2E) 只是巧合。

发布:横评文第二弹 id=100,总纲 id=98 已更新。
2026-09-01 20:13:55 +08:00

409 lines
18 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 math
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
SPREAD = 11 # 重合点沿小圆散开的半径(px),需大于点的直径(14)的一半才看得出分离
BEGIN, END = "<!-- MATRIX:BEGIN -->", "<!-- MATRIX:END -->"
IDX_BEGIN, IDX_END = "<!-- INDEX:BEGIN -->", "<!-- INDEX: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/<aid>.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/<aid>.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", ""),
})
# 按 emlog_id 升序排 = 按发布先后排 = 阅读顺序。
# 这一步不能省:靠文件名 sorted() 排是碰运气——"00-comparison-2.meta.yaml"
# 恰好排在 "00-comparison.meta.yaml" 前面只是因为 '-'(0x2D) < '.'(0x2E),
# 换个命名(如 10-comparison)顺序就反了。系列文章要按发出来的先后读,
# 不能让 ASCII 码表来决定。
out.sort(key=lambda a: a["emlog_id"])
return out
def article_index_section(items: list[dict]) -> str:
"""文章索引。
这段原来是手抄的,结果新发了横评文、索引里却没有它——手写的索引
每发一篇文章就会漂移一次。改成从两处事实源生成:
项目深度文 ← data/projects/*.yaml 的 article 块
跨项目文章 ← articles/*.meta.yaml
"""
L = ["<!-- INDEX:BEGIN -->"]
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("<!-- INDEX:END -->")
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 viewBox="0 0 {W} {H}" width="{W}" height="{H}" '
f'xmlns="http://www.w3.org/2000/svg" '
f'style="font-family:sans-serif;max-width:100%;height:auto">')
svg.append(f'<text x="{W//2}" y="26" text-anchor="middle" font-size="15" '
f'font-weight="600" fill="#1f2328">{title}</text>')
px, py, pw, ph = PAD, 55, W - PAD * 2, H - PAD - 55
svg.append(f'<rect x="{px}" y="{py}" width="{pw}" height="{ph}" fill="#fafbfc" '
f'stroke="#d8dee4"/>')
# 网格
for k in range(1, 4):
gx = px + pw * k / 4
gy = py + ph * k / 4
svg.append(f'<line x1="{gx}" y1="{py}" x2="{gx}" y2="{py+ph}" stroke="#eaeef2"/>')
svg.append(f'<line x1="{px}" y1="{gy}" x2="{px+pw}" y2="{gy}" stroke="#eaeef2"/>')
# 轴标签
svg.append(f'<text x="{px}" y="{py+ph+22}" font-size="11" fill="#6a737d">{xlo}</text>')
svg.append(f'<text x="{px+pw}" y="{py+ph+22}" text-anchor="end" font-size="11" fill="#6a737d">{xhi}</text>')
svg.append(f'<text x="{px-8}" y="{py+ph+6}" text-anchor="end" font-size="11" fill="#6a737d">{ylo}</text>')
svg.append(f'<text x="{px-8}" y="{py+12}" text-anchor="end" font-size="11" fill="#6a737d">{yhi}</text>')
# ---- 重合点处理 ----
# 样本一多,必然出现坐标完全相同的项目。本体系加到第 7 个样本时首次撞上:
# 视图 A 里 Maka 与 OpenClaw 都是 (6,1),视图 B 里 iPolloWork 与 Tutti 都是 (7,7)。
# 完全重合的点会被后画的那个整个盖住——读者会以为少收录了一个项目,
# 这种"静默丢信息"比图难看严重得多,所以必须在这里兜住。
# 做法:把重合组沿一个小圆均匀散开,并在图下加一行说明交代清楚。
pts = []
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
pts.append({"d": d, "xv": float(xv), "yv": float(yv)})
groups: dict[tuple, list] = {}
for p in pts:
groups.setdefault((p["xv"], p["yv"]), []).append(p)
overlapped = [g for g in groups.values() if len(g) > 1]
for g in overlapped:
n = len(g)
for i, p in enumerate(g):
ang = 2 * math.pi * i / n
p["dx"] = SPREAD * math.cos(ang)
p["dy"] = SPREAD * math.sin(ang)
# 点
for p in pts:
d = p["d"]
cx = px + pw * max(0, min(10, p["xv"])) / 10 + p.get("dx", 0.0)
cy = py + ph * (1 - max(0, min(10, p["yv"])) / 10) + p.get("dy", 0.0)
# 散开后别顶出绘图区
cx = max(px + 4, min(px + pw - 4, cx))
cy = max(py + 4, min(py + ph - 4, cy))
name = d.get("name", "")
# 重合组用更高的不透明度,让读者一眼看出"这里挤了不止一个"
op = "0.9" if len(groups[(p["xv"], p["yv"])]) > 1 else "0.75"
svg.append(f'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="7" fill="#0969da" '
f'fill-opacity="{op}" stroke="#fff" stroke-width="1.5"/>')
# 标签默认画在点右侧,但满分点会顶到绘图区右边界(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'<text x="{cx-11:.1f}" y="{cy+4:.1f}" text-anchor="end" '
f'font-size="12" fill="#1f2328">{name}</text>')
else:
svg.append(f'<text x="{cx+11:.1f}" y="{cy+4:.1f}" font-size="12" '
f'fill="#1f2328">{name}</text>')
if overlapped:
# 如实交代散开这件事,别让读者以为点的位置就是精确坐标
who = ";".join("、".join(x["d"].get("name", "") for x in g) for g in overlapped)
svg.append(f'<text x="{px}" y="{H-6}" font-size="10" fill="#8b949e">'
f'※ 坐标完全重合的项目已沿小圆散开以便区分:{who}</text>')
svg.append("</svg>")
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())