@easysociable/cli 0.1.5 → 0.1.7

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 = []
@@ -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(
@@ -1240,7 +1413,9 @@ def build_graph() -> dict:
1240
1413
  "pillars_by_profile": pillars_by_profile,
1241
1414
  "pillars": flat_pillars,
1242
1415
  "needs_index_present": (ROOT / "needs" / "_index.md").exists(),
1416
+ "hits_index_present": (ROOT / "hits" / "_index.md").exists(),
1243
1417
  "products_index_present": (ROOT / "products" / "_index.md").exists(),
1418
+ "keywords": keywords,
1244
1419
  "nodes": list(nodes.values()),
1245
1420
  "edges": uniq,
1246
1421
  }
File without changes