luckiest-co 1.0.46 → 1.0.48

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.
Files changed (266) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/ab-testing/CHANGELOG.md +5 -0
  5. package/skills/ab-testing/SKILL.md +12 -9
  6. package/skills/ab-testing/evals/evals.json +18 -7
  7. package/skills/ab-testing/references/sample-size-guide.md +2 -2
  8. package/skills/ads/CHANGELOG.md +5 -0
  9. package/skills/ads/SKILL.md +4 -4
  10. package/skills/ai-seo/CHANGELOG.md +7 -0
  11. package/skills/ai-seo/SKILL.md +27 -20
  12. package/skills/analytics/CHANGELOG.md +6 -0
  13. package/skills/analytics/SKILL.md +4 -3
  14. package/skills/analytics/evals/evals.json +27 -0
  15. package/skills/analytics/references/ga4-implementation.md +7 -3
  16. package/skills/analytics/references/gtm-implementation.md +8 -2
  17. package/skills/aso/CHANGELOG.md +5 -0
  18. package/skills/aso/SKILL.md +4 -4
  19. package/skills/aso/evals/evals.json +15 -2
  20. package/skills/aso/references/benchmarks.md +4 -2
  21. package/skills/aso/references/google-play-specs.md +5 -1
  22. package/skills/brainstorm/SKILL.md +59 -16
  23. package/skills/brainstorm/builder-check-prompt.md +23 -0
  24. package/skills/churn-prevention/CHANGELOG.md +5 -0
  25. package/skills/churn-prevention/SKILL.md +3 -3
  26. package/skills/churn-prevention/evals/evals.json +14 -0
  27. package/skills/churn-prevention/references/dunning-playbook.md +6 -1
  28. package/skills/coder-brainstorming/CHANGELOG.md +10 -0
  29. package/skills/coder-brainstorming/SKILL.md +62 -20
  30. package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
  31. package/skills/coder-brainstorming/scripts/server.cjs +1 -1
  32. package/skills/coder-debugging/CHANGELOG.md +4 -0
  33. package/skills/coder-debugging/SKILL.md +5 -5
  34. package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
  35. package/skills/coder-requesting-code-review/SKILL.md +4 -4
  36. package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
  37. package/skills/coder-subagent-driven-development/SKILL.md +3 -3
  38. package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
  39. package/skills/cold-email/CHANGELOG.md +7 -0
  40. package/skills/cold-email/SKILL.md +27 -7
  41. package/skills/cold-email/evals/evals.json +30 -0
  42. package/skills/cold-email/references/benchmarks.md +15 -11
  43. package/skills/cold-email/references/deliverability.md +110 -0
  44. package/skills/cold-email/references/linkedin-outreach.md +91 -0
  45. package/skills/cold-email/references/multichannel-cadence.md +79 -0
  46. package/skills/cold-email/references/outbound-plays.md +88 -0
  47. package/skills/cold-email/references/personalization.md +11 -0
  48. package/skills/cold-email/references/reply-handling.md +65 -0
  49. package/skills/competitor-profiling/CHANGELOG.md +5 -0
  50. package/skills/competitor-profiling/SKILL.md +4 -4
  51. package/skills/content-strategy/CHANGELOG.md +5 -0
  52. package/skills/content-strategy/SKILL.md +5 -3
  53. package/skills/copy-editing/CHANGELOG.md +5 -0
  54. package/skills/copy-editing/SKILL.md +5 -5
  55. package/skills/copy-editing/references/content-refresh.md +33 -0
  56. package/skills/cro/CHANGELOG.md +5 -0
  57. package/skills/cro/SKILL.md +14 -3
  58. package/skills/cro/references/form.md +1 -1
  59. package/skills/directory-submissions/CHANGELOG.md +5 -0
  60. package/skills/directory-submissions/SKILL.md +13 -4
  61. package/skills/directory-submissions/evals/evals.json +15 -1
  62. package/skills/directory-submissions/references/directory-list.md +2 -4
  63. package/skills/emails/CHANGELOG.md +5 -0
  64. package/skills/emails/SKILL.md +14 -4
  65. package/skills/launch/CHANGELOG.md +7 -0
  66. package/skills/launch/SKILL.md +8 -4
  67. package/skills/launch/evals/evals.json +29 -0
  68. package/skills/launch/references/shipped-changes.md +84 -0
  69. package/skills/launch/references/site-launch-qa.md +55 -0
  70. package/skills/lead-magnets/CHANGELOG.md +5 -0
  71. package/skills/lead-magnets/SKILL.md +6 -4
  72. package/skills/lead-magnets/evals/evals.json +18 -0
  73. package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
  74. package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
  75. package/skills/lead-magnets/references/content-to-skill.md +108 -0
  76. package/skills/marketing-plan/CHANGELOG.md +5 -0
  77. package/skills/marketing-plan/SKILL.md +3 -3
  78. package/skills/marketing-plan/evals/evals.json +13 -0
  79. package/skills/marketing-plan/references/budget-planning.md +26 -17
  80. package/skills/pricing/CHANGELOG.md +5 -0
  81. package/skills/pricing/SKILL.md +4 -4
  82. package/skills/pricing/evals/evals.json +12 -0
  83. package/skills/pricing/references/research-methods.md +8 -4
  84. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  85. package/skills/programmatic-seo/SKILL.md +8 -4
  86. package/skills/programmatic-seo/evals/evals.json +13 -0
  87. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  88. package/skills/prospecting/CHANGELOG.md +7 -0
  89. package/skills/prospecting/SKILL.md +27 -13
  90. package/skills/prospecting/evals/evals.json +41 -0
  91. package/skills/prospecting/references/account-research.md +59 -0
  92. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  93. package/skills/prospecting/references/compliance.md +44 -6
  94. package/skills/prospecting/references/data-sources.md +13 -15
  95. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  96. package/skills/prospecting/references/saas-prospecting.md +1 -1
  97. package/skills/prospecting/references/signal-plays.md +99 -0
  98. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  99. package/skills/referrals/CHANGELOG.md +5 -0
  100. package/skills/referrals/SKILL.md +10 -4
  101. package/skills/referrals/evals/evals.json +99 -0
  102. package/skills/referrals/references/affiliate-operations.md +64 -0
  103. package/skills/referrals/references/affiliate-programs.md +2 -2
  104. package/skills/referrals/references/program-examples.md +14 -6
  105. package/skills/referrals/references/review-platform-rules.md +36 -0
  106. package/skills/referrals/references/review-program-operations.md +62 -0
  107. package/skills/referrals/references/review-programs.md +96 -0
  108. package/skills/revops/CHANGELOG.md +5 -0
  109. package/skills/revops/SKILL.md +10 -6
  110. package/skills/revops/evals/evals.json +13 -0
  111. package/skills/revops/references/automation-playbooks.md +1 -1
  112. package/skills/revops/references/inbound-routing.md +43 -0
  113. package/skills/revops/references/lifecycle-definitions.md +31 -0
  114. package/skills/sales-enablement/CHANGELOG.md +5 -0
  115. package/skills/sales-enablement/SKILL.md +6 -4
  116. package/skills/sales-enablement/evals/evals.json +14 -0
  117. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  118. package/skills/schema/CHANGELOG.md +5 -0
  119. package/skills/schema/SKILL.md +20 -8
  120. package/skills/schema/evals/evals.json +40 -0
  121. package/skills/schema/references/schema-examples.md +9 -15
  122. package/skills/seo-audit/CHANGELOG.md +5 -0
  123. package/skills/seo-audit/SKILL.md +10 -16
  124. package/skills/seo-audit/evals/evals.json +68 -0
  125. package/skills/seo-audit/references/international-seo.md +9 -11
  126. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  127. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  128. package/skills/seo-audit/references/local-seo.md +86 -0
  129. package/skills/seo-audit/references/rankings-push.md +62 -0
  130. package/skills/seo-audit/references/title-tags.md +89 -0
  131. package/skills/signup/CHANGELOG.md +5 -0
  132. package/skills/signup/SKILL.md +7 -3
  133. package/skills/site-architecture/CHANGELOG.md +5 -0
  134. package/skills/site-architecture/SKILL.md +5 -4
  135. package/skills/site-architecture/evals/evals.json +25 -0
  136. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  137. package/skills/social/CHANGELOG.md +5 -0
  138. package/skills/social/SKILL.md +4 -4
  139. package/skills/trends/.skillignore +3 -0
  140. package/skills/trends/CHANGELOG.md +20 -0
  141. package/skills/trends/SKILL.md +12 -8
  142. package/skills/trends/references/agent-mode.md +36 -0
  143. package/skills/trends/references/category-peers.md +38 -0
  144. package/skills/trends/references/comparison.md +227 -0
  145. package/skills/trends/references/competitors.md +29 -0
  146. package/skills/trends/references/discovery.md +90 -0
  147. package/skills/trends/references/engine-contract.md +111 -1889
  148. package/skills/trends/references/followups.md +112 -0
  149. package/skills/trends/references/grok-bot-x.md +55 -0
  150. package/skills/trends/references/hiring-signals.md +19 -0
  151. package/skills/trends/references/library-queue.md +33 -0
  152. package/skills/trends/references/recommendations.md +86 -0
  153. package/skills/trends/references/research-runbook.md +781 -0
  154. package/skills/trends/references/runtime.md +165 -0
  155. package/skills/trends/references/save-html-brief.md +39 -20
  156. package/skills/trends/references/setup-wizard.md +253 -0
  157. package/skills/trends/references/synthesis.md +289 -0
  158. package/skills/trends/scripts/box_chrome_login.py +209 -0
  159. package/skills/trends/scripts/briefing.py +9 -17
  160. package/skills/trends/scripts/last30days.py +3247 -280
  161. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  162. package/skills/trends/scripts/lib/amazon.py +877 -0
  163. package/skills/trends/scripts/lib/arxiv.py +42 -8
  164. package/skills/trends/scripts/lib/backends.py +769 -0
  165. package/skills/trends/scripts/lib/bird_x.py +340 -57
  166. package/skills/trends/scripts/lib/bluesky.py +84 -10
  167. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  168. package/skills/trends/scripts/lib/brightdata.py +291 -0
  169. package/skills/trends/scripts/lib/categories.py +7 -1
  170. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  171. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  172. package/skills/trends/scripts/lib/cluster.py +31 -54
  173. package/skills/trends/scripts/lib/competitors.py +10 -1
  174. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  175. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  176. package/skills/trends/scripts/lib/corpus.py +439 -0
  177. package/skills/trends/scripts/lib/devto.py +313 -0
  178. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  179. package/skills/trends/scripts/lib/doctor.py +2166 -0
  180. package/skills/trends/scripts/lib/dripstack.py +203 -0
  181. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  182. package/skills/trends/scripts/lib/env.py +1092 -138
  183. package/skills/trends/scripts/lib/fanout.py +11 -1
  184. package/skills/trends/scripts/lib/feed.py +61 -0
  185. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  186. package/skills/trends/scripts/lib/freshness.py +566 -0
  187. package/skills/trends/scripts/lib/fusion.py +314 -28
  188. package/skills/trends/scripts/lib/github.py +438 -24
  189. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  190. package/skills/trends/scripts/lib/grounding.py +129 -11
  191. package/skills/trends/scripts/lib/hackernews.py +61 -4
  192. package/skills/trends/scripts/lib/health.py +373 -0
  193. package/skills/trends/scripts/lib/hosted.py +333 -0
  194. package/skills/trends/scripts/lib/html_publish.py +34 -0
  195. package/skills/trends/scripts/lib/html_render.py +169 -3
  196. package/skills/trends/scripts/lib/http.py +1215 -70
  197. package/skills/trends/scripts/lib/instagram.py +132 -9
  198. package/skills/trends/scripts/lib/jobs.py +4 -2
  199. package/skills/trends/scripts/lib/library.py +308 -0
  200. package/skills/trends/scripts/lib/library_index.py +477 -0
  201. package/skills/trends/scripts/lib/linkedin.py +271 -24
  202. package/skills/trends/scripts/lib/log.py +5 -2
  203. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  204. package/skills/trends/scripts/lib/normalize.py +382 -28
  205. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  206. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  207. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  208. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  209. package/skills/trends/scripts/lib/planner.py +396 -53
  210. package/skills/trends/scripts/lib/polymarket.py +285 -23
  211. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  212. package/skills/trends/scripts/lib/private_files.py +21 -0
  213. package/skills/trends/scripts/lib/providers.py +231 -10
  214. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  215. package/skills/trends/scripts/lib/reddit.py +177 -12
  216. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  217. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  218. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  219. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  220. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  221. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  222. package/skills/trends/scripts/lib/registers.py +134 -0
  223. package/skills/trends/scripts/lib/relevance.py +17 -0
  224. package/skills/trends/scripts/lib/render.py +2161 -271
  225. package/skills/trends/scripts/lib/rerank.py +623 -55
  226. package/skills/trends/scripts/lib/resolve.py +50 -1
  227. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  228. package/skills/trends/scripts/lib/schema.py +782 -1
  229. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  230. package/skills/trends/scripts/lib/signals.py +206 -7
  231. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  232. package/skills/trends/scripts/lib/subproc.py +307 -53
  233. package/skills/trends/scripts/lib/techmeme.py +89 -64
  234. package/skills/trends/scripts/lib/telegram.py +317 -0
  235. package/skills/trends/scripts/lib/tiktok.py +13 -9
  236. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  237. package/skills/trends/scripts/lib/transcribe.py +12 -3
  238. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  239. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  240. package/skills/trends/scripts/lib/ui.py +6 -0
  241. package/skills/trends/scripts/lib/usage.py +132 -0
  242. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  243. package/skills/trends/scripts/lib/windows_job.py +138 -0
  244. package/skills/trends/scripts/lib/x_api.py +824 -0
  245. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  246. package/skills/trends/scripts/lib/x_judge.py +292 -0
  247. package/skills/trends/scripts/lib/xai_x.py +26 -3
  248. package/skills/trends/scripts/lib/xquik.py +89 -15
  249. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  250. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  251. package/skills/trends/scripts/setup-keychain.sh +12 -4
  252. package/skills/trends/scripts/setup-pass.sh +3 -0
  253. package/skills/trends/scripts/store.py +444 -50
  254. package/skills/trends/scripts/watchlist.py +38 -11
  255. package/skills/trends/scripts/x_lane_parity.py +107 -0
  256. package/skills/video-studio/CHANGELOG.md +8 -0
  257. package/skills/video-studio/SKILL.md +6 -5
  258. package/skills/video-studio/references/styles/product-flight.md +45 -0
  259. package/skills/video-studio/references/styles.md +3 -1
  260. package/skills/video-studio/scripts/qa.mjs +64 -2
  261. package/skills/video-studio/scripts/qa.test.mjs +29 -1
  262. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  263. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  264. package/skills/video-studio-motion/SKILL.md +8 -5
  265. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  266. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -2,6 +2,8 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Iterable
