@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.
@@ -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,6 +776,13 @@
648
776
  gap_needs_n: "需求原话未选题 {n}",
649
777
  gap_lib_n: "Capture 未入库 {n}",
650
778
  gap_review_n: "已发但无法复盘 {n}",
779
+ kv_lane: "Lane",
780
+ kv_mode: "生成路径",
781
+ kv_needs: "需求",
782
+ kv_recs: "推荐对象",
783
+ kv_signal: "信号",
784
+ kv_hit_kind: "命中类型",
785
+ kv_hit_source: "来源配方",
651
786
  kv_profile: "Profile",
652
787
  kind_profile: "Profile", kind_account: "账号",
653
788
  profile_pillars: "支柱",
@@ -733,7 +868,7 @@
733
868
  thin_warn: "数据不足:result 不是 win / flat / loss,不画趋势。",
734
869
  topic_bind: "选题绑定:{t} · swipe {s} · claim {c}",
735
870
  supersedes: "取代 {id}", superseded_by: "被取代 {id}",
736
- 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",
737
872
  kind_atom: "Atom", kind_claim: "Claim", kind_run: "Run", kind_published: "发布",
738
873
  kv_kind: "类型", kv_date: "日期", kv_status: "状态", kv_plat: "平台", kv_score: "分数", kv_relevance: "关联度", kv_ownership: "归属度", kv_product_relevance: "产品关联",
739
874
  kv_use: "使用", kv_src: "来源", kv_result: "结果", kv_pillar: "支柱",
@@ -745,12 +880,38 @@
745
880
  title: "Content Engine · Observatory",
746
881
  brand: "Observatory",
747
882
  search: "Search ID, one-liner, source…",
748
- 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",
749
884
  notes_in: "Captured", notes_needs: "Needs", notes_lib: "Catalog",
750
885
  lead: "Pick an IP, then inspect the stock.",
751
- 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",
752
887
  kpi_cap_sub: "Capture", kpi_needs_sub: "Audience quotes", kpi_lib_sub: "Swipe / Atom / Claim",
753
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",
754
915
  ip_section: "IP · Profiles",
755
916
  ip_all: "All profiles",
756
917
  ip_accounts: "{n} account(s)",
@@ -764,6 +925,13 @@
764
925
  gap_needs_n: "{n} needs not yet in a topic",
765
926
  gap_lib_n: "{n} captures not ingested",
766
927
  gap_review_n: "{n} shipped posts not reviewable",
928
+ kv_lane: "Lane",
929
+ kv_mode: "Generation mode",
930
+ kv_needs: "Needs",
931
+ kv_recs: "Recommendations",
932
+ kv_signal: "Signal",
933
+ kv_hit_kind: "Hit kind",
934
+ kv_hit_source: "Source recipe",
767
935
  kv_profile: "Profile",
768
936
  kind_profile: "Profile", kind_account: "Account",
769
937
  profile_pillars: "Pillars",
@@ -849,7 +1017,7 @@
849
1017
  thin_warn: "Too thin to review: result is not win / flat / loss. No chart.",
850
1018
  topic_bind: "Topic: {t} · swipe {s} · claim {c}",
851
1019
  supersedes: "supersedes {id}", superseded_by: "superseded by {id}",
852
- 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",
853
1021
  kind_atom: "Atom", kind_claim: "Claim", kind_run: "Run", kind_published: "Shipped",
854
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",
855
1023
  kv_use: "Use", kv_src: "Source", kv_result: "Result", kv_pillar: "Pillar",
@@ -946,9 +1114,17 @@
946
1114
  let topicPillar = "";
947
1115
  let topicSrc = "";
948
1116
  let topicUsage = "";
949
- let discStatus = "";
950
- let discSort = "recommend";
951
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"];
1127
+ const TOPIC_SOURCES = ["need", "inbox", "user", "product", "recommendation"];
952
1128
  let selected = null;
953
1129
  const $list = document.getElementById("list");
954
1130
  const $insp = document.getElementById("insp");
@@ -1293,7 +1469,7 @@
1293
1469
  sel.innerHTML = `<option value="">${t("ip_all")}</option>` +
1294
1470
  ids.map(id => `<option value="${id}" ${id === profileScope ? "selected" : ""}>${id}</option>`).join("");
1295
1471
  tabs.innerHTML = "";
1296
- 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"]) {
1297
1473
  const b = document.createElement("button");
