@easysociable/cli 0.1.3 → 0.1.5
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 +13 -10
- package/content-engine/scripts/starter.mjs +3 -1
- package/content-engine/scripts/workbench.mjs +13 -8
- package/content-engine/starter/CLAUDE.md +217 -128
- package/content-engine/starter/engine.json +3 -0
- package/content-engine/starter/needs/README.md +38 -0
- package/content-engine/starter/needs/_index.md +31 -0
- package/content-engine/starter/needs/_template.md +24 -0
- package/content-engine/starter/needs/entries/README.md +5 -0
- package/content-engine/starter/products/README.md +38 -0
- package/content-engine/starter/products/_index.md +24 -0
- package/content-engine/starter/products/_template.md +56 -0
- package/content-engine/starter/products/entries/P-001-easysociable.md +150 -0
- package/content-engine/starter/profiles/_index.md +6 -3
- package/content-engine/starter/profiles/default/README.md +4 -1
- package/content-engine/starter/profiles/default/boundaries.md +14 -0
- package/content-engine/starter/profiles/default/expression.md +18 -0
- package/content-engine/starter/profiles/default/lanes.md +12 -0
- package/content-engine/starter/profiles/default/stories.md +15 -0
- package/content-engine/starter/profiles/default/voice.md +14 -0
- package/content-engine/starter/recommendations/README.md +21 -0
- package/content-engine/starter/recommendations/_index.md +21 -0
- package/content-engine/starter/recommendations/_template.md +44 -0
- package/content-engine/starter/runs/_template/brief.md +1 -1
- package/content-engine/starter/runs/_template/pack.md +15 -7
- package/content-engine/starter/runs/_template/packet.md +6 -0
- package/content-engine/starter/swipe/_template.md +3 -1
- package/content-engine/starter/topics/README.md +4 -8
- package/content-engine/starter/topics/_index.md +12 -5
- package/content-engine/starter/topics/_template-item.md +64 -51
- package/content-engine/starter/topics/scoring.md +55 -4
- package/content-engine/workbench/build.py +315 -97
- package/content-engine/workbench/serve.py +0 -0
- package/content-engine/workbench/template.html +172 -94
- package/dist/easysociable.js +191 -133
- package/dist/easysociable.js.map +1 -1
- package/package.json +13 -14
- package/skills/ONBOARDING.md +14 -15
- package/skills/easysociable-content/SKILL.md +100 -19
- package/skills/easysociable-content/workflows/INTERVIEW.md +70 -0
- package/skills/easysociable-content/workflows/NEEDS.md +82 -0
- package/skills/easysociable-slideshow/SKILL.md +79 -45
- package/skills/easysociable-slideshow/references/ARC.md +44 -0
- package/skills/easysociable-slideshow/references/CONTENT_PACK.md +34 -6
- package/skills/easysociable-slideshow/workflows/CREATE_RUN.md +24 -5
- package/skills/easysociable-slideshow/workflows/OUT_OF_SCOPE.md +3 -0
- package/skills/easysociable-template/SKILL.md +13 -16
- package/skills/easysociable-template/references/CATALOG_METADATA.md +21 -18
- package/skills/easysociable-template/scripts/generate-theme-from-hex.mjs +0 -0
- package/skills/easysociable-template/workflows/FILL_AND_RENDER.md +4 -83
- package/skills/easysociable-template/workflows/OUT_OF_SCOPE.md +3 -2
- package/content-engine/starter/discovery/README.md +0 -18
- package/content-engine/starter/discovery/_index.md +0 -27
- package/content-engine/starter/discovery/_template.md +0 -41
- package/content-engine/starter/topics/WATCHLIST.md +0 -65
- package/content-engine/starter/topics/watchlists/README.md +0 -10
- package/content-engine/starter/topics/watchlists/_shared.md +0 -14
- package/content-engine/starter/topics/watchlists/default.md +0 -6
|
@@ -29,6 +29,8 @@ SWIPE_RE = re.compile(r"\bS\d+\b")
|
|
|
29
29
|
ATOM_RE = re.compile(r"\bA-[a-z0-9-]+\b")
|
|
30
30
|
CLAIM_RE = re.compile(r"\bC-(?!\d{8})[a-z0-9-]+\b")
|
|
31
31
|
RUN_RE = re.compile(r"RUN-[A-Za-z0-9-]+")
|
|
32
|
+
PRODUCT_RE = re.compile(r"\bP-[A-Za-z0-9-]+\b")
|
|
33
|
+
RECOMMENDATION_RE = re.compile(r"\bR-[A-Za-z0-9-]+\b")
|
|
32
34
|
WIKI = re.compile(r"\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]")
|
|
33
35
|
|
|
34
36
|
|
|
@@ -165,7 +167,8 @@ HTTP_RE = re.compile(r"https?://[^\s\]\)>'\"|]+")
|
|
|
165
167
|
def wiki_path(cell: str) -> str:
|
|
166
168
|
m = WIKI.search(cell or "")
|
|
167
169
|
if not m:
|
|
168
|
-
|
|
170
|
+
raw = (cell or "").strip().strip("`")
|
|
171
|
+
return raw if raw.endswith(".md") and "://" not in raw else ""
|
|
169
172
|
p = m.group(1).strip()
|
|
170
173
|
if p.endswith(".md"):
|
|
171
174
|
return p
|
|
@@ -304,44 +307,128 @@ def parse_feedback_metrics(text: str) -> dict:
|
|
|
304
307
|
|
|
305
308
|
|
|
306
309
|
ENGAGEMENT_SNAP_RE = re.compile(
|
|
307
|
-
r"engagement\s+snapshot
|
|
310
|
+
r"(?:engagement\s+snapshot|snapshot\s+engagement)[^\n:]*:\s*(.+?)(?:\n|$)",
|
|
311
|
+
re.I,
|
|
312
|
+
)
|
|
313
|
+
ENGAGEMENT_SECTION_RE = re.compile(
|
|
314
|
+
r"^##\s+(?:Engagement\s+snapshot|Snapshot\s+metrics(?:\s*\([^)]*\))?)\s*\n+(.*?)(?=^##\s|\Z)",
|
|
315
|
+
re.I | re.M | re.S,
|
|
316
|
+
)
|
|
317
|
+
ENGAGEMENT_KIND = r"likes?|replies|reply|bookmarks?|views?"
|
|
318
|
+
ENGAGEMENT_NUM = r"~?\d[\d,]*(?:\.\d+)?[kKmMbB]?"
|
|
319
|
+
# kind-first: likes 113 / likes: 3
|
|
320
|
+
ENGAGEMENT_KIND_NUM_RE = re.compile(
|
|
321
|
+
rf"\b({ENGAGEMENT_KIND})\s*[:=]?\s*({ENGAGEMENT_NUM})\b",
|
|
308
322
|
re.I,
|
|
309
323
|
)
|
|
310
|
-
|
|
311
|
-
|
|
324
|
+
# num-first: 1086 likes / ~92.9k views
|
|
325
|
+
ENGAGEMENT_NUM_KIND_RE = re.compile(
|
|
326
|
+
rf"({ENGAGEMENT_NUM})\s+({ENGAGEMENT_KIND})\b",
|
|
312
327
|
re.I,
|
|
313
328
|
)
|
|
314
329
|
ENGAGEMENT_AT_RE = re.compile(r"@\s*(\d{4}-\d{2}-\d{2})")
|
|
330
|
+
ENGAGEMENT_FETCH_AT_RE = re.compile(
|
|
331
|
+
r"(?:fetch|at\s+capture|snapshot_at)\s*[:=]?\s*(\d{4}-\d{2}-\d{2})",
|
|
332
|
+
re.I,
|
|
333
|
+
)
|
|
315
334
|
|
|
316
335
|
|
|
317
|
-
def
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
Only the primary snapshot counts. Parenthetical notes like
|
|
321
|
-
`(quoted earlier demo: likes 67 / bookmarks 115)` are ignored.
|
|
322
|
-
"""
|
|
323
|
-
out = {
|
|
336
|
+
def _empty_engagement() -> dict[str, str]:
|
|
337
|
+
return {
|
|
324
338
|
"src_likes": "",
|
|
325
339
|
"src_replies": "",
|
|
326
340
|
"src_bookmarks": "",
|
|
327
341
|
"src_views": "",
|
|
328
342
|
"engagement_at": "",
|
|
329
343
|
}
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _normalize_metric_kind(kind: str) -> str:
|
|
347
|
+
k = kind.lower()
|
|
348
|
+
if k.startswith("like"):
|
|
349
|
+
return "likes"
|
|
350
|
+
if k.startswith("repl"):
|
|
351
|
+
return "replies"
|
|
352
|
+
if k.startswith("bookmark"):
|
|
353
|
+
return "bookmarks"
|
|
354
|
+
if k.startswith("view"):
|
|
355
|
+
return "views"
|
|
356
|
+
return k
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _parse_compact_count(raw: str) -> str:
|
|
360
|
+
"""'92.9k' → '92900'; '1.1M' → '1100000'; '9,574' → '9574'."""
|
|
361
|
+
s = raw.strip().lstrip("~").replace(",", "")
|
|
362
|
+
if not s:
|
|
363
|
+
return ""
|
|
364
|
+
m = re.fullmatch(r"(\d+(?:\.\d+)?)([kKmMbB])?", s)
|
|
365
|
+
if not m:
|
|
366
|
+
digits = re.sub(r"[^\d]", "", s)
|
|
367
|
+
return digits
|
|
368
|
+
n = float(m.group(1))
|
|
369
|
+
suf = (m.group(2) or "").lower()
|
|
370
|
+
if suf == "k":
|
|
371
|
+
n *= 1_000
|
|
372
|
+
elif suf == "m":
|
|
373
|
+
n *= 1_000_000
|
|
374
|
+
elif suf == "b":
|
|
375
|
+
n *= 1_000_000_000
|
|
376
|
+
return str(int(round(n)))
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _fill_engagement_from_blob(out: dict[str, str], blob: str) -> None:
|
|
380
|
+
if not blob:
|
|
381
|
+
return
|
|
382
|
+
# Prefer kind-first matches (list form), then num-first (capture prose).
|
|
383
|
+
for kind, num in ENGAGEMENT_KIND_NUM_RE.findall(blob):
|
|
384
|
+
key = f"src_{_normalize_metric_kind(kind)}"
|
|
385
|
+
if key in out and not out[key]:
|
|
386
|
+
out[key] = _parse_compact_count(num)
|
|
387
|
+
for num, kind in ENGAGEMENT_NUM_KIND_RE.findall(blob):
|
|
388
|
+
key = f"src_{_normalize_metric_kind(kind)}"
|
|
389
|
+
if key in out and not out[key]:
|
|
390
|
+
out[key] = _parse_compact_count(num)
|
|
391
|
+
if not out["engagement_at"]:
|
|
392
|
+
at = ENGAGEMENT_AT_RE.search(blob) or ENGAGEMENT_FETCH_AT_RE.search(blob)
|
|
393
|
+
if at:
|
|
394
|
+
out["engagement_at"] = at.group(1)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def parse_engagement_snapshot(text: str) -> dict[str, str]:
|
|
398
|
+
"""Parse engagement into src_* fields from Capture / Raw notes.
|
|
399
|
+
|
|
400
|
+
Supports:
|
|
401
|
+
- Compact: `engagement snapshot: likes 113 / replies 18 / ... @ 2026-08-30`
|
|
402
|
+
- Capture prose: `Engagement snapshot (fetch DATE): ~92.9k views · 1086 likes · ...`
|
|
403
|
+
- Raw list under `## Engagement snapshot` with `- likes: 3`
|
|
404
|
+
Parenthetical secondary demos after the primary snapshot are ignored.
|
|
405
|
+
"""
|
|
406
|
+
out = _empty_engagement()
|
|
330
407
|
if not text:
|
|
331
408
|
return out
|
|
332
409
|
m = ENGAGEMENT_SNAP_RE.search(text)
|
|
333
|
-
if
|
|
410
|
+
if m:
|
|
411
|
+
blob = m.group(1).split("(", 1)[0]
|
|
412
|
+
# Date often sits in the label: Engagement snapshot (fetch 2026-08-25)
|
|
413
|
+
label = text[m.start() : m.start(1)]
|
|
414
|
+
_fill_engagement_from_blob(out, blob)
|
|
415
|
+
if not out["engagement_at"]:
|
|
416
|
+
at = ENGAGEMENT_FETCH_AT_RE.search(label) or ENGAGEMENT_AT_RE.search(label)
|
|
417
|
+
if at:
|
|
418
|
+
out["engagement_at"] = at.group(1)
|
|
334
419
|
return out
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
420
|
+
sec = ENGAGEMENT_SECTION_RE.search(text)
|
|
421
|
+
if sec:
|
|
422
|
+
_fill_engagement_from_blob(out, sec.group(1))
|
|
423
|
+
return out
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def merge_engagement(*parts: dict[str, str]) -> dict[str, str]:
|
|
427
|
+
out = _empty_engagement()
|
|
428
|
+
for part in parts:
|
|
429
|
+
for k, v in part.items():
|
|
430
|
+
if k in out and v and not out[k]:
|
|
431
|
+
out[k] = v
|
|
345
432
|
return out
|
|
346
433
|
|
|
347
434
|
def build_graph() -> dict:
|
|
@@ -359,7 +446,7 @@ def build_graph() -> dict:
|
|
|
359
446
|
for p in profiles:
|
|
360
447
|
base = p["path"].rstrip("/") + "/"
|
|
361
448
|
files = {}
|
|
362
|
-
for name in ("README", "voice", "audience", "pillars"):
|
|
449
|
+
for name in ("README", "voice", "audience", "pillars", "lanes"):
|
|
363
450
|
rel = f"{base}{name}.md"
|
|
364
451
|
if (ROOT / rel).exists():
|
|
365
452
|
files[name.lower()] = rel
|
|
@@ -432,102 +519,142 @@ def build_graph() -> dict:
|
|
|
432
519
|
for kind, lib_id in parse_library(row["library"]):
|
|
433
520
|
edges.append({"from": nid("capture", ident), "to": nid(kind, lib_id), "rel": "ingested_as"})
|
|
434
521
|
|
|
435
|
-
|
|
522
|
+
needs_md = read("needs/_index.md")
|
|
523
|
+
needs_rows = table_with(
|
|
524
|
+
needs_md,
|
|
525
|
+
"id",
|
|
526
|
+
"date",
|
|
527
|
+
"profile",
|
|
528
|
+
"source_type",
|
|
529
|
+
"quote",
|
|
530
|
+
"speaker",
|
|
531
|
+
"frequency",
|
|
532
|
+
"status",
|
|
533
|
+
"topic_ids",
|
|
534
|
+
"file",
|
|
535
|
+
)
|
|
536
|
+
for row in needs_rows:
|
|
436
537
|
ident = row["id"]
|
|
437
|
-
|
|
438
|
-
|
|
538
|
+
if not ident.startswith("N-"):
|
|
539
|
+
continue
|
|
540
|
+
fpath = wiki_path(row["file"]) or f"needs/entries/{ident}.md"
|
|
541
|
+
if not fpath.endswith(".md"):
|
|
439
542
|
fpath = f"{fpath}.md"
|
|
543
|
+
topic_ids = (row.get("topic_ids") or "").strip()
|
|
544
|
+
quote = (row.get("quote") or "").strip()
|
|
440
545
|
add_node(
|
|
441
546
|
node(
|
|
442
|
-
"
|
|
547
|
+
"need",
|
|
443
548
|
ident,
|
|
444
|
-
|
|
549
|
+
quote or f"Need {ident}",
|
|
445
550
|
fpath,
|
|
446
551
|
{
|
|
447
552
|
"date": row["date"],
|
|
448
|
-
"
|
|
449
|
-
"
|
|
450
|
-
"
|
|
553
|
+
"profile": row["profile"],
|
|
554
|
+
"source_type": row["source_type"],
|
|
555
|
+
"quote": quote,
|
|
556
|
+
"speaker": row.get("speaker", "") or "",
|
|
557
|
+
"frequency": row.get("frequency", "") or "1",
|
|
558
|
+
"status": row["status"],
|
|
559
|
+
"topic_ids": topic_ids,
|
|
451
560
|
},
|
|
452
561
|
)
|
|
453
562
|
)
|
|
454
|
-
|
|
455
|
-
edge(nid("capture", row["capture_id"]), nid("raw", ident), "has_raw")
|
|
563
|
+
# Topic edges are wired after topic nodes exist (see post-pass below).
|
|
456
564
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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,
|
|
565
|
+
products_path = ROOT / "products" / "_index.md"
|
|
566
|
+
if products_path.exists():
|
|
567
|
+
product_rows = table_with(
|
|
568
|
+
products_path.read_text(encoding="utf-8"),
|
|
477
569
|
"id",
|
|
478
|
-
"
|
|
479
|
-
"
|
|
480
|
-
"
|
|
481
|
-
"
|
|
482
|
-
"
|
|
483
|
-
"
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
"
|
|
487
|
-
"
|
|
570
|
+
"name",
|
|
571
|
+
"status",
|
|
572
|
+
"audience",
|
|
573
|
+
"price",
|
|
574
|
+
"effective_from",
|
|
575
|
+
"file",
|
|
576
|
+
)
|
|
577
|
+
for row in product_rows:
|
|
578
|
+
ident = row["id"]
|
|
579
|
+
if not ident.startswith("P-"):
|
|
580
|
+
continue
|
|
581
|
+
fpath = wiki_path(row["file"]) or f"products/entries/{ident}.md"
|
|
582
|
+
if not fpath.endswith(".md"):
|
|
583
|
+
fpath = f"{fpath}.md"
|
|
584
|
+
add_node(
|
|
585
|
+
node(
|
|
586
|
+
"product",
|
|
587
|
+
ident,
|
|
588
|
+
row["name"] or ident,
|
|
589
|
+
fpath,
|
|
590
|
+
{
|
|
591
|
+
"status": row["status"],
|
|
592
|
+
"audience": row["audience"],
|
|
593
|
+
"price": row["price"],
|
|
594
|
+
"effective_from": row["effective_from"],
|
|
595
|
+
},
|
|
596
|
+
)
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
recommendations_path = ROOT / "recommendations" / "_index.md"
|
|
600
|
+
if recommendations_path.exists():
|
|
601
|
+
recommendation_rows = table_with(
|
|
602
|
+
recommendations_path.read_text(encoding="utf-8"),
|
|
603
|
+
"id",
|
|
604
|
+
"name",
|
|
488
605
|
"status",
|
|
489
|
-
"
|
|
490
|
-
"
|
|
491
|
-
"
|
|
606
|
+
"category",
|
|
607
|
+
"profiles",
|
|
608
|
+
"pillars",
|
|
609
|
+
"last_researched",
|
|
610
|
+
"relationship",
|
|
492
611
|
"file",
|
|
493
612
|
)
|
|
494
|
-
|
|
613
|
+
for row in recommendation_rows:
|
|
614
|
+
ident = row["id"]
|
|
615
|
+
if not ident.startswith("R-"):
|
|
616
|
+
continue
|
|
617
|
+
fpath = wiki_path(row["file"]) or f"recommendations/{ident}.md"
|
|
618
|
+
if not fpath.endswith(".md"):
|
|
619
|
+
fpath = f"{fpath}.md"
|
|
620
|
+
add_node(
|
|
621
|
+
node(
|
|
622
|
+
"recommendation",
|
|
623
|
+
ident,
|
|
624
|
+
row["name"] or ident,
|
|
625
|
+
fpath,
|
|
626
|
+
{
|
|
627
|
+
"status": row["status"],
|
|
628
|
+
"category": row["category"],
|
|
629
|
+
"profiles": row["profiles"],
|
|
630
|
+
"pillars": row["pillars"],
|
|
631
|
+
"last_researched": row["last_researched"],
|
|
632
|
+
"relationship": row["relationship"],
|
|
633
|
+
},
|
|
634
|
+
)
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
for row in table_with(read("raw/_index.md"), "id", "date", "capture_id", "file", "type", "source_url", "one_liner"):
|
|
495
638
|
ident = row["id"]
|
|
496
|
-
if not ident.startswith("D-"):
|
|
497
|
-
continue
|
|
498
639
|
fpath = wiki_path(row["file"])
|
|
499
640
|
if fpath and not fpath.endswith(".md"):
|
|
500
641
|
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
642
|
add_node(
|
|
504
643
|
node(
|
|
505
|
-
"
|
|
644
|
+
"raw",
|
|
506
645
|
ident,
|
|
507
|
-
row["
|
|
646
|
+
row["one_liner"],
|
|
508
647
|
fpath,
|
|
509
648
|
{
|
|
510
649
|
"date": row["date"],
|
|
511
|
-
"
|
|
512
|
-
"platform": row["platform"],
|
|
513
|
-
"source_handle": row["source_handle"],
|
|
650
|
+
"type": row["type"],
|
|
514
651
|
"source_url": row["source_url"],
|
|
515
|
-
"
|
|
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,
|
|
652
|
+
"capture_id": row["capture_id"],
|
|
525
653
|
},
|
|
526
654
|
)
|
|
527
655
|
)
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
edge(nid("discovery", ident), nid("topic", tid), "candidate")
|
|
656
|
+
if row["capture_id"]:
|
|
657
|
+
edge(nid("capture", row["capture_id"]), nid("raw", ident), "has_raw")
|
|
531
658
|
|
|
532
659
|
topic_md = read("topics/_index.md")
|
|
533
660
|
# Current: opp + aud (5C opportunity). Legacy: craft + fit, then single total.
|
|
@@ -542,6 +669,8 @@ def build_graph() -> dict:
|
|
|
542
669
|
"scout",
|
|
543
670
|
"platform",
|
|
544
671
|
"pillar",
|
|
672
|
+
"lane",
|
|
673
|
+
"generation_mode",
|
|
545
674
|
"one_liner",
|
|
546
675
|
"item",
|
|
547
676
|
"source_type",
|
|
@@ -552,6 +681,10 @@ def build_graph() -> dict:
|
|
|
552
681
|
"src_bookmarks",
|
|
553
682
|
"engagement_at",
|
|
554
683
|
"capture_id",
|
|
684
|
+
"need_ids",
|
|
685
|
+
"product_ids",
|
|
686
|
+
"product_fit",
|
|
687
|
+
"recommendation_ids",
|
|
555
688
|
"run",
|
|
556
689
|
)
|
|
557
690
|
if topic_rows:
|
|
@@ -655,6 +788,8 @@ def build_graph() -> dict:
|
|
|
655
788
|
"status": row["status"],
|
|
656
789
|
"platform": row["platform"],
|
|
657
790
|
"pillar": row["pillar"],
|
|
791
|
+
"lane": row.get("lane", "") or "",
|
|
792
|
+
"generation_mode": row.get("generation_mode", "") or "",
|
|
658
793
|
"profile": profile,
|
|
659
794
|
"source_type": row["source_type"],
|
|
660
795
|
"src_url": row["src_url"],
|
|
@@ -664,6 +799,10 @@ def build_graph() -> dict:
|
|
|
664
799
|
"src_bookmarks": row["src_bookmarks"],
|
|
665
800
|
"engagement_at": row["engagement_at"],
|
|
666
801
|
"capture_id": row["capture_id"],
|
|
802
|
+
"need_ids": row.get("need_ids", "") or "",
|
|
803
|
+
"product_ids": row.get("product_ids", "") or "",
|
|
804
|
+
"product_fit": row.get("product_fit", "") or "",
|
|
805
|
+
"recommendation_ids": row.get("recommendation_ids", "") or "",
|
|
667
806
|
"run_id": row["run"].strip(),
|
|
668
807
|
"usage": usage,
|
|
669
808
|
"src_heat_note": "来源帖热度,不是你的帖",
|
|
@@ -674,10 +813,31 @@ def build_graph() -> dict:
|
|
|
674
813
|
edge(nid("topic", ident), nid("capture", row["capture_id"]), "from_capture")
|
|
675
814
|
if row["run"].strip().startswith("RUN-"):
|
|
676
815
|
edge(nid("topic", ident), nid("run", row["run"].strip()), "opened_run")
|
|
816
|
+
# Index cells are enough for edges; item frontmatter may repeat the same ids.
|
|
817
|
+
for need_id in re.findall(r"\bN-\d{8}-\d{2}\b", row.get("need_ids", "") or ""):
|
|
818
|
+
edge(nid("topic", ident), nid("need", need_id), "from_need")
|
|
819
|
+
for product_id in PRODUCT_RE.findall(row.get("product_ids", "") or ""):
|
|
820
|
+
edge(nid("topic", ident), nid("product", product_id), "uses_product")
|
|
821
|
+
for recommendation_id in RECOMMENDATION_RE.findall(row.get("recommendation_ids", "") or ""):
|
|
822
|
+
edge(nid("topic", ident), nid("recommendation", recommendation_id), "reviews")
|
|
677
823
|
if item and (ROOT / item).exists():
|
|
678
824
|
body = read(item)
|
|
825
|
+
fm = re.match(r"^---\n(.*?)\n---", body, re.S)
|
|
826
|
+
if fm:
|
|
827
|
+
mneeds = re.search(r"^need_ids:\s*(.*)$", fm.group(1), re.M)
|
|
828
|
+
if mneeds:
|
|
829
|
+
for need_id in re.findall(r"\bN-\d{8}-\d{2}\b", mneeds.group(1)):
|
|
830
|
+
edge(nid("topic", ident), nid("need", need_id), "from_need")
|
|
831
|
+
mproducts = re.search(r"^product_ids:\s*(.*)$", fm.group(1), re.M)
|
|
832
|
+
if mproducts:
|
|
833
|
+
for product_id in PRODUCT_RE.findall(mproducts.group(1)):
|
|
834
|
+
edge(nid("topic", ident), nid("product", product_id), "uses_product")
|
|
835
|
+
mrecommendations = re.search(r"^recommendation_ids:\s*(.*)$", fm.group(1), re.M)
|
|
836
|
+
if mrecommendations:
|
|
837
|
+
for recommendation_id in RECOMMENDATION_RE.findall(mrecommendations.group(1)):
|
|
838
|
+
edge(nid("topic", ident), nid("recommendation", recommendation_id), "reviews")
|
|
679
839
|
chunk = body
|
|
680
|
-
m = re.search(r"^## Suggested production.*?(?=^## |\Z)", body, re.M | re.S)
|
|
840
|
+
m = re.search(r"^## Suggested production.*?(?=^## |\Z)|^## Trace.*?(?=^## |\Z)", body, re.M | re.S)
|
|
681
841
|
if m:
|
|
682
842
|
chunk = m.group(0)
|
|
683
843
|
for line in chunk.splitlines():
|
|
@@ -697,6 +857,12 @@ def build_graph() -> dict:
|
|
|
697
857
|
for cid in ids_in(rest)["claim"]:
|
|
698
858
|
edges.append({"from": nid("topic", ident), "to": nid("claim", cid), "rel": "suggested"})
|
|
699
859
|
|
|
860
|
+
for n in nodes.values():
|
|
861
|
+
if n["kind"] != "need":
|
|
862
|
+
continue
|
|
863
|
+
for tid in re.findall(r"\bT-\d{8}-\d{2}\b", n.get("topic_ids") or ""):
|
|
864
|
+
edge(n["id"], nid("topic", tid), "evidence_for")
|
|
865
|
+
|
|
700
866
|
for row in table_with(
|
|
701
867
|
read("swipe/_index.md"),
|
|
702
868
|
"id",
|
|
@@ -1004,18 +1170,35 @@ def build_graph() -> dict:
|
|
|
1004
1170
|
for cid in field_ids(row["claim"], "claim"):
|
|
1005
1171
|
edge(nid("published", ident), nid("claim", cid), "uses")
|
|
1006
1172
|
|
|
1007
|
-
# Capture →
|
|
1173
|
+
# Capture → source_url from Raw + engagement from entry/raw notes
|
|
1008
1174
|
for n in list(nodes.values()):
|
|
1009
1175
|
if n["kind"] != "capture":
|
|
1010
1176
|
continue
|
|
1177
|
+
raw_node = None
|
|
1011
1178
|
for e in edges:
|
|
1012
1179
|
if e["rel"] != "has_raw" or e["from"] != n["id"]:
|
|
1013
1180
|
continue
|
|
1014
|
-
|
|
1015
|
-
if
|
|
1016
|
-
n["source_url"] =
|
|
1017
|
-
n["raw_id"] =
|
|
1018
|
-
|
|
1181
|
+
raw_node = nodes.get(e["to"])
|
|
1182
|
+
if raw_node and raw_node.get("source_url"):
|
|
1183
|
+
n["source_url"] = raw_node["source_url"]
|
|
1184
|
+
n["raw_id"] = raw_node["ident"]
|
|
1185
|
+
break
|
|
1186
|
+
entry_text = read(n["path"]) if n.get("path") and (ROOT / n["path"]).exists() else ""
|
|
1187
|
+
raw_text = ""
|
|
1188
|
+
raw_path = ""
|
|
1189
|
+
if raw_node and raw_node.get("path"):
|
|
1190
|
+
raw_path = raw_node["path"]
|
|
1191
|
+
elif n.get("raw_path"):
|
|
1192
|
+
raw_path = n["raw_path"] if str(n["raw_path"]).endswith(".md") else f'{n["raw_path"]}.md'
|
|
1193
|
+
if raw_path and (ROOT / raw_path).exists():
|
|
1194
|
+
raw_text = read(raw_path)
|
|
1195
|
+
eng = merge_engagement(
|
|
1196
|
+
parse_engagement_snapshot(entry_text),
|
|
1197
|
+
parse_engagement_snapshot(raw_text),
|
|
1198
|
+
)
|
|
1199
|
+
for k, v in eng.items():
|
|
1200
|
+
if v:
|
|
1201
|
+
n[k] = v
|
|
1019
1202
|
|
|
1020
1203
|
# drop edges to missing nodes
|
|
1021
1204
|
edges = [e for e in edges if e["from"] in nodes and e["to"] in nodes]
|
|
@@ -1056,11 +1239,41 @@ def build_graph() -> dict:
|
|
|
1056
1239
|
"accounts": accounts,
|
|
1057
1240
|
"pillars_by_profile": pillars_by_profile,
|
|
1058
1241
|
"pillars": flat_pillars,
|
|
1242
|
+
"needs_index_present": (ROOT / "needs" / "_index.md").exists(),
|
|
1243
|
+
"products_index_present": (ROOT / "products" / "_index.md").exists(),
|
|
1059
1244
|
"nodes": list(nodes.values()),
|
|
1060
1245
|
"edges": uniq,
|
|
1061
1246
|
}
|
|
1062
1247
|
|
|
1063
1248
|
|
|
1249
|
+
def _self_check() -> None:
|
|
1250
|
+
# ponytail: tiny parser asserts; expand if capture/raw formats drift again.
|
|
1251
|
+
d = parse_engagement_snapshot(
|
|
1252
|
+
"- **engagement snapshot:** likes 113 / replies 18 / bookmarks 17 / views 9574 @ 2026-08-30\n"
|
|
1253
|
+
)
|
|
1254
|
+
assert d == {
|
|
1255
|
+
"src_likes": "113",
|
|
1256
|
+
"src_replies": "18",
|
|
1257
|
+
"src_bookmarks": "17",
|
|
1258
|
+
"src_views": "9574",
|
|
1259
|
+
"engagement_at": "2026-08-30",
|
|
1260
|
+
}, d
|
|
1261
|
+
c = parse_engagement_snapshot(
|
|
1262
|
+
"6. **Engagement snapshot (fetch 2026-08-25):** ~92.9k views · 1086 likes · 1380 bookmarks · 738 replies · 70 reposts.\n"
|
|
1263
|
+
)
|
|
1264
|
+
assert c["src_views"] == "92900" and c["src_likes"] == "1086", c
|
|
1265
|
+
assert c["src_bookmarks"] == "1380" and c["src_replies"] == "738", c
|
|
1266
|
+
assert c["engagement_at"] == "2026-08-25", c
|
|
1267
|
+
r = parse_engagement_snapshot(
|
|
1268
|
+
"## Engagement snapshot\n\n- likes: 3\n- replies: 3\n- views: 426\n- bookmarks: 2\n- snapshot_at: 2026-09-13\n"
|
|
1269
|
+
)
|
|
1270
|
+
assert r["src_likes"] == "3" and r["src_views"] == "426" and r["engagement_at"] == "2026-09-13", r
|
|
1271
|
+
m = parse_engagement_snapshot(
|
|
1272
|
+
"## Snapshot metrics (at capture)\n\n- Views: ~8091\n- Likes: 103\n- Bookmarks: 29\n- Replies: 20\n"
|
|
1273
|
+
)
|
|
1274
|
+
assert m["src_views"] == "8091" and m["src_likes"] == "103", m
|
|
1275
|
+
assert m["src_bookmarks"] == "29" and m["src_replies"] == "20", m
|
|
1276
|
+
print("parse_engagement_snapshot self-check ok")
|
|
1064
1277
|
def main() -> None:
|
|
1065
1278
|
graph = build_graph()
|
|
1066
1279
|
html = TPL.read_text(encoding="utf-8")
|
|
@@ -1071,4 +1284,9 @@ def main() -> None:
|
|
|
1071
1284
|
|
|
1072
1285
|
|
|
1073
1286
|
if __name__ == "__main__":
|
|
1074
|
-
|
|
1287
|
+
import sys
|
|
1288
|
+
|
|
1289
|
+
if "--self-check" in sys.argv:
|
|
1290
|
+
_self_check()
|
|
1291
|
+
else:
|
|
1292
|
+
main()
|
|
File without changes
|