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:
|
||||
|
||||
Reference in New Issue
Block a user