1298
1474
  b.textContent = t(id);
1299
1475
  b.dataset.view = id;
@@ -1353,7 +1529,7 @@
1353
1529
  function qmatch(n) {
1354
1530
  const q = $q.value.trim().toLowerCase();
1355
1531
  if (!q) return true;
1356
- 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.product_ids, n.product_fit, n.recommendation_ids].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();
1357
1533
  return blob.includes(q);
1358
1534
  }
1359
1535
  function connected(id) {
@@ -1454,7 +1630,7 @@
1454
1630
  }).join("");
1455
1631
  return `<div class="heat" title="${esc(t("heat_hint"))}">${chips}</div>${hint}`;
1456
1632
  }
1457
- function discoveryRowEl(n, extra) {
1633
+ function listRowEl(n, extra) {
1458
1634
  const d = document.createElement("div");
1459
1635
  d.className = "row" + (selected === n.id ? " sel" : "");
1460
1636
  d.dataset.kind = n.kind;
@@ -1473,46 +1649,6 @@
1473
1649
  </div>`;
1474
1650
  return d;
1475
1651
  }
1476
- function angleRecommendRank(n) {
1477
- const r = String(n.angle_recommend || "").toLowerCase();
1478
- if (r === "promote") return 0;
1479
- if (r === "library") return 1;
1480
- if (r === "hold") return 2;
1481
- if (r === "reject") return 3;
1482
- if (String(n.next_action || "") === "promote_to_topic") return 0;
1483
- return 4;
1484
- }
1485
- function discoveryUnprocessed(n) {
1486
- const st = String(n.status || "");
1487
- if (st === "promoted" || st === "rejected") return false;
1488
- const ang = String(n.angle_status || "");
1489
- if (ang === "accepted") return false;
1490
- if (ang === "held" || ang === "rejected") return false;
1491
- if (ang === "proposed" && (n.angle_recommend === "promote" || !n.angle_recommend)) return true;
1492
- if (String(n.next_action || "") === "promote_to_topic") return true;
1493
- return false;
1494
- }
1495
- function sortDiscoveries(rows, mode) {
1496
- const missing = -1;
1497
- const num = (n, key) => {
1498
- const v = Number(String(n[`src_${key}`] ?? "").replace(/,/g, ""));
1499
- return Number.isFinite(v) ? v : missing;
1500
- };
1501
- return rows.slice().sort((a, b) => {
1502
- if (mode === "recommend" || !mode) {
1503
- const rec = angleRecommendRank(a) - angleRecommendRank(b);
1504
- if (rec !== 0) return rec;
1505
- }
1506
- let cmp = 0;
1507
- if (mode === "likes") cmp = num(b, "likes") - num(a, "likes");
1508
- else if (mode === "replies") cmp = num(b, "replies") - num(a, "replies");
1509
- else if (mode === "views") cmp = num(b, "views") - num(a, "views");
1510
- else if (mode === "date") cmp = String(b.date || "").localeCompare(String(a.date || ""));
1511
- else cmp = num(b, "bookmarks") - num(a, "bookmarks");
1512
- if (cmp !== 0) return cmp;
1513
- return String(b.date || "").localeCompare(String(a.date || ""));
1514
- });
1515
- }
1516
1652
  function pills(n) {
1517
1653
  const bits = [];
1518
1654
  if (n.kind === "need") {
@@ -1522,18 +1658,18 @@
1522
1658
  if (n.source_type) bits.push(`<span class="pill">${esc(n.source_type)}</span>`);
1523
1659
  if (n.frequency) bits.push(`<span class="pill">×${esc(n.frequency)}</span>`);
1524
1660
  }
1525
- if (n.kind === "discovery") {
1661
+ if (n.kind === "hit") {
1526
1662
  const st = n.status || "";
1527
- const cls = st === "read" || st === "promoted" ? "ok"
1528
- : (st === "saved" || st === "rejected" || st === "unread" ? "gap" : "");
1663
+ const cls = st === "triage" ? "gap" : (st === "discarded" ? "" : "ok");
1529
1664
  if (st) bits.push(`<span class="pill ${cls}">${esc(st)}</span>`);
1530
- if (n.angle_recommend) bits.push(`<span class="pill ${n.angle_recommend === "promote" && discoveryUnprocessed(n) ? "gap" : ""}">${esc(n.angle_recommend)}</span>`);
1531
- else if (discoveryUnprocessed(n)) bits.push(`<span class="pill gap">${esc(t("gap_angle"))}</span>`);
1532
- if (n.source_role) bits.push(`<span class="pill">${esc(n.source_role)}</span>`);
1533
- if (n.next_action && n.next_action !== "none") bits.push(`<span class="pill">${esc(n.next_action)}</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>`);
1534
1668
  }
1535
1669
  if (n.kind === "topic") {
1536
1670
  bits.push(`<span class="pill">${esc(n.status)}</span>`);
1671
+ if (n.source_type) bits.push(`<span class="pill">${esc(n.source_type)}</span>`);
1672
+ if (n.generation_mode) bits.push(`<span class="pill">${esc(n.generation_mode)}</span>`);
1537
1673
  bits.push(n.usage === "unused" ? `<span class="pill gap">${t("unused")}</span>` : `<span class="pill ok">${esc(n.usage)}</span>`);
1538
1674
  }
1539
1675
  if (n.kind === "run") {
@@ -1574,6 +1710,7 @@
1574
1710
  for (const b of tabs.querySelectorAll("button")) b.classList.toggle("on", b.dataset.view === view);
1575
1711
  if (view === "overview") renderOverview();
1576
1712
  if (view === "needs") renderNeeds();
1713
+ if (view === "hits") renderHits();
1577
1714
  if (view === "products") renderProducts();
1578
1715
  if (view === "recommendations") renderRecommendations();
1579
1716
  if (view === "topics") renderTopics();
@@ -1594,7 +1731,7 @@
1594
1731
  n.frequency ? `×${n.frequency}` : "",
1595
1732
  n.topic_ids && n.topic_ids !== "none" ? n.topic_ids : "",
1596
1733
  ].filter(Boolean).join(" · ");
1597
- cards.appendChild(discoveryRowEl(n, meta));
1734
+ cards.appendChild(listRowEl(n, meta));
1598
1735
  });
1599
1736
  if (!needs.length) {
1600
1737
  cards.innerHTML = `<p class='empty'>${t("no_need")}</p><p class="note">${esc(t("need_empty_hint"))}</p>`;
@@ -1608,6 +1745,299 @@
1608
1745
  renderNeedsList($list);
1609
1746
  }
1610
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
+
1611
2041
  function renderProducts() {
1612
2042
  $list.innerHTML = "";
1613
2043
  const products = nodesOf("product").filter(qmatch).sort((a, b) => String(a.label).localeCompare(String(b.label)));
@@ -1709,58 +2139,30 @@
1709
2139
  onReady(wrap);
1710
2140
  }
1711
2141
 
1712
- function renderDiscovery() {
1713
- $list.innerHTML = "";
1714
- const statuses = [...new Set(nodesOf("discovery").map(x => x.status).filter(Boolean))];
1715
- const sortOpts = [
1716
- ["recommend", "f_sort_recommend"],
1717
- ["bookmarks", "f_sort_bookmarks"],
1718
- ["replies", "f_sort_replies"],
1719
- ["likes", "f_sort_likes"],
1720
- ["views", "f_sort_views"],
1721
- ["date", "f_sort_date"],
1722
- ];
1723
- filterBar(
1724
- `<select id="d-st"><option value="">${t("f_status")}</option>${statuses.map(s => `<option value="${s}" ${s === discStatus ? "selected" : ""}>${esc(s)}</option>`).join("")}</select>
1725
- <select id="d-sort" title="${esc(t("f_sort"))}"><option value="" disabled>${esc(t("f_sort"))}</option>${sortOpts.map(([v, k]) => `<option value="${v}" ${v === discSort ? "selected" : ""}>${esc(t(k))}</option>`).join("")}</select>`,
1726
- (w) => {
1727
- const status = w.querySelector("#d-st");
1728
- const sortEl = w.querySelector("#d-sort");
1729
- const paint = () => {
1730
- discStatus = status.value;
1731
- discSort = sortEl.value || "recommend";
1732
- const cards = document.createElement("div");
1733
- cards.className = "cards";
1734
- let rows = nodesOf("discovery").filter(qmatch);
1735
- if (discStatus) rows = rows.filter(x => x.status === discStatus);
1736
- rows = sortDiscoveries(rows, discSort);
1737
- rows.forEach(x => {
1738
- const meta = [
1739
- x.angle_recommend || "",
1740
- x.source_role,
1741
- x.next_action && x.next_action !== "none" ? x.next_action : "",
1742
- `@${x.source_handle || "—"}`,
1743
- ].filter(Boolean).join(" · ");
1744
- cards.appendChild(discoveryRowEl(x, meta));
1745
- });
1746
- if (!rows.length) cards.innerHTML = `<p class='empty'>${t("no_discovery")}</p>`;
1747
- [...$list.querySelectorAll(".cards")].forEach(n => n.remove());
1748
- $list.appendChild(cards);
1749
- };
1750
- status.onchange = paint;
1751
- sortEl.onchange = paint;
1752
- paint();
1753
- }
1754
- );
2142
+ function topicMeta(x) {
2143
+ const score = x.fit ? `${x.craft}/${x.fit}` : (x.craft || x.total || "—");
2144
+ const bits = [
2145
+ score,
2146
+ formatPillar(x.pillar),
2147
+ x.lane,
2148
+ x.generation_mode,
2149
+ x.source_type,
2150
+ x.need_ids && x.need_ids !== "none" ? x.need_ids : "",
2151
+ x.product_ids && x.product_ids !== "none" ? x.product_ids : "",
2152
+ x.recommendation_ids && x.recommendation_ids !== "none" ? x.recommendation_ids : "",
2153
+ x.capture_id || "",
2154
+ ].filter(Boolean);
2155
+ return bits.join(" · ");
1755
2156
  }
1756
2157
 
1757
2158
  function renderTopics() {
1758
2159
  $list.innerHTML = "";
2160
+ if (topicSrc && !TOPIC_SOURCES.includes(topicSrc)) topicSrc = "";
1759
2161
  const statuses = [...new Set(nodesOf("topic").map(x => x.status).filter(Boolean))];
1760
2162
  filterBar(
1761
2163
  `<select id="f-st"><option value="">${t("f_status")}</option>${statuses.map(s => `<option value="${s}" ${s === topicStatus ? "selected" : ""}>${esc(s)}</option>`).join("")}</select>
1762
2164
  <select id="f-p"><option value="">${t("f_pillar")}</option>${pillarIds().map(id => `<option value="${id}" ${id === topicPillar ? "selected" : ""}>${esc(formatPillar(id))}</option>`).join("")}</select>
1763
- <select id="f-src"><option value="">${t("f_src")}</option>${["watchlist", "inbox", "user", "discovery", "need"].map(s => `<option value="${s}" ${s === topicSrc ? "selected" : ""}>${s}</option>`).join("")}</select>
2165
+ <select id="f-src"><option value="">${t("f_src")}</option>${TOPIC_SOURCES.map(s => `<option value="${s}" ${s === topicSrc ? "selected" : ""}>${s}</option>`).join("")}</select>
1764
2166
  <select id="f-u"><option value="">${t("f_use")}</option>
1765
2167
  <option value="unused" ${topicUsage === "unused" ? "selected" : ""}>${t("f_unused")}</option>
1766
2168
  <option value="promoted" ${topicUsage === "promoted" ? "selected" : ""}>${t("f_promoted")}</option></select>`,
@@ -1783,8 +2185,7 @@
1783
2185
  if (topicSrc) rows = rows.filter(x => x.source_type === topicSrc);
1784
2186
  if (topicUsage) rows = rows.filter(x => x.usage === topicUsage);
1785
2187
  rows.forEach(x => {
1786
- const score = x.fit ? `${x.craft}/${x.fit}` : (x.craft || x.total || "—");
1787
- cards.appendChild(rowEl(x, `${score} · ${formatPillar(x.pillar)} · ${x.source_type} @${x.src_handle || "—"}`));
2188
+ cards.appendChild(rowEl(x, topicMeta(x)));
1788
2189
  });
1789
2190
  if (!rows.length) cards.innerHTML = `<p class='empty'>${t("no_topic")}</p>`;
1790
2191
  $list.appendChild(cards);
@@ -1826,7 +2227,7 @@
1826
2227
  let caps = nodesOf("capture").filter(qmatch).sort((a, b) => (b.date || "").localeCompare(a.date || ""));
1827
2228
  if (capIngest === "ingested") caps = caps.filter(c => c.status === "ingested");
1828
2229
  if (capIngest === "not_ingested") caps = caps.filter(c => c.status !== "ingested");
1829
- caps.forEach(c => cards.appendChild(discoveryRowEl(c, `${c.type} · ${c.status} · raw ${c.has_raw ? t("raw_yes") : t("raw_no")}`)));
2230
+ caps.forEach(c => cards.appendChild(listRowEl(c, `${c.type} · ${c.status} · raw ${c.has_raw ? t("raw_yes") : t("raw_no")}`)));
1830
2231
  if (!caps.length) cards.innerHTML = `<p class='empty'>${t("no_note")}</p>`;
1831
2232
  $list.appendChild(cards);
1832
2233
  return;
@@ -2026,6 +2427,7 @@
2026
2427
  return {
2027
2428
  capture: nodesOfAll("capture").filter(n => n.profile === profileId).length,
2028
2429
  need: nodesOfAll("need").filter(n => n.profile === profileId).length,
2430
+ hit: nodesOfAll("hit").filter(n => n.profile === profileId).length,
2029
2431
  topic: nodesOfAll("topic").filter(n => n.profile === profileId).length,
2030
2432
  lib: GRAPH.nodes.filter(n => n.lib && (String(n.profiles || "").includes(profileId) || String(n.profiles || "").includes("*"))).length,
2031
2433
  run: nodesOfAll("run").filter(n => n.profile === profileId).length,
@@ -2038,6 +2440,7 @@
2038
2440
  return {
2039
2441
  capture: nodesOfAll("capture").length,
2040
2442
  need: nodesOfAll("need").length,
2443
+ hit: nodesOfAll("hit").length,
2041
2444
  topic: nodesOfAll("topic").length,
2042
2445
  lib: GRAPH.nodes.filter(n => n.lib).length,
2043
2446
  run: nodesOfAll("run").length,
@@ -2047,20 +2450,21 @@
2047
2450
 
2048
2451
  function gapsFor(profileId) {
2049
2452
  const topics = profileId ? nodesOfAll("topic").filter(n => n.profile === profileId) : nodesOfAll("topic");
2050
- const discs = profileId ? nodesOfAll("discovery").filter(n => n.profile === profileId) : nodesOfAll("discovery");
2051
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");
2052
2455
  const caps = profileId ? nodesOfAll("capture").filter(n => n.profile === profileId) : nodesOfAll("capture");
2053
2456
  const pubs = profileId ? nodesOfAll("published").filter(n => n.profile === profileId) : nodesOfAll("published");
2054
2457
  const needCount = needs.length;
2055
2458
  const gaps = [
2459
+ [hits.filter(h => h.status === "triage").length, "gap_hits_n"],
2056
2460
  [topics.filter(x => x.status === "ready" && x.usage === "unused").length, "gap_ready_n"],
2057
- [discs.filter(discoveryUnprocessed).length, "gap_angle_n"],
2058
2461
  [needs.filter(n => n.status === "captured" && (!n.topic_ids || n.topic_ids === "none")).length, "gap_needs_n"],
2059
2462
  [caps.filter(c => c.status !== "ingested").length, "gap_lib_n"],
2060
2463
  [pubs.filter(p => !p.reviewable).length, "gap_review_n"],
2061
2464
  ].filter(([n]) => n > 0);
2062
2465
  if (GRAPH.needs_index_present && needCount < 3) {
2063
- gaps.splice(2, 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"]);
2064
2468
  }
2065
2469
  return gaps;
2066
2470
  }
@@ -2069,6 +2473,7 @@
2069
2473
  return `<div class="prof-stats">
2070
2474
  <div><b>${counts.capture}</b><span>${t("kpi_cap")}</span></div>
2071
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>
2072
2477
  <div><b>${counts.topic}</b><span>${t("kpi_topics")}</span></div>
2073
2478
  <div><b>${counts.lib}</b><span>${t("kpi_lib")}</span></div>
2074
2479
  <div><b>${counts.run}</b><span>Run</span></div>
@@ -2103,6 +2508,8 @@
2103
2508
  ["audience", t("doc_audience")],
2104
2509
  ["pillars", t("doc_pillars")],
2105
2510
  ["lanes", t("doc_lanes")],
2511
+ ["keywords", t("doc_keywords")],
2512
+ ["handles", t("doc_handles")],
2106
2513
  ];
2107
2514
  const pillarRows = Object.entries(pillars).map(([id, p]) => {
2108
2515
  const title = lang === "zh" ? p.zh : p.en;
@@ -2166,20 +2573,26 @@
2166
2573
  ? `${n.win || 0}w / ${n.loss || 0}l / n=${n.n}`
2167
2574
  : "",
2168
2575
  [t("kv_pillar")]: formatPillar(n.pillar),
2169
- Lane: n.kind === "topic" ? (n.lane || "") : "",
2170
- "Generation mode": n.kind === "topic" ? (n.generation_mode || "") : "",
2576
+ [t("kv_lane")]: n.kind === "topic" ? (n.lane || "") : "",
2577
+ [t("kv_mode")]: n.kind === "topic" ? (n.generation_mode || "") : "",
2171
2578
  [t("kv_plat")]: n.platform || n.platforms,
2172
2579
  [t("kv_profile")]: n.profile || n.profile_id || (n.profiles || ""),
2173
2580
  [t("kv_score")]: n.fit ? `${n.craft} opp / ${n.fit} aud` : n.total,
2174
- [t("kv_use")]: n.kind === "discovery" ? (n.angle_recommend || n.next_action || "") : n.usage,
2175
- [t("kv_src")]: n.kind === "discovery" ? (n.source_role || "") : n.source_type,
2176
- Angle: n.kind === "discovery" ? ([n.angle_status, n.need_ids].filter(Boolean).join(" · ")) : "",
2581
+ [t("kv_use")]: n.usage,
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 || "") : "",
2587
+ [t("kv_needs")]: n.kind === "topic" ? (n.need_ids || "") : "",
2177
2588
  Quote: n.kind === "need" ? (n.quote || "") : "",
2589
+ Excerpt: n.kind === "hit" ? (n.excerpt || "") : "",
2178
2590
  Speaker: n.kind === "need" ? (n.speaker || "") : "",
2179
2591
  Frequency: n.kind === "need" ? (n.frequency || "") : "",
2180
2592
  Topics: n.kind === "need" ? (n.topic_ids || "") : "",
2181
2593
  Products: n.kind === "topic" ? (n.product_ids || "") : "",
2182
2594
  "Product fit": n.kind === "topic" ? (n.product_fit || "") : "",
2595
+ [t("kv_recs")]: n.kind === "topic" ? (n.recommendation_ids || "") : "",
2183
2596
  Audience: n.kind === "product" ? (n.audience || "") : "",
2184
2597
  Price: n.kind === "product" ? (n.price || "") : "",
2185
2598
  "Effective from": n.kind === "product" ? (n.effective_from || "") : "",
@@ -2187,16 +2600,23 @@
2187
2600
  Relationship: n.kind === "recommendation" ? (n.relationship || "") : "",
2188
2601
  "Last researched": n.kind === "recommendation" ? (n.last_researched || "") : "",
2189
2602
  Handle: n.source_handle || n.src_handle || "",
2190
- [t("kv_heat_at")]: (n.kind === "discovery" || n.kind === "capture") ? (n.engagement_at || "") : "",
2603
+ [t("kv_heat_at")]: (n.kind === "capture" || n.kind === "hit") ? (n.engagement_at || "") : "",
2191
2604
  [t("kv_result")]: n.result,
2192
2605
  }).filter(([, v]) => v);
