@easysociable/cli 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/easysociable.js +301 -2829
- package/dist/easysociable.js.map +1 -1
- package/package.json +17 -12
- package/skills/ONBOARDING.md +3 -1
- package/skills/easysociable-content/INSTALL.md +60 -0
- package/skills/easysociable-content/SKILL.md +107 -96
- package/skills/easysociable-content/fixtures/golden-run-en/draft.md +13 -0
- package/skills/easysociable-content/fixtures/golden-run-en/idea.md +26 -0
- package/skills/easysociable-content/fixtures/golden-run-en/pack-tiktok.json +11 -0
- package/skills/easysociable-content/platforms/claude.md +1 -2
- package/skills/easysociable-content/platforms/codex.md +1 -2
- package/skills/easysociable-content/platforms/hermes.md +1 -2
- package/skills/easysociable-content/platforms/openclaw.md +1 -2
- package/skills/easysociable-content/references/CLI.md +25 -12
- package/skills/easysociable-content/references/CONTRACT_SYNC.md +42 -0
- package/skills/easysociable-content/references/MCP.md +7 -12
- package/skills/easysociable-content/references/PAGE_PACK.md +128 -5
- package/skills/easysociable-content/references/PLATFORM_GUIDE.md +36 -0
- package/skills/easysociable-content/references/RUN_LAYOUT.md +73 -0
- package/skills/easysociable-content/references/TROUBLESHOOTING.md +11 -9
- package/skills/easysociable-content/references/VAULT.md +32 -12
- package/skills/easysociable-content/references/VOICE.md +16 -0
- package/skills/easysociable-content/scripts/contracts.mjs +183 -0
- package/skills/easysociable-content/scripts/export-contracts.mjs +76 -0
- package/skills/easysociable-content/scripts/fixture.test.mjs +24 -0
- package/skills/easysociable-content/scripts/package-skill.mjs +49 -0
- package/skills/easysociable-content/scripts/setup.mjs +127 -83
- package/skills/easysociable-content/scripts/setup.test.mjs +49 -38
- package/skills/easysociable-content/scripts/starter.mjs +50 -48
- package/skills/easysociable-content/scripts/validate-export.mjs +42 -0
- package/skills/easysociable-content/scripts/validate-pack.mjs +48 -0
- package/skills/easysociable-content/scripts/validate-run.mjs +100 -0
- package/skills/easysociable-content/scripts/validate.test.mjs +101 -0
- package/skills/easysociable-content/scripts/workbench.mjs +135 -0
- package/skills/easysociable-content/starter/CLAUDE.md +756 -0
- package/skills/easysociable-content/starter/accounts/README.md +9 -0
- package/skills/easysociable-content/starter/accounts/_index.md +31 -0
- package/skills/easysociable-content/starter/accounts/overrides/README.md +3 -0
- package/skills/easysociable-content/starter/discovery/README.md +18 -0
- package/skills/easysociable-content/starter/discovery/_index.md +27 -0
- package/skills/easysociable-content/starter/discovery/_template.md +41 -0
- package/skills/easysociable-content/starter/engine.json +19 -0
- package/skills/easysociable-content/starter/exports/README.md +28 -0
- package/skills/easysociable-content/starter/inbox/README.md +9 -0
- package/skills/easysociable-content/starter/inbox/_index.md +24 -0
- package/skills/easysociable-content/starter/inbox/entries/README.md +3 -0
- package/skills/easysociable-content/starter/platforms/README.md +19 -0
- package/skills/easysociable-content/starter/platforms/ig.md +69 -0
- package/skills/easysociable-content/starter/platforms/instagram.md +5 -0
- package/skills/easysociable-content/starter/platforms/linkedin.md +72 -0
- package/skills/easysociable-content/starter/platforms/threads.md +69 -0
- package/skills/easysociable-content/starter/platforms/tiktok.md +71 -0
- package/skills/easysociable-content/starter/platforms/x.md +78 -0
- package/skills/easysociable-content/starter/platforms/xiaohongshu.md +19 -0
- package/skills/easysociable-content/starter/profiles/_index.md +13 -0
- package/skills/easysociable-content/starter/profiles/default/README.md +11 -0
- package/skills/easysociable-content/starter/profiles/default/audience.md +38 -0
- package/skills/easysociable-content/starter/profiles/default/pillars.md +33 -0
- package/skills/easysociable-content/starter/profiles/default/voice.md +24 -0
- package/skills/easysociable-content/starter/published/_index.md +20 -0
- package/skills/easysociable-content/starter/raw/README.md +4 -0
- package/skills/easysociable-content/starter/raw/_index.md +12 -0
- package/skills/easysociable-content/starter/runs/README.md +22 -0
- package/skills/easysociable-content/starter/runs/_index.md +41 -0
- package/skills/easysociable-content/starter/runs/_template/brief.md +66 -0
- package/skills/easysociable-content/starter/runs/_template/draft.md +11 -0
- package/skills/easysociable-content/starter/runs/_template/editor.md +18 -0
- package/skills/easysociable-content/starter/runs/_template/feedback.md +15 -0
- package/skills/easysociable-content/starter/runs/_template/idea.md +27 -0
- package/skills/easysociable-content/starter/runs/_template/pack.md +47 -0
- package/skills/easysociable-content/starter/runs/_template/packet.md +25 -0
- package/skills/easysociable-content/starter/runs/_template/rubric.md +19 -0
- package/skills/easysociable-content/starter/swipe/_index.md +27 -0
- package/skills/easysociable-content/starter/swipe/_template.md +34 -0
- package/skills/easysociable-content/starter/topics/README.md +25 -0
- package/skills/easysociable-content/starter/topics/WATCHLIST.md +65 -0
- package/skills/easysociable-content/starter/topics/_index.md +44 -0
- package/skills/easysociable-content/starter/topics/_template-item.md +90 -0
- package/skills/easysociable-content/starter/topics/items/README.md +4 -0
- package/skills/easysociable-content/starter/topics/scoring.md +201 -0
- package/skills/easysociable-content/starter/topics/watchlists/README.md +10 -0
- package/skills/easysociable-content/starter/topics/watchlists/_shared.md +14 -0
- package/skills/easysociable-content/starter/topics/watchlists/default.md +6 -0
- package/skills/easysociable-content/starter/wiki/README.md +13 -0
- package/skills/easysociable-content/starter/wiki/atoms/_index.md +23 -0
- package/skills/easysociable-content/starter/wiki/atoms/_template.md +31 -0
- package/skills/easysociable-content/starter/wiki/claims/_index.md +26 -0
- package/skills/easysociable-content/starter/wiki/claims/_template.md +38 -0
- package/skills/easysociable-content/starter/workbench/README.md +17 -0
- package/skills/easysociable-content/workbench/build.py +1074 -0
- package/skills/easysociable-content/workbench/serve.py +53 -0
- package/skills/easysociable-content/workbench/template.html +2119 -0
- package/skills/easysociable-content/workflows/ANALYSIS_AND_COVERAGE.md +14 -5
- package/skills/easysociable-content/workflows/CONTENT_EXPORT.md +16 -11
- package/skills/easysociable-content/workflows/GETTING_STARTED.md +40 -0
- package/skills/easysociable-content/workflows/MCP_FIRST.md +3 -8
- package/skills/easysociable-content/workflows/OUT_OF_SCOPE.md +18 -0
- package/skills/easysociable-content/workflows/PACK_AUTHORING.md +42 -0
- package/skills/easysociable-content/workflows/SWIPE_CAPTURE.md +18 -0
- package/skills/easysociable-content/workflows/TOPIC_DRAFT.md +28 -4
- package/skills/easysociable-content/workflows/WORKING_DRAFT.md +17 -8
- package/skills/easysociable-slideshow/SKILL.md +20 -13
- package/skills/easysociable-slideshow/references/VAULT.md +13 -11
- package/skills/easysociable-template/scripts/generate-theme-from-hex.mjs +0 -0
- package/skills/easysociable-content/workflows/DEVICE_TASKS.md +0 -5
- package/skills/easysociable-content/workflows/EXPERIMENTS.md +0 -6
- package/skills/easysociable-content/workflows/PUBLICATION.md +0 -5
|
@@ -0,0 +1,1074 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Scan Content Engine indexes → workbench/index.html (read-only)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
ROOT = Path(__file__).resolve().parent.parent
|
|
12
|
+
OUT = Path(__file__).resolve().parent / "index.html"
|
|
13
|
+
TPL = Path(__file__).resolve().parent / "template.html"
|
|
14
|
+
|
|
15
|
+
PILLAR_HEAD = re.compile(r"^###\s+(P\d+)\s+[—–-]\s+(.+)$", re.M)
|
|
16
|
+
# Known Chinese titles keyed by English heading from pillars.md (profile-agnostic fallbacks by P-id still OK for current vault).
|
|
17
|
+
PILLAR_ZH_BY_EN = {
|
|
18
|
+
"Agency content problems": "机构内容生产问题",
|
|
19
|
+
"AI content systems": "AI 内容系统",
|
|
20
|
+
"Building EasySociable": "建造 EasySociable",
|
|
21
|
+
}
|
|
22
|
+
PILLAR_ZH_BY_ID = {
|
|
23
|
+
"P1": "机构内容生产问题",
|
|
24
|
+
"P2": "AI 内容系统",
|
|
25
|
+
"P3": "建造 EasySociable",
|
|
26
|
+
}
|
|
27
|
+
CAPTURE_RE = re.compile(r"C-\d{8}-\d{2}")
|
|
28
|
+
SWIPE_RE = re.compile(r"\bS\d+\b")
|
|
29
|
+
ATOM_RE = re.compile(r"\bA-[a-z0-9-]+\b")
|
|
30
|
+
CLAIM_RE = re.compile(r"\bC-(?!\d{8})[a-z0-9-]+\b")
|
|
31
|
+
RUN_RE = re.compile(r"RUN-[A-Za-z0-9-]+")
|
|
32
|
+
WIKI = re.compile(r"\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_pillars_file(path: Path) -> dict:
|
|
36
|
+
if not path.exists():
|
|
37
|
+
return {}
|
|
38
|
+
out = {}
|
|
39
|
+
for m in PILLAR_HEAD.finditer(path.read_text(encoding="utf-8")):
|
|
40
|
+
pid, title = m.group(1), m.group(2)
|
|
41
|
+
title = re.sub(r"\s*\([^)]*\)\s*$", "", title).strip()
|
|
42
|
+
out[pid] = {
|
|
43
|
+
"en": title,
|
|
44
|
+
"zh": PILLAR_ZH_BY_EN.get(title) or PILLAR_ZH_BY_ID.get(pid, title),
|
|
45
|
+
}
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_registry() -> tuple[list[dict], list[dict], dict]:
|
|
50
|
+
"""profiles, accounts, pillars_by_profile."""
|
|
51
|
+
profiles = []
|
|
52
|
+
for row in table_with(read("profiles/_index.md"), "id", "path", "voice", "one_liner", "accounts"):
|
|
53
|
+
if not row["id"] or row["id"].startswith("---"):
|
|
54
|
+
continue
|
|
55
|
+
path = row["path"].strip().strip("`") or f"profiles/{row['id']}/"
|
|
56
|
+
if not path.endswith("/"):
|
|
57
|
+
path = f"{path}/"
|
|
58
|
+
voice = row["voice"].strip().strip("`")
|
|
59
|
+
voice = re.split(r"\s*\(", voice, 1)[0].strip().strip("`")
|
|
60
|
+
profiles.append(
|
|
61
|
+
{
|
|
62
|
+
"id": row["id"],
|
|
63
|
+
"path": path,
|
|
64
|
+
"voice": voice,
|
|
65
|
+
"one_liner": row["one_liner"],
|
|
66
|
+
"accounts": [a.strip() for a in row["accounts"].split(",") if a.strip()],
|
|
67
|
+
}
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
accounts = []
|
|
71
|
+
for row in table_with(
|
|
72
|
+
read("accounts/_index.md"),
|
|
73
|
+
"handle",
|
|
74
|
+
"profile_id",
|
|
75
|
+
"voice",
|
|
76
|
+
"platforms",
|
|
77
|
+
"default_platform",
|
|
78
|
+
"overrides",
|
|
79
|
+
"status",
|
|
80
|
+
):
|
|
81
|
+
if not row["handle"] or row["handle"].startswith("---"):
|
|
82
|
+
continue
|
|
83
|
+
accounts.append(
|
|
84
|
+
{
|
|
85
|
+
"handle": row["handle"],
|
|
86
|
+
"profile_id": row["profile_id"],
|
|
87
|
+
"voice": row["voice"],
|
|
88
|
+
"platforms": row["platforms"],
|
|
89
|
+
"default_platform": row["default_platform"],
|
|
90
|
+
"overrides": row["overrides"],
|
|
91
|
+
"status": row["status"],
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
pillars_by_profile = {}
|
|
96
|
+
for p in profiles:
|
|
97
|
+
pillars_by_profile[p["id"]] = parse_pillars_file(ROOT / p["path"] / "pillars.md")
|
|
98
|
+
return profiles, accounts, pillars_by_profile
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def read(rel: str) -> str:
|
|
102
|
+
return (ROOT / rel).read_text(encoding="utf-8")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def extract_tables(md: str) -> list[dict]:
|
|
106
|
+
blocks: list[list[str]] = []
|
|
107
|
+
cur: list[str] = []
|
|
108
|
+
for line in md.splitlines():
|
|
109
|
+
if line.strip().startswith("|"):
|
|
110
|
+
cur.append(line)
|
|
111
|
+
elif cur:
|
|
112
|
+
blocks.append(cur)
|
|
113
|
+
cur = []
|
|
114
|
+
if cur:
|
|
115
|
+
blocks.append(cur)
|
|
116
|
+
tables = []
|
|
117
|
+
for block in blocks:
|
|
118
|
+
rows = []
|
|
119
|
+
for line in block:
|
|
120
|
+
raw = line.replace("\\|", "\x1e")
|
|
121
|
+
cells = [c.replace("\x1e", "|").strip() for c in raw.strip().strip("|").split("|")]
|
|
122
|
+
rows.append(cells)
|
|
123
|
+
if len(rows) < 2:
|
|
124
|
+
continue
|
|
125
|
+
headers = [h.strip() for h in rows[0]]
|
|
126
|
+
if all(set(h) <= set("-: ") for h in rows[1]):
|
|
127
|
+
body = rows[2:]
|
|
128
|
+
else:
|
|
129
|
+
body = rows[1:]
|
|
130
|
+
recs = []
|
|
131
|
+
for row in body:
|
|
132
|
+
if not any(row):
|
|
133
|
+
continue
|
|
134
|
+
rec = {}
|
|
135
|
+
for i, h in enumerate(headers):
|
|
136
|
+
rec[h] = row[i].strip() if i < len(row) else ""
|
|
137
|
+
recs.append(rec)
|
|
138
|
+
tables.append({"headers": headers, "rows": recs})
|
|
139
|
+
return tables
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def table_with(md: str, *need: str) -> list[dict]:
|
|
143
|
+
need_l = [n.lower() for n in need]
|
|
144
|
+
for t in extract_tables(md):
|
|
145
|
+
hs = [h.lower() for h in t["headers"]]
|
|
146
|
+
if all(n in hs for n in need_l):
|
|
147
|
+
key = {h.lower(): h for h in t["headers"]}
|
|
148
|
+
out = []
|
|
149
|
+
for row in t["rows"]:
|
|
150
|
+
out.append({n: row.get(key[n], "") for n in need_l})
|
|
151
|
+
return out
|
|
152
|
+
return []
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def hit_int(row: dict, key: str) -> int:
|
|
156
|
+
try:
|
|
157
|
+
return int(str(row.get(key, "") or "0").strip() or 0)
|
|
158
|
+
except ValueError:
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
HTTP_RE = re.compile(r"https?://[^\s\]\)>'\"|]+")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def wiki_path(cell: str) -> str:
|
|
166
|
+
m = WIKI.search(cell or "")
|
|
167
|
+
if not m:
|
|
168
|
+
return ""
|
|
169
|
+
p = m.group(1).strip()
|
|
170
|
+
if p.endswith(".md"):
|
|
171
|
+
return p
|
|
172
|
+
return p
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def run_folder_from_cell(cell: str) -> str:
|
|
176
|
+
p = wiki_path(cell)
|
|
177
|
+
parts = [x for x in p.replace("\\", "/").split("/") if x]
|
|
178
|
+
if len(parts) >= 2 and parts[0] == "runs":
|
|
179
|
+
return parts[1]
|
|
180
|
+
return ""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def frontmatter(text: str) -> dict[str, str]:
|
|
184
|
+
if not text.startswith("---"):
|
|
185
|
+
return {}
|
|
186
|
+
end = text.find("\n---", 3)
|
|
187
|
+
if end < 0:
|
|
188
|
+
return {}
|
|
189
|
+
meta = {}
|
|
190
|
+
for line in text[4:end].splitlines():
|
|
191
|
+
if ":" not in line:
|
|
192
|
+
continue
|
|
193
|
+
k, v = line.split(":", 1)
|
|
194
|
+
meta[k.strip()] = v.strip().strip("\"'")
|
|
195
|
+
return meta
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def parse_library(cell: str) -> list[tuple[str, str]]:
|
|
199
|
+
if not cell or cell.strip() in ("none", "—", "-"):
|
|
200
|
+
return []
|
|
201
|
+
out = []
|
|
202
|
+
for part in cell.split(";"):
|
|
203
|
+
part = part.strip()
|
|
204
|
+
if ":" not in part:
|
|
205
|
+
continue
|
|
206
|
+
kind, rest = part.split(":", 1)
|
|
207
|
+
kind = kind.strip().lower()
|
|
208
|
+
kind = {"atoms": "atom", "claims": "claim", "swipes": "swipe"}.get(kind, kind)
|
|
209
|
+
for tok in rest.split(","):
|
|
210
|
+
tok = tok.strip()
|
|
211
|
+
if tok:
|
|
212
|
+
out.append((kind, tok))
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def nid(kind: str, ident: str) -> str:
|
|
217
|
+
return f"{kind}:{ident}"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def vault_file(path: str) -> str:
|
|
221
|
+
path = path.replace("\\", "/").lstrip("/")
|
|
222
|
+
if path and not path.endswith(".md") and "." not in Path(path).name:
|
|
223
|
+
# folder wiki → idea.md if run folder
|
|
224
|
+
pass
|
|
225
|
+
return f"Content Engine/{path}"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def obsidian_uri(engine_rel: str) -> str:
|
|
229
|
+
file_enc = vault_file(engine_rel).replace(" ", "%20")
|
|
230
|
+
return f"obsidian://open?vault=Obsidian%20Vault&file={file_enc}"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def node(
|
|
234
|
+
kind: str,
|
|
235
|
+
ident: str,
|
|
236
|
+
label: str,
|
|
237
|
+
path: str,
|
|
238
|
+
extra: dict,
|
|
239
|
+
) -> dict:
|
|
240
|
+
d = {
|
|
241
|
+
"id": nid(kind, ident),
|
|
242
|
+
"kind": kind,
|
|
243
|
+
"ident": ident,
|
|
244
|
+
"label": label,
|
|
245
|
+
"path": path,
|
|
246
|
+
"obsidian": obsidian_uri(path) if path else "",
|
|
247
|
+
**extra,
|
|
248
|
+
}
|
|
249
|
+
return d
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def ids_in(text: str) -> dict[str, list[str]]:
|
|
253
|
+
return {
|
|
254
|
+
"capture": sorted(set(CAPTURE_RE.findall(text or ""))),
|
|
255
|
+
"swipe": sorted(set(SWIPE_RE.findall(text or ""))),
|
|
256
|
+
"atom": sorted(set(ATOM_RE.findall(text or ""))),
|
|
257
|
+
"claim": sorted(set(CLAIM_RE.findall(text or ""))),
|
|
258
|
+
"run": sorted(set(RUN_RE.findall(text or ""))),
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def field_ids(val: str, kind: str) -> list[str]:
|
|
263
|
+
v = (val or "").strip()
|
|
264
|
+
if not v or v.lower() == "none":
|
|
265
|
+
return []
|
|
266
|
+
return ids_in(v).get(kind, [])
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
STAGE_NAMES = (
|
|
270
|
+
"idea",
|
|
271
|
+
"brief",
|
|
272
|
+
"packet",
|
|
273
|
+
"draft",
|
|
274
|
+
"editor",
|
|
275
|
+
"rubric",
|
|
276
|
+
"pack",
|
|
277
|
+
"feedback",
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def run_stage_files(folder: Path, folder_name: str) -> dict[str, str]:
|
|
282
|
+
"""Stage name → vault-relative .md path (only files that exist)."""
|
|
283
|
+
base = f"runs/{folder_name}"
|
|
284
|
+
out: dict[str, str] = {}
|
|
285
|
+
for name in STAGE_NAMES:
|
|
286
|
+
p = folder / f"{name}.md"
|
|
287
|
+
if p.exists():
|
|
288
|
+
out[name] = f"{base}/{name}.md"
|
|
289
|
+
if "pack" not in out:
|
|
290
|
+
packs = sorted(folder.glob("pack-*.md"))
|
|
291
|
+
if packs:
|
|
292
|
+
out["pack"] = f"{base}/{packs[0].name}"
|
|
293
|
+
return out
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def parse_feedback_metrics(text: str) -> dict:
|
|
297
|
+
out = {"has_numbers": False, "result": ""}
|
|
298
|
+
fm = frontmatter(text)
|
|
299
|
+
if fm.get("result"):
|
|
300
|
+
out["result"] = fm["result"]
|
|
301
|
+
if re.search(r"(views|likes|replies|bookmarks)\s*:", text, re.I):
|
|
302
|
+
out["has_numbers"] = True
|
|
303
|
+
return out
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
ENGAGEMENT_SNAP_RE = re.compile(
|
|
307
|
+
r"engagement\s+snapshot:\s*(.+?)(?:\n|$)",
|
|
308
|
+
re.I,
|
|
309
|
+
)
|
|
310
|
+
ENGAGEMENT_PART_RE = re.compile(
|
|
311
|
+
r"(likes|replies|bookmarks|views)\s+(\d[\d,]*)",
|
|
312
|
+
re.I,
|
|
313
|
+
)
|
|
314
|
+
ENGAGEMENT_AT_RE = re.compile(r"@\s*(\d{4}-\d{2}-\d{2})")
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def parse_engagement_snapshot(text: str) -> dict[str, str]:
|
|
318
|
+
"""Parse Discovery `engagement snapshot` line into src_* fields.
|
|
319
|
+
|
|
320
|
+
Only the primary snapshot counts. Parenthetical notes like
|
|
321
|
+
`(quoted earlier demo: likes 67 / bookmarks 115)` are ignored.
|
|
322
|
+
"""
|
|
323
|
+
out = {
|
|
324
|
+
"src_likes": "",
|
|
325
|
+
"src_replies": "",
|
|
326
|
+
"src_bookmarks": "",
|
|
327
|
+
"src_views": "",
|
|
328
|
+
"engagement_at": "",
|
|
329
|
+
}
|
|
330
|
+
if not text:
|
|
331
|
+
return out
|
|
332
|
+
m = ENGAGEMENT_SNAP_RE.search(text)
|
|
333
|
+
if not m:
|
|
334
|
+
return out
|
|
335
|
+
# Keep only the primary snapshot; drop parenthetical secondary demos.
|
|
336
|
+
blob = m.group(1).split("(", 1)[0]
|
|
337
|
+
for kind, num in ENGAGEMENT_PART_RE.findall(blob):
|
|
338
|
+
key = f"src_{kind.lower()}"
|
|
339
|
+
# First occurrence wins (do not let later noise overwrite).
|
|
340
|
+
if key in out and not out[key]:
|
|
341
|
+
out[key] = num.replace(",", "")
|
|
342
|
+
at = ENGAGEMENT_AT_RE.search(blob)
|
|
343
|
+
if at:
|
|
344
|
+
out["engagement_at"] = at.group(1)
|
|
345
|
+
return out
|
|
346
|
+
|
|
347
|
+
def build_graph() -> dict:
|
|
348
|
+
nodes: dict[str, dict] = {}
|
|
349
|
+
edges: list[dict] = []
|
|
350
|
+
profiles, accounts, pillars_by_profile = parse_registry()
|
|
351
|
+
|
|
352
|
+
def add_node(n: dict) -> None:
|
|
353
|
+
nodes[n["id"]] = n
|
|
354
|
+
|
|
355
|
+
def edge(a: str, b: str, rel: str) -> None:
|
|
356
|
+
if a in nodes and b in nodes:
|
|
357
|
+
edges.append({"from": a, "to": b, "rel": rel})
|
|
358
|
+
|
|
359
|
+
for p in profiles:
|
|
360
|
+
base = p["path"].rstrip("/") + "/"
|
|
361
|
+
files = {}
|
|
362
|
+
for name in ("README", "voice", "audience", "pillars"):
|
|
363
|
+
rel = f"{base}{name}.md"
|
|
364
|
+
if (ROOT / rel).exists():
|
|
365
|
+
files[name.lower()] = rel
|
|
366
|
+
readme = files.get("readme") or files.get("pillars") or f"{base}pillars.md"
|
|
367
|
+
add_node(
|
|
368
|
+
node(
|
|
369
|
+
"profile",
|
|
370
|
+
p["id"],
|
|
371
|
+
p["one_liner"] or p["id"],
|
|
372
|
+
readme,
|
|
373
|
+
{
|
|
374
|
+
"one_liner": p["one_liner"],
|
|
375
|
+
"voice": p["voice"],
|
|
376
|
+
"accounts": p["accounts"],
|
|
377
|
+
"pillar_ids": list(pillars_by_profile.get(p["id"], {}).keys()),
|
|
378
|
+
"files": files,
|
|
379
|
+
"dir": base,
|
|
380
|
+
},
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
for a in accounts:
|
|
384
|
+
add_node(
|
|
385
|
+
node(
|
|
386
|
+
"account",
|
|
387
|
+
a["handle"],
|
|
388
|
+
a["handle"],
|
|
389
|
+
"accounts/_index.md",
|
|
390
|
+
{
|
|
391
|
+
"profile_id": a["profile_id"],
|
|
392
|
+
"platforms": a["platforms"],
|
|
393
|
+
"default_platform": a["default_platform"],
|
|
394
|
+
"status": a["status"],
|
|
395
|
+
"voice": a["voice"],
|
|
396
|
+
"overrides": a["overrides"],
|
|
397
|
+
},
|
|
398
|
+
)
|
|
399
|
+
)
|
|
400
|
+
if a["profile_id"]:
|
|
401
|
+
edges.append(
|
|
402
|
+
{"from": nid("account", a["handle"]), "to": nid("profile", a["profile_id"]), "rel": "uses_profile"}
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
for row in table_with(read("inbox/_index.md"), "id", "date", "type", "one_liner", "platforms", "profile", "entry", "raw", "library", "status"):
|
|
406
|
+
ident = row["id"]
|
|
407
|
+
if not ident.startswith("C-"):
|
|
408
|
+
continue
|
|
409
|
+
entry = wiki_path(row["entry"]) or f"inbox/entries/{ident}"
|
|
410
|
+
if not entry.endswith(".md"):
|
|
411
|
+
entry = f"{entry}.md"
|
|
412
|
+
raw_path = wiki_path(row["raw"])
|
|
413
|
+
add_node(
|
|
414
|
+
node(
|
|
415
|
+
"capture",
|
|
416
|
+
ident,
|
|
417
|
+
row["one_liner"],
|
|
418
|
+
entry,
|
|
419
|
+
{
|
|
420
|
+
"date": row["date"],
|
|
421
|
+
"type": row["type"],
|
|
422
|
+
"platforms": row["platforms"],
|
|
423
|
+
"profile": row["profile"],
|
|
424
|
+
"status": row["status"],
|
|
425
|
+
"library_cell": row["library"],
|
|
426
|
+
"raw_path": raw_path,
|
|
427
|
+
"has_raw": bool(raw_path),
|
|
428
|
+
"ingested": row["status"] == "ingested",
|
|
429
|
+
},
|
|
430
|
+
)
|
|
431
|
+
)
|
|
432
|
+
for kind, lib_id in parse_library(row["library"]):
|
|
433
|
+
edges.append({"from": nid("capture", ident), "to": nid(kind, lib_id), "rel": "ingested_as"})
|
|
434
|
+
|
|
435
|
+
for row in table_with(read("raw/_index.md"), "id", "date", "capture_id", "file", "type", "source_url", "one_liner"):
|
|
436
|
+
ident = row["id"]
|
|
437
|
+
fpath = wiki_path(row["file"])
|
|
438
|
+
if fpath and not fpath.endswith(".md"):
|
|
439
|
+
fpath = f"{fpath}.md"
|
|
440
|
+
add_node(
|
|
441
|
+
node(
|
|
442
|
+
"raw",
|
|
443
|
+
ident,
|
|
444
|
+
row["one_liner"],
|
|
445
|
+
fpath,
|
|
446
|
+
{
|
|
447
|
+
"date": row["date"],
|
|
448
|
+
"type": row["type"],
|
|
449
|
+
"source_url": row["source_url"],
|
|
450
|
+
"capture_id": row["capture_id"],
|
|
451
|
+
},
|
|
452
|
+
)
|
|
453
|
+
)
|
|
454
|
+
if row["capture_id"]:
|
|
455
|
+
edge(nid("capture", row["capture_id"]), nid("raw", ident), "has_raw")
|
|
456
|
+
|
|
457
|
+
discovery_md = read("discovery/_index.md")
|
|
458
|
+
# Current minimal schema. Legacy rows may still carry relevance/ownership/candidates.
|
|
459
|
+
discovery_rows = table_with(
|
|
460
|
+
discovery_md,
|
|
461
|
+
"id",
|
|
462
|
+
"date",
|
|
463
|
+
"profile",
|
|
464
|
+
"platform",
|
|
465
|
+
"source_handle",
|
|
466
|
+
"source_url",
|
|
467
|
+
"source_claim",
|
|
468
|
+
"source_role",
|
|
469
|
+
"status",
|
|
470
|
+
"next_action",
|
|
471
|
+
"topic_ids",
|
|
472
|
+
"file",
|
|
473
|
+
)
|
|
474
|
+
if not discovery_rows:
|
|
475
|
+
discovery_rows = table_with(
|
|
476
|
+
discovery_md,
|
|
477
|
+
"id",
|
|
478
|
+
"date",
|
|
479
|
+
"profile",
|
|
480
|
+
"platform",
|
|
481
|
+
"source_handle",
|
|
482
|
+
"source_url",
|
|
483
|
+
"source_claim",
|
|
484
|
+
"source_role",
|
|
485
|
+
"relevance",
|
|
486
|
+
"ownership",
|
|
487
|
+
"product_relevance",
|
|
488
|
+
"status",
|
|
489
|
+
"next_action",
|
|
490
|
+
"candidates",
|
|
491
|
+
"topic_ids",
|
|
492
|
+
"file",
|
|
493
|
+
)
|
|
494
|
+
for row in discovery_rows:
|
|
495
|
+
ident = row["id"]
|
|
496
|
+
if not ident.startswith("D-"):
|
|
497
|
+
continue
|
|
498
|
+
fpath = wiki_path(row["file"])
|
|
499
|
+
if fpath and not fpath.endswith(".md"):
|
|
500
|
+
fpath = f"{fpath}.md"
|
|
501
|
+
topic_ids = (row.get("topic_ids") or "").strip()
|
|
502
|
+
eng = parse_engagement_snapshot(read(fpath) if fpath and (ROOT / fpath).exists() else "")
|
|
503
|
+
add_node(
|
|
504
|
+
node(
|
|
505
|
+
"discovery",
|
|
506
|
+
ident,
|
|
507
|
+
row["source_claim"] or f"Discovery {ident}",
|
|
508
|
+
fpath,
|
|
509
|
+
{
|
|
510
|
+
"date": row["date"],
|
|
511
|
+
"profile": row["profile"],
|
|
512
|
+
"platform": row["platform"],
|
|
513
|
+
"source_handle": row["source_handle"],
|
|
514
|
+
"source_url": row["source_url"],
|
|
515
|
+
"source_claim": row["source_claim"],
|
|
516
|
+
"source_role": row["source_role"],
|
|
517
|
+
"relevance": row.get("relevance", "") or "",
|
|
518
|
+
"ownership": row.get("ownership", "") or "",
|
|
519
|
+
"product_relevance": row.get("product_relevance", "") or "",
|
|
520
|
+
"status": row["status"],
|
|
521
|
+
"next_action": row.get("next_action", "") or "",
|
|
522
|
+
"candidates": row.get("candidates", "") or "",
|
|
523
|
+
"topic_ids": topic_ids,
|
|
524
|
+
**eng,
|
|
525
|
+
},
|
|
526
|
+
)
|
|
527
|
+
)
|
|
528
|
+
linked = re.findall(r"\bT-\d{8}-\d{2}\b", f"{topic_ids} {row.get('candidates') or ''}")
|
|
529
|
+
for tid in linked:
|
|
530
|
+
edge(nid("discovery", ident), nid("topic", tid), "candidate")
|
|
531
|
+
|
|
532
|
+
topic_md = read("topics/_index.md")
|
|
533
|
+
# Current: opp + aud (5C opportunity). Legacy: craft + fit, then single total.
|
|
534
|
+
topic_rows = table_with(
|
|
535
|
+
topic_md,
|
|
536
|
+
"id",
|
|
537
|
+
"date",
|
|
538
|
+
"opp",
|
|
539
|
+
"aud",
|
|
540
|
+
"status",
|
|
541
|
+
"purpose",
|
|
542
|
+
"scout",
|
|
543
|
+
"platform",
|
|
544
|
+
"pillar",
|
|
545
|
+
"one_liner",
|
|
546
|
+
"item",
|
|
547
|
+
"source_type",
|
|
548
|
+
"src_url",
|
|
549
|
+
"src_handle",
|
|
550
|
+
"src_likes",
|
|
551
|
+
"src_replies",
|
|
552
|
+
"src_bookmarks",
|
|
553
|
+
"engagement_at",
|
|
554
|
+
"capture_id",
|
|
555
|
+
"run",
|
|
556
|
+
)
|
|
557
|
+
if topic_rows:
|
|
558
|
+
topic_rows = [
|
|
559
|
+
{
|
|
560
|
+
**row,
|
|
561
|
+
"craft": row.get("opp", "") or "",
|
|
562
|
+
"fit": row.get("aud", "") or "",
|
|
563
|
+
}
|
|
564
|
+
for row in topic_rows
|
|
565
|
+
]
|
|
566
|
+
if not topic_rows:
|
|
567
|
+
topic_rows = table_with(
|
|
568
|
+
topic_md,
|
|
569
|
+
"id",
|
|
570
|
+
"date",
|
|
571
|
+
"craft",
|
|
572
|
+
"fit",
|
|
573
|
+
"status",
|
|
574
|
+
"purpose",
|
|
575
|
+
"scout",
|
|
576
|
+
"platform",
|
|
577
|
+
"pillar",
|
|
578
|
+
"one_liner",
|
|
579
|
+
"item",
|
|
580
|
+
"source_type",
|
|
581
|
+
"src_url",
|
|
582
|
+
"src_handle",
|
|
583
|
+
"src_likes",
|
|
584
|
+
"src_replies",
|
|
585
|
+
"src_bookmarks",
|
|
586
|
+
"engagement_at",
|
|
587
|
+
"capture_id",
|
|
588
|
+
"run",
|
|
589
|
+
)
|
|
590
|
+
if not topic_rows:
|
|
591
|
+
topic_rows = [
|
|
592
|
+
{
|
|
593
|
+
**row,
|
|
594
|
+
"craft": row["total"],
|
|
595
|
+
"fit": "",
|
|
596
|
+
"purpose": "",
|
|
597
|
+
"scout": "",
|
|
598
|
+
}
|
|
599
|
+
for row in table_with(
|
|
600
|
+
topic_md,
|
|
601
|
+
"id",
|
|
602
|
+
"date",
|
|
603
|
+
"total",
|
|
604
|
+
"status",
|
|
605
|
+
"platform",
|
|
606
|
+
"pillar",
|
|
607
|
+
"one_liner",
|
|
608
|
+
"item",
|
|
609
|
+
"source_type",
|
|
610
|
+
"src_url",
|
|
611
|
+
"src_handle",
|
|
612
|
+
"src_likes",
|
|
613
|
+
"src_replies",
|
|
614
|
+
"src_bookmarks",
|
|
615
|
+
"engagement_at",
|
|
616
|
+
"capture_id",
|
|
617
|
+
"run",
|
|
618
|
+
)
|
|
619
|
+
]
|
|
620
|
+
|
|
621
|
+
for row in topic_rows:
|
|
622
|
+
ident = row["id"]
|
|
623
|
+
if not ident.startswith("T-"):
|
|
624
|
+
continue
|
|
625
|
+
item = wiki_path(row["item"])
|
|
626
|
+
if item and not item.endswith(".md"):
|
|
627
|
+
item = f"{item}.md"
|
|
628
|
+
usage = "killed" if row["status"] == "killed" else ("promoted" if row["run"].strip() else "unused")
|
|
629
|
+
craft = row.get("craft", "") or ""
|
|
630
|
+
fit = row.get("fit", "") or ""
|
|
631
|
+
# Sort key: opp (aliased as craft) / legacy total
|
|
632
|
+
total = craft or row.get("total", "") or ""
|
|
633
|
+
profile = ""
|
|
634
|
+
if item and (ROOT / item).exists():
|
|
635
|
+
fm = re.match(r"^---\n(.*?)\n---", read(item), re.S)
|
|
636
|
+
if fm:
|
|
637
|
+
mprof = re.search(r"^profile:\s*(\S+)", fm.group(1), re.M)
|
|
638
|
+
if mprof:
|
|
639
|
+
profile = mprof.group(1).strip()
|
|
640
|
+
add_node(
|
|
641
|
+
node(
|
|
642
|
+
"topic",
|
|
643
|
+
ident,
|
|
644
|
+
row["one_liner"],
|
|
645
|
+
item,
|
|
646
|
+
{
|
|
647
|
+
"date": row["date"],
|
|
648
|
+
"total": total,
|
|
649
|
+
"opp": craft,
|
|
650
|
+
"aud": fit,
|
|
651
|
+
"craft": craft,
|
|
652
|
+
"fit": fit,
|
|
653
|
+
"purpose": row.get("purpose", "") or "",
|
|
654
|
+
"scout": row.get("scout", "") or "",
|
|
655
|
+
"status": row["status"],
|
|
656
|
+
"platform": row["platform"],
|
|
657
|
+
"pillar": row["pillar"],
|
|
658
|
+
"profile": profile,
|
|
659
|
+
"source_type": row["source_type"],
|
|
660
|
+
"src_url": row["src_url"],
|
|
661
|
+
"src_handle": row["src_handle"],
|
|
662
|
+
"src_likes": row["src_likes"],
|
|
663
|
+
"src_replies": row["src_replies"],
|
|
664
|
+
"src_bookmarks": row["src_bookmarks"],
|
|
665
|
+
"engagement_at": row["engagement_at"],
|
|
666
|
+
"capture_id": row["capture_id"],
|
|
667
|
+
"run_id": row["run"].strip(),
|
|
668
|
+
"usage": usage,
|
|
669
|
+
"src_heat_note": "来源帖热度,不是你的帖",
|
|
670
|
+
},
|
|
671
|
+
)
|
|
672
|
+
)
|
|
673
|
+
if row["capture_id"]:
|
|
674
|
+
edge(nid("topic", ident), nid("capture", row["capture_id"]), "from_capture")
|
|
675
|
+
if row["run"].strip().startswith("RUN-"):
|
|
676
|
+
edge(nid("topic", ident), nid("run", row["run"].strip()), "opened_run")
|
|
677
|
+
if item and (ROOT / item).exists():
|
|
678
|
+
body = read(item)
|
|
679
|
+
chunk = body
|
|
680
|
+
m = re.search(r"^## Suggested production.*?(?=^## |\Z)", body, re.M | re.S)
|
|
681
|
+
if m:
|
|
682
|
+
chunk = m.group(0)
|
|
683
|
+
for line in chunk.splitlines():
|
|
684
|
+
if ":" not in line:
|
|
685
|
+
continue
|
|
686
|
+
key, rest = line.split(":", 1)
|
|
687
|
+
key, rest = key.strip().lower(), rest.strip()
|
|
688
|
+
if rest.lower().startswith("none"):
|
|
689
|
+
continue
|
|
690
|
+
if "swipe" in key:
|
|
691
|
+
for sid in ids_in(rest)["swipe"]:
|
|
692
|
+
edges.append({"from": nid("topic", ident), "to": nid("swipe", sid), "rel": "suggested"})
|
|
693
|
+
elif "atom" in key:
|
|
694
|
+
for aid in ids_in(rest)["atom"]:
|
|
695
|
+
edges.append({"from": nid("topic", ident), "to": nid("atom", aid), "rel": "suggested"})
|
|
696
|
+
elif "claim" in key:
|
|
697
|
+
for cid in ids_in(rest)["claim"]:
|
|
698
|
+
edges.append({"from": nid("topic", ident), "to": nid("claim", cid), "rel": "suggested"})
|
|
699
|
+
|
|
700
|
+
for row in table_with(
|
|
701
|
+
read("swipe/_index.md"),
|
|
702
|
+
"id",
|
|
703
|
+
"file",
|
|
704
|
+
"platforms",
|
|
705
|
+
"profiles",
|
|
706
|
+
"form",
|
|
707
|
+
"beats_or_pages",
|
|
708
|
+
"status",
|
|
709
|
+
"n",
|
|
710
|
+
"win",
|
|
711
|
+
"loss",
|
|
712
|
+
"one_liner",
|
|
713
|
+
"source",
|
|
714
|
+
):
|
|
715
|
+
ident = row["id"]
|
|
716
|
+
fpath = row["file"]
|
|
717
|
+
if fpath and not fpath.startswith("swipe/"):
|
|
718
|
+
fpath = f"swipe/{fpath}"
|
|
719
|
+
if fpath and not fpath.endswith(".md"):
|
|
720
|
+
fpath = f"{fpath}.md"
|
|
721
|
+
add_node(
|
|
722
|
+
node(
|
|
723
|
+
"swipe",
|
|
724
|
+
ident,
|
|
725
|
+
row["one_liner"],
|
|
726
|
+
fpath,
|
|
727
|
+
{
|
|
728
|
+
"platforms": row["platforms"],
|
|
729
|
+
"profiles": row["profiles"],
|
|
730
|
+
"form": row["form"],
|
|
731
|
+
"beats": row["beats_or_pages"],
|
|
732
|
+
"status": row["status"],
|
|
733
|
+
"n": hit_int(row, "n"),
|
|
734
|
+
"win": hit_int(row, "win"),
|
|
735
|
+
"loss": hit_int(row, "loss"),
|
|
736
|
+
"source_cell": row["source"],
|
|
737
|
+
"lib": True,
|
|
738
|
+
},
|
|
739
|
+
)
|
|
740
|
+
)
|
|
741
|
+
for cid in CAPTURE_RE.findall(row["source"]):
|
|
742
|
+
edge(nid("swipe", ident), nid("capture", cid), "sourced_from")
|
|
743
|
+
|
|
744
|
+
for row in table_with(
|
|
745
|
+
read("wiki/atoms/_index.md"),
|
|
746
|
+
"id",
|
|
747
|
+
"file",
|
|
748
|
+
"type",
|
|
749
|
+
"platforms",
|
|
750
|
+
"profiles",
|
|
751
|
+
"status",
|
|
752
|
+
"n",
|
|
753
|
+
"win",
|
|
754
|
+
"loss",
|
|
755
|
+
"one_liner",
|
|
756
|
+
):
|
|
757
|
+
ident = row["id"]
|
|
758
|
+
fpath = row["file"]
|
|
759
|
+
if fpath and not fpath.startswith("wiki/"):
|
|
760
|
+
fpath = f"wiki/atoms/{fpath}"
|
|
761
|
+
if fpath and not fpath.endswith(".md"):
|
|
762
|
+
fpath = f"{fpath}.md"
|
|
763
|
+
add_node(
|
|
764
|
+
node(
|
|
765
|
+
"atom",
|
|
766
|
+
ident,
|
|
767
|
+
row["one_liner"],
|
|
768
|
+
fpath,
|
|
769
|
+
{
|
|
770
|
+
"atom_type": row["type"],
|
|
771
|
+
"platforms": row["platforms"],
|
|
772
|
+
"profiles": row["profiles"],
|
|
773
|
+
"status": row["status"],
|
|
774
|
+
"n": hit_int(row, "n"),
|
|
775
|
+
"win": hit_int(row, "win"),
|
|
776
|
+
"loss": hit_int(row, "loss"),
|
|
777
|
+
"lib": True,
|
|
778
|
+
},
|
|
779
|
+
)
|
|
780
|
+
)
|
|
781
|
+
if (ROOT / fpath).exists():
|
|
782
|
+
for cid in CAPTURE_RE.findall(read(fpath)):
|
|
783
|
+
edge(nid("atom", ident), nid("capture", cid), "sourced_from")
|
|
784
|
+
|
|
785
|
+
for row in table_with(
|
|
786
|
+
read("wiki/claims/_index.md"),
|
|
787
|
+
"id",
|
|
788
|
+
"file",
|
|
789
|
+
"type",
|
|
790
|
+
"platforms",
|
|
791
|
+
"profiles",
|
|
792
|
+
"status",
|
|
793
|
+
"n",
|
|
794
|
+
"win",
|
|
795
|
+
"loss",
|
|
796
|
+
"one_liner",
|
|
797
|
+
):
|
|
798
|
+
ident = row["id"]
|
|
799
|
+
fpath = row["file"]
|
|
800
|
+
if fpath and not fpath.startswith("wiki/"):
|
|
801
|
+
fpath = f"wiki/claims/{fpath}"
|
|
802
|
+
if fpath and not fpath.endswith(".md"):
|
|
803
|
+
fpath = f"{fpath}.md"
|
|
804
|
+
evidence_runs = []
|
|
805
|
+
if (ROOT / fpath).exists():
|
|
806
|
+
body = read(fpath)
|
|
807
|
+
for cid in CAPTURE_RE.findall(body):
|
|
808
|
+
edge(nid("claim", ident), nid("capture", cid), "sourced_from")
|
|
809
|
+
in_ev = False
|
|
810
|
+
for line in body.splitlines():
|
|
811
|
+
if line.startswith("## Evidence"):
|
|
812
|
+
in_ev = True
|
|
813
|
+
continue
|
|
814
|
+
if in_ev and line.startswith("## "):
|
|
815
|
+
break
|
|
816
|
+
if in_ev:
|
|
817
|
+
evidence_runs += RUN_RE.findall(line)
|
|
818
|
+
add_node(
|
|
819
|
+
node(
|
|
820
|
+
"claim",
|
|
821
|
+
ident,
|
|
822
|
+
row["one_liner"],
|
|
823
|
+
fpath,
|
|
824
|
+
{
|
|
825
|
+
"claim_type": row["type"],
|
|
826
|
+
"platforms": row["platforms"],
|
|
827
|
+
"profiles": row["profiles"],
|
|
828
|
+
"status": row["status"],
|
|
829
|
+
"n": hit_int(row, "n"),
|
|
830
|
+
"win": hit_int(row, "win"),
|
|
831
|
+
"loss": hit_int(row, "loss"),
|
|
832
|
+
"evidence_runs": evidence_runs,
|
|
833
|
+
"has_evidence": bool(evidence_runs),
|
|
834
|
+
"lib": True,
|
|
835
|
+
},
|
|
836
|
+
)
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
for row in table_with(
|
|
840
|
+
read("runs/_index.md"),
|
|
841
|
+
"run_id",
|
|
842
|
+
"path",
|
|
843
|
+
"date",
|
|
844
|
+
"status",
|
|
845
|
+
"account",
|
|
846
|
+
"profile",
|
|
847
|
+
"primary_platform",
|
|
848
|
+
"platforms",
|
|
849
|
+
"topic_id",
|
|
850
|
+
"pillar",
|
|
851
|
+
"one_liner",
|
|
852
|
+
"pack",
|
|
853
|
+
"export",
|
|
854
|
+
"notes",
|
|
855
|
+
):
|
|
856
|
+
ident = row["run_id"]
|
|
857
|
+
if not ident.startswith("RUN-"):
|
|
858
|
+
continue
|
|
859
|
+
folder_wiki = wiki_path(row["path"])
|
|
860
|
+
folder = folder_wiki.replace("runs/", "").strip("/")
|
|
861
|
+
rdir = ROOT / "runs" / folder if folder else None
|
|
862
|
+
stage_files = run_stage_files(rdir, folder) if rdir and rdir.is_dir() and folder else {}
|
|
863
|
+
stages = {n: n in stage_files for n in STAGE_NAMES}
|
|
864
|
+
idea_path = stage_files.get("idea") or (f"runs/{folder}/idea.md" if folder else "")
|
|
865
|
+
selection = {"swipe": "none", "atoms": "none", "claim": "none"}
|
|
866
|
+
supersedes = ""
|
|
867
|
+
superseded_by = ""
|
|
868
|
+
capture_ids = []
|
|
869
|
+
if idea_path and (ROOT / idea_path).exists():
|
|
870
|
+
idea = read(idea_path)
|
|
871
|
+
fm = frontmatter(idea)
|
|
872
|
+
supersedes = fm.get("supersedes", "")
|
|
873
|
+
superseded_by = fm.get("superseded_by", "")
|
|
874
|
+
for line in idea.splitlines():
|
|
875
|
+
if "swipe_id" in line:
|
|
876
|
+
selection["swipe"] = line.split(":", 1)[-1].strip().lstrip("- ").replace("**swipe_id:**", "").strip()
|
|
877
|
+
if "**atoms:**" in line or line.strip().startswith("- **atoms:**"):
|
|
878
|
+
selection["atoms"] = line.split(":", 1)[-1].strip().lstrip("- ").replace("**atoms:**", "").strip()
|
|
879
|
+
if "hypothesis" in line and "swipe_why" not in line:
|
|
880
|
+
selection["claim"] = line.split(":", 1)[-1].strip().lstrip("- ").replace("**hypothesis:**", "").strip()
|
|
881
|
+
if "claim_id" in line:
|
|
882
|
+
selection["claim"] = line.split(":", 1)[-1].strip().lstrip("- ").replace("**claim_id:**", "").strip()
|
|
883
|
+
if "capture_ids" in line:
|
|
884
|
+
capture_ids = CAPTURE_RE.findall(line)
|
|
885
|
+
sm = re.search(r"\*\*swipe_id:\*\*\s*(\S+)", idea)
|
|
886
|
+
if sm:
|
|
887
|
+
selection["swipe"] = sm.group(1)
|
|
888
|
+
am = re.search(r"\*\*atoms:\*\*\s*(.+)", idea)
|
|
889
|
+
if am:
|
|
890
|
+
selection["atoms"] = am.group(1).strip()
|
|
891
|
+
cm = re.search(r"\*\*(?:claim_id|hypothesis):\*\*\s*(\S+)", idea)
|
|
892
|
+
if cm:
|
|
893
|
+
selection["claim"] = cm.group(1)
|
|
894
|
+
metrics = {}
|
|
895
|
+
ship_urls = []
|
|
896
|
+
fb = rdir / "feedback.md" if rdir else None
|
|
897
|
+
if fb and fb.exists():
|
|
898
|
+
fb_text = fb.read_text(encoding="utf-8")
|
|
899
|
+
metrics = parse_feedback_metrics(fb_text)
|
|
900
|
+
ship_urls = [u.rstrip(".,;") for u in HTTP_RE.findall(fb_text)]
|
|
901
|
+
add_node(
|
|
902
|
+
node(
|
|
903
|
+
"run",
|
|
904
|
+
ident,
|
|
905
|
+
row["one_liner"],
|
|
906
|
+
f"runs/{folder}" if folder else "",
|
|
907
|
+
{
|
|
908
|
+
"date": row["date"],
|
|
909
|
+
"status": row["status"],
|
|
910
|
+
"account": row["account"],
|
|
911
|
+
"profile": row["profile"],
|
|
912
|
+
"primary_platform": row["primary_platform"],
|
|
913
|
+
"platforms": row["platforms"],
|
|
914
|
+
"topic_id": row["topic_id"] if row["topic_id"] != "none" else "",
|
|
915
|
+
"pillar": row["pillar"],
|
|
916
|
+
"pack": row["pack"],
|
|
917
|
+
"export": row["export"],
|
|
918
|
+
"notes": row["notes"],
|
|
919
|
+
"folder": folder,
|
|
920
|
+
"stages": stages,
|
|
921
|
+
"stage_files": stage_files,
|
|
922
|
+
"selection": selection,
|
|
923
|
+
"supersedes": supersedes,
|
|
924
|
+
"superseded_by": superseded_by,
|
|
925
|
+
"metrics": metrics,
|
|
926
|
+
"ship_urls": ship_urls,
|
|
927
|
+
"open_file": idea_path,
|
|
928
|
+
"obsidian": obsidian_uri(idea_path) if idea_path else "",
|
|
929
|
+
},
|
|
930
|
+
)
|
|
931
|
+
)
|
|
932
|
+
if row["topic_id"] not in ("", "none"):
|
|
933
|
+
edge(nid("run", ident), nid("topic", row["topic_id"]), "from_topic")
|
|
934
|
+
for cid in capture_ids:
|
|
935
|
+
edge(nid("run", ident), nid("capture", cid), "from_capture")
|
|
936
|
+
for sid in field_ids(selection["swipe"], "swipe"):
|
|
937
|
+
edge(nid("run", ident), nid("swipe", sid), "uses")
|
|
938
|
+
for aid in field_ids(selection["atoms"], "atom"):
|
|
939
|
+
edge(nid("run", ident), nid("atom", aid), "uses")
|
|
940
|
+
for cid in field_ids(selection["claim"], "claim"):
|
|
941
|
+
edge(nid("run", ident), nid("claim", cid), "uses")
|
|
942
|
+
if supersedes.startswith("RUN-"):
|
|
943
|
+
edges.append({"from": nid("run", ident), "to": nid("run", supersedes), "rel": "supersedes"})
|
|
944
|
+
|
|
945
|
+
folder_to_run = {
|
|
946
|
+
n["folder"]: n["ident"] for n in nodes.values() if n["kind"] == "run" and n.get("folder")
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
for row in table_with(
|
|
950
|
+
read("published/_index.md"),
|
|
951
|
+
"date",
|
|
952
|
+
"platform",
|
|
953
|
+
"url",
|
|
954
|
+
"run",
|
|
955
|
+
"pillar",
|
|
956
|
+
"swipe",
|
|
957
|
+
"atoms",
|
|
958
|
+
"claim",
|
|
959
|
+
"result",
|
|
960
|
+
"notes",
|
|
961
|
+
):
|
|
962
|
+
if not row["url"].startswith("http"):
|
|
963
|
+
continue
|
|
964
|
+
run_cell = row["run"]
|
|
965
|
+
run_folder = run_folder_from_cell(run_cell)
|
|
966
|
+
run_id = (RUN_RE.findall(run_cell) or [""])[0]
|
|
967
|
+
if not run_id and run_folder in folder_to_run:
|
|
968
|
+
run_id = folder_to_run[run_folder]
|
|
969
|
+
run_node = nodes.get(nid("run", run_id)) if run_id else None
|
|
970
|
+
profile = (run_node or {}).get("profile", "")
|
|
971
|
+
ident = f"{row['date']}-{row['platform']}-{run_id or 'unknown'}"
|
|
972
|
+
add_node(
|
|
973
|
+
node(
|
|
974
|
+
"published",
|
|
975
|
+
ident,
|
|
976
|
+
row["url"],
|
|
977
|
+
"",
|
|
978
|
+
{
|
|
979
|
+
"date": row["date"],
|
|
980
|
+
"platform": row["platform"],
|
|
981
|
+
"url": row["url"],
|
|
982
|
+
"run_id": run_id,
|
|
983
|
+
"run_folder": run_folder,
|
|
984
|
+
"profile": profile,
|
|
985
|
+
"topic_id": (run_node or {}).get("topic_id", "") or "",
|
|
986
|
+
"pillar": row["pillar"],
|
|
987
|
+
"swipe": row["swipe"],
|
|
988
|
+
"atoms": row["atoms"],
|
|
989
|
+
"claim": row["claim"],
|
|
990
|
+
"result": row["result"],
|
|
991
|
+
"notes": row["notes"],
|
|
992
|
+
"reviewable": row["result"] in ("win", "flat", "loss"),
|
|
993
|
+
"obsidian": obsidian_uri("published/_index.md"),
|
|
994
|
+
"path": "published/_index.md",
|
|
995
|
+
},
|
|
996
|
+
)
|
|
997
|
+
)
|
|
998
|
+
if run_id:
|
|
999
|
+
edge(nid("published", ident), nid("run", run_id), "from_run")
|
|
1000
|
+
for sid in field_ids(row["swipe"], "swipe"):
|
|
1001
|
+
edge(nid("published", ident), nid("swipe", sid), "uses")
|
|
1002
|
+
for aid in field_ids(row["atoms"], "atom"):
|
|
1003
|
+
edge(nid("published", ident), nid("atom", aid), "uses")
|
|
1004
|
+
for cid in field_ids(row["claim"], "claim"):
|
|
1005
|
+
edge(nid("published", ident), nid("claim", cid), "uses")
|
|
1006
|
+
|
|
1007
|
+
# Capture → surface source_url from linked Raw
|
|
1008
|
+
for n in list(nodes.values()):
|
|
1009
|
+
if n["kind"] != "capture":
|
|
1010
|
+
continue
|
|
1011
|
+
for e in edges:
|
|
1012
|
+
if e["rel"] != "has_raw" or e["from"] != n["id"]:
|
|
1013
|
+
continue
|
|
1014
|
+
raw = nodes.get(e["to"])
|
|
1015
|
+
if raw and raw.get("source_url"):
|
|
1016
|
+
n["source_url"] = raw["source_url"]
|
|
1017
|
+
n["raw_id"] = raw["ident"]
|
|
1018
|
+
break
|
|
1019
|
+
|
|
1020
|
+
# drop edges to missing nodes
|
|
1021
|
+
edges = [e for e in edges if e["from"] in nodes and e["to"] in nodes]
|
|
1022
|
+
seen = set()
|
|
1023
|
+
uniq = []
|
|
1024
|
+
for e in edges:
|
|
1025
|
+
k = (e["from"], e["to"], e["rel"])
|
|
1026
|
+
if k not in seen:
|
|
1027
|
+
seen.add(k)
|
|
1028
|
+
uniq.append(e)
|
|
1029
|
+
|
|
1030
|
+
run_uses: dict[str, set[str]] = {}
|
|
1031
|
+
for e in uniq:
|
|
1032
|
+
if e["rel"] != "uses":
|
|
1033
|
+
continue
|
|
1034
|
+
src = nodes.get(e["from"])
|
|
1035
|
+
dst = e["to"]
|
|
1036
|
+
if not src:
|
|
1037
|
+
continue
|
|
1038
|
+
if src["kind"] == "run":
|
|
1039
|
+
run_uses.setdefault(dst, set()).add(src["id"])
|
|
1040
|
+
elif src["kind"] == "published":
|
|
1041
|
+
run_uses.setdefault(dst, set()).add(nid("run", src["run_id"]) if src.get("run_id") else src["id"])
|
|
1042
|
+
for n in nodes.values():
|
|
1043
|
+
if n["kind"] in ("swipe", "atom", "claim"):
|
|
1044
|
+
n["refs"] = len(run_uses.get(n["id"], ()))
|
|
1045
|
+
|
|
1046
|
+
# flat pillars: prefer first profile for legacy callers; JS uses pillars_by_profile
|
|
1047
|
+
flat_pillars = {}
|
|
1048
|
+
for pid in sorted(pillars_by_profile.keys()):
|
|
1049
|
+
for k, v in pillars_by_profile[pid].items():
|
|
1050
|
+
flat_pillars.setdefault(k, v)
|
|
1051
|
+
|
|
1052
|
+
return {
|
|
1053
|
+
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
|
|
1054
|
+
"vault": str(ROOT),
|
|
1055
|
+
"profiles": profiles,
|
|
1056
|
+
"accounts": accounts,
|
|
1057
|
+
"pillars_by_profile": pillars_by_profile,
|
|
1058
|
+
"pillars": flat_pillars,
|
|
1059
|
+
"nodes": list(nodes.values()),
|
|
1060
|
+
"edges": uniq,
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def main() -> None:
|
|
1065
|
+
graph = build_graph()
|
|
1066
|
+
html = TPL.read_text(encoding="utf-8")
|
|
1067
|
+
payload = json.dumps(graph, ensure_ascii=False)
|
|
1068
|
+
html = html.replace("/*__GRAPH__*/null", payload)
|
|
1069
|
+
OUT.write_text(html, encoding="utf-8")
|
|
1070
|
+
print(f"wrote {OUT} ({len(graph['nodes'])} nodes, {len(graph['edges'])} edges)")
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
if __name__ == "__main__":
|
|
1074
|
+
main()
|