新增项目档案(五维 + 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 已更新。
409 lines
18 KiB
Python
Executable File
409 lines
18 KiB
Python
Executable File
#!/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())
|