6
+
5
7
  from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
6
8
 
7
9
  from . import schema
@@ -11,21 +13,32 @@ RRF_K = 60
11
13
 
12
14
 
13
15
  def _candidate_sort_key(c: schema.Candidate) -> tuple:
14
- return (-c.rrf_score, -c.local_relevance, -c.freshness, schema.candidate_source_label(c), c.title)
16
+ # Out-of-window evidence sorts strictly below anything in the window. A
17
+ # "last 30 days" brief that ranks a nine-month-old video at #1 breaks its
18
+ # own contract, however relevant that video is; it still appears, just
19
+ # never above in-window evidence.
20
+ return (
21
+ 1 if schema.candidate_out_of_window(c) else 0,
22
+ -c.rrf_score,
23
+ -c.local_relevance,
24
+ -c.freshness,
25
+ schema.candidate_source_label(c),
26
+ c.title,
27
+ )
15
28
 
16
29
 
17
30
  def _normalize_url(url: str) -> str:
18
- """Normalize URL for dedup: lowercase, strip www/old/m prefixes, remove tracking params."""
19
- parsed = urlparse(url.strip().lower())
20
- netloc = parsed.netloc
31
+ """Normalize URL for dedup: lowercase host, strip www/old/m prefixes, remove tracking params."""
32
+ parsed = urlparse(url.strip())
33
+ netloc = parsed.netloc.lower()
21
34
  for prefix in ("www.", "old.", "m."):
