Tighten relevance scoring and Polymarket ranking
Score against original user intent on Reddit, remove the artificial low-end relevance floor, and make Polymarket semantics dominate generic market quality signals. Also apply the relevance filter to Polymarket and update the affected cross-source tests. Validation: uv run python -m unittest
This commit is contained in:
@@ -1838,6 +1838,7 @@ def main():
|
||||
deduped_hn = score.relevance_filter(deduped_hn, "HN")
|
||||
deduped_bsky = score.relevance_filter(deduped_bsky, "BLUESKY")
|
||||
deduped_ts = score.relevance_filter(deduped_ts, "TRUTHSOCIAL")
|
||||
deduped_pm = score.relevance_filter(deduped_pm, "POLYMARKET") if deduped_pm else []
|
||||
|
||||
# Cross-source linking: annotate items that discuss the same story
|
||||
dedupe.cross_source_link(
|
||||
|
||||
+16
-24
@@ -13,6 +13,7 @@ from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import quote_plus, urlencode
|
||||
|
||||
from . import http
|
||||
from .relevance import token_overlap_relevance
|
||||
|
||||
GAMMA_SEARCH_URL = "https://gamma-api.polymarket.com/public-search"
|
||||
|
||||
@@ -314,8 +315,8 @@ def _shorten_question(question: str) -> str:
|
||||
def _compute_text_similarity(topic: str, title: str, outcomes: List[str] = None) -> float:
|
||||
"""Score how well the event title (or outcome names) match the search topic.
|
||||
|
||||
Returns 0.0-1.0. Title substring match gets 1.0, outcome match gets 0.85/0.7,
|
||||
title token overlap gets proportional score.
|
||||
Returns 0.0-1.0. Exact title phrase match gets 1.0. Otherwise we reuse the
|
||||
shared query-centric relevance scorer and take the best title/outcome match.
|
||||
"""
|
||||
core = _extract_core_subject(topic).lower()
|
||||
title_lower = title.lower()
|
||||
@@ -326,27 +327,17 @@ def _compute_text_similarity(topic: str, title: str, outcomes: List[str] = None)
|
||||
if core in title_lower:
|
||||
return 1.0
|
||||
|
||||
# Check if topic appears in any outcome name (bidirectional)
|
||||
best_score = token_overlap_relevance(core, title)
|
||||
|
||||
if outcomes:
|
||||
core_tokens = set(core.split())
|
||||
best_outcome_score = 0.0
|
||||
for outcome_name in outcomes:
|
||||
outcome_lower = outcome_name.lower()
|
||||
# Bidirectional: "arizona" in "arizona basketball" OR "arizona basketball" contains "arizona"
|
||||
outcome_score = token_overlap_relevance(core, outcome_name)
|
||||
if core in outcome_lower or outcome_lower in core:
|
||||
best_outcome_score = max(best_outcome_score, 0.85)
|
||||
elif core_tokens & set(outcome_lower.split()):
|
||||
best_outcome_score = max(best_outcome_score, 0.7)
|
||||
if best_outcome_score > 0:
|
||||
return best_outcome_score
|
||||
outcome_score = max(outcome_score, 0.92 if len(outcome_lower.split()) >= 2 else 0.88)
|
||||
best_score = max(best_score, outcome_score)
|
||||
|
||||
# Token overlap fallback against title
|
||||
topic_tokens = set(core.split())
|
||||
title_tokens = set(title_lower.split())
|
||||
if not topic_tokens:
|
||||
return 0.5
|
||||
overlap = len(topic_tokens & title_tokens)
|
||||
return overlap / len(topic_tokens)
|
||||
return round(best_score, 2)
|
||||
|
||||
|
||||
def _safe_float(val, default=0.0) -> float:
|
||||
@@ -484,7 +475,8 @@ def parse_polymarket_response(response: Dict[str, Any], topic: str = "") -> List
|
||||
except (IndexError, TypeError):
|
||||
end_date = None
|
||||
|
||||
# Quality-signal relevance (replaces position-based decay)
|
||||
# Semantic relevance should dominate. Market quality should refine
|
||||
# relevant matches, not rescue unrelated high-liquidity events.
|
||||
text_score = _compute_text_similarity(topic, title, all_outcome_names) if topic else 0.5
|
||||
|
||||
# Volume signal: log-scaled monthly volume (most stable signal)
|
||||
@@ -504,13 +496,13 @@ def parse_polymarket_response(response: Dict[str, Any], topic: str = "") -> List
|
||||
# Competitive bonus: markets near 50/50 are more interesting
|
||||
competitive_score = event_competitive
|
||||
|
||||
relevance = min(1.0, (
|
||||
0.30 * text_score +
|
||||
0.30 * vol_score +
|
||||
0.15 * liq_score +
|
||||
market_quality = (
|
||||
0.50 * vol_score +
|
||||
0.25 * liq_score +
|
||||
0.15 * movement_score +
|
||||
0.10 * competitive_score
|
||||
))
|
||||
)
|
||||
relevance = min(1.0, text_score * (0.75 + 0.25 * market_quality))
|
||||
|
||||
# Surface the topic-matching outcome to the front before truncating
|
||||
if topic and outcome_prices:
|
||||
|
||||
@@ -108,12 +108,14 @@ def expand_reddit_queries(topic: str, depth: str) -> List[str]:
|
||||
if core.lower() != original_clean.lower() and len(original_clean.split()) <= 8:
|
||||
queries.append(original_clean)
|
||||
|
||||
# Add opinion/review variant except for how_to/comparison queries
|
||||
# Opinion/review variants help mostly for product and opinion queries.
|
||||
# They contaminate broader searches like predictions or breaking news.
|
||||
qtype = detect_query_type(topic)
|
||||
if depth in ("default", "deep") and qtype not in ("how_to", "comparison"):
|
||||
if depth in ("default", "deep") and qtype in ("product", "opinion"):
|
||||
queries.append(f"{core} worth it OR thoughts OR review")
|
||||
|
||||
if depth == "deep":
|
||||
# Problem/bug variants are useful for tool workflows, not generic news.
|
||||
if depth == "deep" and qtype in ("product", "opinion", "how_to"):
|
||||
queries.append(f"{core} issues OR problems OR bug OR broken")
|
||||
|
||||
return queries
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Shared token-overlap relevance scoring for search result ranking.
|
||||
|
||||
Tokenizes text, expands synonyms, and computes query-to-content overlap ratios.
|
||||
The score is intentionally query-centric:
|
||||
- exact phrase matches should score very high
|
||||
- partial matches should pay a meaningful penalty
|
||||
- matches on generic words alone ("odds", "review") should not pass as relevant
|
||||
"""
|
||||
|
||||
import re
|
||||
@@ -36,6 +39,18 @@ SYNONYMS = {
|
||||
'vuejs': {'vue'},
|
||||
}
|
||||
|
||||
# Generic query words that should not carry relevance on their own.
|
||||
# They still help when paired with stronger entity/topic matches.
|
||||
LOW_SIGNAL_QUERY_TOKENS = frozenset({
|
||||
'advice', 'animation', 'animations', 'best', 'chance', 'chances',
|
||||
'code', 'compare', 'comparison', 'differences', 'explain', 'guide',
|
||||
'guides', 'how', 'latest', 'news', 'odds', 'opinion', 'opinions',
|
||||
'prediction', 'predictions', 'probability', 'probabilities', 'prompt',
|
||||
'prompting', 'prompts', 'rate', 'review', 'reviews', 'thoughts',
|
||||
'tip', 'tips', 'tutorial', 'tutorials', 'update', 'updates', 'use',
|
||||
'using', 'versus', 'vs', 'worth',
|
||||
})
|
||||
|
||||
|
||||
def tokenize(text: str) -> Set[str]:
|
||||
"""Lowercase, strip punctuation, remove stopwords, drop single-char tokens.
|
||||
@@ -51,15 +66,25 @@ def tokenize(text: str) -> Set[str]:
|
||||
return expanded
|
||||
|
||||
|
||||
def _normalize_phrase(text: str) -> str:
|
||||
"""Normalize text for phrase containment checks."""
|
||||
return ' '.join(re.sub(r'[^\w\s]', ' ', text.lower()).split())
|
||||
|
||||
|
||||
def token_overlap_relevance(
|
||||
query: str,
|
||||
text: str,
|
||||
hashtags: Optional[List[str]] = None,
|
||||
) -> float:
|
||||
"""Compute relevance as ratio of query tokens found in text.
|
||||
"""Compute a query-centric relevance score between 0.0 and 1.0.
|
||||
|
||||
Uses ratio overlap (intersection / query_length) so short queries
|
||||
score higher when fully represented in the text. Floors at 0.1.
|
||||
The score combines:
|
||||
- query coverage
|
||||
- informative-token coverage
|
||||
- a small precision term to penalize extra noise
|
||||
- an exact phrase bonus
|
||||
|
||||
Generic tokens alone are capped below the post-retrieval 0.3 threshold.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
@@ -68,7 +93,7 @@ def token_overlap_relevance(
|
||||
hashtags are split to match query tokens (e.g. "claudecode" matches "claude").
|
||||
|
||||
Returns:
|
||||
Float between 0.1 and 1.0 (0.5 for empty queries)
|
||||
Float between 0.0 and 1.0 (0.5 for empty queries)
|
||||
"""
|
||||
q_tokens = tokenize(query)
|
||||
|
||||
@@ -89,6 +114,35 @@ def token_overlap_relevance(
|
||||
if not q_tokens:
|
||||
return 0.5 # Neutral fallback for empty/stopword-only queries
|
||||
|
||||
overlap = len(q_tokens & t_tokens)
|
||||
ratio = overlap / len(q_tokens)
|
||||
return max(0.1, min(1.0, ratio))
|
||||
overlap_tokens = q_tokens & t_tokens
|
||||
overlap = len(overlap_tokens)
|
||||
if overlap == 0:
|
||||
return 0.0
|
||||
|
||||
informative_q_tokens = {t for t in q_tokens if t not in LOW_SIGNAL_QUERY_TOKENS}
|
||||
if not informative_q_tokens:
|
||||
informative_q_tokens = q_tokens
|
||||
|
||||
coverage = overlap / len(q_tokens)
|
||||
informative_overlap = len(informative_q_tokens & t_tokens) / len(informative_q_tokens)
|
||||
precision_denominator = min(len(t_tokens), len(q_tokens) + 4) or 1
|
||||
precision = overlap / precision_denominator
|
||||
|
||||
phrase_bonus = 0.0
|
||||
normalized_query = _normalize_phrase(query)
|
||||
normalized_text = _normalize_phrase(combined)
|
||||
if normalized_query and normalized_query in normalized_text:
|
||||
phrase_bonus = 0.12 if len(normalized_query.split()) > 1 else 0.16
|
||||
|
||||
base = (
|
||||
0.55 * (coverage ** 1.35) +
|
||||
0.25 * informative_overlap +
|
||||
0.20 * precision
|
||||
)
|
||||
|
||||
# If we only matched generic query words, keep the score below the
|
||||
# normal relevance filter threshold so these do not survive by default.
|
||||
if informative_q_tokens and not (informative_q_tokens & t_tokens):
|
||||
return round(min(0.24, base), 2)
|
||||
|
||||
return round(min(1.0, base + phrase_bonus), 2)
|
||||
|
||||
@@ -11,6 +11,12 @@ WEIGHT_RELEVANCE = 0.45
|
||||
WEIGHT_RECENCY = 0.25
|
||||
WEIGHT_ENGAGEMENT = 0.30
|
||||
|
||||
# Polymarket needs stronger semantic weighting because volume/liquidity already
|
||||
# show up as engagement and lightly influence parse-time relevance.
|
||||
PM_WEIGHT_RELEVANCE = 0.60
|
||||
PM_WEIGHT_RECENCY = 0.20
|
||||
PM_WEIGHT_ENGAGEMENT = 0.20
|
||||
|
||||
# WebSearch weights (no engagement data available)
|
||||
WEBSEARCH_WEIGHT_RELEVANCE = 0.55
|
||||
WEBSEARCH_WEIGHT_RECENCY = 0.45
|
||||
@@ -632,9 +638,9 @@ def score_polymarket_items(items: List[schema.PolymarketItem]) -> List[schema.Po
|
||||
)
|
||||
|
||||
overall = (
|
||||
WEIGHT_RELEVANCE * rel_score +
|
||||
WEIGHT_RECENCY * rec_score +
|
||||
WEIGHT_ENGAGEMENT * eng_score
|
||||
PM_WEIGHT_RELEVANCE * rel_score +
|
||||
PM_WEIGHT_RECENCY * rec_score +
|
||||
PM_WEIGHT_ENGAGEMENT * eng_score
|
||||
)
|
||||
|
||||
if eng_raw[i] is None:
|
||||
|
||||
@@ -57,9 +57,9 @@ class TestComputeRelevance(unittest.TestCase):
|
||||
boosted = instagram._compute_relevance("claude code", "random video about stuff", ["claudecode", "ai"])
|
||||
self.assertGreater(boosted, base)
|
||||
|
||||
def test_floor_at_01(self):
|
||||
def test_no_match_returns_zero(self):
|
||||
rel = instagram._compute_relevance("quantum physics", "cat dancing video")
|
||||
self.assertGreaterEqual(rel, 0.1)
|
||||
self.assertEqual(rel, 0.0)
|
||||
|
||||
def test_empty_query_returns_default(self):
|
||||
rel = instagram._compute_relevance("", "Some video title")
|
||||
|
||||
@@ -569,8 +569,9 @@ class TestTextSimilarity(unittest.TestCase):
|
||||
|
||||
def test_partial_token_overlap(self):
|
||||
score = polymarket._compute_text_similarity("Arizona Basketball", "Will Arizona win?")
|
||||
# "Arizona" matches, "Basketball" doesn't -> 0.5
|
||||
self.assertAlmostEqual(score, 0.5)
|
||||
# Partial informative match should stay below exact match.
|
||||
self.assertGreater(score, 0.3)
|
||||
self.assertLess(score, 0.6)
|
||||
|
||||
def test_no_overlap(self):
|
||||
score = polymarket._compute_text_similarity("Arizona Basketball", "Will AI regulation pass?")
|
||||
@@ -595,7 +596,7 @@ class TestTextSimilarity(unittest.TestCase):
|
||||
"Who will be the #1 overall seed?",
|
||||
outcomes=["Duke", "Arizona", "Houston"],
|
||||
)
|
||||
self.assertEqual(score, 0.85)
|
||||
self.assertEqual(score, 1.0)
|
||||
|
||||
def test_outcome_bidirectional_match(self):
|
||||
"""Topic 'Arizona Basketball' should match outcome 'Arizona' (outcome in core)."""
|
||||
@@ -604,16 +605,17 @@ class TestTextSimilarity(unittest.TestCase):
|
||||
"Who will be the #1 overall seed?",
|
||||
outcomes=["Duke", "Arizona", "Houston"],
|
||||
)
|
||||
self.assertEqual(score, 0.85)
|
||||
self.assertEqual(score, 0.88)
|
||||
|
||||
def test_outcome_token_overlap(self):
|
||||
"""Partial token overlap with outcome gets 0.7 when no substring match."""
|
||||
"""Partial token overlap with outcome gets a moderate score."""
|
||||
score = polymarket._compute_text_similarity(
|
||||
"Iran War",
|
||||
"Unrelated geopolitics title",
|
||||
outcomes=["War continues", "Peace deal"],
|
||||
)
|
||||
self.assertEqual(score, 0.7)
|
||||
self.assertGreater(score, 0.3)
|
||||
self.assertLess(score, 0.6)
|
||||
|
||||
def test_outcome_no_match(self):
|
||||
"""No outcome match falls through to title token overlap."""
|
||||
@@ -632,7 +634,15 @@ class TestTextSimilarity(unittest.TestCase):
|
||||
"Unrelated title",
|
||||
outcomes=["Arizona"],
|
||||
)
|
||||
self.assertEqual(score, 0.85)
|
||||
self.assertEqual(score, 1.0)
|
||||
|
||||
def test_generic_only_odds_match_stays_below_threshold(self):
|
||||
score = polymarket._compute_text_similarity(
|
||||
"Anthropic odds",
|
||||
"Republican 2026 House odds",
|
||||
outcomes=["Yes", "No"],
|
||||
)
|
||||
self.assertLess(score, 0.3)
|
||||
|
||||
def test_title_match_still_beats_outcome(self):
|
||||
"""Title substring match (1.0) takes priority over outcome match (0.85)."""
|
||||
|
||||
+16
-4
@@ -47,16 +47,28 @@ class TestExpandRedditQueries(unittest.TestCase):
|
||||
self.assertGreaterEqual(len(queries), 1)
|
||||
|
||||
def test_default_includes_review_variant(self):
|
||||
queries = reddit.expand_reddit_queries("cursor IDE", "default")
|
||||
queries = reddit.expand_reddit_queries("cursor IDE pricing", "default")
|
||||
self.assertTrue(any("worth it" in q or "review" in q for q in queries))
|
||||
|
||||
def test_default_skips_review_variant_for_prediction(self):
|
||||
queries = reddit.expand_reddit_queries("anthropic odds", "default")
|
||||
self.assertFalse(any("worth it" in q or "review" in q for q in queries))
|
||||
|
||||
def test_default_skips_review_variant_for_breaking_news(self):
|
||||
queries = reddit.expand_reddit_queries("kanye west", "default")
|
||||
self.assertFalse(any("worth it" in q or "review" in q for q in queries))
|
||||
|
||||
def test_deep_includes_issues_variant(self):
|
||||
queries = reddit.expand_reddit_queries("cursor IDE", "deep")
|
||||
queries = reddit.expand_reddit_queries("cursor IDE pricing", "deep")
|
||||
self.assertTrue(any("issues" in q or "problems" in q for q in queries))
|
||||
|
||||
def test_deep_skips_issues_variant_for_prediction(self):
|
||||
queries = reddit.expand_reddit_queries("anthropic odds", "deep")
|
||||
self.assertFalse(any("issues" in q or "problems" in q for q in queries))
|
||||
|
||||
def test_deep_has_more_queries_than_quick(self):
|
||||
quick = reddit.expand_reddit_queries("cursor IDE", "quick")
|
||||
deep = reddit.expand_reddit_queries("cursor IDE", "deep")
|
||||
quick = reddit.expand_reddit_queries("cursor IDE pricing", "quick")
|
||||
deep = reddit.expand_reddit_queries("cursor IDE pricing", "deep")
|
||||
self.assertGreater(len(deep), len(quick))
|
||||
|
||||
|
||||
|
||||
+10
-1
@@ -75,7 +75,7 @@ class TestTokenOverlapRelevance(unittest.TestCase):
|
||||
|
||||
def test_floor_at_0_1(self):
|
||||
rel = token_overlap_relevance("quantum physics", "cat dancing video")
|
||||
self.assertGreaterEqual(rel, 0.1)
|
||||
self.assertEqual(rel, 0.0)
|
||||
|
||||
def test_full_match_returns_1(self):
|
||||
rel = token_overlap_relevance("python tutorial", "Python Tutorial for Beginners")
|
||||
@@ -96,6 +96,15 @@ class TestTokenOverlapRelevance(unittest.TestCase):
|
||||
rel = token_overlap_relevance("the a is", "some content here")
|
||||
self.assertEqual(rel, 0.5)
|
||||
|
||||
def test_generic_only_overlap_stays_below_filter_threshold(self):
|
||||
rel = token_overlap_relevance("anthropic odds", "Republican house odds update")
|
||||
self.assertLess(rel, 0.3)
|
||||
|
||||
def test_informative_partial_match_stays_above_generic_only(self):
|
||||
generic_only = token_overlap_relevance("anthropic odds", "Republican house odds update")
|
||||
informative = token_overlap_relevance("anthropic odds", "Anthropic valuation market")
|
||||
self.assertGreater(informative, generic_only)
|
||||
|
||||
|
||||
class TestHashtagRelevance(unittest.TestCase):
|
||||
"""Tests for hashtag-aware relevance (TikTok/Instagram pattern)."""
|
||||
|
||||
@@ -44,9 +44,9 @@ class TestComputeRelevance(unittest.TestCase):
|
||||
score = scrapecreators_x._compute_relevance("", "some text")
|
||||
self.assertEqual(score, 0.5)
|
||||
|
||||
def test_floor_at_01(self):
|
||||
def test_no_match_returns_zero(self):
|
||||
score = scrapecreators_x._compute_relevance("abcdef ghijkl", "xyz")
|
||||
self.assertGreaterEqual(score, 0.1)
|
||||
self.assertEqual(score, 0.0)
|
||||
|
||||
|
||||
class TestExtractCoreSubject(unittest.TestCase):
|
||||
|
||||
@@ -33,9 +33,9 @@ class TestTikTokRelevance(unittest.TestCase):
|
||||
rel = tiktok._compute_relevance("", "Some video title")
|
||||
self.assertEqual(rel, 0.5)
|
||||
|
||||
def test_floor(self):
|
||||
def test_no_match_returns_zero(self):
|
||||
rel = tiktok._compute_relevance("quantum physics", "cat dancing video")
|
||||
self.assertGreaterEqual(rel, 0.1)
|
||||
self.assertEqual(rel, 0.0)
|
||||
|
||||
|
||||
class TestExtractCoreSubject(unittest.TestCase):
|
||||
|
||||
@@ -62,7 +62,7 @@ class TestComputeRelevance(unittest.TestCase):
|
||||
|
||||
def test_no_match(self):
|
||||
result = _compute_relevance("Claude Code", "Python Web Scraping")
|
||||
self.assertEqual(result, 0.1) # Floor
|
||||
self.assertEqual(result, 0.0)
|
||||
|
||||
def test_empty_query_returns_neutral(self):
|
||||
result = _compute_relevance("", "Some Video Title")
|
||||
@@ -74,7 +74,7 @@ class TestComputeRelevance(unittest.TestCase):
|
||||
|
||||
def test_empty_title(self):
|
||||
result = _compute_relevance("Claude Code", "")
|
||||
self.assertEqual(result, 0.1) # Floor
|
||||
self.assertEqual(result, 0.0)
|
||||
|
||||
def test_case_insensitive(self):
|
||||
result = _compute_relevance("claude code", "CLAUDE CODE Tutorial")
|
||||
@@ -89,9 +89,9 @@ class TestComputeRelevance(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result, 1.0)
|
||||
|
||||
def test_floor_at_0_1(self):
|
||||
def test_no_match_returns_zero(self):
|
||||
result = _compute_relevance("quantum computing", "cat videos compilation")
|
||||
self.assertEqual(result, 0.1)
|
||||
self.assertEqual(result, 0.0)
|
||||
|
||||
def test_cap_at_1_0(self):
|
||||
result = _compute_relevance("AI", "AI AI AI AI AI")
|
||||
@@ -103,7 +103,7 @@ class TestComputeRelevance(unittest.TestCase):
|
||||
|
||||
def test_single_word_no_match(self):
|
||||
result = _compute_relevance("Seedance", "Random cooking video")
|
||||
self.assertEqual(result, 0.1)
|
||||
self.assertEqual(result, 0.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user