Files
agent-radar/scripts/build_matrix.py
T
gitea-publish e5cdb1801e track: 新增 OpenWork / Orca / Maka 三项目,样本达到 4 个
样本
- openwork (different-ai/openwork) 23.2k★ 能力分发层,DCO + evals + worlds
- orca (stablyai/orca) 58.8k★ 并行编排外壳,5000 issue 实为团队自开任务板
- maka (apache/maka) 4.4k★ 执行记录层,Apache 孵化但无正式 release

档案
- 三份五维档案填完,risk.claim_vs_reality 均已核实到证据
- ipollowork 象限坐标从占位 9/9/9/9 校准为 8/7/7/7(与新样本同一标尺)

文章
- 新增横评文《四个 Agent 工作台的路线分野:把「多引擎中立」拆开看》(emlog id=99)
- 总纲文回填四项目矩阵 + 双视图象限图 (emlog id=98)

镜像
- gitinbox/maka 已推送(public,source-only 快照 12 MB)
- openwork(291MB) / orca(671MB) 受 50KB/s 带宽约束,档案标 mode: pending

规则
- license-tiers.yaml 新增「目录切分许可」降级流程(OpenWork 判例:
  排除 ee/ 后为纯 MIT → public,完整镜像 → private)
- registry.yaml openwork 可见性改 public + mirror_excludes: ["ee/"]

脚本修复
- _common.py 新增 upsert_block:手工整块覆盖 ecosystem 删掉 metrics 时能自愈
- mirror_sync.sh:SOURCE_ONLY 未初始化(set -u 下偶发炸);
  浅仓推送被服务端拒绝 → 自动 --unshallow,失败降级快照;
  量体积前先 repack(此前新 commit 全是 loose object,恒打印 0 MB)
- build_matrix.py:成熟度长文本移出表格(新增 maturity_level 短标签);
  象限图标签靠右时翻转到左侧防溢出;SVG 加 max-width 防窄屏溢出;
  文章索引改为自动生成(原先手抄,横评文发了索引里却没有)
- publish_emlog.py:ID 回写按「有无同名档案」分流,
  00-* 跨项目文章落到 articles/<id>.meta.yaml(此前 ID 无处可写)
2026-09-01 19:27:21 +08:00

369 lines
15 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 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 = "<!-- 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", ""),
})
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>')
# 点
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'<circle cx="{cx:.1f}" cy="{cy:.1f}" r="7" fill="#0969da" '
f'fill-opacity="0.75" 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>')
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())