22
35
  if netloc.startswith(prefix):
23
36
  netloc = netloc[len(prefix):]
24
37
  # Strip tracking params
25
38
  params = parse_qs(parsed.query)
26
- clean_params = {k: v for k, v in params.items() if not k.startswith("utm_")}
39
+ clean_params = {k: v for k, v in params.items() if not k.lower().startswith("utm_")}
27
40
  query = urlencode(clean_params, doseq=True)
28
- return urlunparse((parsed.scheme, netloc, parsed.path.rstrip("/"), "", query, ""))
41
+ return urlunparse((parsed.scheme.lower(), netloc, parsed.path.rstrip("/"), "", query, ""))
29
42
 
30
43
 
31
44
  def candidate_key(item: schema.SourceItem) -> str:
@@ -34,16 +47,175 @@ def candidate_key(item: schema.SourceItem) -> str:
34
47
  return f"{item.source}:{item.item_id}"
35
48
 
36
49
 
50
+ def source_item_key(item: schema.SourceItem) -> tuple[str, str]:
51
+ """Identity of one source item, for same-source de-duplication.
52
+
53
+ Usually the normalized URL (see :func:`candidate_key`). A Hacker News
54
+ item's ``url`` is the linked article, and one article can be submitted
55
+ several times as distinct stories with their own points and comments, so
56
+ HN identity is the discussion URL. ``candidate_key`` still clusters those
57
+ submissions (and other sources linking the article) into one candidate.
58
+ """
59
+ if item.source == "hackernews":
60
+ hn_url = item.metadata.get("hn_url")
61
+ if hn_url:
62
+ return (item.source, _normalize_url(str(hn_url)))
63
+ return (item.source, candidate_key(item))
64
+
65
+
66
+ # Enrichment that one copy of a thread may carry and another may lack. When
67
+ # the same URL arrives from two subquery streams (each stream enriches its own
68
+ # top-N), the copy that won a comment slot must survive de-duplication.
69
+ _ENRICHMENT_KEYS = (
70
+ "top_comments",
71
+ "comment_insights",
72
+ "transcript_highlights",
73
+ "transcript_snippet",
74
+ "transcript",
75
+ )
76
+
77
+
78
+ def merge_source_items(existing: schema.SourceItem, incoming: schema.SourceItem) -> schema.SourceItem:
79
+ """Fold ``incoming``'s enrichment and counts into ``existing`` (same thread).
80
+
81
+ Keeps the richer value per field: an enrichment list the existing copy
82
+ lacks (or a longer one), the larger numeric engagement counters, and the
83
+ longer body/snippet. Mutates and returns ``existing``.
84
+ """
85
+ for key in _ENRICHMENT_KEYS:
86
+ theirs = incoming.metadata.get(key)
87
+ if not theirs:
88
+ continue
89
+ mine = existing.metadata.get(key)
90
+ if not mine or (isinstance(theirs, list) and isinstance(mine, list) and len(theirs) > len(mine)):
91
+ existing.metadata[key] = theirs
92
+ for field_name, value in (incoming.engagement or {}).items():
93
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
94
+ if existing.engagement.get(field_name) is None and value is not None:
95
+ existing.engagement[field_name] = value
96
+ continue
97
+ current = existing.engagement.get(field_name)
98
+ if not isinstance(current, (int, float)) or isinstance(current, bool) or value > current:
99
+ existing.engagement[field_name] = value
100
+ if len(incoming.body or "") > len(existing.body or ""):
101
+ existing.body = incoming.body
102
+ if len(incoming.snippet or "") > len(existing.snippet or ""):
103
+ existing.snippet = incoming.snippet
104
+ return existing
105
+
106
+
107
+ def collapse_duplicate_urls(items: list[schema.SourceItem]) -> list[schema.SourceItem]:
108
+ """Collapse same-source, same-URL copies, keeping order and merging enrichment.
109
+
110
+ Per-stream item ids (``R1``, ``X1``) collide across subqueries, so identity
111
+ is :func:`source_item_key` (the normalized URL, or the HN discussion URL),
112
+ not the id. The first occurrence stays in place and
113
+ absorbs later copies via :func:`merge_source_items`.
114
+ """
115
+ first_by_key: dict[tuple[str, str], schema.SourceItem] = {}
116
+ kept: list[schema.SourceItem] = []
117
+ for item in items:
118
+ key = source_item_key(item)
119
+ existing = first_by_key.get(key)
120
+ if existing is None:
121
+ first_by_key[key] = item
122
+ kept.append(item)
123
+ else:
124
+ merge_source_items(existing, item)
125
+ return kept
126
+
127
+
37
128
  _DIVERSITY_RELEVANCE_THRESHOLD = 0.25
