@easysociable/cli 0.1.5 → 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.
@@ -164,6 +164,7 @@
164
164
  .row[data-kind="swipe"] .stripe { background: #c4a06a; }
165
165
  .row[data-kind="atom"] .stripe { background: #a78bb0; }
166
166
  .row[data-kind="claim"] .stripe { background: #d08a78; }
167
+ .row[data-kind="hit"] .stripe { background: #6b8f9e; }
167
168
  .row[data-kind="run"] .stripe { background: #8aa87a; }
168
169
  .row[data-kind="profile"] .stripe { background: #8b6b4a; }
169
170
  .row[data-kind="account"] .stripe { background: #5c6b7a; }
@@ -189,6 +190,107 @@
189
190
  .heat-chip.empty { opacity: .55; font-weight: 400; }
190
191
  .heat-note { font-size: 11px; color: var(--faint); margin: 6px 0 0; }
191
192
  .insp .heat { margin: 0 0 14px; }
193
+ .kw-filter { margin: 0 0 12px; display: flex; flex-direction: column; gap: 8px; }
194
+ .kw-filter-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
195
+ .kw-search {
196
+ position: relative;
197
+ flex: 1 1 180px;
198
+ min-width: 160px;
199
+ max-width: 320px;
200
+ }
201
+ .kw-search::before {
202
+ content: ""; position: absolute; left: 12px; top: 50%; width: 12px; height: 12px;
203
+ margin-top: -7px; border: 1.5px solid var(--faint); border-radius: 50%;
204
+ pointer-events: none;
205
+ }
206
+ .kw-search::after {
207
+ content: ""; position: absolute; left: 22px; top: 50%; width: 6px; height: 1.5px;
208
+ margin-top: 3px; background: var(--faint); transform: rotate(45deg);
209
+ pointer-events: none;
210
+ }
211
+ .kw-search input[type="search"] {
212
+ width: 100%;
213
+ background: var(--paper);
214
+ border: 1px solid var(--line);
215
+ color: var(--text);
216
+ border-radius: 999px;
217
+ padding: 8px 14px 8px 32px;
218
+ font: inherit;
219
+ font-size: 13px;
220
+ outline: none;
221
+ box-shadow: inset 0 1px 0 rgba(255, 250, 240, .55);
222
+ transition: border-color .15s ease, box-shadow .15s ease;
223
+ -webkit-appearance: none;
224
+ appearance: none;
225
+ }
226
+ .kw-search input[type="search"]::placeholder { color: var(--faint); }
227
+ .kw-search input[type="search"]::-webkit-search-decoration,
228
+ .kw-search input[type="search"]::-webkit-search-cancel-button { -webkit-appearance: none; }
229
+ .kw-search input[type="search"]:hover { border-color: color-mix(in srgb, var(--line) 40%, var(--accent-dim)); }
230
+ .kw-search input[type="search"]:focus {
231
+ border-color: var(--accent-dim);
232
+ box-shadow: inset 0 1px 0 rgba(255, 250, 240, .55), 0 0 0 3px color-mix(in srgb, var(--accent) 16%, transparent);
233
+ }
234
+ .kw-quick { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; width: 100%; }
235
+ .kw-quick .kw-chip.on { background: var(--accent); color: #fff; border-color: var(--accent); }
236
+ .kw-active {
237
+ display: flex; flex-wrap: wrap; gap: 8px; align-items: baseline;
238
+ border: 1px solid var(--line); background: var(--bg-2);
239
+ border-radius: 10px; padding: 8px 10px; font-size: 12px;
240
+ }
241
+ .kw-active .kid {
242
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
243
+ color: var(--accent); font-weight: 600;
244
+ }
245
+ .kw-active .why { color: var(--text); flex: 1 1 180px; }
246
+ .kw-active .meta { color: var(--muted); }
247
+ .kw-active .clear {
248
+ border: 0; background: transparent; color: var(--muted); cursor: pointer; font: inherit; font-size: 12px;
249
+ }
250
+ .kw-active .clear:hover { color: var(--text); }
251
+ .kw-chip {
252
+ display: inline-flex; align-items: center; gap: 4px;
253
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
254
+ font-size: 11px; padding: 3px 8px; border-radius: 999px;
255
+ border: 1px solid var(--line); background: var(--bg-3); color: var(--accent);
256
+ cursor: pointer;
257
+ }
258
+ .kw-chip .n { font-family: inherit; color: var(--muted); font-weight: 600; }
259
+ .kw-chip.on .n { color: rgba(255,255,255,.85); }
260
+ .kw-chip:hover { border-color: var(--accent); }
261
+ .kw-catalog-grid {
262
+ display: grid;
263
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
264
+ gap: 10px;
265
+ margin-top: 12px;
266
+ }
267
+ .kw-catalog-card {
268
+ border: 1px solid var(--line); background: var(--bg-2);
269
+ border-radius: 12px; padding: 12px; text-align: left; color: inherit;
270
+ cursor: pointer; display: flex; flex-direction: column; gap: 6px;
271
+ }
272
+ .kw-catalog-card:hover { border-color: var(--accent); }
273
+ .kw-catalog-card .kid {
274
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
275
+ font-size: 12px; color: var(--accent); font-weight: 600;
276
+ }
277
+ .kw-catalog-card .meta { font-size: 11px; color: var(--muted); }
278
+ .kw-catalog-card .query {
279
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
280
+ font-size: 12px; color: var(--text); line-height: 1.4;
281
+ word-break: break-word;
282
+ }
283
+ .kw-catalog-card .why { font-size: 12px; color: var(--muted); line-height: 1.35; }
284
+ .kw-catalog-card .count { font-size: 11px; color: var(--faint); margin-top: auto; }
285
+ .kw-active .query {
286
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
287
+ font-size: 12px; color: var(--text); flex: 1 1 220px; word-break: break-word;
288
+ }
289
+ .kw-catalog-card .count b { color: var(--text); }
290
+ .kw-catalog-card .go {
291
+ font-size: 11px; color: var(--accent); border: 0; background: transparent;
292
+ padding: 0; cursor: pointer; text-align: left; font: inherit;
293
+ }
192
294
  .row .id {
193
295
  font-family: ui-monospace, "SF Mono", Menlo, monospace;
194
296
  font-size: 11px; color: var(--accent); letter-spacing: .02em;
@@ -623,18 +725,44 @@
623
725
  </div>
624
726
  <script>
625
727
  const GRAPH = /*__GRAPH__*/null;
626
- const TRACE_ORDER = ["raw", "capture", "need", "product", "recommendation", "topic", "swipe", "atom", "claim", "run", "published"];
728
+ const TRACE_ORDER = ["raw", "capture", "hit", "need", "product", "recommendation", "topic", "swipe", "atom", "claim", "run", "published"];
627
729
  const STR = {
628
730
  zh: {
629
731
  title: "Content Engine · 观察台",
630
732
  brand: "观察台",
631
733
  search: "搜索 ID、一句话、来源…",
632
- overview: "总览", needs: "需求库", products: "产品库", recommendations: "推荐库", topics: "选题库", notes: "记录与知识", runs: "Run 档案", pub: "发布与复盘",
734
+ overview: "总览", needs: "需求库", hits: "Hits", products: "产品库", recommendations: "推荐库", topics: "选题库", notes: "记录与知识", runs: "Run 档案", pub: "发布与复盘",
633
735
  notes_in: "记下的", notes_needs: "需求原话", notes_lib: "进库的",
634
736
  lead: "先认 IP,再看库存。",
635
- kpi_cap: "记下的", kpi_needs: "需求", kpi_lib: "进库的", kpi_topics: "选题", kpi_out: "下场",
737
+ kpi_cap: "记下的", kpi_needs: "需求", kpi_hits: "Hits", kpi_lib: "进库的", kpi_topics: "选题", kpi_out: "下场",
636
738
  kpi_cap_sub: "Capture", kpi_needs_sub: "Needs 原话", kpi_lib_sub: "Swipe / Atom / Claim",
637
739
  kpi_topics_sub: "{n} 条 ready 未开单", kpi_out_sub: "Run / 已发帖",
740
+ kpi_hits_sub: "待分流 triage",
741
+ no_hit: "尚无 Hits",
742
+ no_keyword: "尚无 active 关键词",
743
+ hit_empty_hint: "对 Agent 说「扫关键词」写入 triage。路由后才会进 Needs 或 Library。",
744
+ gap_hits_n: "Hits 待分流 {n}",
745
+ f_signal: "信号",
746
+ f_hit_kind: "类型",
747
+ f_keyword: "关键词",
748
+ kw_search: "搜 K- / why…",
749
+ kw_triage: "待分流",
750
+ kw_hits: "命中",
751
+ kw_clear: "清除",
752
+ hits_sub_list: "命中",
753
+ hits_sub_catalog: "关键词",
754
+ kw_catalog_search: "搜全部关键词…",
755
+ kw_catalog_empty: "无匹配关键词",
756
+ kw_open_hits: "查看命中",
757
+ kw_track_all: "全部轨",
758
+ kw_track_listen: "listen 社交",
759
+ kw_track_search: "search SEO",
760
+ kw_track_ask: "ask GEO",
761
+ kw_vol: "量",
762
+
763
+ kw_catalog_count: "共 {n} 条",
764
+ doc_keywords: "Keywords",
765
+ doc_handles: "Handles",
638
766
  ip_section: "IP · Profiles",
639
767
  ip_all: "全部 Profile",
640
768
  ip_accounts: "账号 {n}",
@@ -648,11 +776,13 @@
648
776
  gap_needs_n: "需求原话未选题 {n}",
649
777
  gap_lib_n: "Capture 未入库 {n}",
650
778
  gap_review_n: "已发但无法复盘 {n}",
651
- topics_lead: "选题必须有明确输入:need / inbox / user / product / recommendation。没有 Discovery 中间层;来源热度不是选题。",
652
779
  kv_lane: "Lane",
653
780
  kv_mode: "生成路径",
654
781
  kv_needs: "需求",
655
782
  kv_recs: "推荐对象",
783
+ kv_signal: "信号",
784
+ kv_hit_kind: "命中类型",
785
+ kv_hit_source: "来源配方",
656
786
  kv_profile: "Profile",
657
787
  kind_profile: "Profile", kind_account: "账号",
658
788
  profile_pillars: "支柱",
@@ -738,7 +868,7 @@
738
868
  thin_warn: "数据不足:result 不是 win / flat / loss,不画趋势。",
739
869
  topic_bind: "选题绑定:{t} · swipe {s} · claim {c}",
740
870
  supersedes: "取代 {id}", superseded_by: "被取代 {id}",
741
- kind_raw: "Raw", kind_capture: "Capture", kind_need: "需求", kind_product: "产品", kind_recommendation: "推荐对象", kind_topic: "选题", kind_swipe: "Swipe",
871
+ kind_raw: "Raw", kind_capture: "Capture", kind_hit: "Hit", kind_need: "需求", kind_product: "产品", kind_recommendation: "推荐对象", kind_topic: "选题", kind_swipe: "Swipe",
742
872
  kind_atom: "Atom", kind_claim: "Claim", kind_run: "Run", kind_published: "发布",
743
873
  kv_kind: "类型", kv_date: "日期", kv_status: "状态", kv_plat: "平台", kv_score: "分数", kv_relevance: "关联度", kv_ownership: "归属度", kv_product_relevance: "产品关联",
744
874
  kv_use: "使用", kv_src: "来源", kv_result: "结果", kv_pillar: "支柱",
@@ -750,12 +880,38 @@
750
880
  title: "Content Engine · Observatory",
751
881
  brand: "Observatory",
752
882
  search: "Search ID, one-liner, source…",
753
- overview: "Overview", needs: "Needs", products: "Products", recommendations: "Recommendations", topics: "Topics", notes: "Notes & library", runs: "Run files", pub: "Shipped & review",
883
+ overview: "Overview", needs: "Needs", hits: "Hits", products: "Products", recommendations: "Recommendations", topics: "Topics", notes: "Notes & library", runs: "Run files", pub: "Shipped & review",
754
884
  notes_in: "Captured", notes_needs: "Needs", notes_lib: "Catalog",
755
885
  lead: "Pick an IP, then inspect the stock.",
756
- kpi_cap: "Captured", kpi_needs: "Needs", kpi_lib: "Catalog", kpi_topics: "Topics", kpi_out: "Outcomes",
886
+ kpi_cap: "Captured", kpi_needs: "Needs", kpi_hits: "Hits", kpi_lib: "Catalog", kpi_topics: "Topics", kpi_out: "Outcomes",
757
887
  kpi_cap_sub: "Capture", kpi_needs_sub: "Audience quotes", kpi_lib_sub: "Swipe / Atom / Claim",
758
888
  kpi_topics_sub: "{n} ready, never opened as a run", kpi_out_sub: "Runs / shipped URLs",
889
+ kpi_hits_sub: "awaiting triage",
890
+ no_hit: "No hits yet",
891
+ no_keyword: "No active keywords",
892
+ hit_empty_hint: "Ask the Agent to scan keywords. Hits stay in triage until you route them.",
893
+ gap_hits_n: "{n} hits awaiting triage",
894
+ f_signal: "Signal",
895
+ f_hit_kind: "Kind",
896
+ f_keyword: "Keyword",
897
+ kw_search: "Search K- / why…",
898
+ kw_triage: "triage",
899
+ kw_hits: "hits",
900
+ kw_clear: "Clear",
901
+ hits_sub_list: "Hits",
902
+ hits_sub_catalog: "Keywords",
903
+ kw_catalog_search: "Search all keywords…",
904
+ kw_catalog_empty: "No matching keywords",
905
+ kw_open_hits: "View hits",
906
+ kw_track_all: "All tracks",
907
+ kw_track_listen: "listen",
908
+ kw_track_search: "search SEO",
909
+ kw_track_ask: "ask GEO",
910
+ kw_vol: "vol",
911
+
912
+ kw_catalog_count: "{n} total",
913
+ doc_keywords: "Keywords",
914
+ doc_handles: "Handles",
759
915
  ip_section: "IP · Profiles",
760
916
  ip_all: "All profiles",
761
917
  ip_accounts: "{n} account(s)",
@@ -769,11 +925,13 @@
769
925
  gap_needs_n: "{n} needs not yet in a topic",
770
926
  gap_lib_n: "{n} captures not ingested",
771
927
  gap_review_n: "{n} shipped posts not reviewable",
772
- topics_lead: "Topics need an explicit input: need, inbox, user, product, or recommendation. No Discovery layer; source heat is not a topic.",
773
928
  kv_lane: "Lane",
774
929
  kv_mode: "Generation mode",
775
930
  kv_needs: "Needs",
776
931
  kv_recs: "Recommendations",
932
+ kv_signal: "Signal",
933
+ kv_hit_kind: "Hit kind",
934
+ kv_hit_source: "Source recipe",
777
935
  kv_profile: "Profile",
778
936
  kind_profile: "Profile", kind_account: "Account",
779
937
  profile_pillars: "Pillars",
@@ -859,7 +1017,7 @@
859
1017
  thin_warn: "Too thin to review: result is not win / flat / loss. No chart.",
860
1018
  topic_bind: "Topic: {t} · swipe {s} · claim {c}",
861
1019
  supersedes: "supersedes {id}", superseded_by: "superseded by {id}",
862
- kind_raw: "Raw", kind_capture: "Capture", kind_need: "Need", kind_product: "Product", kind_recommendation: "Recommendation", kind_topic: "Topic", kind_swipe: "Swipe",
1020
+ kind_raw: "Raw", kind_capture: "Capture", kind_hit: "Hit", kind_need: "Need", kind_product: "Product", kind_recommendation: "Recommendation", kind_topic: "Topic", kind_swipe: "Swipe",
863
1021
  kind_atom: "Atom", kind_claim: "Claim", kind_run: "Run", kind_published: "Shipped",
864
1022
  kv_kind: "Kind", kv_date: "Date", kv_status: "Status", kv_plat: "Platform", kv_score: "Score", kv_relevance: "Relevance", kv_ownership: "Ownership", kv_product_relevance: "Product relevance",
865
1023
  kv_use: "Use", kv_src: "Source", kv_result: "Result", kv_pillar: "Pillar",
@@ -957,6 +1115,15 @@
957
1115
  let topicSrc = "";
958
1116
  let topicUsage = "";
959
1117
  let capIngest = "";
1118
+ let hitKw = "";
1119
+ let hitKwQ = "";
1120
+ let hitSt = "";
1121
+ let hitSig = "";
1122
+ let hitsSub = "list"; // list = 命中 · catalog = 关键词
1123
+ let kwCatalogQ = "";
1124
+ let kwCatalogTrack = ""; // "" | listen | search | ask
1125
+
1126
+ const KW_TRACKS = ["listen", "search", "ask"];
960
1127
  const TOPIC_SOURCES = ["need", "inbox", "user", "product", "recommendation"];
961
1128
  let selected = null;
962
1129
  const $list = document.getElementById("list");
@@ -1302,7 +1469,7 @@
1302
1469
  sel.innerHTML = `<option value="">${t("ip_all")}</option>` +
1303
1470
  ids.map(id => `<option value="${id}" ${id === profileScope ? "selected" : ""}>${id}</option>`).join("");
1304
1471
  tabs.innerHTML = "";
1305
- for (const id of ["overview", "needs", "products", "recommendations", "topics", "notes", "runs", "pub"]) {
1472
+ for (const id of ["overview", "hits", "needs", "products", "recommendations", "topics", "notes", "runs", "pub"]) {
1306
1473
  const b = document.createElement("button");
1307
1474
  b.textContent = t(id);
1308
1475
  b.dataset.view = id;
@@ -1362,7 +1529,7 @@
1362
1529
  function qmatch(n) {
1363
1530
  const q = $q.value.trim().toLowerCase();
1364
1531
  if (!q) return true;
1365
- const blob = [n.ident, n.label, n.kind, n.status, n.pillar, n.lane, n.generation_mode, n.profile, n.profiles, n.src_handle, n.url, n.notes, n.type, n.atom_type, n.topic_id, n.audience, n.need_ids, n.product_ids, n.product_fit, n.recommendation_ids, n.source_type, n.capture_id].filter(Boolean).join(" ").toLowerCase();
1532
+ const blob = [n.ident, n.label, n.kind, n.status, n.pillar, n.lane, n.generation_mode, n.profile, n.profiles, n.src_handle, n.url, n.notes, n.type, n.atom_type, n.topic_id, n.audience, n.need_ids, n.product_ids, n.product_fit, n.recommendation_ids, n.source_type, n.capture_id, n.source, n.keyword_id, n.hit_kind, n.signal, n.excerpt].filter(Boolean).join(" ").toLowerCase();
1366
1533
  return blob.includes(q);
1367
1534
  }
1368
1535
  function connected(id) {
@@ -1491,6 +1658,14 @@
1491
1658
  if (n.source_type) bits.push(`<span class="pill">${esc(n.source_type)}</span>`);
1492
1659
  if (n.frequency) bits.push(`<span class="pill">×${esc(n.frequency)}</span>`);
1493
1660
  }
1661
+ if (n.kind === "hit") {
1662
+ const st = n.status || "";
1663
+ const cls = st === "triage" ? "gap" : (st === "discarded" ? "" : "ok");
1664
+ if (st) bits.push(`<span class="pill ${cls}">${esc(st)}</span>`);
1665
+ if (n.keyword_id) bits.push(`<button type="button" class="kw-chip" data-kw-filter="${esc(n.keyword_id)}">${esc(n.keyword_id)}</button>`);
1666
+ if (n.signal) bits.push(`<span class="pill">${esc(n.signal)}</span>`);
1667
+ if (n.hit_kind) bits.push(`<span class="pill">${esc(n.hit_kind)}</span>`);
1668
+ }
1494
1669
  if (n.kind === "topic") {
1495
1670
  bits.push(`<span class="pill">${esc(n.status)}</span>`);
1496
1671
  if (n.source_type) bits.push(`<span class="pill">${esc(n.source_type)}</span>`);
@@ -1535,6 +1710,7 @@
1535
1710
  for (const b of tabs.querySelectorAll("button")) b.classList.toggle("on", b.dataset.view === view);
1536
1711
  if (view === "overview") renderOverview();
1537
1712
  if (view === "needs") renderNeeds();
1713
+ if (view === "hits") renderHits();
1538
1714
  if (view === "products") renderProducts();
1539
1715
  if (view === "recommendations") renderRecommendations();
1540
1716
  if (view === "topics") renderTopics();
@@ -1569,6 +1745,299 @@
1569
1745
  renderNeedsList($list);
1570
1746
  }
1571
1747
 
1748
+ function keywordsInScope(opts = {}) {
1749
+ const all = GRAPH.keywords || [];
1750
+ let rows = profileScope
1751
+ ? all.filter(k => k.profile === profileScope)
1752
+ : all.slice();
1753
+ if (opts.activeOnly !== false) rows = rows.filter(k => k.status === "active" || !k.status);
1754
+ if (opts.track) rows = rows.filter(k => (k.track || "listen") === opts.track);
1755
+ return rows;
1756
+ }
1757
+ function listenKeywordsInScope() {
1758
+ return keywordsInScope({ track: "listen" });
1759
+ }
1760
+ function hitCountForKeyword(kid) {
1761
+ return nodesOf("hit").filter(h => h.keyword_id === kid).length;
1762
+ }
1763
+ function triageCountForKeyword(kid) {
1764
+ return nodesOf("hit").filter(h => h.keyword_id === kid && h.status === "triage").length;
1765
+ }
1766
+ function keywordMatchesQuery(k, q) {
1767
+ if (!q) return true;
1768
+ const blob = [k.id, k.why, k.query, k.intent, k.layer, k.pillar].filter(Boolean).join(" ").toLowerCase();
1769
+ return blob.includes(q);
1770
+ }
1771
+ function rankedKeywords(opts = {}) {
1772
+ const base = opts.forHits ? listenKeywordsInScope() : keywordsInScope({ activeOnly: false, track: opts.track || "" });
1773
+ return base
1774
+ .map(k => ({
1775
+ ...k,
1776
+ track: k.track || "listen",
1777
+ triage: triageCountForKeyword(k.id),
1778
+ total: hitCountForKeyword(k.id),
1779
+ }))
1780
+ .sort((a, b) => (b.triage - a.triage) || (b.total - a.total) || (Number(b.weight || 0) - Number(a.weight || 0)));
1781
+ }
1782
+ function keywordById(kid) {
1783
+ return (GRAPH.keywords || []).find(k => k.id === kid && (!profileScope || k.profile === profileScope))
1784
+ || (GRAPH.keywords || []).find(k => k.id === kid)
1785
+ || null;
1786
+ }
1787
+ function keywordIdNum(id) {
1788
+ const m = String(id || "").match(/K-(\d+)/i);
1789
+ return m ? Number(m[1]) : 0;
1790
+ }
1791
+ function keywordOptionLabel(k) {
1792
+ const triage = typeof k.triage === "number" ? k.triage : triageCountForKeyword(k.id);
1793
+ const total = typeof k.total === "number" ? k.total : hitCountForKeyword(k.id);
1794
+ const bits = [k.id];
1795
+ if (k.track && k.track !== "listen") bits.push(k.track);
1796
+ if (k.layer) bits.push(k.layer);
1797
+ bits.push(`${triage}/${total}`);
1798
+ return bits.join(" · ");
1799
+ }
1800
+ function bindKwFilters(root) {
1801
+ root.querySelectorAll("[data-kw-filter]").forEach(btn => {
1802
+ btn.onclick = (e) => {
1803
+ e.preventDefault();
1804
+ e.stopPropagation();
1805
+ const kid = btn.getAttribute("data-kw-filter") || "";
1806
+ hitKw = hitKw === kid ? "" : kid;
1807
+ hitsSub = "list";
1808
+ renderHits();
1809
+ };
1810
+ });
1811
+ }
1812
+ function renderKeywordFilter(into) {
1813
+ const wrap = document.createElement("div");
1814
+ wrap.className = "kw-filter";
1815
+ const ranked = rankedKeywords({ forHits: true });
1816
+ const q = hitKwQ.trim().toLowerCase();
1817
+ const filtered = ranked.filter(k => keywordMatchesQuery(k, q));
1818
+ const withHits = filtered.filter(k => k.total > 0);
1819
+ const listenN = listenKeywordsInScope().length;
1820
+
1821
+ const row = document.createElement("div");
1822
+ row.className = "kw-filter-row";
1823
+ const searchWrap = document.createElement("div");
1824
+ searchWrap.className = "kw-search";
1825
+ const search = document.createElement("input");
1826
+ search.type = "search";
1827
+ search.placeholder = t("kw_search");
1828
+ search.setAttribute("aria-label", t("kw_search"));
1829
+ search.value = hitKwQ;
1830
+ search.oninput = () => {
1831
+ const pos = search.selectionStart;
1832
+ hitKwQ = search.value;
1833
+ renderHits();
1834
+ const s2 = $list.querySelector(".kw-search input[type='search']");
1835
+ if (s2) {
1836
+ s2.focus();
1837
+ try { s2.setSelectionRange(pos, pos); } catch (_) {}
1838
+ }
1839
+ };
1840
+ searchWrap.appendChild(search);
1841
+ row.appendChild(searchWrap);
1842
+ wrap.appendChild(row);
1843
+
1844
+ const quickWrap = document.createElement("div");
1845
+ quickWrap.className = "kw-quick";
1846
+ if (!listenN) {
1847
+ quickWrap.innerHTML = `<span class="vacant">${esc(t("no_keyword"))}</span>`;
1848
+ } else if (!withHits.length) {
1849
+ quickWrap.innerHTML = `<span class="vacant">${esc(t("no_hit"))}</span>`;
1850
+ } else {
1851
+ withHits.forEach(k => {
1852
+ const b = document.createElement("button");
1853
+ b.type = "button";
1854
+ b.className = "kw-chip" + (hitKw === k.id ? " on" : "");
1855
+ b.innerHTML = `${esc(k.id)}<span class="n">${k.triage}</span>`;
1856
+ b.title = [k.query, k.why, `${k.triage} ${t("kw_triage")} · ${k.total} ${t("kw_hits")}`].filter(Boolean).join("\n");
1857
+ b.onclick = () => {
1858
+ hitKw = hitKw === k.id ? "" : k.id;
1859
+ renderHits();
1860
+ };
1861
+ quickWrap.appendChild(b);
1862
+ });
1863
+ }
1864
+ wrap.appendChild(quickWrap);
1865
+
1866
+ if (hitKw) {
1867
+ const k = keywordById(hitKw);
1868
+ const active = document.createElement("div");
1869
+ active.className = "kw-active";
1870
+ const triage = triageCountForKeyword(hitKw);
1871
+ const total = hitCountForKeyword(hitKw);
1872
+ active.innerHTML = `<span class="kid">${esc(hitKw)}</span>
1873
+ <span class="meta">${esc([(k && k.layer) || "", (k && k.intent) || "", (k && k.pillar) || ""].filter(Boolean).join(" · "))}</span>
1874
+ <span class="query" title="${esc((k && k.why) || "")}">${esc((k && k.query) || "")}</span>
1875
+ <span class="meta"><b>${triage}</b> ${esc(t("kw_triage"))} · ${total} ${esc(t("kw_hits"))}</span>
1876
+ <button type="button" class="clear">${esc(t("kw_clear"))}</button>`;
1877
+ active.querySelector(".clear").onclick = () => { hitKw = ""; renderHits(); };
1878
+ wrap.appendChild(active);
1879
+ }
1880
+
1881
+ into.appendChild(wrap);
1882
+ return { filtered };
1883
+ }
1884
+ function renderHitsSubtabs() {
1885
+ const sub = document.createElement("div");
1886
+ sub.className = "subtabs";
1887
+ sub.innerHTML = `<button type="button" data-hits-sub="list">${t("hits_sub_list")}</button><button type="button" data-hits-sub="catalog">${t("hits_sub_catalog")}</button>`;
1888
+ sub.querySelectorAll("button").forEach(b => {
1889
+ b.classList.toggle("on", b.dataset.hitsSub === hitsSub);
1890
+ b.onclick = () => {
1891
+ hitsSub = b.dataset.hitsSub;
1892
+ renderHits();
1893
+ };
1894
+ });
1895
+ $list.appendChild(sub);
1896
+ }
1897
+ function openKeywordInHits(kid) {
1898
+ hitKw = kid || "";
1899
+ hitsSub = "list";
1900
+ renderHits();
1901
+ }
1902
+ function renderKeywordCatalog() {
1903
+ const row = document.createElement("div");
1904
+ row.className = "kw-filter-row";
1905
+ const searchWrap = document.createElement("div");
1906
+ searchWrap.className = "kw-search";
1907
+ const search = document.createElement("input");
1908
+ search.type = "search";
1909
+ search.placeholder = t("kw_catalog_search");
1910
+ search.setAttribute("aria-label", t("kw_catalog_search"));
1911
+ search.value = kwCatalogQ;
1912
+ search.oninput = () => {
1913
+ const pos = search.selectionStart;
1914
+ kwCatalogQ = search.value;
1915
+ renderHits();
1916
+ const s2 = $list.querySelector(".kw-search input[type='search']");
1917
+ if (s2) {
1918
+ s2.focus();
1919
+ try { s2.setSelectionRange(pos, pos); } catch (_) {}
1920
+ }
1921
+ };
1922
+ searchWrap.appendChild(search);
1923
+ row.appendChild(searchWrap);
1924
+
1925
+ const tracks = document.createElement("div");
1926
+ tracks.className = "kw-quick";
1927
+ [
1928
+ ["", "kw_track_all"],
1929
+ ["listen", "kw_track_listen"],
1930
+ ["search", "kw_track_search"],
1931
+ ["ask", "kw_track_ask"],
1932
+ ].forEach(([id, labelKey]) => {
1933
+ const b = document.createElement("button");
1934
+ b.type = "button";
1935
+ b.className = "kw-chip" + (kwCatalogTrack === id ? " on" : "");
1936
+ b.textContent = t(labelKey);
1937
+ b.onclick = () => { kwCatalogTrack = id; renderHits(); };
1938
+ tracks.appendChild(b);
1939
+ });
1940
+ row.appendChild(tracks);
1941
+ $list.appendChild(row);
1942
+
1943
+ const q = kwCatalogQ.trim().toLowerCase();
1944
+ const keys = rankedKeywords({ track: kwCatalogTrack || "" })
1945
+ .filter(k => keywordMatchesQuery(k, q))
1946
+ .sort((a, b) => keywordIdNum(a.id) - keywordIdNum(b.id));
1947
+ const count = document.createElement("p");
1948
+ count.className = "note";
1949
+ count.style.margin = "8px 0 0";
1950
+ count.textContent = t("kw_catalog_count", { n: keys.length });
1951
+ $list.appendChild(count);
1952
+ const grid = document.createElement("div");
1953
+ grid.className = "kw-catalog-grid";
1954
+ if (!keys.length) {
1955
+ grid.innerHTML = `<p class='empty'>${esc(t("kw_catalog_empty"))}</p>`;
1956
+ } else {
1957
+ keys.forEach(k => {
1958
+ const card = document.createElement("button");
1959
+ card.type = "button";
1960
+ card.className = "kw-catalog-card";
1961
+ const vol = k.track === "search" && k.volume
1962
+ ? ` · ${t("kw_vol")} ${esc(k.volume)}`
1963
+ : "";
1964
+ const go = k.track === "listen"
1965
+ ? `<span class="go">${esc(t("kw_open_hits"))} →</span>`
1966
+ : `<span class="meta">${esc(k.track)}</span>`;
1967
+ card.innerHTML = `<div class="kid">${esc(k.id)} <span class="pill">${esc(k.track || "listen")}</span></div>
1968
+ <div class="meta">${esc([k.layer, k.intent, k.pillar, k.status].filter(Boolean).join(" · "))}${vol}</div>
1969
+ <div class="query">${esc(k.query || "")}</div>
1970
+ ${k.why ? `<div class="why">${esc(k.why)}</div>` : ""}
1971
+ <div class="count"><b>${k.triage}</b> ${esc(t("kw_triage"))} · ${k.total} ${esc(t("kw_hits"))}${k.last_scanned ? ` · ${esc(k.last_scanned)}` : ""}</div>
1972
+ ${go}`;
1973
+ card.onclick = () => {
1974
+ if ((k.track || "listen") === "listen") openKeywordInHits(k.id);
1975
+ };
1976
+ grid.appendChild(card);
1977
+ });
1978
+ }
1979
+ $list.appendChild(grid);
1980
+ }
1981
+ function renderHitsList() {
1982
+ const statuses = [...new Set(nodesOf("hit").map(x => x.status).filter(Boolean))];
1983
+ const signals = [...new Set(nodesOf("hit").map(x => x.signal).filter(Boolean))];
1984
+ const { filtered } = renderKeywordFilter($list);
1985
+ const kwOptions = filtered.length ? filtered : rankedKeywords({ forHits: true });
1986
+ filterBar(
1987
+ `<select id="h-st"><option value="">${t("f_status")}</option>${statuses.map(s => `<option value="${s}" ${s === hitSt ? "selected" : ""}>${esc(s)}</option>`).join("")}</select>
1988
+ <select id="h-sig"><option value="">${t("f_signal")}</option>${signals.map(s => `<option value="${s}" ${s === hitSig ? "selected" : ""}>${esc(s)}</option>`).join("")}</select>
1989
+ <select id="h-kw"><option value="">${t("f_keyword")} (${listenKeywordsInScope().length})</option>${kwOptions.map(k => `<option value="${esc(k.id)}" ${k.id === hitKw ? "selected" : ""}>${esc(keywordOptionLabel(k))}</option>`).join("")}</select>`,
1990
+ (w) => {
1991
+ const st = w.querySelector("#h-st");
1992
+ const sig = w.querySelector("#h-sig");
1993
+ const kw = w.querySelector("#h-kw");
1994
+ if (hitKw && ![...kw.options].some(o => o.value === hitKw)) {
1995
+ const opt = document.createElement("option");
1996
+ opt.value = hitKw;
1997
+ opt.textContent = keywordOptionLabel(keywordById(hitKw) || { id: hitKw });
1998
+ opt.selected = true;
1999
+ kw.appendChild(opt);
2000
+ }
2001
+ const paintCards = () => {
2002
+ [...$list.querySelectorAll(".cards")].forEach(n => n.remove());
2003
+ const cards = document.createElement("div");
2004
+ cards.className = "cards";
2005
+ let rows = nodesOf("hit").filter(qmatch).sort((a, b) => {
2006
+ const ta = a.status === "triage" ? 0 : 1;
2007
+ const tb = b.status === "triage" ? 0 : 1;
2008
+ if (ta !== tb) return ta - tb;
2009
+ const ha = Number(String(a.src_likes || "0").replace(/,/g, "")) || 0;
2010
+ const hb = Number(String(b.src_likes || "0").replace(/,/g, "")) || 0;
2011
+ if (hb !== ha) return hb - ha;
2012
+ return String(b.date || "").localeCompare(String(a.date || ""));
2013
+ });
2014
+ if (hitSt) rows = rows.filter(x => x.status === hitSt);
2015
+ if (hitSig) rows = rows.filter(x => x.signal === hitSig);
2016
+ if (hitKw) rows = rows.filter(x => x.keyword_id === hitKw);
2017
+ rows.forEach(x => {
2018
+ const meta = [x.platform, x.hit_kind, x.signal].filter(Boolean).join(" · ");
2019
+ cards.appendChild(listRowEl(x, meta));
2020
+ });
2021
+ if (!rows.length) {
2022
+ cards.innerHTML = `<p class='empty'>${t("no_hit")}</p><p class="note">${esc(t("hit_empty_hint"))}</p>`;
2023
+ }
2024
+ $list.appendChild(cards);
2025
+ bindKwFilters(cards);
2026
+ };
2027
+ st.onchange = () => { hitSt = st.value || ""; paintCards(); };
2028
+ sig.onchange = () => { hitSig = sig.value || ""; paintCards(); };
2029
+ kw.onchange = () => { hitKw = kw.value || ""; renderHits(); };
2030
+ paintCards();
2031
+ }
2032
+ );
2033
+ }
2034
+ function renderHits() {
2035
+ $list.innerHTML = "";
2036
+ renderHitsSubtabs();
2037
+ if (hitsSub === "catalog") renderKeywordCatalog();
2038
+ else renderHitsList();
2039
+ }
2040
+
1572
2041
  function renderProducts() {
1573
2042
  $list.innerHTML = "";
1574
2043
  const products = nodesOf("product").filter(qmatch).sort((a, b) => String(a.label).localeCompare(String(b.label)));
@@ -1688,7 +2157,6 @@
1688
2157
 
1689
2158
  function renderTopics() {
1690
2159
  $list.innerHTML = "";
1691
- $list.insertAdjacentHTML("beforeend", `<p class="lead">${esc(t("topics_lead"))}</p>`);
1692
2160
  if (topicSrc && !TOPIC_SOURCES.includes(topicSrc)) topicSrc = "";
1693
2161
  const statuses = [...new Set(nodesOf("topic").map(x => x.status).filter(Boolean))];
1694
2162
  filterBar(
@@ -1959,6 +2427,7 @@
1959
2427
  return {
1960
2428
  capture: nodesOfAll("capture").filter(n => n.profile === profileId).length,
1961
2429
  need: nodesOfAll("need").filter(n => n.profile === profileId).length,
2430
+ hit: nodesOfAll("hit").filter(n => n.profile === profileId).length,
1962
2431
  topic: nodesOfAll("topic").filter(n => n.profile === profileId).length,
1963
2432
  lib: GRAPH.nodes.filter(n => n.lib && (String(n.profiles || "").includes(profileId) || String(n.profiles || "").includes("*"))).length,
1964
2433
  run: nodesOfAll("run").filter(n => n.profile === profileId).length,
@@ -1971,6 +2440,7 @@
1971
2440
  return {
1972
2441
  capture: nodesOfAll("capture").length,
1973
2442
  need: nodesOfAll("need").length,
2443
+ hit: nodesOfAll("hit").length,
1974
2444
  topic: nodesOfAll("topic").length,
1975
2445
  lib: GRAPH.nodes.filter(n => n.lib).length,
1976
2446
  run: nodesOfAll("run").length,
@@ -1981,17 +2451,20 @@
1981
2451
  function gapsFor(profileId) {
1982
2452
  const topics = profileId ? nodesOfAll("topic").filter(n => n.profile === profileId) : nodesOfAll("topic");
1983
2453
  const needs = profileId ? nodesOfAll("need").filter(n => n.profile === profileId) : nodesOfAll("need");
2454
+ const hits = profileId ? nodesOfAll("hit").filter(n => n.profile === profileId) : nodesOfAll("hit");
1984
2455
  const caps = profileId ? nodesOfAll("capture").filter(n => n.profile === profileId) : nodesOfAll("capture");
1985
2456
  const pubs = profileId ? nodesOfAll("published").filter(n => n.profile === profileId) : nodesOfAll("published");
1986
2457
  const needCount = needs.length;
1987
2458
  const gaps = [
2459
+ [hits.filter(h => h.status === "triage").length, "gap_hits_n"],
1988
2460
  [topics.filter(x => x.status === "ready" && x.usage === "unused").length, "gap_ready_n"],
1989
2461
  [needs.filter(n => n.status === "captured" && (!n.topic_ids || n.topic_ids === "none")).length, "gap_needs_n"],
1990
2462
  [caps.filter(c => c.status !== "ingested").length, "gap_lib_n"],
1991
2463
  [pubs.filter(p => !p.reviewable).length, "gap_review_n"],
1992
2464
  ].filter(([n]) => n > 0);
1993
2465
  if (GRAPH.needs_index_present && needCount < 3) {
1994
- gaps.splice(1, 0, [needCount, "gap_needs_thin_n"]);
2466
+ const idx = gaps.findIndex(([, k]) => k === "gap_needs_n");
2467
+ gaps.splice(idx < 0 ? gaps.length : idx, 0, [needCount, "gap_needs_thin_n"]);
1995
2468
  }
1996
2469
  return gaps;
1997
2470
  }
@@ -2000,6 +2473,7 @@
2000
2473
  return `<div class="prof-stats">
2001
2474
  <div><b>${counts.capture}</b><span>${t("kpi_cap")}</span></div>
2002
2475
  <div><b>${counts.need || 0}</b><span>${t("kpi_needs")}</span></div>
2476
+ <div><b>${counts.hit || 0}</b><span>${t("kpi_hits")}</span></div>
2003
2477
  <div><b>${counts.topic}</b><span>${t("kpi_topics")}</span></div>
2004
2478
  <div><b>${counts.lib}</b><span>${t("kpi_lib")}</span></div>
2005
2479
  <div><b>${counts.run}</b><span>Run</span></div>
@@ -2034,6 +2508,8 @@
2034
2508
  ["audience", t("doc_audience")],
2035
2509
  ["pillars", t("doc_pillars")],
2036
2510
  ["lanes", t("doc_lanes")],
2511
+ ["keywords", t("doc_keywords")],
2512
+ ["handles", t("doc_handles")],
2037
2513
  ];
2038
2514
  const pillarRows = Object.entries(pillars).map(([id, p]) => {
2039
2515
  const title = lang === "zh" ? p.zh : p.en;
@@ -2103,9 +2579,14 @@
2103
2579
  [t("kv_profile")]: n.profile || n.profile_id || (n.profiles || ""),
2104
2580
  [t("kv_score")]: n.fit ? `${n.craft} opp / ${n.fit} aud` : n.total,
2105
2581
  [t("kv_use")]: n.usage,
2106
- [t("kv_src")]: n.source_type,
2582
+ [t("kv_src")]: n.kind === "hit" ? (n.source || "") : n.source_type,
2583
+ [t("kv_hit_kind")]: n.kind === "hit" ? (n.hit_kind || "") : "",
2584
+ [t("kv_signal")]: n.kind === "hit" ? (n.signal || "") : "",
2585
+ Keyword: n.kind === "hit" ? (n.keyword_id || "") : "",
2586
+ [t("kv_hit_source")]: n.kind === "hit" ? (n.source || "") : "",
2107
2587
  [t("kv_needs")]: n.kind === "topic" ? (n.need_ids || "") : "",
2108
2588
  Quote: n.kind === "need" ? (n.quote || "") : "",
2589
+ Excerpt: n.kind === "hit" ? (n.excerpt || "") : "",
2109
2590
  Speaker: n.kind === "need" ? (n.speaker || "") : "",
2110
2591
  Frequency: n.kind === "need" ? (n.frequency || "") : "",
2111
2592
  Topics: n.kind === "need" ? (n.topic_ids || "") : "",
@@ -2119,16 +2600,23 @@
2119
2600
  Relationship: n.kind === "recommendation" ? (n.relationship || "") : "",
2120
2601
  "Last researched": n.kind === "recommendation" ? (n.last_researched || "") : "",
2121
2602
  Handle: n.source_handle || n.src_handle || "",
2122
- [t("kv_heat_at")]: n.kind === "capture" ? (n.engagement_at || "") : "",
2603
+ [t("kv_heat_at")]: (n.kind === "capture" || n.kind === "hit") ? (n.engagement_at || "") : "",
2123
2604
  [t("kv_result")]: n.result,
2124
2605
  }).filter(([, v]) => v);
2125
- const heatBlock = n.kind === "capture"
2606
+ const kwMeta = n.kind === "hit" && n.keyword_id
2607
+ ? (GRAPH.keywords || []).find(k => k.id === n.keyword_id && (!n.profile || k.profile === n.profile))
2608
+ : null;
2609
+ const kwBlock = n.kind === "hit" && n.keyword_id
2610
+ ? `<p class="note"><button type="button" class="kw-chip" data-kw-filter="${esc(n.keyword_id)}">${esc(n.keyword_id)}</button>${kwMeta ? ` · ${esc([kwMeta.layer, kwMeta.intent, kwMeta.why].filter(Boolean).join(" · "))}` : ""}</p>`
2611
+ : "";
2612
+ const heatBlock = (n.kind === "capture" || n.kind === "hit")
2126
2613
  ? `${heatStripHtml(n, { hint: true })}${n.source_url ? `<p class="note">${t("src_url")}<a href="${esc(n.source_url)}" target="_blank" rel="noreferrer">${esc(n.source_url)}</a></p>` : ""}`
2127
2614
  : "";
2128
2615
  $insp.innerHTML = `<h2>${t("obj")}</h2>
2129
2616
  <p class="kicker">${esc(kindName(n.kind))} · ${esc(n.ident)}</p>
2130
2617
  <p class="title">${n.kind === "run" ? `<button type="button" class="ghost-link title-btn" data-run-open="${esc(n.id)}">${esc(n.label)}</button>` : esc(n.label)}</p>
2131
2618
  <dl class="kv">${kv.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join("")}</dl>
2619
+ ${kwBlock}
2132
2620
  ${heatBlock}
2133
2621
  ${n.kind === "swipe" || n.kind === "atom" ? `<p class="note">${esc(t("lib_use_hist"))}</p>` : ""}
2134
2622
  ${n.kind === "claim" ? `<p class="note">${esc(t("lib_claim_ev"))}</p>` : ""}
@@ -2143,6 +2631,7 @@
2143
2631
  $insp.querySelectorAll("[data-goto]").forEach(btn => {
2144
2632
  btn.onclick = () => select(btn.getAttribute("data-goto"));
2145
2633
  });
2634
+ bindKwFilters($insp);
2146
2635
  const trace = $insp.querySelector("#trace");
2147
2636
  for (const k of TRACE_ORDER) {
2148
2637
  const items = k === "published" ? publishedFor(n, ctx) : (byKind[k] || []);