@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.
@@ -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
- **结论:** ready | backlog | parked
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
- 用 2–4 句话说明现实问题。不要复述标题。
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
- 用人话列出 Needs、Product、Recommendation、Capture 或其他依据。
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
- 平台、Lane 和推荐内容形式。暂时不写完整脚本。
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, set the smallest honest `generation_mode`:
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
 
@@ -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
- if line.strip().startswith("|"):
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 (discovery + list form), then num-first (capture prose).
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 Discovery or Capture/Raw notes.
403
+ """Parse engagement into src_* fields from Capture / Raw notes.
399
404
 
400
405
  Supports:
401
- - Discovery: `engagement snapshot: likes 113 / replies 18 / ... @ 2026-08-30`
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/discovery formats drift again.
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