38
129
 
130
+ # Reddit engagement reservation: pool slots held for the highest-engagement
131
+ # entity-grounded in-window Reddit candidates, scaled by pool size (quick 15
132
+ # -> 2, default 40 -> 3, deep 60 -> 4). The fused order is RRF-first and each
133
+ # stream is relevance-first, so the month's most-discussed on-topic thread can
134
+ # otherwise lose its slot to a one-upvote post with better title overlap.
135
+ _REDDIT_RESERVE_BY_POOL = ((15, 2), (40, 3))
136
+ _REDDIT_RESERVE_MAX = 4
137
+
138
+
139
+ def _reddit_reserve_for(pool_limit: int) -> int:
140
+ for ceiling, reserve in _REDDIT_RESERVE_BY_POOL:
141
+ if pool_limit <= ceiling:
142
+ return reserve
143
+ return _REDDIT_RESERVE_MAX
144
+
145
+
146
+ def relevance_floor_for_entity(entity: str) -> float:
147
+ """Relevance a Reddit thread must clear to earn a reservation or keeper slot.
148
+
149
+ Grounding keys on the entity's head token (see ``rerank._entity_grounded``).
150
+ A generic head ("ai", "x", "new") matches almost anything, so the floor
151
+ rises to the diversity threshold; a distinctive head keeps the shared
152
+ ``RELEVANCE_FLOOR``.
153
+ """
154
+ from . import relevance
155
+
156
+ head = (entity or "").lower().split()[:1]
157
+ if not head:
158
+ return relevance.RELEVANCE_FLOOR
159
+ token = head[0]
160
+ generic = (
161
+ len(token) <= 2
162
+ or token in relevance.STOPWORDS
163
+ or token in relevance.LOW_SIGNAL_QUERY_TOKENS
164
+ )
165
+ return _DIVERSITY_RELEVANCE_THRESHOLD if generic else relevance.RELEVANCE_FLOOR
166
+
167
+
168
+ def raw_engagement(item: schema.SourceItem) -> float:
169
+ """Upvotes plus comments as a plain number, for engagement-first ordering."""
170
+ eng = item.engagement or {}
171
+ total = 0.0
172
+ for key in ("score", "num_comments"):
173
+ value = eng.get(key)
174
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
175
+ total += float(value)
176
+ return total
177
+
178
+
179
+ def reddit_thread_qualifies(
180
+ item: schema.SourceItem,
181
+ entity: str,
182
+ floor: float,
183
+ relevance: float | None = None,
184
+ ) -> bool:
185
+ """On-topic enough for an engagement slot: clears the floor and names the entity.
186
+
187
+ ``relevance`` overrides the item's own ``local_relevance`` (a fused
188
+ candidate carries the max across its copies).
189
+ """
190
+ from . import rerank
191
+
192
+ score = relevance if relevance is not None else (item.local_relevance or 0.0)
193
+ if score < floor:
194
+ return False
195
+ if raw_engagement(item) <= 0:
196
+ return False
197
+ if not entity:
198
+ return True
199
+ return rerank._entity_grounded(f"{item.title or ''} {item.body or ''}", entity)
200
+
39
201
  # Per-author cap: no single author/handle should dominate the pool.
