235 lines
10 KiB
Python
Executable File
235 lines
10 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 -->"
|
|
|
|
|
|
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'))} | {cell((d.get('risk') or {}).get('maturity'))} | "
|
|
f"{cell(m.get('data_date'))}{stale} |")
|
|
|
|
# 宣称 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 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)
|
|
svg.append(f'<svg viewBox="0 0 {W} {H}" width="{W}" height="{H}" '
|
|
f'xmlns="http://www.w3.org/2000/svg" style="font-family:sans-serif">')
|
|
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"/>')
|
|
svg.append(f'<text x="{cx+11:.1f}" y="{cy+4:.1f}" font-size="12" 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"{'00-overview'}.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)
|
|
path.write_text(text, encoding="utf-8")
|
|
print(f"==> 已回填总纲文:{path}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|