diff --git a/scripts/last30days.py b/scripts/last30days.py index 29365ad..4bdfb02 100644 --- a/scripts/last30days.py +++ b/scripts/last30days.py @@ -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( diff --git a/scripts/lib/polymarket.py b/scripts/lib/polymarket.py index 27c93da..f9da705 100644 --- a/scripts/lib/polymarket.py +++ b/scripts/lib/polymarket.py @@ -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: diff --git a/scripts/lib/reddit.py b/scripts/lib/reddit.py index 8714ed1..6871eb0 100644 --- a/scripts/lib/reddit.py +++ b/scripts/lib/reddit.py @@ -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 diff --git a/scripts/lib/relevance.py b/scripts/lib/relevance.py index eb57b61..88c0f0e 100644 --- a/scripts/lib/relevance.py +++ b/scripts/lib/relevance.py @@ -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) diff --git a/scripts/lib/score.py b/scripts/lib/score.py index 9ac8502..2e0a0ad 100644 --- a/scripts/lib/score.py +++ b/scripts/lib/score.py @@ -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: diff --git a/tests/test_instagram_sc.py b/tests/test_instagram_sc.py index 59fa3a8..6ca2b14 100644 --- a/tests/test_instagram_sc.py +++ b/tests/test_instagram_sc.py @@ -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") diff --git a/tests/test_polymarket.py b/tests/test_polymarket.py index 8298740..0c4e73b 100644 --- a/tests/test_polymarket.py +++ b/tests/test_polymarket.py @@ -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).""" diff --git a/tests/test_reddit_sc.py b/tests/test_reddit_sc.py index d621de8..a212055 100644 --- a/tests/test_reddit_sc.py +++ b/tests/test_reddit_sc.py @@ -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)) diff --git a/tests/test_relevance.py b/tests/test_relevance.py index c641e76..368b1d1 100644 --- a/tests/test_relevance.py +++ b/tests/test_relevance.py @@ -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).""" diff --git a/tests/test_scrapecreators_x.py b/tests/test_scrapecreators_x.py index 0ebb429..2359c7a 100644 --- a/tests/test_scrapecreators_x.py +++ b/tests/test_scrapecreators_x.py @@ -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): diff --git a/tests/test_tiktok.py b/tests/test_tiktok.py index 3307dcc..91e68be 100644 --- a/tests/test_tiktok.py +++ b/tests/test_tiktok.py @@ -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): diff --git a/tests/test_youtube_relevance.py b/tests/test_youtube_relevance.py index 432cb69..51e2ceb 100644 --- a/tests/test_youtube_relevance.py +++ b/tests/test_youtube_relevance.py @@ -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__":