2193
- const heatBlock = (n.kind === "discovery" || 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")
2194
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>` : ""}`
2195
2614
  : "";
2196
2615
  $insp.innerHTML = `<h2>${t("obj")}</h2>
2197
2616
  <p class="kicker">${esc(kindName(n.kind))} · ${esc(n.ident)}</p>
2198
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>
2199
2618
  <dl class="kv">${kv.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join("")}</dl>
2619
+ ${kwBlock}
2200
2620
  ${heatBlock}
2201
2621
  ${n.kind === "swipe" || n.kind === "atom" ? `<p class="note">${esc(t("lib_use_hist"))}</p>` : ""}
2202
2622
  ${n.kind === "claim" ? `<p class="note">${esc(t("lib_claim_ev"))}</p>` : ""}
@@ -2211,6 +2631,7 @@
2211
2631
  $insp.querySelectorAll("[data-goto]").forEach(btn => {
2212
2632
  btn.onclick = () => select(btn.getAttribute("data-goto"));
2213
2633
  });
2634
+ bindKwFilters($insp);
2214
2635
  const trace = $insp.querySelector("#trace");
2215
2636
  for (const k of TRACE_ORDER) {
2216
2637
  const items = k === "published" ? publishedFor(n, ctx) : (byKind[k] || []);