luckiest-co 1.0.47 → 1.0.49
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/ab-testing/CHANGELOG.md +5 -0
- package/skills/ab-testing/SKILL.md +12 -9
- package/skills/ab-testing/evals/evals.json +18 -7
- package/skills/ab-testing/references/sample-size-guide.md +2 -2
- package/skills/ads/CHANGELOG.md +5 -0
- package/skills/ads/SKILL.md +4 -4
- package/skills/ai-seo/CHANGELOG.md +7 -0
- package/skills/ai-seo/SKILL.md +27 -20
- package/skills/analytics/CHANGELOG.md +6 -0
- package/skills/analytics/SKILL.md +4 -3
- package/skills/analytics/evals/evals.json +27 -0
- package/skills/analytics/references/ga4-implementation.md +7 -3
- package/skills/analytics/references/gtm-implementation.md +8 -2
- package/skills/aso/CHANGELOG.md +5 -0
- package/skills/aso/SKILL.md +4 -4
- package/skills/aso/evals/evals.json +15 -2
- package/skills/aso/references/benchmarks.md +4 -2
- package/skills/aso/references/google-play-specs.md +5 -1
- package/skills/brainstorm/SKILL.md +59 -16
- package/skills/brainstorm/builder-check-prompt.md +23 -0
- package/skills/churn-prevention/CHANGELOG.md +5 -0
- package/skills/churn-prevention/SKILL.md +3 -3
- package/skills/churn-prevention/evals/evals.json +14 -0
- package/skills/churn-prevention/references/dunning-playbook.md +6 -1
- package/skills/coder-brainstorming/CHANGELOG.md +10 -0
- package/skills/coder-brainstorming/SKILL.md +62 -20
- package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
- package/skills/coder-brainstorming/scripts/server.cjs +1 -1
- package/skills/coder-debugging/CHANGELOG.md +4 -0
- package/skills/coder-debugging/SKILL.md +5 -5
- package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
- package/skills/coder-requesting-code-review/SKILL.md +4 -4
- package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
- package/skills/coder-subagent-driven-development/SKILL.md +3 -3
- package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
- package/skills/cold-email/CHANGELOG.md +7 -0
- package/skills/cold-email/SKILL.md +27 -7
- package/skills/cold-email/evals/evals.json +30 -0
- package/skills/cold-email/references/benchmarks.md +15 -11
- package/skills/cold-email/references/deliverability.md +110 -0
- package/skills/cold-email/references/linkedin-outreach.md +91 -0
- package/skills/cold-email/references/multichannel-cadence.md +79 -0
- package/skills/cold-email/references/outbound-plays.md +88 -0
- package/skills/cold-email/references/personalization.md +11 -0
- package/skills/cold-email/references/reply-handling.md +65 -0
- package/skills/competitor-profiling/CHANGELOG.md +5 -0
- package/skills/competitor-profiling/SKILL.md +4 -4
- package/skills/content-strategy/CHANGELOG.md +5 -0
- package/skills/content-strategy/SKILL.md +5 -3
- package/skills/copy-editing/CHANGELOG.md +5 -0
- package/skills/copy-editing/SKILL.md +5 -5
- package/skills/copy-editing/references/content-refresh.md +33 -0
- package/skills/cro/CHANGELOG.md +5 -0
- package/skills/cro/SKILL.md +14 -3
- package/skills/cro/references/form.md +1 -1
- package/skills/directory-submissions/CHANGELOG.md +5 -0
- package/skills/directory-submissions/SKILL.md +13 -4
- package/skills/directory-submissions/evals/evals.json +15 -1
- package/skills/directory-submissions/references/directory-list.md +2 -4
- package/skills/emails/CHANGELOG.md +5 -0
- package/skills/emails/SKILL.md +14 -4
- package/skills/launch/CHANGELOG.md +7 -0
- package/skills/launch/SKILL.md +8 -4
- package/skills/launch/evals/evals.json +29 -0
- package/skills/launch/references/shipped-changes.md +84 -0
- package/skills/launch/references/site-launch-qa.md +55 -0
- package/skills/lead-magnets/CHANGELOG.md +5 -0
- package/skills/lead-magnets/SKILL.md +6 -4
- package/skills/lead-magnets/evals/evals.json +18 -0
- package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
- package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
- package/skills/lead-magnets/references/content-to-skill.md +108 -0
- package/skills/marketing-plan/CHANGELOG.md +5 -0
- package/skills/marketing-plan/SKILL.md +3 -3
- package/skills/marketing-plan/evals/evals.json +13 -0
- package/skills/marketing-plan/references/budget-planning.md +26 -17
- package/skills/model-router/CHANGELOG.md +6 -0
- package/skills/model-router/SKILL.md +3 -3
- package/skills/model-router/scripts/route.py +1 -1
- package/skills/model-router/scripts/test_route.py +5 -0
- package/skills/pricing/CHANGELOG.md +5 -0
- package/skills/pricing/SKILL.md +4 -4
- package/skills/pricing/evals/evals.json +12 -0
- package/skills/pricing/references/research-methods.md +8 -4
- package/skills/programmatic-seo/CHANGELOG.md +5 -0
- package/skills/programmatic-seo/SKILL.md +8 -4
- package/skills/programmatic-seo/evals/evals.json +13 -0
- package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
- package/skills/prospecting/CHANGELOG.md +7 -0
- package/skills/prospecting/SKILL.md +27 -13
- package/skills/prospecting/evals/evals.json +41 -0
- package/skills/prospecting/references/account-research.md +59 -0
- package/skills/prospecting/references/b2b-prospecting.md +1 -1
- package/skills/prospecting/references/compliance.md +44 -6
- package/skills/prospecting/references/data-sources.md +13 -15
- package/skills/prospecting/references/enrichment-playbook.md +61 -0
- package/skills/prospecting/references/saas-prospecting.md +1 -1
- package/skills/prospecting/references/signal-plays.md +99 -0
- package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
- package/skills/referrals/CHANGELOG.md +5 -0
- package/skills/referrals/SKILL.md +10 -4
- package/skills/referrals/evals/evals.json +99 -0
- package/skills/referrals/references/affiliate-operations.md +64 -0
- package/skills/referrals/references/affiliate-programs.md +2 -2
- package/skills/referrals/references/program-examples.md +14 -6
- package/skills/referrals/references/review-platform-rules.md +36 -0
- package/skills/referrals/references/review-program-operations.md +62 -0
- package/skills/referrals/references/review-programs.md +96 -0
- package/skills/revops/CHANGELOG.md +5 -0
- package/skills/revops/SKILL.md +10 -6
- package/skills/revops/evals/evals.json +13 -0
- package/skills/revops/references/automation-playbooks.md +1 -1
- package/skills/revops/references/inbound-routing.md +43 -0
- package/skills/revops/references/lifecycle-definitions.md +31 -0
- package/skills/sales-enablement/CHANGELOG.md +5 -0
- package/skills/sales-enablement/SKILL.md +6 -4
- package/skills/sales-enablement/evals/evals.json +14 -0
- package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
- package/skills/schema/CHANGELOG.md +5 -0
- package/skills/schema/SKILL.md +20 -8
- package/skills/schema/evals/evals.json +40 -0
- package/skills/schema/references/schema-examples.md +9 -15
- package/skills/seo-audit/CHANGELOG.md +5 -0
- package/skills/seo-audit/SKILL.md +10 -16
- package/skills/seo-audit/evals/evals.json +68 -0
- package/skills/seo-audit/references/international-seo.md +9 -11
- package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
- package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
- package/skills/seo-audit/references/local-seo.md +86 -0
- package/skills/seo-audit/references/rankings-push.md +62 -0
- package/skills/seo-audit/references/title-tags.md +89 -0
- package/skills/signup/CHANGELOG.md +5 -0
- package/skills/signup/SKILL.md +7 -3
- package/skills/site-architecture/CHANGELOG.md +5 -0
- package/skills/site-architecture/SKILL.md +5 -4
- package/skills/site-architecture/evals/evals.json +25 -0
- package/skills/site-architecture/references/platforms-and-migration.md +84 -0
- package/skills/social/CHANGELOG.md +5 -0
- package/skills/social/SKILL.md +4 -4
- package/skills/trends/.skillignore +3 -0
- package/skills/trends/CHANGELOG.md +20 -0
- package/skills/trends/SKILL.md +12 -8
- package/skills/trends/references/agent-mode.md +36 -0
- package/skills/trends/references/category-peers.md +38 -0
- package/skills/trends/references/comparison.md +227 -0
- package/skills/trends/references/competitors.md +29 -0
- package/skills/trends/references/discovery.md +90 -0
- package/skills/trends/references/engine-contract.md +111 -1889
- package/skills/trends/references/followups.md +112 -0
- package/skills/trends/references/grok-bot-x.md +55 -0
- package/skills/trends/references/hiring-signals.md +19 -0
- package/skills/trends/references/library-queue.md +33 -0
- package/skills/trends/references/recommendations.md +86 -0
- package/skills/trends/references/research-runbook.md +781 -0
- package/skills/trends/references/runtime.md +165 -0
- package/skills/trends/references/save-html-brief.md +39 -20
- package/skills/trends/references/setup-wizard.md +253 -0
- package/skills/trends/references/synthesis.md +289 -0
- package/skills/trends/scripts/box_chrome_login.py +209 -0
- package/skills/trends/scripts/briefing.py +9 -17
- package/skills/trends/scripts/last30days.py +3247 -280
- package/skills/trends/scripts/lib/agentcookie.py +196 -0
- package/skills/trends/scripts/lib/amazon.py +877 -0
- package/skills/trends/scripts/lib/arxiv.py +42 -8
- package/skills/trends/scripts/lib/backends.py +769 -0
- package/skills/trends/scripts/lib/bird_x.py +340 -57
- package/skills/trends/scripts/lib/bluesky.py +84 -10
- package/skills/trends/scripts/lib/bounded_get.py +156 -0
- package/skills/trends/scripts/lib/brightdata.py +291 -0
- package/skills/trends/scripts/lib/categories.py +7 -1
- package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
- package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
- package/skills/trends/scripts/lib/cluster.py +31 -54
- package/skills/trends/scripts/lib/competitors.py +10 -1
- package/skills/trends/scripts/lib/cookie_extract.py +221 -44
- package/skills/trends/scripts/lib/cookie_paths.py +27 -0
- package/skills/trends/scripts/lib/corpus.py +439 -0
- package/skills/trends/scripts/lib/devto.py +313 -0
- package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
- package/skills/trends/scripts/lib/doctor.py +2166 -0
- package/skills/trends/scripts/lib/dripstack.py +203 -0
- package/skills/trends/scripts/lib/entity_extract.py +37 -0
- package/skills/trends/scripts/lib/env.py +1092 -138
- package/skills/trends/scripts/lib/fanout.py +11 -1
- package/skills/trends/scripts/lib/feed.py +61 -0
- package/skills/trends/scripts/lib/fetch_memo.py +135 -0
- package/skills/trends/scripts/lib/freshness.py +566 -0
- package/skills/trends/scripts/lib/fusion.py +314 -28
- package/skills/trends/scripts/lib/github.py +438 -24
- package/skills/trends/scripts/lib/grok_x.py +1237 -0
- package/skills/trends/scripts/lib/grounding.py +129 -11
- package/skills/trends/scripts/lib/hackernews.py +61 -4
- package/skills/trends/scripts/lib/health.py +373 -0
- package/skills/trends/scripts/lib/hosted.py +333 -0
- package/skills/trends/scripts/lib/html_publish.py +34 -0
- package/skills/trends/scripts/lib/html_render.py +169 -3
- package/skills/trends/scripts/lib/http.py +1215 -70
- package/skills/trends/scripts/lib/instagram.py +132 -9
- package/skills/trends/scripts/lib/jobs.py +4 -2
- package/skills/trends/scripts/lib/library.py +308 -0
- package/skills/trends/scripts/lib/library_index.py +477 -0
- package/skills/trends/scripts/lib/linkedin.py +271 -24
- package/skills/trends/scripts/lib/log.py +5 -2
- package/skills/trends/scripts/lib/meta_ads.py +982 -0
- package/skills/trends/scripts/lib/normalize.py +382 -28
- package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
- package/skills/trends/scripts/lib/permission_preflight.py +42 -3
- package/skills/trends/scripts/lib/perplexity.py +1080 -468
- package/skills/trends/scripts/lib/pipeline.py +5079 -211
- package/skills/trends/scripts/lib/planner.py +396 -53
- package/skills/trends/scripts/lib/polymarket.py +285 -23
- package/skills/trends/scripts/lib/prescriptions.py +432 -0
- package/skills/trends/scripts/lib/private_files.py +21 -0
- package/skills/trends/scripts/lib/providers.py +231 -10
- package/skills/trends/scripts/lib/quality_nudge.py +122 -37
- package/skills/trends/scripts/lib/reddit.py +177 -12
- package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
- package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
- package/skills/trends/scripts/lib/reddit_listing.py +215 -14
- package/skills/trends/scripts/lib/reddit_public.py +12 -13
- package/skills/trends/scripts/lib/reddit_search.py +304 -0
- package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
- package/skills/trends/scripts/lib/registers.py +134 -0
- package/skills/trends/scripts/lib/relevance.py +17 -0
- package/skills/trends/scripts/lib/render.py +2161 -271
- package/skills/trends/scripts/lib/rerank.py +623 -55
- package/skills/trends/scripts/lib/resolve.py +50 -1
- package/skills/trends/scripts/lib/safari_cookies.py +42 -15
- package/skills/trends/scripts/lib/schema.py +782 -1
- package/skills/trends/scripts/lib/setup_wizard.py +779 -120
- package/skills/trends/scripts/lib/signals.py +206 -7
- package/skills/trends/scripts/lib/stocktwits.py +374 -0
- package/skills/trends/scripts/lib/subproc.py +307 -53
- package/skills/trends/scripts/lib/techmeme.py +89 -64
- package/skills/trends/scripts/lib/telegram.py +317 -0
- package/skills/trends/scripts/lib/tiktok.py +13 -9
- package/skills/trends/scripts/lib/topic_shape.py +340 -0
- package/skills/trends/scripts/lib/transcribe.py +12 -3
- package/skills/trends/scripts/lib/trustpilot.py +256 -5
- package/skills/trends/scripts/lib/truthsocial.py +9 -1
- package/skills/trends/scripts/lib/ui.py +6 -0
- package/skills/trends/scripts/lib/usage.py +132 -0
- package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
- package/skills/trends/scripts/lib/windows_job.py +138 -0
- package/skills/trends/scripts/lib/x_api.py +824 -0
- package/skills/trends/scripts/lib/x_envelope.py +733 -0
- package/skills/trends/scripts/lib/x_judge.py +292 -0
- package/skills/trends/scripts/lib/xai_x.py +26 -3
- package/skills/trends/scripts/lib/xquik.py +89 -15
- package/skills/trends/scripts/lib/xurl_x.py +284 -91
- package/skills/trends/scripts/lib/youtube_yt.py +570 -119
- package/skills/trends/scripts/setup-keychain.sh +12 -4
- package/skills/trends/scripts/setup-pass.sh +3 -0
- package/skills/trends/scripts/store.py +444 -50
- package/skills/trends/scripts/watchlist.py +38 -11
- package/skills/trends/scripts/x_lane_parity.py +107 -0
- package/skills/video-studio/CHANGELOG.md +4 -0
- package/skills/video-studio/SKILL.md +4 -4
- package/skills/video-studio/references/styles/product-flight.md +45 -0
- package/skills/video-studio/references/styles.md +3 -1
- package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
- package/skills/video-studio-motion/CHANGELOG.md +4 -0
- package/skills/video-studio-motion/SKILL.md +8 -5
- package/skills/youtube-planner/ATTRIBUTION.md +25 -0
- package/skills/youtube-planner/CHANGELOG.md +12 -0
- package/skills/youtube-planner/SKILL.md +191 -0
- package/skills/youtube-planner/references/plan-template.md +57 -0
- package/skills/youtube-planner/references/stages.md +119 -0
- package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
- package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
- 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
|
-
|
|
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()
|
|
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
|
-
|
|
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 <
|
|
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
|
|
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
|
|
120
|
-
|
|
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
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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)
|