Files
agent-radar/scripts/build_matrix.py
T
gitea-publish 56e2c8a403 fix: 排除规则漏了根目录的 EE 许可证引用文件;脚本补 public 方向修正
修一个会误导读者的排除漏洞:
只排除 ee/ 时,LICENSES/LicenseRef-OpenWork-EE.txt 仍会被推进镜像。
它是 REUSE 规范的引用文件,存在的唯一意义是给 ee/ 下的文件做 SPDX 引用
(ee/ 里写着 "SPDX-License-Identifier: LicenseRef-OpenWork-EE")。
ee/ 一删,它就成了孤儿。许可证文本公開本身不侵权(上游自己就公开),
但一个不含 EE 代码的仓库里躺着 EE 许可证,读者只会以为我们漏排了。

许可证块补 redistributable_after_excludes: true。
依据:根 LICENSE 原文(已读远端全文)——
  "All content that resides under the /ee directory ... OpenWork EE License"
  "Content outside ... is available under the 'MIT' license"
排除 ee/ 后为纯 MIT。

mirror_sync.sh 补 private → public 方向修正,但默认不自动翻:
原先脚本只有「收紧」方向(应为私有就 PATCH private:true),
没有放宽方向——openwork 判定为 public 但仓库停在 private,
重跑多少次都不会翻过来,档案与事实不一致却看不出原因。
现在会报告「应为 public 但当前 private」,要求显式加 --set-public。
公开不可逆,放松方向永远不该自动做。

一句话结论去掉样本计数:
maka「四个里唯一」、QwenPaw/Tutti「七个里…」——每加一批样本就过时一次。
改成不带计数的表述,「已收录样本中」本就是隐含的。

总纲 id=98 已重发。
2026-09-02 00:26:49 +08:00

436 lines
19 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)
# ---------------------------------------------------------------- 主流程
# sync_note 是手写备注,mode / last_sync 是脚本回写的事实。
# 两者会漂移:镜像跑完之后 mode 变成了 source-only,note 里还写着「尚未执行,待带宽窗口」。
# 档案里出现和事实相反的话,比没有备注更糟——读者会照着错的那个信。
STALE_PHRASES = ("尚未执行", "暂未镜像", "待带宽", "尚未镜像", "未镜像")
def check_mirror_notes(items: list[dict]) -> None:
"""mirror 块已跑完(mode 非 pending/空)却还写着「尚未执行」→ 提示改。"""
bad = []
for d in items:
m = d.get("mirror") or {}
mode = str(m.get("mode") or "").strip()
if mode in ("", "pending", "link-only"):
continue # 没跑过 / 明确不镜像,note 说「待执行」是对的
note = str(m.get("sync_note") or "")
hit = [p for p in STALE_PHRASES if p in note]
if hit:
bad.append((d.get("id") or d.get("name"), mode, "/".join(hit)))
if not bad:
return
print(f" [warn] {len(bad)} 个档案的 mirror.sync_note 与实际状态矛盾:")
for aid, mode, hit in bad:
print(f" - {aid}:mode={mode},但 note 里仍有「{hit}」")
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)}")
check_mirror_notes(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())