@easysociable/cli 0.1.4 → 0.1.6
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/content-engine/scripts/starter.mjs +1 -0
- package/content-engine/starter/CLAUDE.md +100 -38
- package/content-engine/starter/engine.json +1 -0
- package/content-engine/starter/hits/README.md +5 -0
- package/content-engine/starter/hits/_index.md +30 -0
- package/content-engine/starter/hits/_template.md +46 -0
- package/content-engine/starter/hits/entries/README.md +3 -0
- package/content-engine/starter/products/_index.md +1 -0
- package/content-engine/starter/products/entries/P-002-content-engine.md +136 -0
- package/content-engine/starter/profiles/default/README.md +3 -0
- package/content-engine/starter/profiles/default/handles.md +8 -0
- package/content-engine/starter/profiles/default/keywords.md +31 -0
- package/content-engine/starter/runs/_template/brief.md +41 -31
- package/content-engine/starter/topics/_template-item.md +30 -27
- package/content-engine/starter/topics/scoring.md +5 -2
- package/content-engine/workbench/__pycache__/build.cpython-310.pyc +0 -0
- package/content-engine/workbench/build.py +190 -6
- package/content-engine/workbench/serve.py +0 -0
- package/content-engine/workbench/template.html +538 -117
- package/dist/easysociable.js +2 -1
- package/dist/easysociable.js.map +1 -1
- package/package.json +14 -13
- package/skills/easysociable-content/SKILL.md +18 -9
- package/skills/easysociable-content/workflows/NEEDS.md +20 -3
- package/skills/easysociable-template/scripts/generate-theme-from-hex.mjs +0 -0
|
@@ -18,55 +18,58 @@ product_fit: none
|
|
|
18
18
|
recommendation_ids: none
|
|
19
19
|
---
|
|
20
20
|
|
|
21
|
-
#
|
|
21
|
+
# Decision card
|
|
22
22
|
|
|
23
|
-
>
|
|
23
|
+
> A content opportunity under discussion — not a final publish title.
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
**状态原因:** 一句话说明为什么得到这个结论
|
|
27
|
-
**适合谁:** 一句话说明具体受众和场景
|
|
28
|
-
**核心判断:** 一句话说明这条内容真正要讨论什么
|
|
29
|
-
**下一步:** 开始生成 / 补需求 / 补证据 / 修改角度 / 放弃
|
|
30
|
-
**产品连接:** none | P-… · direct | adjacent · 一句话说明连接方式
|
|
31
|
-
**生成路径:** demand | demand_to_offer | demand_to_review | review | offer_education | profile_thesis
|
|
25
|
+
**Language:** Match `profiles/<id>/voice.md` **Language** (else pillars “Primary public language”, else `engine.json` locale). Readable judgment + `one_liner` must use that language. Verbatim Needs/Capture quotes stay untranslated. Operator chat may add a short gloss in another language; the file does not.
|
|
32
26
|
|
|
33
|
-
|
|
27
|
+
**Verdict:** ready | backlog | parked
|
|
28
|
+
**Why this status:** one sentence
|
|
29
|
+
**Who it’s for:** one sentence — concrete audience and situation
|
|
30
|
+
**Core judgment:** one sentence — what this piece actually argues
|
|
31
|
+
**Next step:** open a run / fill needs / fill evidence / re-angle / kill
|
|
32
|
+
**Product link:** none | P-… · direct | adjacent · one sentence
|
|
33
|
+
**Generation path:** demand | demand_to_offer | demand_to_review | review | offer_education | profile_thesis
|
|
34
|
+
*(Agent must ask the user to choose before `produce`; do not silent-default.)*
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
## What problem does this solve?
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
2–4 sentences on the real-world problem. Do not restate a title.
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
## What do they believe now?
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
Name the false belief or stuck confusion.
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
## What cut are we making?
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
Our angle only. Not a full headline. Do not rewrite a source post as our thesis.
|
|
46
47
|
|
|
47
|
-
|
|
48
|
+
## Why now?
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
What changed, and why this audience must face it now. Not a news summary.
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
## What should change after reading?
|
|
52
53
|
|
|
53
|
-
|
|
54
|
+
Before → after for belief or action.
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
## What is this based on?
|
|
56
57
|
|
|
57
|
-
|
|
58
|
+
Plain-language Needs, Product, Recommendation, Capture, or other evidence.
|
|
58
59
|
|
|
59
|
-
|
|
60
|
+
## Current gaps
|
|
60
61
|
|
|
61
|
-
|
|
62
|
+
Missing need evidence, cases, feedback, or product facts. Write `none` if empty.
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
## Suggested form
|
|
65
|
+
|
|
66
|
+
Platform, lane, and form. No full script yet.
|
|
64
67
|
|
|
65
68
|
---
|
|
66
69
|
|
|
67
|
-
##
|
|
70
|
+
## Score / trace appendix
|
|
68
71
|
|
|
69
|
-
|
|
72
|
+
For review, machines, and handoff — not the primary reading surface.
|
|
70
73
|
|
|
71
74
|
### Score
|
|
72
75
|
|
|
@@ -185,7 +185,9 @@ raise the audience score.
|
|
|
185
185
|
|
|
186
186
|
### Generation-mode gates
|
|
187
187
|
|
|
188
|
-
Before scoring,
|
|
188
|
+
Before scoring a `produce` Topic, **ask the user to choose `generation_mode`**.
|
|
189
|
+
Recommend `demand` when Need-led; do not silently assume. Then enforce required
|
|
190
|
+
inputs:
|
|
189
191
|
|
|
190
192
|
| mode | Required |
|
|
191
193
|
|------|----------|
|
|
@@ -198,7 +200,8 @@ Before scoring, set the smallest honest `generation_mode`:
|
|
|
198
200
|
|
|
199
201
|
Do not use `review` when the only evidence is vendor marketing. Review entries
|
|
200
202
|
must state limitations, research date, and commercial disclosure where
|
|
201
|
-
relevant. Product-only and recommendation-only inputs do not imply demand
|
|
203
|
+
relevant. Product-only and recommendation-only inputs do not imply demand and
|
|
204
|
+
never raise audience / `ready` alone.
|
|
202
205
|
|
|
203
206
|
When `evidence < 5`, agent should flag an **evidence gap** on the topic item and may suggest **research** before finalizing the score. Topics can be re-scored after research fills the gap.
|
|
204
207
|
|
|
Binary file
|
|
@@ -108,8 +108,13 @@ def extract_tables(md: str) -> list[dict]:
|
|
|
108
108
|
blocks: list[list[str]] = []
|
|
109
109
|
cur: list[str] = []
|
|
110
110
|
for line in md.splitlines():
|
|
111
|
-
|
|
111
|
+
stripped = line.strip()
|
|
112
|
+
if stripped.startswith("|"):
|
|
112
113
|
cur.append(line)
|
|
114
|
+
elif not stripped:
|
|
115
|
+
# Blank lines inside a GFM table must not split the block,
|
|
116
|
+
# or later rows are parsed as a new headerless table.
|
|
117
|
+
continue
|
|
113
118
|
elif cur:
|
|
114
119
|
blocks.append(cur)
|
|
115
120
|
cur = []
|
|
@@ -379,7 +384,7 @@ def _parse_compact_count(raw: str) -> str:
|
|
|
379
384
|
def _fill_engagement_from_blob(out: dict[str, str], blob: str) -> None:
|
|
380
385
|
if not blob:
|
|
381
386
|
return
|
|
382
|
-
# Prefer kind-first matches (
|
|
387
|
+
# Prefer kind-first matches (list form), then num-first (capture prose).
|
|
383
388
|
for kind, num in ENGAGEMENT_KIND_NUM_RE.findall(blob):
|
|
384
389
|
key = f"src_{_normalize_metric_kind(kind)}"
|
|
385
390
|
if key in out and not out[key]:
|
|
@@ -395,10 +400,10 @@ def _fill_engagement_from_blob(out: dict[str, str], blob: str) -> None:
|
|
|
395
400
|
|
|
396
401
|
|
|
397
402
|
def parse_engagement_snapshot(text: str) -> dict[str, str]:
|
|
398
|
-
"""Parse engagement into src_* fields from
|
|
403
|
+
"""Parse engagement into src_* fields from Capture / Raw notes.
|
|
399
404
|
|
|
400
405
|
Supports:
|
|
401
|
-
-
|
|
406
|
+
- Compact: `engagement snapshot: likes 113 / replies 18 / ... @ 2026-08-30`
|
|
402
407
|
- Capture prose: `Engagement snapshot (fetch DATE): ~92.9k views · 1086 likes · ...`
|
|
403
408
|
- Raw list under `## Engagement snapshot` with `- likes: 3`
|
|
404
409
|
Parenthetical secondary demos after the primary snapshot are ignored.
|
|
@@ -446,7 +451,7 @@ def build_graph() -> dict:
|
|
|
446
451
|
for p in profiles:
|
|
447
452
|
base = p["path"].rstrip("/") + "/"
|
|
448
453
|
files = {}
|
|
449
|
-
for name in ("README", "voice", "audience", "pillars", "lanes"):
|
|
454
|
+
for name in ("README", "voice", "audience", "pillars", "lanes", "keywords", "handles"):
|
|
450
455
|
rel = f"{base}{name}.md"
|
|
451
456
|
if (ROOT / rel).exists():
|
|
452
457
|
files[name.lower()] = rel
|
|
@@ -562,6 +567,174 @@ def build_graph() -> dict:
|
|
|
562
567
|
)
|
|
563
568
|
# Topic edges are wired after topic nodes exist (see post-pass below).
|
|
564
569
|
|
|
570
|
+
hits_path = ROOT / "hits" / "_index.md"
|
|
571
|
+
if hits_path.exists():
|
|
572
|
+
hits_rows = table_with(
|
|
573
|
+
hits_path.read_text(encoding="utf-8"),
|
|
574
|
+
"id",
|
|
575
|
+
"date",
|
|
576
|
+
"profile",
|
|
577
|
+
"platform",
|
|
578
|
+
"keyword_id",
|
|
579
|
+
"source",
|
|
580
|
+
"hit_kind",
|
|
581
|
+
"signal",
|
|
582
|
+
"status",
|
|
583
|
+
"excerpt",
|
|
584
|
+
"src_likes",
|
|
585
|
+
"src_replies",
|
|
586
|
+
"src_bookmarks",
|
|
587
|
+
"src_views",
|
|
588
|
+
"engagement_at",
|
|
589
|
+
"file",
|
|
590
|
+
)
|
|
591
|
+
if not hits_rows:
|
|
592
|
+
hits_rows = table_with(
|
|
593
|
+
hits_path.read_text(encoding="utf-8"),
|
|
594
|
+
"id",
|
|
595
|
+
"date",
|
|
596
|
+
"profile",
|
|
597
|
+
"platform",
|
|
598
|
+
"source",
|
|
599
|
+
"hit_kind",
|
|
600
|
+
"signal",
|
|
601
|
+
"status",
|
|
602
|
+
"excerpt",
|
|
603
|
+
"file",
|
|
604
|
+
)
|
|
605
|
+
for row in hits_rows:
|
|
606
|
+
ident = row["id"]
|
|
607
|
+
if not ident.startswith("H-"):
|
|
608
|
+
continue
|
|
609
|
+
fpath = wiki_path(row["file"]) or f"hits/entries/{ident}.md"
|
|
610
|
+
if not fpath.endswith(".md"):
|
|
611
|
+
fpath = f"{fpath}.md"
|
|
612
|
+
excerpt = (row.get("excerpt") or "").strip()
|
|
613
|
+
keyword_id = (row.get("keyword_id") or "").strip()
|
|
614
|
+
if not keyword_id:
|
|
615
|
+
m = re.search(r"\bK-\d{2}\b", row.get("source", "") or "")
|
|
616
|
+
if m:
|
|
617
|
+
keyword_id = m.group(0)
|
|
618
|
+
eng = {
|
|
619
|
+
"src_likes": row.get("src_likes", "") or "",
|
|
620
|
+
"src_replies": row.get("src_replies", "") or "",
|
|
621
|
+
"src_bookmarks": row.get("src_bookmarks", "") or "",
|
|
622
|
+
"src_views": row.get("src_views", "") or "",
|
|
623
|
+
"engagement_at": row.get("engagement_at", "") or "",
|
|
624
|
+
}
|
|
625
|
+
source_url = ""
|
|
626
|
+
if fpath and (ROOT / fpath).exists():
|
|
627
|
+
body = read(fpath)
|
|
628
|
+
eng = merge_engagement(eng, parse_engagement_snapshot(body))
|
|
629
|
+
fm = re.match(r"^---\n(.*?)\n---", body, re.S)
|
|
630
|
+
if fm:
|
|
631
|
+
murl = re.search(r"^source_url:\s*(\S+)", fm.group(1), re.M)
|
|
632
|
+
if murl:
|
|
633
|
+
source_url = murl.group(1).strip()
|
|
634
|
+
if not keyword_id:
|
|
635
|
+
mk = re.search(r"^keyword_id:\s*(\S+)", fm.group(1), re.M)
|
|
636
|
+
if mk:
|
|
637
|
+
keyword_id = mk.group(1).strip()
|
|
638
|
+
add_node(
|
|
639
|
+
node(
|
|
640
|
+
"hit",
|
|
641
|
+
ident,
|
|
642
|
+
excerpt or f"Hit {ident}",
|
|
643
|
+
fpath,
|
|
644
|
+
{
|
|
645
|
+
"date": row["date"],
|
|
646
|
+
"profile": row["profile"],
|
|
647
|
+
"platform": row.get("platform", "") or "",
|
|
648
|
+
"keyword_id": keyword_id,
|
|
649
|
+
"source": row.get("source", "") or keyword_id,
|
|
650
|
+
"hit_kind": row.get("hit_kind", "") or "",
|
|
651
|
+
"signal": row.get("signal", "") or "",
|
|
652
|
+
"status": row["status"],
|
|
653
|
+
"excerpt": excerpt,
|
|
654
|
+
"source_url": source_url,
|
|
655
|
+
"src_likes": eng.get("src_likes", "") or "",
|
|
656
|
+
"src_replies": eng.get("src_replies", "") or "",
|
|
657
|
+
"src_bookmarks": eng.get("src_bookmarks", "") or "",
|
|
658
|
+
"src_views": eng.get("src_views", "") or "",
|
|
659
|
+
"engagement_at": eng.get("engagement_at", "") or "",
|
|
660
|
+
"src_heat_note": "来源帖热度,不是你的帖",
|
|
661
|
+
},
|
|
662
|
+
)
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
# Keyword recipes (profile listening config) + hit counts for Hits rail
|
|
666
|
+
keywords: list[dict] = []
|
|
667
|
+
for p in profiles:
|
|
668
|
+
kpath = ROOT / p["path"].rstrip("/") / "keywords.md"
|
|
669
|
+
if not kpath.exists():
|
|
670
|
+
continue
|
|
671
|
+
krows = table_with(
|
|
672
|
+
kpath.read_text(encoding="utf-8"),
|
|
673
|
+
"id",
|
|
674
|
+
"track",
|
|
675
|
+
"query",
|
|
676
|
+
"platform",
|
|
677
|
+
"intent",
|
|
678
|
+
"layer",
|
|
679
|
+
"pillar",
|
|
680
|
+
"why",
|
|
681
|
+
"weight",
|
|
682
|
+
"status",
|
|
683
|
+
"seed_from",
|
|
684
|
+
"pair_id",
|
|
685
|
+
"volume",
|
|
686
|
+
"kd",
|
|
687
|
+
"serp_intent",
|
|
688
|
+
"volume_source",
|
|
689
|
+
"volume_at",
|
|
690
|
+
"ask_engines",
|
|
691
|
+
"last_scanned",
|
|
692
|
+
)
|
|
693
|
+
if not krows:
|
|
694
|
+
krows = table_with(
|
|
695
|
+
kpath.read_text(encoding="utf-8"),
|
|
696
|
+
"id",
|
|
697
|
+
"query",
|
|
698
|
+
"platform",
|
|
699
|
+
"intent",
|
|
700
|
+
"layer",
|
|
701
|
+
"pillar",
|
|
702
|
+
"why",
|
|
703
|
+
"weight",
|
|
704
|
+
"status",
|
|
705
|
+
"last_scanned",
|
|
706
|
+
)
|
|
707
|
+
for row in krows:
|
|
708
|
+
kid = (row.get("id") or "").strip()
|
|
709
|
+
if not kid.startswith("K-"):
|
|
710
|
+
continue
|
|
711
|
+
track = (row.get("track") or "").strip() or "listen"
|
|
712
|
+
keywords.append(
|
|
713
|
+
{
|
|
714
|
+
"id": kid,
|
|
715
|
+
"profile": p["id"],
|
|
716
|
+
"track": track,
|
|
717
|
+
"query": row.get("query", "") or "",
|
|
718
|
+
"platform": row.get("platform", "") or "",
|
|
719
|
+
"intent": row.get("intent", "") or "",
|
|
720
|
+
"layer": row.get("layer", "") or "",
|
|
721
|
+
"pillar": row.get("pillar", "") or "",
|
|
722
|
+
"why": row.get("why", "") or "",
|
|
723
|
+
"weight": row.get("weight", "") or "",
|
|
724
|
+
"status": row.get("status", "") or "",
|
|
725
|
+
"seed_from": row.get("seed_from", "") or "",
|
|
726
|
+
"pair_id": row.get("pair_id", "") or "",
|
|
727
|
+
"volume": row.get("volume", "") or "",
|
|
728
|
+
"kd": row.get("kd", "") or "",
|
|
729
|
+
"serp_intent": row.get("serp_intent", "") or "",
|
|
730
|
+
"volume_source": row.get("volume_source", "") or "",
|
|
731
|
+
"volume_at": row.get("volume_at", "") or "",
|
|
732
|
+
"ask_engines": row.get("ask_engines", "") or "",
|
|
733
|
+
"last_scanned": row.get("last_scanned", "") or "",
|
|
734
|
+
"path": f"{p['path'].rstrip('/')}/keywords.md",
|
|
735
|
+
}
|
|
736
|
+
)
|
|
737
|
+
|
|
565
738
|
products_path = ROOT / "products" / "_index.md"
|
|
566
739
|
if products_path.exists():
|
|
567
740
|
product_rows = table_with(
|
|
@@ -684,6 +857,7 @@ def build_graph() -> dict:
|
|
|
684
857
|
"need_ids",
|
|
685
858
|
"product_ids",
|
|
686
859
|
"product_fit",
|
|
860
|
+
"recommendation_ids",
|
|
687
861
|
"run",
|
|
688
862
|
)
|
|
689
863
|
if topic_rows:
|
|
@@ -801,6 +975,7 @@ def build_graph() -> dict:
|
|
|
801
975
|
"need_ids": row.get("need_ids", "") or "",
|
|
802
976
|
"product_ids": row.get("product_ids", "") or "",
|
|
803
977
|
"product_fit": row.get("product_fit", "") or "",
|
|
978
|
+
"recommendation_ids": row.get("recommendation_ids", "") or "",
|
|
804
979
|
"run_id": row["run"].strip(),
|
|
805
980
|
"usage": usage,
|
|
806
981
|
"src_heat_note": "来源帖热度,不是你的帖",
|
|
@@ -811,6 +986,13 @@ def build_graph() -> dict:
|
|
|
811
986
|
edge(nid("topic", ident), nid("capture", row["capture_id"]), "from_capture")
|
|
812
987
|
if row["run"].strip().startswith("RUN-"):
|
|
813
988
|
edge(nid("topic", ident), nid("run", row["run"].strip()), "opened_run")
|
|
989
|
+
# Index cells are enough for edges; item frontmatter may repeat the same ids.
|
|
990
|
+
for need_id in re.findall(r"\bN-\d{8}-\d{2}\b", row.get("need_ids", "") or ""):
|
|
991
|
+
edge(nid("topic", ident), nid("need", need_id), "from_need")
|
|
992
|
+
for product_id in PRODUCT_RE.findall(row.get("product_ids", "") or ""):
|
|
993
|
+
edge(nid("topic", ident), nid("product", product_id), "uses_product")
|
|
994
|
+
for recommendation_id in RECOMMENDATION_RE.findall(row.get("recommendation_ids", "") or ""):
|
|
995
|
+
edge(nid("topic", ident), nid("recommendation", recommendation_id), "reviews")
|
|
814
996
|
if item and (ROOT / item).exists():
|
|
815
997
|
body = read(item)
|
|
816
998
|
fm = re.match(r"^---\n(.*?)\n---", body, re.S)
|
|
@@ -1231,14 +1413,16 @@ def build_graph() -> dict:
|
|
|
1231
1413
|
"pillars_by_profile": pillars_by_profile,
|
|
1232
1414
|
"pillars": flat_pillars,
|
|
1233
1415
|
"needs_index_present": (ROOT / "needs" / "_index.md").exists(),
|
|
1416
|
+
"hits_index_present": (ROOT / "hits" / "_index.md").exists(),
|
|
1234
1417
|
"products_index_present": (ROOT / "products" / "_index.md").exists(),
|
|
1418
|
+
"keywords": keywords,
|
|
1235
1419
|
"nodes": list(nodes.values()),
|
|
1236
1420
|
"edges": uniq,
|
|
1237
1421
|
}
|
|
1238
1422
|
|
|
1239
1423
|
|
|
1240
1424
|
def _self_check() -> None:
|
|
1241
|
-
# ponytail: tiny parser asserts; expand if capture/
|
|
1425
|
+
# ponytail: tiny parser asserts; expand if capture/raw formats drift again.
|
|
1242
1426
|
d = parse_engagement_snapshot(
|
|
1243
1427
|
"- **engagement snapshot:** likes 113 / replies 18 / bookmarks 17 / views 9574 @ 2026-08-30\n"
|
|
1244
1428
|
)
|
|
File without changes
|