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
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import math
|
|
6
7
|
import re
|
|
8
|
+
from datetime import datetime
|
|
7
9
|
|
|
8
|
-
from . import http, providers,
|
|
10
|
+
from . import http, providers, relevance, schema, signals
|
|
9
11
|
|
|
10
12
|
|
|
11
13
|
# Penalty applied when a candidate does not mention the primary entity
|
|
@@ -17,6 +19,18 @@ from . import http, providers, query, schema, signals
|
|
|
17
19
|
# Hermes content.
|
|
18
20
|
ENTITY_MISS_PENALTY = 25.0
|
|
19
21
|
|
|
22
|
+
# A fallback entity miss is hidden from synthesized evidence only when it also
|
|
23
|
+
# lacks every stable raw-topic anchor. Explicitly scoped sources such as GitHub
|
|
24
|
+
# project mode carry a high local-relevance floor and therefore escape this
|
|
25
|
+
# visibility gate even when their short title omits the user's wording.
|
|
26
|
+
FALLBACK_ENTITY_MISS_CONFIDENCE_ESCAPE = 0.5
|
|
27
|
+
FALLBACK_ENTITY_MISS_TOPIC_ESCAPE = 0.25
|
|
28
|
+
_FALLBACK_ENTITY_MISS_EXPLANATION = "fallback-local-score (entity-miss demotion)"
|
|
29
|
+
# Explanation stamped on a first-party post whose entity-miss marker was
|
|
30
|
+
# cleared by _apply_first_party_floor. Carries no "entity-miss" substring, so
|
|
31
|
+
# every downstream relevance gate treats the post as grounded.
|
|
32
|
+
_FIRST_PARTY_EXPLANATION = "first-party post (authored by a resolved handle)"
|
|
33
|
+
|
|
20
34
|
# Small additive credit for a post authored by one of the run's resolved
|
|
21
35
|
# handles (see rerank_candidates / _fallback_tuple). Deliberately small: the
|
|
22
36
|
# goal is to stop *burying* first-party posts, not to auto-win the ranking on
|
|
@@ -25,6 +39,136 @@ ENTITY_MISS_PENALTY = 25.0
|
|
|
25
39
|
# neutral floor so it survives into the visible band.
|
|
26
40
|
FIRST_PARTY_AUTHOR_CREDIT = 5.0
|
|
27
41
|
|
|
42
|
+
_DISCOVERY_ENGAGEMENT_FIELDS = {
|
|
43
|
+
"reddit": ("score", "num_comments"),
|
|
44
|
+
"hackernews": ("points", "comments"),
|
|
45
|
+
"devto": ("reactions", "comments"),
|
|
46
|
+
"digg": ("postCount", "uniqueAuthors"),
|
|
47
|
+
"x": ("likes", "reposts", "replies", "quotes"),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def discovery_engagement_total(item: schema.SourceItem) -> float:
|
|
52
|
+
"""Return comparable native interaction counts for discovery evidence."""
|
|
53
|
+
fields = _DISCOVERY_ENGAGEMENT_FIELDS.get(item.source)
|
|
54
|
+
if fields is None:
|
|
55
|
+
fields = tuple(
|
|
56
|
+
field
|
|
57
|
+
for field in item.engagement
|
|
58
|
+
if field.lower() not in {"rank", "rank_score", "upvote_ratio", "rating"}
|
|
59
|
+
)
|
|
60
|
+
return sum(
|
|
61
|
+
float(item.engagement.get(field) or 0)
|
|
62
|
+
for field in fields
|
|
63
|
+
if isinstance(item.engagement.get(field), (int, float))
|
|
64
|
+
and not isinstance(item.engagement.get(field), bool)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def engagement_velocity_score(
|
|
69
|
+
item: schema.SourceItem,
|
|
70
|
+
*,
|
|
71
|
+
as_of_date: str,
|
|
72
|
+
) -> float:
|
|
73
|
+
"""Weight native engagement by age, with an explicit first-week boost."""
|
|
74
|
+
engagement = discovery_engagement_total(item)
|
|
75
|
+
if engagement <= 0:
|
|
76
|
+
return 0.0
|
|
77
|
+
try:
|
|
78
|
+
published = datetime.fromisoformat((item.published_at or "").replace("Z", "+00:00")).date()
|
|
79
|
+
as_of = datetime.fromisoformat(as_of_date.replace("Z", "+00:00")).date()
|
|
80
|
+
age_days = max(0, (as_of - published).days)
|
|
81
|
+
except (TypeError, ValueError):
|
|
82
|
+
age_days = 30
|
|
83
|
+
recency_weight = 1.0 / math.sqrt(age_days + 1)
|
|
84
|
+
if age_days < 7:
|
|
85
|
+
recency_weight *= 1.5
|
|
86
|
+
return round(engagement * recency_weight, 4)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def discovery_velocity_score(
|
|
90
|
+
items: list[schema.SourceItem],
|
|
91
|
+
*,
|
|
92
|
+
as_of_date: str,
|
|
93
|
+
) -> float:
|
|
94
|
+
"""Score a topic cluster and reward independent cross-source confirmation."""
|
|
95
|
+
raw = sum(engagement_velocity_score(item, as_of_date=as_of_date) for item in items)
|
|
96
|
+
source_count = len({item.source for item in items})
|
|
97
|
+
corroboration = 1.0 + (0.15 * max(0, source_count - 1))
|
|
98
|
+
return round(raw * corroboration, 4)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# Discovery confidence floor. The named 2026-07-12 failure mode: quiet feeds
|
|
102
|
+
# left the sweep ranking noise against noise, and it dutifully emitted five
|
|
103
|
+
# 1-like tweets as a "trend list". The floor makes "nothing solid this window"
|
|
104
|
+
# a first-class outcome instead. Constants are deliberately tunable:
|
|
105
|
+
# - FLOOR_MIN_ENGAGEMENT kills absolute junk (a 1-like tweet can never rank).
|
|
106
|
+
# - A topic then clears via EITHER independent cross-source confirmation
|
|
107
|
+
# (>= FLOOR_MIN_SOURCES) OR a genuinely strong single-source spike
|
|
108
|
+
# (>= FLOOR_SINGLE_SOURCE_ENGAGEMENT) - a 1,600-point single-source HN
|
|
109
|
+
# thread is a real story, a 30-upvote single-source meme is not.
|
|
110
|
+
# - Junk-shaped topics (help-me/beginner/musing shapes flagged by the stage-1
|
|
111
|
+
# judge or the topic_shape heuristics) get a stricter read: the
|
|
112
|
+
# single-source engagement bypass is OFF (a 226-comment "help me choose"
|
|
113
|
+
# thread is a busy support thread, not a story), and their
|
|
114
|
+
# FLOOR_MIN_SOURCES corroboration is counted against SEED listing sources
|
|
115
|
+
# when the caller provides that count - a successful enrichment pass pulls
|
|
116
|
+
# a multi-source corpus for almost any topic, so an enriched-count check
|
|
117
|
+
# would never bind.
|
|
118
|
+
FLOOR_MIN_ENGAGEMENT = 25.0
|
|
119
|
+
FLOOR_MIN_SOURCES = 2
|
|
120
|
+
FLOOR_SINGLE_SOURCE_ENGAGEMENT = 200.0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def passes_discovery_floor(
|
|
124
|
+
*,
|
|
125
|
+
source_count: int,
|
|
126
|
+
engagement_total: float,
|
|
127
|
+
item_count: int,
|
|
128
|
+
junk_shape: bool = False,
|
|
129
|
+
seed_source_count: int | None = None,
|
|
130
|
+
) -> bool:
|
|
131
|
+
"""Whether a discovery topic's evidence is strong enough to show a user.
|
|
132
|
+
|
|
133
|
+
Below this floor the honest output is "nothing solid this window", not a
|
|
134
|
+
ranked list of whatever survived the sweep.
|
|
135
|
+
|
|
136
|
+
``junk_shape=True`` removes the single-source engagement bypass and
|
|
137
|
+
evaluates the corroboration requirement against ``seed_source_count``
|
|
138
|
+
(distinct SEED listing sources) when provided, falling back to
|
|
139
|
+
``source_count`` otherwise. Non-junk topics are unaffected by both
|
|
140
|
+
parameters.
|
|
141
|
+
"""
|
|
142
|
+
if item_count <= 0 or engagement_total < FLOOR_MIN_ENGAGEMENT:
|
|
143
|
+
return False
|
|
144
|
+
if junk_shape:
|
|
145
|
+
corroboration = seed_source_count if seed_source_count is not None else source_count
|
|
146
|
+
return corroboration >= FLOOR_MIN_SOURCES
|
|
147
|
+
if source_count >= FLOOR_MIN_SOURCES:
|
|
148
|
+
return True
|
|
149
|
+
return engagement_total >= FLOOR_SINGLE_SOURCE_ENGAGEMENT
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Stage-1 discovery judge (nominate stage). The top JUDGE_POOL_LIMIT clusters
|
|
153
|
+
# by velocity get ONE batched LLM verdict each (short searchable name, junk
|
|
154
|
+
# flag, 0-100 content-worthiness); clusters beyond the pool keep heuristic
|
|
155
|
+
# names and their velocity-only score. Worthiness blends into the ranking
|
|
156
|
+
# score as
|
|
157
|
+
# blended = velocity * (JUDGE_BLEND_BASE + worthiness / 100)
|
|
158
|
+
# so velocity stays dominant (the multiplier spans 0.5x-1.5x) but a quiet,
|
|
159
|
+
# highly content-worthy cluster can overtake a viral junk one. A missing
|
|
160
|
+
# worthiness (heuristic fallback, judge skipped a row) is neutral at 50 -
|
|
161
|
+
# the multiplier is exactly 1.0, i.e. the plain velocity score.
|
|
162
|
+
JUDGE_POOL_LIMIT = 15
|
|
163
|
+
JUDGE_BLEND_BASE = 0.5
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def judge_blended_score(velocity: float, worthiness: float | None) -> float:
|
|
167
|
+
"""Velocity-dominant, worthiness-weighted ranking score (constants above)."""
|
|
168
|
+
effective = 50.0 if worthiness is None else max(0.0, min(100.0, worthiness))
|
|
169
|
+
return velocity * (JUDGE_BLEND_BASE + effective / 100.0)
|
|
170
|
+
|
|
171
|
+
|
|
28
172
|
# Engagement rescue: a high-engagement X post that is on-topic (entity-grounded
|
|
29
173
|
# or first-party) cannot be fully zeroed by the other penalties. The floor is a
|
|
30
174
|
# function of the post's engagement percentile *within the run's X pool* (so it
|
|
@@ -52,21 +196,24 @@ INTERACTION_FLOOR = 35.0
|
|
|
52
196
|
# not a win.
|
|
53
197
|
FIRST_PARTY_FLOOR = 25.0
|
|
54
198
|
|
|
55
|
-
#
|
|
56
|
-
#
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
_INTENT_MODIFIER_RE = re.compile(
|
|
199
|
+
# Only strip trailing intent modifiers. A word such as "review" may instead be
|
|
200
|
+
# the subject of a longer topic ("AI code review bottleneck").
|
|
201
|
+
_TRAILING_INTENT_MODIFIER_RE = re.compile(
|
|
202
|
+
r"(?:\s+(?:and|or|&|,)\s*)?"
|
|
60
203
|
r"\b("
|
|
61
204
|
r"use cases|use case|workflows|workflow|"
|
|
62
205
|
r"examples|example|tutorial|tutorials|"
|
|
63
206
|
r"review|reviews|comparison|applications|"
|
|
64
207
|
r"in practice|production use|production|"
|
|
65
208
|
r"how i use"
|
|
66
|
-
r")\b",
|
|
209
|
+
r")\b[\s?.,:;!]*$",
|
|
67
210
|
re.IGNORECASE,
|
|
68
211
|
)
|
|
69
212
|
|
|
213
|
+
_GENERIC_GROUNDING_MIN_TOKENS = 4
|
|
214
|
+
_GROUNDING_ANCHOR_MIN_LENGTH = 6
|
|
215
|
+
_GROUNDING_LOW_SIGNAL_TOKENS = relevance.LOW_SIGNAL_QUERY_TOKENS | {"still", "work"}
|
|
216
|
+
|
|
70
217
|
INTENT_SCORING_HINTS: dict[str, str] = {
|
|
71
218
|
"comparison": (
|
|
72
219
|
"Prefer items that directly compare, contrast, or benchmark the entities"
|
|
@@ -119,6 +266,7 @@ def rerank_candidates(
|
|
|
119
266
|
model: str | None,
|
|
120
267
|
shortlist_size: int,
|
|
121
268
|
resolved_handles: set[str] | None = None,
|
|
269
|
+
primary_handle: str | None = None,
|
|
122
270
|
) -> list[schema.Candidate]:
|
|
123
271
|
"""Rerank the fused shortlist, demoting candidates the reranker scored as irrelevant.
|
|
124
272
|
|
|
@@ -128,10 +276,15 @@ def rerank_candidates(
|
|
|
128
276
|
exempted from the entity-miss demotion in ``_fallback_tuple`` (a post almost
|
|
129
277
|
never repeats its own author's name, so the body-text grounding check would
|
|
130
278
|
otherwise bury the subject's own highest-signal posts).
|
|
279
|
+
|
|
280
|
+
``primary_handle`` is the run's ``--x-handle``: the one handle in the
|
|
281
|
+
subject role. Only it can be the topic's own account (_subject_handles);
|
|
282
|
+
``--x-related`` and discovered handles are first-party but never subjects.
|
|
131
283
|
"""
|
|
132
284
|
handles = resolved_handles or set()
|
|
133
285
|
shortlisted = candidates[:shortlist_size]
|
|
134
286
|
primary_entity = _primary_entity(topic)
|
|
287
|
+
subject = _subject_handles(primary_handle, topic)
|
|
135
288
|
if provider and model and shortlisted:
|
|
136
289
|
try:
|
|
137
290
|
response = provider.generate_json(
|
|
@@ -141,17 +294,32 @@ def rerank_candidates(
|
|
|
141
294
|
except (ValueError, KeyError, json.JSONDecodeError, OSError, http.HTTPError) as exc:
|
|
142
295
|
import sys
|
|
143
296
|
print(f"[Rerank] LLM reranking failed, using local fallback: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
144
|
-
_apply_fallback_scores(
|
|
297
|
+
_apply_fallback_scores(
|
|
298
|
+
shortlisted, primary_entity=primary_entity, resolved_handles=handles, subject_handles=subject
|
|
299
|
+
)
|
|
145
300
|
else:
|
|
146
|
-
_apply_fallback_scores(
|
|
301
|
+
_apply_fallback_scores(
|
|
302
|
+
shortlisted, primary_entity=primary_entity, resolved_handles=handles, subject_handles=subject
|
|
303
|
+
)
|
|
147
304
|
|
|
148
305
|
if len(candidates) > shortlist_size:
|
|
149
306
|
tail = candidates[shortlist_size:]
|
|
150
|
-
_apply_fallback_scores(
|
|
307
|
+
_apply_fallback_scores(
|
|
308
|
+
tail, primary_entity=primary_entity, resolved_handles=handles, subject_handles=subject
|
|
309
|
+
)
|
|
151
310
|
|
|
152
|
-
_apply_first_party_floor(
|
|
153
|
-
|
|
154
|
-
|
|
311
|
+
_apply_first_party_floor(
|
|
312
|
+
candidates, resolved_handles=handles, primary_entity=primary_entity, topic=topic,
|
|
313
|
+
primary_handle=primary_handle,
|
|
314
|
+
)
|
|
315
|
+
_apply_engagement_rescue(
|
|
316
|
+
candidates, primary_entity=primary_entity, resolved_handles=handles, topic=topic,
|
|
317
|
+
primary_handle=primary_handle,
|
|
318
|
+
)
|
|
319
|
+
_apply_interaction_signal(
|
|
320
|
+
candidates, resolved_handles=handles, primary_entity=primary_entity, topic=topic,
|
|
321
|
+
primary_handle=primary_handle,
|
|
322
|
+
)
|
|
155
323
|
|
|
156
324
|
return sorted(
|
|
157
325
|
candidates,
|
|
@@ -171,12 +339,33 @@ def _intent_hint_block(plan: schema.QueryPlan) -> str:
|
|
|
171
339
|
return ""
|
|
172
340
|
|
|
173
341
|
|
|
342
|
+
_UNTRUSTED_FENCE_TAG = re.compile(r"<\s*/?\s*untrusted_content\s*>", re.IGNORECASE)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _defang_untrusted_fence(value: str) -> str:
|
|
346
|
+
"""Scraped content must not be able to terminate the fence that contains it.
|
|
347
|
+
|
|
348
|
+
A title carrying the literal closing tag would otherwise end the block
|
|
349
|
+
early, leaving the rest of the scraped text outside the fence and
|
|
350
|
+
indistinguishable from engine-authored prompt text.
|
|
351
|
+
|
|
352
|
+
Only the tag form is rewritten. A bare ``untrusted_content`` identifier in
|
|
353
|
+
scraped prose or code is left byte-exact: this is a research tool, and
|
|
354
|
+
altering evidence to defend the fence would corrupt what the judge scores.
|
|
355
|
+
Matched case-insensitively and tolerant of inner whitespace because the
|
|
356
|
+
reader is a model, not an XML parser.
|
|
357
|
+
"""
|
|
358
|
+
return _UNTRUSTED_FENCE_TAG.sub(
|
|
359
|
+
lambda match: match.group(0).replace("_", "-"), value
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
174
363
|
def _fenced_untrusted_content(candidate_block: str) -> str:
|
|
175
364
|
return (
|
|
176
365
|
f"{UNTRUSTED_CONTENT_NOTICE}\n\n"
|
|
177
366
|
"Candidates:\n"
|
|
178
367
|
"<untrusted_content>\n"
|
|
179
|
-
f"{candidate_block}\n"
|
|
368
|
+
f"{_defang_untrusted_fence(candidate_block)}\n"
|
|
180
369
|
"</untrusted_content>"
|
|
181
370
|
)
|
|
182
371
|
|
|
@@ -273,22 +462,36 @@ def _apply_llm_scores(
|
|
|
273
462
|
str(row.get("reason") or "").strip() or None,
|
|
274
463
|
)
|
|
275
464
|
for candidate in candidates:
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
465
|
+
if candidate.candidate_id in scores:
|
|
466
|
+
rerank_score, reason = scores[candidate.candidate_id]
|
|
467
|
+
provenance = RERANK_PROVENANCE_LLM
|
|
468
|
+
else:
|
|
469
|
+
rerank_score, reason = _fallback_tuple(candidate, resolved_handles=handles)
|
|
470
|
+
provenance = RERANK_PROVENANCE_FALLBACK
|
|
279
471
|
candidate.rerank_score = rerank_score
|
|
280
472
|
candidate.explanation = reason
|
|
473
|
+
_set_rerank_provenance(candidate, provenance)
|
|
281
474
|
candidate.final_score = _final_score(candidate)
|
|
282
475
|
|
|
283
476
|
|
|
284
477
|
def _apply_fallback_scores(
|
|
285
|
-
candidates: list[schema.Candidate],
|
|
478
|
+
candidates: list[schema.Candidate],
|
|
479
|
+
*,
|
|
480
|
+
primary_entity: str = "",
|
|
481
|
+
resolved_handles: set[str] | None = None,
|
|
482
|
+
subject_handles: set[str] | None = None,
|
|
286
483
|
) -> None:
|
|
287
484
|
handles = resolved_handles or set()
|
|
288
485
|
for candidate in candidates:
|
|
289
|
-
rerank_score, reason = _fallback_tuple(
|
|
486
|
+
rerank_score, reason = _fallback_tuple(
|
|
487
|
+
candidate,
|
|
488
|
+
primary_entity=primary_entity,
|
|
489
|
+
resolved_handles=handles,
|
|
490
|
+
subject_handles=subject_handles,
|
|
491
|
+
)
|
|
290
492
|
candidate.rerank_score = rerank_score
|
|
291
493
|
candidate.explanation = reason
|
|
494
|
+
_set_rerank_provenance(candidate, RERANK_PROVENANCE_FALLBACK)
|
|
292
495
|
candidate.final_score = _final_score(candidate)
|
|
293
496
|
|
|
294
497
|
|
|
@@ -315,6 +518,112 @@ def _is_first_party(candidate: schema.Candidate, resolved_handles: set[str]) ->
|
|
|
315
518
|
return _candidate_author_handle(candidate) in resolved_handles
|
|
316
519
|
|
|
317
520
|
|
|
521
|
+
def _handle_is_topic(handle: str, topic: str) -> bool:
|
|
522
|
+
"""True when the handle names the topic itself, not someone near it.
|
|
523
|
+
|
|
524
|
+
Matches a handle that contains a topic word ("ghostty" for "Ghostty
|
|
525
|
+
terminal", "karpathy" for "Andrej Karpathy") or is spelled from prefixes
|
|
526
|
+
of distinct topic words ("steipete" = stei+pete for "Peter Steinberger",
|
|
527
|
+
"sama" for "Sam Altman"). A founder or commentator handle on a product
|
|
528
|
+
topic ("mitchellh" for "Ghostty terminal") does not match.
|
|
529
|
+
"""
|
|
530
|
+
letters = re.sub(r"[^a-z]", "", (handle or "").lower())
|
|
531
|
+
if len(letters) < 3:
|
|
532
|
+
return False
|
|
533
|
+
words = [
|
|
534
|
+
word
|
|
535
|
+
for word in re.findall(r"[a-z]+", (topic or "").lower())
|
|
536
|
+
if len(word) >= 3
|
|
537
|
+
and word not in relevance.STOPWORDS
|
|
538
|
+
and word not in relevance.LOW_SIGNAL_QUERY_TOKENS
|
|
539
|
+
]
|
|
540
|
+
if any(len(word) >= 4 and (word in letters or letters in word) for word in words):
|
|
541
|
+
return True
|
|
542
|
+
return _spelled_from_word_prefixes(letters, words, frozenset())
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _spelled_from_word_prefixes(rest: str, words: list[str], used: frozenset[int]) -> bool:
|
|
546
|
+
"""Whether ``rest`` splits into prefixes of distinct ``words``.
|
|
547
|
+
|
|
548
|
+
Every chunk but the last must be at least 3 letters, and a single chunk
|
|
549
|
+
alone never counts (that case is the containment test above).
|
|
550
|
+
"""
|
|
551
|
+
if not rest:
|
|
552
|
+
return len(used) >= 2
|
|
553
|
+
for index, word in enumerate(words):
|
|
554
|
+
if index in used:
|
|
555
|
+
continue
|
|
556
|
+
for size in range(min(len(word), len(rest)), 0, -1):
|
|
557
|
+
if rest[:size] != word[:size]:
|
|
558
|
+
continue
|
|
559
|
+
remaining = rest[size:]
|
|
560
|
+
if size < 3 and remaining:
|
|
561
|
+
continue
|
|
562
|
+
if _spelled_from_word_prefixes(remaining, words, used | {index}):
|
|
563
|
+
return True
|
|
564
|
+
return False
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _subject_handles(primary_handle: str | None, topic: str) -> set[str]:
|
|
568
|
+
"""The topic's own account: the primary handle, when it names the topic.
|
|
569
|
+
|
|
570
|
+
Only ``--x-handle`` holds the subject role. A related handle is someone
|
|
571
|
+
near the topic, so it never qualifies, even when its letters happen to sit
|
|
572
|
+
inside a topic word ("art" in "Smart home"). The primary handle must still
|
|
573
|
+
name the topic (_handle_is_topic), so ``--x-handle mitchellh`` on
|
|
574
|
+
"Ghostty terminal" is a related voice, not the subject. Known false
|
|
575
|
+
negative: an alias handle that shares no letters with the name
|
|
576
|
+
("mrbeast" for "Jimmy Donaldson") is not a subject; its on-topic posts
|
|
577
|
+
still earn the floors through grounding or relevance.
|
|
578
|
+
"""
|
|
579
|
+
handle = (primary_handle or "").lstrip("@").strip().lower()
|
|
580
|
+
if handle and _handle_is_topic(handle, topic):
|
|
581
|
+
return {handle}
|
|
582
|
+
return set()
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _is_repo_snapshot_candidate(candidate: schema.Candidate) -> bool:
|
|
586
|
+
"""True when every source item is a GitHub repo card (signals.is_repo_snapshot)."""
|
|
587
|
+
return bool(candidate.source_items) and all(
|
|
588
|
+
signals.is_repo_snapshot(item) for item in candidate.source_items
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _first_party_floor_eligible(
|
|
593
|
+
candidate: schema.Candidate,
|
|
594
|
+
*,
|
|
595
|
+
resolved_handles: set[str],
|
|
596
|
+
primary_entity: str = "",
|
|
597
|
+
subject_handles: set[str] | None = None,
|
|
598
|
+
) -> bool:
|
|
599
|
+
"""Whether a first-party post earns the authorship credit and floors.
|
|
600
|
+
|
|
601
|
+
First-party authorship always exempts a post from pruning, but the lift
|
|
602
|
+
into the visible band is reserved for posts that are on-topic (entity
|
|
603
|
+
grounded, or clearing the normal relevance floor) and for the topic's own
|
|
604
|
+
account, whose posts never name their author. A related handle's
|
|
605
|
+
off-topic reply ("@friend Nanit" on a Ghostty run) stays where its own
|
|
606
|
+
score puts it. With no primary entity every post counts as on-topic.
|
|
607
|
+
|
|
608
|
+
Grounding counts only when it is positively established. For a short
|
|
609
|
+
generic topic ("Go") _entity_grounded cannot test a match and passes
|
|
610
|
+
everything, so there the post must clear the relevance floor instead.
|
|
611
|
+
"""
|
|
612
|
+
if not _is_first_party(candidate, resolved_handles):
|
|
613
|
+
return False
|
|
614
|
+
# A repo snapshot card's "author" is the repo owner or the --github-user,
|
|
615
|
+
# not someone posting in the window (and a project owner such as "sqlite"
|
|
616
|
+
# can match a topic-token handle), so it never earns the lift.
|
|
617
|
+
if _is_repo_snapshot_candidate(candidate):
|
|
618
|
+
return False
|
|
619
|
+
if subject_handles and _candidate_author_handle(candidate) in subject_handles:
|
|
620
|
+
return True
|
|
621
|
+
return (
|
|
622
|
+
_is_entity_positively_grounded(candidate, primary_entity)
|
|
623
|
+
or candidate.local_relevance >= relevance.RELEVANCE_FLOOR
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
|
|
318
627
|
def _is_x_candidate(candidate: schema.Candidate) -> bool:
|
|
319
628
|
"""True when the candidate originates from X (top-level or any source item)."""
|
|
320
629
|
if candidate.source == "x":
|
|
@@ -326,6 +635,20 @@ def _candidate_engagement(candidate: schema.Candidate) -> float:
|
|
|
326
635
|
return candidate.engagement if candidate.engagement is not None else 0.0
|
|
327
636
|
|
|
328
637
|
|
|
638
|
+
def _is_entity_positively_grounded(candidate: schema.Candidate, primary_entity: str) -> bool:
|
|
639
|
+
"""Like _is_entity_grounded, but False where grounding cannot be tested.
|
|
640
|
+
|
|
641
|
+
_entity_grounded passes every candidate when the entity offers no safe
|
|
642
|
+
anchor (a short generic topic such as "Go"); that keeps it from demoting,
|
|
643
|
+
but it is not evidence that a candidate is on topic. No primary entity
|
|
644
|
+
still counts as grounded, as in _is_entity_grounded.
|
|
645
|
+
"""
|
|
646
|
+
if not primary_entity:
|
|
647
|
+
return True
|
|
648
|
+
haystack = _candidate_haystack(candidate)
|
|
649
|
+
return bool(haystack.strip()) and _entity_grounding_verdict(haystack, primary_entity) is True
|
|
650
|
+
|
|
651
|
+
|
|
329
652
|
def _is_entity_grounded(candidate: schema.Candidate, primary_entity: str) -> bool:
|
|
330
653
|
"""Whether the candidate plausibly mentions the primary entity in its text.
|
|
331
654
|
|
|
@@ -369,53 +692,102 @@ def _interaction_targets(candidate: schema.Candidate, resolved_handles: set[str]
|
|
|
369
692
|
|
|
370
693
|
|
|
371
694
|
def _apply_interaction_signal(
|
|
372
|
-
candidates: list[schema.Candidate],
|
|
695
|
+
candidates: list[schema.Candidate],
|
|
696
|
+
*,
|
|
697
|
+
resolved_handles: set[str],
|
|
698
|
+
primary_entity: str = "",
|
|
699
|
+
topic: str = "",
|
|
700
|
+
primary_handle: str | None = None,
|
|
373
701
|
) -> None:
|
|
374
702
|
"""Float and tag first-party posts directed at another account. The relational
|
|
375
703
|
tell (the subject personally engaging someone) is invisible to keyword and
|
|
376
704
|
engagement scoring, so these are floored into the visible band and tagged so
|
|
377
|
-
synthesis reads them as signal.
|
|
705
|
+
synthesis reads them as signal. The floor applies only where
|
|
706
|
+
_first_party_floor_eligible holds; an off-topic reply is tagged, not lifted."""
|
|
378
707
|
if not resolved_handles:
|
|
379
708
|
return
|
|
709
|
+
subject = _subject_handles(primary_handle, topic)
|
|
380
710
|
for c in candidates:
|
|
381
711
|
targets = _interaction_targets(c, resolved_handles)
|
|
382
712
|
if not targets:
|
|
383
713
|
continue
|
|
384
714
|
c.metadata = {**(c.metadata or {}), "interaction_targets": sorted(targets)}
|
|
715
|
+
if not _first_party_floor_eligible(
|
|
716
|
+
c, resolved_handles=resolved_handles, primary_entity=primary_entity, subject_handles=subject
|
|
717
|
+
):
|
|
718
|
+
continue
|
|
385
719
|
if c.final_score < INTERACTION_FLOOR:
|
|
386
720
|
c.final_score = INTERACTION_FLOOR
|
|
387
721
|
|
|
388
722
|
|
|
389
723
|
def _apply_first_party_floor(
|
|
390
|
-
candidates: list[schema.Candidate],
|
|
724
|
+
candidates: list[schema.Candidate],
|
|
725
|
+
*,
|
|
726
|
+
resolved_handles: set[str],
|
|
727
|
+
primary_entity: str = "",
|
|
728
|
+
topic: str = "",
|
|
729
|
+
primary_handle: str | None = None,
|
|
391
730
|
) -> None:
|
|
392
|
-
"""Floor
|
|
731
|
+
"""Floor on-topic first-party posts above the zero band, on any scoring path.
|
|
393
732
|
|
|
394
733
|
Backstops the LLM rerank path, where the grounding hint would otherwise cap
|
|
395
734
|
a first-party post (which never names its own author) at <=30 and re-bury
|
|
396
|
-
it. Floor only lifts; it never lowers a post the scorer rated higher.
|
|
735
|
+
it. Floor only lifts; it never lowers a post the scorer rated higher. Only
|
|
736
|
+
posts passing _first_party_floor_eligible are lifted (the topic's own
|
|
737
|
+
account always is); every first-party post still has its entity-miss
|
|
738
|
+
marker cleared below, so none is pruned for omitting its author's name.
|
|
397
739
|
"""
|
|
398
740
|
if not resolved_handles:
|
|
399
741
|
return
|
|
742
|
+
subject = _subject_handles(primary_handle, topic)
|
|
400
743
|
for c in candidates:
|
|
401
|
-
if _is_first_party(c, resolved_handles)
|
|
744
|
+
if not _is_first_party(c, resolved_handles):
|
|
745
|
+
continue
|
|
746
|
+
if c.final_score < FIRST_PARTY_FLOOR and _first_party_floor_eligible(
|
|
747
|
+
c, resolved_handles=resolved_handles, primary_entity=primary_entity, subject_handles=subject
|
|
748
|
+
):
|
|
402
749
|
c.final_score = FIRST_PARTY_FLOOR
|
|
750
|
+
# Clear the entity-miss marker here, at the one site that knows the
|
|
751
|
+
# resolved handles. Downstream relevance gates key on the marker, not
|
|
752
|
+
# on handle knowledge, so neutralizing it once lets the carve-out
|
|
753
|
+
# propagate instead of forcing every gate to re-derive first-party.
|
|
754
|
+
# A first-party post is entity-grounded by authorship: nobody repeats
|
|
755
|
+
# their own name in their own post.
|
|
756
|
+
if c.explanation and "entity-miss" in c.explanation.lower():
|
|
757
|
+
c.explanation = _FIRST_PARTY_EXPLANATION
|
|
403
758
|
|
|
404
759
|
|
|
405
760
|
def _apply_engagement_rescue(
|
|
406
|
-
candidates: list[schema.Candidate],
|
|
761
|
+
candidates: list[schema.Candidate],
|
|
762
|
+
*,
|
|
763
|
+
primary_entity: str,
|
|
764
|
+
resolved_handles: set[str],
|
|
765
|
+
topic: str = "",
|
|
766
|
+
primary_handle: str | None = None,
|
|
407
767
|
) -> None:
|
|
408
|
-
"""Floor final_score for high-engagement X posts that are
|
|
409
|
-
|
|
410
|
-
(entity-miss) collision posts
|
|
768
|
+
"""Floor final_score for high-engagement X posts that are entity-grounded
|
|
769
|
+
or floor-eligible first-party (see _first_party_floor_eligible), so a viral
|
|
770
|
+
on-topic post can't sit at ~0. Off-topic (entity-miss) collision posts and
|
|
771
|
+
a related handle's off-topic posts are excluded, preserving noise
|
|
772
|
+
suppression.
|
|
411
773
|
"""
|
|
412
774
|
x_cands = [c for c in candidates if _is_x_candidate(c)]
|
|
413
775
|
if len(x_cands) < 2:
|
|
414
776
|
return
|
|
777
|
+
subject = _subject_handles(primary_handle, topic)
|
|
415
778
|
engagements = sorted(_candidate_engagement(c) for c in x_cands)
|
|
416
779
|
n = len(engagements)
|
|
417
780
|
for c in x_cands:
|
|
418
|
-
|
|
781
|
+
# A first-party post qualifies only through _first_party_floor_eligible
|
|
782
|
+
# (which needs positive grounding); the bare grounding pass below would
|
|
783
|
+
# wave a related handle's off-topic post through on a short topic.
|
|
784
|
+
if _is_first_party(c, resolved_handles):
|
|
785
|
+
qualifies = _first_party_floor_eligible(
|
|
786
|
+
c, resolved_handles=resolved_handles, primary_entity=primary_entity, subject_handles=subject
|
|
787
|
+
)
|
|
788
|
+
else:
|
|
789
|
+
qualifies = _is_entity_grounded(c, primary_entity)
|
|
790
|
+
if not qualifies:
|
|
419
791
|
continue
|
|
420
792
|
e = _candidate_engagement(c)
|
|
421
793
|
# Percentile rank in [0, 1]: fraction of the X pool strictly below e.
|
|
@@ -468,26 +840,58 @@ def _entity_grounded(haystack: str, primary_entity: str) -> bool:
|
|
|
468
840
|
items that omit the descriptor. Items that never name the brand at all still
|
|
469
841
|
miss the head token and stay demoted.
|
|
470
842
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
843
|
+
Long natural-language topics with a generic head use stronger trailing
|
|
844
|
+
anchors. Short generic-headed topics remain a safe no-op so entities such as
|
|
845
|
+
"Go" are not falsely demoted.
|
|
846
|
+
"""
|
|
847
|
+
verdict = _entity_grounding_verdict(haystack, primary_entity)
|
|
848
|
+
return True if verdict is None else verdict
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def _entity_grounding_verdict(haystack: str, primary_entity: str) -> bool | None:
|
|
852
|
+
"""_entity_grounded's test, with None where no safe anchor exists.
|
|
853
|
+
|
|
854
|
+
None means the entity cannot be checked (no tokens, a short generic-headed
|
|
855
|
+
topic, or no strong trailing anchor); _entity_grounded treats that as a
|
|
856
|
+
pass so it never demotes, and _is_entity_positively_grounded as a miss.
|
|
477
857
|
"""
|
|
478
858
|
haystack = haystack.lower()
|
|
479
|
-
tokens = primary_entity.lower()
|
|
859
|
+
tokens = re.findall(r"\w+", primary_entity.lower())
|
|
480
860
|
if not tokens:
|
|
481
|
-
return
|
|
482
|
-
|
|
861
|
+
return None
|
|
862
|
+
head = tokens[0]
|
|
863
|
+
if len(head) > 3 and head not in relevance.LOW_SIGNAL_QUERY_TOKENS:
|
|
864
|
+
return head in haystack
|
|
865
|
+
|
|
866
|
+
# A long natural-language topic headed by "AI", "how", or another generic
|
|
867
|
+
# word needs a stronger anchor. Short entity-like topics keep the historical
|
|
868
|
+
# safe no-op rather than risking false demotion.
|
|
869
|
+
if len(tokens) < _GENERIC_GROUNDING_MIN_TOKENS:
|
|
870
|
+
return None
|
|
871
|
+
anchors = [
|
|
872
|
+
token
|
|
873
|
+
for token in tokens[1:]
|
|
874
|
+
if len(token) >= _GROUNDING_ANCHOR_MIN_LENGTH
|
|
875
|
+
and token not in relevance.STOPWORDS
|
|
876
|
+
and token not in _GROUNDING_LOW_SIGNAL_TOKENS
|
|
877
|
+
]
|
|
878
|
+
if not anchors:
|
|
879
|
+
return None
|
|
880
|
+
return any(re.search(rf"\b{re.escape(token)}", haystack) for token in anchors)
|
|
483
881
|
|
|
484
882
|
|
|
485
883
|
def _fallback_tuple(
|
|
486
|
-
candidate: schema.Candidate,
|
|
884
|
+
candidate: schema.Candidate,
|
|
885
|
+
*,
|
|
886
|
+
primary_entity: str = "",
|
|
887
|
+
resolved_handles: set[str] | None = None,
|
|
888
|
+
subject_handles: set[str] | None = None,
|
|
487
889
|
) -> tuple[float, str]:
|
|
890
|
+
# Relevance and source quality only. Freshness enters once, in
|
|
891
|
+
# _final_score; counting it here too gave it ~0.22 of the final score and
|
|
892
|
+
# let a 3-point story from today outrank a 380-point one from last week.
|
|
488
893
|
score = (
|
|
489
894
|
(candidate.local_relevance * 100.0 * 0.7)
|
|
490
|
-
+ (candidate.freshness * 0.2)
|
|
491
895
|
+ (candidate.source_quality * 100.0 * 0.1)
|
|
492
896
|
)
|
|
493
897
|
reason = "fallback-local-score"
|
|
@@ -500,9 +904,33 @@ def _fallback_tuple(
|
|
|
500
904
|
# _final_score's secondary penalty (which greps for it) is also skipped.
|
|
501
905
|
# A small bounded credit lifts a first-party post just off neutral without
|
|
502
906
|
# letting authorship alone outrank a genuinely strong on-topic third party.
|
|
907
|
+
# The credit, like the floors, goes only to on-topic posts or the topic's
|
|
908
|
+
# own account (_first_party_floor_eligible); an off-topic reply from a
|
|
909
|
+
# related handle keeps the exemption (so it is not pruned) but earns no lift.
|
|
503
910
|
if resolved_handles and _is_first_party(candidate, resolved_handles):
|
|
504
|
-
|
|
911
|
+
if _first_party_floor_eligible(
|
|
912
|
+
candidate,
|
|
913
|
+
resolved_handles=resolved_handles,
|
|
914
|
+
primary_entity=primary_entity,
|
|
915
|
+
subject_handles=subject_handles,
|
|
916
|
+
):
|
|
917
|
+
score += FIRST_PARTY_AUTHOR_CREDIT
|
|
918
|
+
# A repo card keeps the exemption (a person-mode card never names the
|
|
919
|
+
# person) but is not authored in the window, so it is not labeled so.
|
|
920
|
+
if _is_repo_snapshot_candidate(candidate):
|
|
921
|
+
return max(0.0, min(100.0, score)), "fallback-local-score (repo snapshot)"
|
|
505
922
|
return max(0.0, min(100.0, score)), "fallback-local-score (first-party authorship)"
|
|
923
|
+
# Grounding-exempt evidence (currently Amazon): the adapter gated these
|
|
924
|
+
# against the model-supplied keyword before they existed, so the
|
|
925
|
+
# entity-miss demotion below would punish them for a match they were
|
|
926
|
+
# never going to make -- a "Weber Grills" run legitimately surfaces a
|
|
927
|
+
# product called "Spirit E-325" whose reviews discuss searing, not Weber.
|
|
928
|
+
# Returning here also skips _final_score's secondary penalty, which greps
|
|
929
|
+
# the reason string for "entity-miss": one flag, both paths, per the
|
|
930
|
+
# propagation pattern in
|
|
931
|
+
# docs/solutions/logic-errors/entity-grounding-full-phrase-false-demotion.md
|
|
932
|
+
if _is_grounding_exempt(candidate):
|
|
933
|
+
return max(0.0, min(100.0, score)), "fallback-local-score (grounding-exempt source)"
|
|
506
934
|
# Entity-grounding demotion: subtract ENTITY_MISS_PENALTY when the candidate
|
|
507
935
|
# never mentions the primary entity's head token, across all text surfaces
|
|
508
936
|
# (title, snippet, transcript, transcript highlights, top comments,
|
|
@@ -525,12 +953,81 @@ def _primary_entity(topic: str) -> str:
|
|
|
525
953
|
string for topics that are all intent modifier with no entity, so
|
|
526
954
|
callers can skip the grounding check.
|
|
527
955
|
"""
|
|
528
|
-
stripped =
|
|
529
|
-
|
|
956
|
+
stripped = topic
|
|
957
|
+
while True:
|
|
958
|
+
shortened = _TRAILING_INTENT_MODIFIER_RE.sub("", stripped, count=1)
|
|
959
|
+
if shortened == stripped:
|
|
960
|
+
break
|
|
961
|
+
stripped = shortened
|
|
530
962
|
stripped = re.sub(r"\s+", " ", stripped).strip(" \t\r\n?.,:;!")
|
|
531
963
|
return stripped
|
|
532
964
|
|
|
533
965
|
|
|
966
|
+
def _is_grounding_exempt(candidate: schema.Candidate) -> bool:
|
|
967
|
+
"""True when the candidate carries the relevant-by-construction label.
|
|
968
|
+
|
|
969
|
+
Set by adapters that already gated their results against an explicit
|
|
970
|
+
keyword at retrieval time (see normalize._normalize_amazon). Checked on
|
|
971
|
+
the candidate's own metadata and on any of its source items, since
|
|
972
|
+
clustering can build a candidate from several items.
|
|
973
|
+
"""
|
|
974
|
+
metadata = candidate.metadata or {}
|
|
975
|
+
if isinstance(metadata, dict) and metadata.get("grounding_exempt"):
|
|
976
|
+
return True
|
|
977
|
+
return any(
|
|
978
|
+
isinstance(item.metadata, dict) and item.metadata.get("grounding_exempt")
|
|
979
|
+
for item in candidate.source_items
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
|
|
983
|
+
def _is_corpus_candidate(candidate: schema.Candidate) -> bool:
|
|
984
|
+
"""True when the candidate carries private corpus evidence."""
|
|
985
|
+
if candidate.source == "corpus":
|
|
986
|
+
return True
|
|
987
|
+
return any(item.source == "corpus" for item in candidate.source_items)
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def prune_fallback_entity_misses(
|
|
991
|
+
candidates: list[schema.Candidate],
|
|
992
|
+
*,
|
|
993
|
+
topic: str,
|
|
994
|
+
) -> list[schema.Candidate]:
|
|
995
|
+
"""Hide unanchored, low-confidence fallback misses from visible evidence.
|
|
996
|
+
|
|
997
|
+
Broad recommendation queries can be misread as one long primary entity,
|
|
998
|
+
causing every fallback candidate to receive the entity-miss marker. The
|
|
999
|
+
marker alone is therefore not a safe filter. A candidate is removed only
|
|
1000
|
+
when its stable title and snippet do not clear a meaningful raw-topic
|
|
1001
|
+
relevance floor and it lacks a strong local-relevance signal from an
|
|
1002
|
+
explicitly scoped retrieval path. Comments and transcripts are excluded
|
|
1003
|
+
from this escape because incidental words there do not ground the candidate
|
|
1004
|
+
itself. Private corpus candidates always escape: retrieval already accepted
|
|
1005
|
+
them on body text, and titles are often filenames that omit the head token.
|
|
1006
|
+
Source items remain in the report's diagnostic source dump.
|
|
1007
|
+
"""
|
|
1008
|
+
if not topic:
|
|
1009
|
+
return candidates
|
|
1010
|
+
|
|
1011
|
+
kept: list[schema.Candidate] = []
|
|
1012
|
+
for candidate in candidates:
|
|
1013
|
+
if candidate.explanation != _FALLBACK_ENTITY_MISS_EXPLANATION:
|
|
1014
|
+
kept.append(candidate)
|
|
1015
|
+
continue
|
|
1016
|
+
if _is_corpus_candidate(candidate):
|
|
1017
|
+
kept.append(candidate)
|
|
1018
|
+
continue
|
|
1019
|
+
if candidate.local_relevance >= FALLBACK_ENTITY_MISS_CONFIDENCE_ESCAPE:
|
|
1020
|
+
kept.append(candidate)
|
|
1021
|
+
continue
|
|
1022
|
+
primary_text = f"{candidate.title or ''} {candidate.snippet or ''}"
|
|
1023
|
+
if (
|
|
1024
|
+
relevance.token_overlap_relevance(topic, primary_text)
|
|
1025
|
+
>= FALLBACK_ENTITY_MISS_TOPIC_ESCAPE
|
|
1026
|
+
):
|
|
1027
|
+
kept.append(candidate)
|
|
1028
|
+
return kept
|
|
1029
|
+
|
|
1030
|
+
|
|
534
1031
|
#: Secondary entity-miss penalty applied directly to final_score (not just
|
|
535
1032
|
#: rerank_score). The -25 on rerank_score composes to only -15 on final_score
|
|
536
1033
|
#: via the 0.60 weight, which engagement bonus partially offsets on
|
|
@@ -541,35 +1038,82 @@ def _primary_entity(topic: str) -> str:
|
|
|
541
1038
|
#: the dilute penalty. This backstop makes the demotion actually decisive.
|
|
542
1039
|
ENTITY_MISS_FINAL_PENALTY = 20.0
|
|
543
1040
|
|
|
1041
|
+
#: Multiplier applied to a candidate whose every dated item falls outside the
|
|
1042
|
+
#: run's window. The tool's whole promise is the window, so a stale item must
|
|
1043
|
+
#: not lead the ranked clusters however relevant it reads — a 2025-10 video
|
|
1044
|
+
#: ranked #1 in a 2026-07 brief, and a 2025-12 one ranked #5, both correctly
|
|
1045
|
+
#: flagged [date:low] and both ranked anyway. Scaling rather than subtracting
|
|
1046
|
+
#: keeps the ordering *among* older items intact, so the "still worth reading"
|
|
1047
|
+
#: signal survives underneath the in-window evidence.
|
|
1048
|
+
OUT_OF_WINDOW_FINAL_MULTIPLIER = 0.35
|
|
1049
|
+
|
|
1050
|
+
|
|
1051
|
+
#: Weight of engagement (0-100, absolute per-source log scale from
|
|
1052
|
+
#: signals.engagement_score) in _final_score. Its full range is worth 15
|
|
1053
|
+
#: points, nearly twice freshness's effective 8 (freshness spans 10-90), so a
|
|
1054
|
+
#: large engagement gap (a 380-point HN story vs a 3-point one, ~78 points ->
|
|
1055
|
+
#: ~12) can outweigh most of a month's age gap (~7), while a modest gap cannot.
|
|
1056
|
+
#: Relevance still dominates: one tenth of local relevance is worth 4.2 points
|
|
1057
|
+
#: through the fallback rerank score alone.
|
|
1058
|
+
ENGAGEMENT_FINAL_WEIGHT = 0.15
|
|
1059
|
+
|
|
1060
|
+
|
|
1061
|
+
#: Candidate.metadata key recording which path produced rerank_score. Set by
|
|
1062
|
+
#: _apply_fallback_scores and _apply_llm_scores; the explanation text is not
|
|
1063
|
+
#: provenance (an LLM reason may start with any word).
|
|
1064
|
+
RERANK_PROVENANCE_KEY = "rerank_provenance"
|
|
1065
|
+
RERANK_PROVENANCE_FALLBACK = "fallback"
|
|
1066
|
+
RERANK_PROVENANCE_LLM = "llm"
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def _set_rerank_provenance(candidate: schema.Candidate, provenance: str) -> None:
|
|
1070
|
+
candidate.metadata = {**(candidate.metadata or {}), RERANK_PROVENANCE_KEY: provenance}
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _is_fallback_scored(candidate: schema.Candidate) -> bool:
|
|
1074
|
+
"""True when rerank_score came from _fallback_tuple, not an LLM or host.
|
|
1075
|
+
|
|
1076
|
+
Reads the provenance the scoring path recorded. A candidate with none
|
|
1077
|
+
recorded is treated as judge-scored, so the cliff still applies.
|
|
1078
|
+
"""
|
|
1079
|
+
return (candidate.metadata or {}).get(RERANK_PROVENANCE_KEY) == RERANK_PROVENANCE_FALLBACK
|
|
1080
|
+
|
|
544
1081
|
|
|
545
1082
|
def _final_score(candidate: schema.Candidate) -> float:
|
|
546
1083
|
normalized_rrf = _normalized_rrf(candidate.rrf_score)
|
|
547
1084
|
rerank_score = candidate.rerank_score or 0.0
|
|
548
|
-
# Engagement
|
|
549
|
-
#
|
|
550
|
-
#
|
|
551
|
-
# TikTok scores ~15 and a 1500-view one scores ~7. The 0.05 weight gives a
|
|
552
|
-
# meaningful but not dominant boost.
|
|
1085
|
+
# Engagement is already 0-100 on an absolute per-source log scale
|
|
1086
|
+
# (signals.engagement_score): ~8 for a 3-like X post, ~50 for a 254-upvote
|
|
1087
|
+
# Reddit thread, ~90 for a 380-point HN story.
|
|
553
1088
|
engagement_val = candidate.engagement if candidate.engagement is not None else 0.0
|
|
554
1089
|
base = (
|
|
555
1090
|
0.60 * rerank_score
|
|
556
1091
|
+ 0.20 * normalized_rrf
|
|
557
1092
|
+ 0.10 * candidate.freshness
|
|
558
1093
|
+ 0.05 * (candidate.source_quality * 100.0)
|
|
559
|
-
+
|
|
1094
|
+
+ ENGAGEMENT_FINAL_WEIGHT * max(0.0, min(engagement_val, 100.0))
|
|
560
1095
|
)
|
|
561
|
-
|
|
1096
|
+
# The cliff demotes items a judge explicitly called irrelevant. A fallback
|
|
1097
|
+
# score below 20 only means weak lexical overlap, which the 0.60 weight
|
|
1098
|
+
# already prices in; cliffing it too buried on-topic threads (a 254-upvote
|
|
1099
|
+
# r/Ghostty post ranked last at score 7).
|
|
1100
|
+
if (
|
|
1101
|
+
candidate.rerank_score is not None
|
|
1102
|
+
and candidate.rerank_score < 20.0
|
|
1103
|
+
and not _is_fallback_scored(candidate)
|
|
1104
|
+
):
|
|
562
1105
|
base *= 0.3
|
|
563
1106
|
# Secondary entity-grounding penalty: when the fallback path flagged
|
|
564
1107
|
# entity-miss via candidate.explanation, apply an additional penalty
|
|
565
1108
|
# at final_score level so engagement signal can't mask the demotion.
|
|
566
1109
|
if candidate.explanation and "entity-miss" in candidate.explanation:
|
|
567
1110
|
base = max(0.0, base - ENTITY_MISS_FINAL_PENALTY)
|
|
1111
|
+
# Recency contract: out-of-window evidence never leads the ranked output.
|
|
1112
|
+
if schema.candidate_out_of_window(candidate):
|
|
1113
|
+
base *= OUT_OF_WINDOW_FINAL_MULTIPLIER
|
|
568
1114
|
return base
|
|
569
1115
|
|
|
570
1116
|
|
|
571
|
-
|
|
572
|
-
|
|
573
1117
|
def score_fun(
|
|
574
1118
|
*,
|
|
575
1119
|
topic: str,
|
|
@@ -625,7 +1169,10 @@ def _extract_comment_text(candidate: schema.Candidate) -> str:
|
|
|
625
1169
|
parts = []
|
|
626
1170
|
for item in candidate.source_items:
|
|
627
1171
|
for comment in item.metadata.get("top_comments", [])[:3]:
|
|
628
|
-
body =
|
|
1172
|
+
body = (
|
|
1173
|
+
comment.get("excerpt") or comment.get("body", "")
|
|
1174
|
+
if isinstance(comment, dict) else str(comment)
|
|
1175
|
+
)
|
|
629
1176
|
if body:
|
|
630
1177
|
parts.append(body[:150])
|
|
631
1178
|
for insight in item.metadata.get("comment_insights", [])[:2]:
|
|
@@ -644,7 +1191,7 @@ def _extract_comment_text_scored(candidate: schema.Candidate) -> str:
|
|
|
644
1191
|
for item in candidate.source_items:
|
|
645
1192
|
for comment in item.metadata.get("top_comments", [])[:3]:
|
|
646
1193
|
if isinstance(comment, dict):
|
|
647
|
-
body = comment.get("body", "")
|
|
1194
|
+
body = comment.get("excerpt") or comment.get("body", "")
|
|
648
1195
|
if not body:
|
|
649
1196
|
continue
|
|
650
1197
|
score = comment.get("score")
|
|
@@ -706,3 +1253,24 @@ def _normalized_rrf(rrf_score: float) -> float:
|
|
|
706
1253
|
# Max single-stream RRF at rank 1 is 1/(K+1) ~ 0.016; multi-stream
|
|
707
1254
|
# accumulation reaches ~0.08.
|
|
708
1255
|
return max(0.0, min(100.0, (rrf_score / 0.08) * 100.0))
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def candidate_relevance_ok(candidate: schema.Candidate) -> bool:
|
|
1259
|
+
"""Shared gate: is this candidate topically usable for display surfaces?
|
|
1260
|
+
|
|
1261
|
+
Single owner of the entity-miss demotion test. Render-side surfaces (Best
|
|
1262
|
+
Takes, cluster visibility) must call this rather than re-testing the
|
|
1263
|
+
explanation string themselves -- a second copy of the predicate is how the
|
|
1264
|
+
documented mirrored-predicate drift bug recurs, and it means a carve-out
|
|
1265
|
+
added here silently fails to reach them.
|
|
1266
|
+
|
|
1267
|
+
First-party posts are handled upstream: ``_apply_first_party_floor`` clears
|
|
1268
|
+
their entity-miss marker at the one site that knows the resolved handles,
|
|
1269
|
+
so this predicate needs no handle knowledge.
|
|
1270
|
+
"""
|
|
1271
|
+
explanation = (candidate.explanation or "").lower()
|
|
1272
|
+
if "entity-miss" in explanation:
|
|
1273
|
+
return False
|
|
1274
|
+
if (candidate.final_score or 0.0) <= 0.0:
|
|
1275
|
+
return False
|
|
1276
|
+
return True
|