40
202
  _MAX_ITEMS_PER_AUTHOR = 3
41
203
 
204
+ # Raised cap for the subject of the topic (a handle in the run's
205
+ # resolved_handles). On a person or company topic the subject is what the user
206
+ # asked about, so the flat cap discards exactly the evidence the run worked
207
+ # hardest to retrieve -- the measured 'Peter Steinberger steipete' baseline
208
+ # recovered 8 subject-authored posts and would have kept 3. Still bounded: a
209
+ # prolific subject must not crowd out commentary about them, which is the other
210
+ # half of the answer a user wants.
211
+ _MAX_ITEMS_PER_FIRST_PARTY_AUTHOR = 8
212
+
42
213
 
43
214
  def _extract_author(candidate: schema.Candidate) -> str | None:
44
215
  """Return a normalized author key from a candidate's source items."""
45
216
  for item in candidate.source_items:
46
- if item.author:
217
+ # Jobs use the ATS provider as author, not an individual contributor.
218
+ if item.source != "jobs" and item.author:
47
219
  return item.author.strip().lower()
48
220
  return None
49
221
 
@@ -51,12 +223,23 @@ def _extract_author(candidate: schema.Candidate) -> str | None:
51
223
  def _apply_per_author_cap(
52
224
  candidates: list[schema.Candidate],
53
225
  max_per_author: int = _MAX_ITEMS_PER_AUTHOR,
226
+ first_party_handles: Iterable[str] | None = None,
227
+ max_per_first_party_author: int = _MAX_ITEMS_PER_FIRST_PARTY_AUTHOR,
54
228
  ) -> list[schema.Candidate]:
