样本 - 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 无处可写)
369 lines
15 KiB
Python
Executable File
369 lines
15 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 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())
|