55
229
  """Keep at most *max_per_author* items from any single author.
56
230
 
231
+ Authors named in *first_party_handles* -- the subject of the topic -- get
232
+ the higher *max_per_first_party_author* allowance instead, because their
233
+ own posts are the point of the query rather than one voice among many.
234
+
57
235
  Candidates are assumed to already be sorted by quality (rrf_score etc.),
58
236
  so the first N encountered per author are the best ones.
59
237
  """
238
+ first_party = {
239
+ h.strip().lstrip("@").lower()
240
+ for h in (first_party_handles or ())
241
+ if h and h.strip()
242
+ }
60
243
  author_counts: dict[str, int] = {}
61
244
  result: list[schema.Candidate] = []
62
245
  for c in candidates:
@@ -64,22 +247,56 @@ def _apply_per_author_cap(
64
247
  if author is None:
65
248
  result.append(c)
66
249
  continue
250
+ limit = (
251
+ max_per_first_party_author
252
+ if author.strip().lstrip("@").lower() in first_party
253
+ else max_per_author
254
+ )
67
255
  count = author_counts.get(author, 0)
68
- if count < max_per_author:
256
+ if count < limit:
69
257
  result.append(c)
70
258
  author_counts[author] = count + 1
71
259
  return result
72
260
 
73
261
 
262
+ def _reddit_engagement_reservation(
263
+ fused: list[schema.Candidate],
264
+ reserve: int,
265
+ entity: str,
266
+ ) -> list[schema.Candidate]:
267
+ """The *reserve* highest-engagement Reddit candidates that are in-window
268
+ and on-topic, in engagement order."""
269
+ if reserve <= 0:
270
+ return []
271
+ floor = relevance_floor_for_entity(entity)
272
+ eligible = []
273
+ for c in fused:
274
+ if c.source != "reddit" or schema.candidate_out_of_window(c):
275
+ continue
276
+ reddit_items = [it for it in c.source_items if it.source == "reddit"]
277
+ if not reddit_items:
278
+ continue
279
+ best = max(reddit_items, key=raw_engagement)
280
+ relevance = max(c.local_relevance or 0.0, best.local_relevance or 0.0)
281
+ if not reddit_thread_qualifies(best, entity, floor, relevance=relevance):
282
+ continue
283
+ eligible.append((raw_engagement(best), c))
284
+ eligible.sort(key=lambda pair: -pair[0])
285
+ return [c for _, c in eligible[:reserve]]
286
+
287
+
74
288
  def _diversify_pool(
75
289
  fused: list[schema.Candidate],
76
290
  pool_limit: int,
77
291
  min_per_source: int = 2,
292
+ entity: str = "",
78
293
  ) -> list[schema.Candidate]:
79
294
  """Ensure at least *min_per_source* items per qualifying source survive truncation.
80
295
 
81
296
  Sources only qualify for reserved slots if their best item exceeds
82
297
  the relevance threshold. Low-relevance sources compete on merit only.
298
+ Reddit additionally gets an engagement reservation (see
299
+ ``_reddit_reserve_for``) for its most-discussed on-topic threads.
83
300
  """
84
301
  max_relevance: dict[str, float] = {}
85
302
  for c in fused:
@@ -87,16 +304,22 @@ def _diversify_pool(
87
304
  if c.local_relevance > current:
88
305
  max_relevance[c.source] = c.local_relevance
89
306
 
307
+ protected = _reddit_engagement_reservation(fused, _reddit_reserve_for(pool_limit), entity)
308
+ protected_ids = {c.candidate_id for c in protected}
309
+ pool: list[schema.Candidate] = list(protected)
310
+ seen = set(protected_ids)
90
311
  reserved: dict[str, list[schema.Candidate]] = {}
91
312
  remainder: list[schema.Candidate] = []
92
313
  for c in fused:
314
+ if c.candidate_id in seen:
315
+ continue
93
316
  qualifies = max_relevance.get(c.source, 0.0) >= _DIVERSITY_RELEVANCE_THRESHOLD
94
317
  bucket = reserved.setdefault(c.source, [])
95
318
  if qualifies and len(bucket) < min_per_source:
96
319
  bucket.append(c)
97
320
  else:
98
321
  remainder.append(c)
99
- pool = [c for per_source in reserved.values() for c in per_source]
322
+ pool.extend(c for per_source in reserved.values() for c in per_source)
100
323
  seen = {c.candidate_id for c in pool}
101
324
  for c in remainder:
102
325
  if len(pool) >= pool_limit:
@@ -104,20 +327,69 @@ def _diversify_pool(
104
327
  if c.candidate_id not in seen:
105
328
  pool.append(c)
106
329
  pool.sort(key=_candidate_sort_key)
330
+ if len(pool) > pool_limit:
331
+ # The per-source buckets can overfill a small pool. The Reddit
332
+ # reservation is low-RRF by construction, so a plain slice would cut
333
+ # exactly the threads it exists to keep: trim unprotected candidates
334
+ # from the sorted tail instead.
335
+ keep_unprotected = pool_limit - len(protected_ids)
336
+ trimmed: list[schema.Candidate] = []
337
+ for c in pool:
338
+ if c.candidate_id in protected_ids:
339
+ trimmed.append(c)
340
+ elif keep_unprotected > 0:
341
+ trimmed.append(c)
342
+ keep_unprotected -= 1
343
+ pool = trimmed
107
344
  return pool[:pool_limit]
108
345
 
109
346
 
347
+ def _pin_snippet_to_primary_submission(candidate: schema.Candidate) -> None:
348
+ """Keep a card's Evidence on the item whose metrics it shows.
349
+
350
+ The card renders :func:`schema.candidate_primary_item`'s metrics beside
351
+ ``candidate.snippet``. When the candidate also holds a distinct
352
+ same-source sibling (two HN submissions of one article), the longest- or
353
+ best-snippet rule above can pick the sibling's text, so one story's
354
+ numbers would sit beside another story's words. Other candidates keep
355
+ their snippet unchanged.
356
+ """
357
+ primary = schema.candidate_primary_item(candidate)
358
+ if primary is None:
359
+ return
360
+ primary_key = source_item_key(primary)
361
+ if any(
362
+ item.source == primary.source and source_item_key(item) != primary_key
363
+ for item in candidate.source_items
364
+ ):
365
+ candidate.snippet = primary.snippet or ""
366
+
367
+
110
368
  def weighted_rrf(
111
369
  streams: dict[tuple[str, str], list[schema.SourceItem]],
112
370
  plan: schema.QueryPlan,
113
371
  *,
114
372
  pool_limit: int,
373
+ range_from: str | None = None,
374
+ range_to: str | None = None,
375
+ first_party_handles: Iterable[str] | None = None,
115
376
  ) -> list[schema.Candidate]:
116
- """Fuse ranked lists into a single candidate pool."""
377
+ """Fuse ranked lists into a single candidate pool.
378
+
379
+ When ``range_from`` and ``range_to`` are provided, they are stored in each
380
+ candidate's metadata so ``candidate_out_of_window`` can compare the actual
381
+ date against the run window (instead of relying solely on adapter-provided
382
+ ``date_confidence``). ``first_party_handles`` raises the per-author cap
383
+ for the topic's subject so their own posts are not flattened to the
384
+ incidental-account allowance.
385
+ """
117
386
  subqueries = {subquery.label: subquery for subquery in plan.subqueries}
118
387
  candidates: dict[str, schema.Candidate] = {}
119
- # Track (source, item_id) pairs already attached to each candidate for O(1) dedup.
120
- seen_source_items: dict[str, set[tuple[str, str]]] = {}
388
+ # Track source items already attached to each candidate, keyed by
389
+ # source_item_key (source + normalized URL, or the HN discussion URL):
390
+ # per-stream ids collide across subqueries, and
391
+ # a repeat copy may carry enrichment the first one lacks.
392
+ seen_source_items: dict[str, dict[tuple[str, str], schema.SourceItem]] = {}
121
393
 
122
394
  for (label, source), items in streams.items():
123
395
  subquery = subqueries[label]
@@ -129,6 +401,20 @@ def weighted_rrf(
129
401
  item_freshness = item.freshness if item.freshness is not None else int(item.metadata.get("freshness", 0))
130
402
  item_source_quality = item.source_quality if item.source_quality is not None else float(item.metadata.get("source_quality", 0.6))
131
403
  if key not in candidates:
404
+ candidate_metadata: dict = {
405
+ "provenance": [
406
+ {
407
+ "source": source,
408
+ "subquery_label": label,
409
+ "native_rank": rank,
410
+ "item_id": item.item_id,
411
+ }
412
+ ]
413
+ }
414
+ if range_from:
415
+ candidate_metadata["range_from"] = range_from
416
+ if range_to:
417
+ candidate_metadata["range_to"] = range_to
132
418
  candidates[key] = schema.Candidate(
133
419
  candidate_id=key,
134
420
  item_id=item.item_id,
@@ -145,18 +431,9 @@ def weighted_rrf(
145
431
  rrf_score=score,
146
432
  sources=[item.source],
147
433
  source_items=[item],
148
- metadata={
149
- "provenance": [
150
- {
151
- "source": source,
152
- "subquery_label": label,
153
- "native_rank": rank,
154
- "item_id": item.item_id,
155
- }
156
- ]
157
- },
434
+ metadata=candidate_metadata,
158
435
  )
159
- seen_source_items[key] = {(item.source, item.item_id)}
436
+ seen_source_items[key] = {source_item_key(item): item}
160
437
  continue
161
438
 
162
439
  candidate = candidates[key]
@@ -182,10 +459,13 @@ def weighted_rrf(
182
459
  candidate.subquery_labels.append(label)
183
460
  if item.source not in candidate.sources:
184
461
  candidate.sources.append(item.source)
185
- source_item_key = (item.source, item.item_id)
186
- if source_item_key not in seen_source_items[key]:
187
- seen_source_items[key].add(source_item_key)
462
+ item_key = source_item_key(item)
463
+ existing_item = seen_source_items[key].get(item_key)
464
+ if existing_item is None:
465
+ seen_source_items[key][item_key] = item
188
466
  candidate.source_items.append(item)
467
+ else:
468
+ merge_source_items(existing_item, item)
189
469
  candidate.metadata.setdefault("provenance", []).append(
190
470
  {
191
471
  "source": source,
@@ -202,6 +482,12 @@ def weighted_rrf(
202
482
  if len(candidate.snippet.split()) < len(item.snippet.split()):
203
483
  candidate.snippet = item.snippet
204
484
 
485
+ for candidate in candidates.values():
486
+ _pin_snippet_to_primary_submission(candidate)
487
+
205
488
  fused = sorted(candidates.values(), key=_candidate_sort_key)
206
- fused = _apply_per_author_cap(fused)
207
- return _diversify_pool(fused, pool_limit)
489
+ fused = _apply_per_author_cap(fused, first_party_handles=first_party_handles)
490
+ from . import rerank
491
+
492
+ entity = rerank._primary_entity(plan.raw_topic or "") if plan.raw_topic else ""
493
+ return _diversify_pool(fused, pool_limit, entity=entity)