Merge pull request #65 from j-sperling/feat/search-quality-consolidation
Consolidate query/relevance modules and improve search quality
This commit is contained in:
@@ -79,6 +79,38 @@ class TestConfigPrecedence(unittest.TestCase):
|
||||
config = env.get_config()
|
||||
self.assertEqual(config['BRAVE_API_KEY'], 'env-key')
|
||||
|
||||
def test_gemini_keys_load_from_project_env(self):
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
project_dir = Path(tmpdir) / ".claude"
|
||||
project_dir.mkdir()
|
||||
project_env = project_dir / "last30days.env"
|
||||
project_env.write_text("GEMINI_API_KEY=gem-key\nGEMINI_MODEL=gemini-3-pro-preview\n")
|
||||
|
||||
with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
|
||||
patch.object(env, 'CONFIG_FILE', None), \
|
||||
patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop('GEMINI_API_KEY', None)
|
||||
os.environ.pop('GEMINI_MODEL', None)
|
||||
config = env.get_config()
|
||||
self.assertEqual(config['GEMINI_API_KEY'], 'gem-key')
|
||||
self.assertEqual(config['GEMINI_MODEL'], 'gemini-3-pro-preview')
|
||||
|
||||
def test_google_api_key_loads_from_project_env(self):
|
||||
import tempfile
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
project_dir = Path(tmpdir) / ".claude"
|
||||
project_dir.mkdir()
|
||||
project_env = project_dir / "last30days.env"
|
||||
project_env.write_text("GOOGLE_API_KEY=google-key\n")
|
||||
|
||||
with patch.object(Path, 'cwd', return_value=Path(tmpdir)), \
|
||||
patch.object(env, 'CONFIG_FILE', None), \
|
||||
patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop('GOOGLE_API_KEY', None)
|
||||
config = env.get_config()
|
||||
self.assertEqual(config['GOOGLE_API_KEY'], 'google-key')
|
||||
|
||||
|
||||
class TestConfigSource(unittest.TestCase):
|
||||
"""Tests for _CONFIG_SOURCE tracking."""
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Tests for the local search-quality evaluation harness."""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||
|
||||
import evaluate_search_quality as evalsq
|
||||
|
||||
|
||||
class TestMetrics(unittest.TestCase):
|
||||
def test_jaccard(self):
|
||||
self.assertAlmostEqual(evalsq.jaccard({"a", "b"}, {"b", "c"}), 1 / 3)
|
||||
|
||||
def test_retention(self):
|
||||
self.assertAlmostEqual(evalsq.retention({"a", "b"}, {"b", "c"}), 0.5)
|
||||
|
||||
def test_precision_at_k(self):
|
||||
ranking = [
|
||||
{"key": "a", "source": "reddit"},
|
||||
{"key": "b", "source": "x"},
|
||||
{"key": "c", "source": "youtube"},
|
||||
]
|
||||
judgments = {"a": 3, "b": 1, "c": 2}
|
||||
self.assertAlmostEqual(evalsq.precision_at_k(ranking, judgments, 2), 0.5)
|
||||
|
||||
def test_ndcg_at_k(self):
|
||||
ranking = [
|
||||
{"key": "a", "source": "reddit"},
|
||||
{"key": "b", "source": "x"},
|
||||
{"key": "c", "source": "youtube"},
|
||||
]
|
||||
judgments = {"a": 3, "b": 0, "c": 2}
|
||||
self.assertGreater(evalsq.ndcg_at_k(ranking, judgments, 3), 0.8)
|
||||
|
||||
def test_ndcg_at_k_uses_best_items_from_judged_pool(self):
|
||||
ranking = [
|
||||
{"key": "a", "source": "reddit"},
|
||||
{"key": "b", "source": "x"},
|
||||
{"key": "c", "source": "youtube"},
|
||||
]
|
||||
judged_pool = ranking + [
|
||||
{"key": "d", "source": "reddit"},
|
||||
{"key": "e", "source": "x"},
|
||||
]
|
||||
judgments = {"a": 3, "b": 0, "c": 0, "d": 3, "e": 2}
|
||||
self.assertLess(
|
||||
evalsq.ndcg_at_k(ranking, judgments, 3, judged_pool),
|
||||
1.0,
|
||||
)
|
||||
|
||||
def test_source_coverage_recall_uses_union_pool(self):
|
||||
judged_pool = [
|
||||
{"key": "a", "source": "reddit"},
|
||||
{"key": "b", "source": "x"},
|
||||
{"key": "c", "source": "youtube"},
|
||||
]
|
||||
ranking = [
|
||||
{"key": "a", "source": "reddit"},
|
||||
{"key": "b", "source": "x"},
|
||||
]
|
||||
judgments = {"a": 3, "b": 0, "c": 2}
|
||||
self.assertAlmostEqual(evalsq.source_coverage_recall(ranking, judged_pool, judgments), 0.5)
|
||||
|
||||
|
||||
class TestRankedItems(unittest.TestCase):
|
||||
def test_build_ranked_items_sorts_by_score(self):
|
||||
report = {
|
||||
"reddit": [{"id": "R1", "title": "Low", "url": "r1", "score": 20}],
|
||||
"x": [{"id": "X1", "text": "High", "url": "x1", "score": 90}],
|
||||
"youtube": [],
|
||||
"tiktok": [],
|
||||
"instagram": [],
|
||||
"hackernews": [],
|
||||
"bluesky": [],
|
||||
"truthsocial": [],
|
||||
"polymarket": [],
|
||||
"websearch": [],
|
||||
}
|
||||
ranked = evalsq.build_ranked_items(report, per_source_limit=5)
|
||||
self.assertEqual(ranked[0]["key"], "x1")
|
||||
|
||||
|
||||
class TestPathWithoutNode(unittest.TestCase):
|
||||
def test_removes_node_entries(self):
|
||||
path = "/usr/bin:/tmp/node-bin:/opt/homebrew/bin"
|
||||
|
||||
def fake_exists(path_obj):
|
||||
return str(path_obj).endswith("/tmp/node-bin/node")
|
||||
|
||||
with patch.object(evalsq.Path, "exists", fake_exists):
|
||||
filtered = evalsq.path_without_node(path)
|
||||
self.assertEqual(filtered, "/usr/bin:/opt/homebrew/bin")
|
||||
|
||||
|
||||
class TestEvalToolPath(unittest.TestCase):
|
||||
def test_wraps_ytdlp_with_ignore_config(self):
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
eval_home = Path(tmpdir)
|
||||
with patch.object(evalsq.shutil, "which", return_value="/opt/homebrew/bin/yt-dlp"):
|
||||
path_value = evalsq.create_eval_tool_path(eval_home, "/usr/bin")
|
||||
wrapper = eval_home / "bin" / "yt-dlp"
|
||||
self.assertTrue(wrapper.exists())
|
||||
text = wrapper.read_text()
|
||||
self.assertIn("--ignore-config", text)
|
||||
self.assertIn("--no-cookies-from-browser", text)
|
||||
self.assertEqual(path_value, f"{eval_home / 'bin'}:/usr/bin")
|
||||
|
||||
|
||||
class TestJudgeKeyResolution(unittest.TestCase):
|
||||
def test_prefers_google_api_key(self):
|
||||
config = {
|
||||
"GOOGLE_API_KEY": "google-key",
|
||||
"GEMINI_API_KEY": "gem-key",
|
||||
"GOOGLE_GENAI_API_KEY": "genai-key",
|
||||
}
|
||||
self.assertEqual(evalsq.resolve_google_judge_api_key(config), "google-key")
|
||||
|
||||
def test_falls_back_to_gemini_aliases(self):
|
||||
self.assertEqual(
|
||||
evalsq.resolve_google_judge_api_key({"GEMINI_API_KEY": "gem-key"}),
|
||||
"gem-key",
|
||||
)
|
||||
self.assertEqual(
|
||||
evalsq.resolve_google_judge_api_key({"GOOGLE_GENAI_API_KEY": "genai-key"}),
|
||||
"genai-key",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -8,34 +8,35 @@ from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||
|
||||
from lib import instagram
|
||||
from lib.relevance import tokenize as _tokenize
|
||||
|
||||
|
||||
class TestTokenize(unittest.TestCase):
|
||||
"""Tests for _tokenize()."""
|
||||
"""Tests for tokenize() from relevance module."""
|
||||
|
||||
def test_strips_stopwords(self):
|
||||
tokens = instagram._tokenize("how to use the AI tools")
|
||||
tokens = _tokenize("how to use the AI tools")
|
||||
self.assertNotIn("how", tokens)
|
||||
self.assertNotIn("the", tokens)
|
||||
self.assertNotIn("to", tokens)
|
||||
|
||||
def test_expands_synonyms(self):
|
||||
tokens = instagram._tokenize("ai tools")
|
||||
tokens = _tokenize("ai tools")
|
||||
self.assertTrue("artificial" in tokens or "intelligence" in tokens)
|
||||
|
||||
def test_removes_single_char(self):
|
||||
tokens = instagram._tokenize("a b c python")
|
||||
tokens = _tokenize("a b c python")
|
||||
self.assertNotIn("a", tokens)
|
||||
self.assertNotIn("b", tokens)
|
||||
self.assertIn("python", tokens)
|
||||
|
||||
def test_lowercases(self):
|
||||
tokens = instagram._tokenize("Python REACT")
|
||||
tokens = _tokenize("Python REACT")
|
||||
self.assertIn("python", tokens)
|
||||
self.assertIn("react", tokens)
|
||||
|
||||
def test_strips_punctuation(self):
|
||||
tokens = instagram._tokenize("hello, world!")
|
||||
tokens = _tokenize("hello, world!")
|
||||
self.assertIn("hello", tokens)
|
||||
self.assertIn("world", tokens)
|
||||
|
||||
@@ -56,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")
|
||||
|
||||
@@ -30,6 +30,12 @@ class TestParseVersion(unittest.TestCase):
|
||||
|
||||
class TestIsSearchCapableModel(unittest.TestCase):
|
||||
def test_gpt5_is_capable(self):
|
||||
"""gpt-5 supports web_search when reasoning is not set to 'minimal'.
|
||||
|
||||
Per OpenAI docs, gpt-5 with reasoning effort="minimal" does NOT
|
||||
support web_search. We never set reasoning params (our usage is
|
||||
tool invocation + JSON extraction only), so gpt-5 is safe here.
|
||||
"""
|
||||
self.assertTrue(models.is_search_capable_model("gpt-5"))
|
||||
|
||||
def test_gpt52_is_capable(self):
|
||||
@@ -134,6 +140,27 @@ class TestSelectOpenAIModel(unittest.TestCase):
|
||||
self.assertEqual(result, "gpt-4.1-mini")
|
||||
|
||||
|
||||
class TestSelectOpenAIModelErrorPaths(unittest.TestCase):
|
||||
def setUp(self):
|
||||
from lib import cache
|
||||
cache.MODEL_CACHE_FILE.unlink(missing_ok=True)
|
||||
|
||||
def test_http_error_returns_fallback(self):
|
||||
"""HTTPError during model fetch should return fallback, not crash."""
|
||||
from unittest.mock import patch
|
||||
from lib import http
|
||||
with patch('lib.http.get', side_effect=http.HTTPError("Unauthorized", status_code=401)):
|
||||
result = models.select_openai_model("bad-key", policy="auto")
|
||||
self.assertEqual(result, models.OPENAI_FALLBACK_MODELS[0])
|
||||
|
||||
def test_http_403_returns_fallback(self):
|
||||
from unittest.mock import patch
|
||||
from lib import http
|
||||
with patch('lib.http.get', side_effect=http.HTTPError("Forbidden", status_code=403)):
|
||||
result = models.select_openai_model("bad-key", policy="auto")
|
||||
self.assertEqual(result, models.OPENAI_FALLBACK_MODELS[0])
|
||||
|
||||
|
||||
class TestSelectXAIModel(unittest.TestCase):
|
||||
def test_latest_policy(self):
|
||||
result = models.select_xai_model(
|
||||
|
||||
+60
-13
@@ -78,6 +78,12 @@ class TestExpandQueries(unittest.TestCase):
|
||||
self.assertIn("new", queries)
|
||||
self.assertIn("idea", queries)
|
||||
|
||||
def test_low_signal_words_not_expanded_standalone(self):
|
||||
queries = polymarket._expand_queries("anthropic odds")
|
||||
self.assertIn("anthropic odds", queries)
|
||||
self.assertIn("anthropic", queries)
|
||||
self.assertNotIn("odds", queries)
|
||||
|
||||
|
||||
class TestExtractDomainQueries(unittest.TestCase):
|
||||
def _make_tag(self, label):
|
||||
@@ -195,6 +201,37 @@ class TestFormatPriceMovement(unittest.TestCase):
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
class TestTextSimilarity(unittest.TestCase):
|
||||
def test_short_binary_outcome_does_not_match_substring(self):
|
||||
score = polymarket._compute_text_similarity(
|
||||
"nano banana pro prompting",
|
||||
"NATO x Russia military clash by...?",
|
||||
["No", "Yes"],
|
||||
)
|
||||
self.assertLess(score, 0.3)
|
||||
|
||||
def test_outcome_only_match_is_capped_for_non_prediction_queries(self):
|
||||
score = polymarket._compute_text_similarity(
|
||||
"kanye west",
|
||||
"Top Spotify artist in March?",
|
||||
["Kanye West", "Taylor Swift"],
|
||||
)
|
||||
self.assertLess(score, 0.3)
|
||||
|
||||
def test_direct_title_match_beats_outcome_only_prediction_market(self):
|
||||
direct = polymarket._compute_text_similarity(
|
||||
"anthropic odds",
|
||||
"Will Anthropic or OpenAI IPO first?",
|
||||
[],
|
||||
)
|
||||
generic = polymarket._compute_text_similarity(
|
||||
"anthropic odds",
|
||||
"Which company will have the best AI model for coding on March 31",
|
||||
["Anthropic", "OpenAI", "Google"],
|
||||
)
|
||||
self.assertGreater(direct, generic)
|
||||
|
||||
|
||||
class TestParseOutcomePrices(unittest.TestCase):
|
||||
def test_binary_market_json_strings(self):
|
||||
market = {
|
||||
@@ -569,8 +606,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?")
|
||||
@@ -589,31 +627,32 @@ class TestTextSimilarity(unittest.TestCase):
|
||||
self.assertEqual(score, 1.0)
|
||||
|
||||
def test_outcome_substring_match(self):
|
||||
"""Topic 'Arizona' should match outcome 'Arizona' even when title has no overlap."""
|
||||
"""Prediction queries can still use outcome-only entity matches."""
|
||||
score = polymarket._compute_text_similarity(
|
||||
"Arizona",
|
||||
"Arizona odds",
|
||||
"Who will be the #1 overall seed?",
|
||||
outcomes=["Duke", "Arizona", "Houston"],
|
||||
)
|
||||
self.assertEqual(score, 0.85)
|
||||
self.assertEqual(score, 0.55)
|
||||
|
||||
def test_outcome_bidirectional_match(self):
|
||||
"""Topic 'Arizona Basketball' should match outcome 'Arizona' (outcome in core)."""
|
||||
"""Longer prediction topics keep the same moderated outcome-only cap."""
|
||||
score = polymarket._compute_text_similarity(
|
||||
"Arizona Basketball",
|
||||
"Arizona Basketball odds",
|
||||
"Who will be the #1 overall seed?",
|
||||
outcomes=["Duke", "Arizona", "Houston"],
|
||||
)
|
||||
self.assertEqual(score, 0.85)
|
||||
self.assertEqual(score, 0.55)
|
||||
|
||||
def test_outcome_token_overlap(self):
|
||||
"""Partial token overlap with outcome gets 0.7 when no substring match."""
|
||||
"""Outcome-only prediction matches stay moderate, not dominant."""
|
||||
score = polymarket._compute_text_similarity(
|
||||
"Iran War",
|
||||
"Iran War odds",
|
||||
"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."""
|
||||
@@ -628,11 +667,19 @@ class TestTextSimilarity(unittest.TestCase):
|
||||
"""Outcomes with price <= 1% should be filtered by the caller, not this function."""
|
||||
# This function doesn't filter - it trusts the caller to pass only relevant outcomes
|
||||
score = polymarket._compute_text_similarity(
|
||||
"Arizona",
|
||||
"Arizona odds",
|
||||
"Unrelated title",
|
||||
outcomes=["Arizona"],
|
||||
)
|
||||
self.assertEqual(score, 0.85)
|
||||
self.assertEqual(score, 0.55)
|
||||
|
||||
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)."""
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Tests for query.py — shared query utilities."""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||
|
||||
from lib.query import NOISE_WORDS, extract_compound_terms, extract_core_subject
|
||||
|
||||
|
||||
class TestExtractCoreSubject(unittest.TestCase):
|
||||
"""Tests for extract_core_subject() with default noise set."""
|
||||
|
||||
def test_strips_what_are_prefix(self):
|
||||
self.assertEqual(extract_core_subject("what are the best AI tools"), "ai")
|
||||
|
||||
def test_strips_how_to_prefix(self):
|
||||
self.assertEqual(extract_core_subject("how to use cursor IDE"), "cursor ide")
|
||||
|
||||
def test_strips_what_do_people_think(self):
|
||||
result = extract_core_subject("what do people think about React Server Components")
|
||||
self.assertEqual(result, "react server components")
|
||||
|
||||
def test_preserves_product_name(self):
|
||||
self.assertEqual(extract_core_subject("cursor IDE"), "cursor ide")
|
||||
|
||||
def test_strips_trailing_punctuation(self):
|
||||
result = extract_core_subject("what is Claude?")
|
||||
self.assertFalse(result.endswith("?"))
|
||||
|
||||
def test_empty_string(self):
|
||||
self.assertEqual(extract_core_subject(""), "")
|
||||
|
||||
def test_all_noise_returns_original(self):
|
||||
# When all words are noise, fall back to original text
|
||||
result = extract_core_subject("best latest new")
|
||||
self.assertTrue(len(result) > 0)
|
||||
|
||||
def test_only_first_prefix_stripped(self):
|
||||
# "how to" should match, stripping once, not recursively
|
||||
result = extract_core_subject("how to use how to debug")
|
||||
self.assertIn("debug", result)
|
||||
|
||||
|
||||
class TestMaxWords(unittest.TestCase):
|
||||
"""Tests for max_words parameter."""
|
||||
|
||||
def test_max_words_caps_output(self):
|
||||
result = extract_core_subject(
|
||||
"multi agent reinforcement learning framework",
|
||||
max_words=5,
|
||||
)
|
||||
self.assertLessEqual(len(result.split()), 5)
|
||||
|
||||
def test_max_words_none_no_cap(self):
|
||||
result = extract_core_subject("cursor IDE react native components")
|
||||
# Without max_words, no cap applied
|
||||
self.assertGreaterEqual(len(result.split()), 3)
|
||||
|
||||
def test_max_words_fallback_on_empty(self):
|
||||
# All words filtered + max_words should fall back to original
|
||||
result = extract_core_subject("best top latest", max_words=3)
|
||||
self.assertTrue(len(result) > 0)
|
||||
|
||||
|
||||
class TestStripSuffixes(unittest.TestCase):
|
||||
"""Tests for strip_suffixes parameter."""
|
||||
|
||||
def test_strips_best_practices(self):
|
||||
result = extract_core_subject(
|
||||
"claude code best practices",
|
||||
strip_suffixes=True,
|
||||
)
|
||||
self.assertNotIn("practices", result)
|
||||
|
||||
def test_strips_use_cases(self):
|
||||
result = extract_core_subject(
|
||||
"react hooks use cases",
|
||||
strip_suffixes=True,
|
||||
)
|
||||
self.assertNotIn("cases", result)
|
||||
|
||||
def test_no_strip_without_flag(self):
|
||||
result = extract_core_subject("claude code best practices")
|
||||
# "best" and "practices" are noise words so they get filtered anyway
|
||||
# but the suffix phase doesn't run
|
||||
self.assertIn("claude", result)
|
||||
|
||||
|
||||
class TestCustomNoise(unittest.TestCase):
|
||||
"""Tests for noise override parameter."""
|
||||
|
||||
def test_custom_noise_keeps_tips(self):
|
||||
# YouTube keeps tips/tricks/tutorial — pass a noise set without them
|
||||
youtube_noise = frozenset({
|
||||
'best', 'top', 'good', 'great', 'awesome', 'killer',
|
||||
'latest', 'new', 'news', 'update', 'updates',
|
||||
'trending', 'hottest', 'popular', 'viral',
|
||||
'practices', 'features',
|
||||
'recommendations', 'advice',
|
||||
'prompt', 'prompts', 'prompting',
|
||||
'methods', 'strategies', 'approaches',
|
||||
})
|
||||
result = extract_core_subject("best react tips", noise=youtube_noise)
|
||||
self.assertIn("tips", result)
|
||||
|
||||
def test_default_noise_removes_tips(self):
|
||||
result = extract_core_subject("best react tips")
|
||||
self.assertNotIn("tips", result)
|
||||
|
||||
|
||||
class TestNoiseWordsCompleteness(unittest.TestCase):
|
||||
"""Verify NOISE_WORDS superset covers all platform sets."""
|
||||
|
||||
def test_question_words_present(self):
|
||||
for w in ('who', 'why', 'when', 'where', 'does', 'should', 'could', 'would'):
|
||||
self.assertIn(w, NOISE_WORDS, f"Missing question word: {w}")
|
||||
|
||||
def test_core_filler_present(self):
|
||||
for w in ('the', 'a', 'an', 'is', 'are', 'for', 'with', 'about'):
|
||||
self.assertIn(w, NOISE_WORDS)
|
||||
|
||||
def test_research_meta_present(self):
|
||||
for w in ('best', 'top', 'latest', 'trending', 'popular'):
|
||||
self.assertIn(w, NOISE_WORDS)
|
||||
|
||||
|
||||
|
||||
class TestExtractCompoundTerms(unittest.TestCase):
|
||||
"""Tests for extract_compound_terms()."""
|
||||
|
||||
def test_hyphenated(self):
|
||||
terms = extract_compound_terms("multi-agent reinforcement learning")
|
||||
self.assertIn("multi-agent", terms)
|
||||
|
||||
def test_title_case(self):
|
||||
terms = extract_compound_terms("Claude Code and React Native")
|
||||
self.assertTrue(any("Claude Code" in t for t in terms))
|
||||
self.assertTrue(any("React Native" in t for t in terms))
|
||||
|
||||
def test_no_compounds(self):
|
||||
terms = extract_compound_terms("python tutorial")
|
||||
self.assertEqual(len(terms), 0)
|
||||
|
||||
def test_multiple_hyphens(self):
|
||||
terms = extract_compound_terms("vc-backed start-up")
|
||||
self.assertIn("vc-backed", terms)
|
||||
self.assertIn("start-up", terms)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -20,6 +20,7 @@ class TestDetectQueryType(unittest.TestCase):
|
||||
self.assertEqual(detect_query_type("cursor IDE pricing"), "product")
|
||||
self.assertEqual(detect_query_type("is Claude Pro worth the cost"), "product")
|
||||
self.assertEqual(detect_query_type("best free tier LLM API"), "product")
|
||||
self.assertEqual(detect_query_type("nano banana pro prompting"), "product")
|
||||
|
||||
def test_concept_queries(self):
|
||||
self.assertEqual(detect_query_type("what is WebTransport"), "concept")
|
||||
|
||||
+35
-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))
|
||||
|
||||
|
||||
@@ -146,5 +158,24 @@ class TestDepthConfig(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestPostRelevance(unittest.TestCase):
|
||||
def test_body_cannot_rescue_weak_title_too_far(self):
|
||||
score = reddit._compute_post_relevance(
|
||||
"anthropic odds",
|
||||
"President Trump orders agencies to stop using Anthropic technology",
|
||||
"Long body text eventually mentions odds and other tangential details.",
|
||||
)
|
||||
self.assertLess(score, 0.7)
|
||||
self.assertGreaterEqual(score, 0.5)
|
||||
|
||||
def test_exact_title_match_stays_high(self):
|
||||
score = reddit._compute_post_relevance(
|
||||
"claude code tips",
|
||||
"Claude Code tips for faster workflows",
|
||||
"",
|
||||
)
|
||||
self.assertGreater(score, 0.7)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Tests for relevance.py — shared relevance scoring.
|
||||
|
||||
Migrated from test_youtube_relevance.py + new hashtag/synonym tests.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||
|
||||
from lib.relevance import STOPWORDS, SYNONYMS, token_overlap_relevance, tokenize
|
||||
|
||||
|
||||
class TestTokenize(unittest.TestCase):
|
||||
"""Tests for tokenize()."""
|
||||
|
||||
def test_removes_stopwords(self):
|
||||
tokens = tokenize("how to use the AI tools")
|
||||
self.assertNotIn("how", tokens)
|
||||
self.assertNotIn("to", tokens)
|
||||
self.assertNotIn("the", tokens)
|
||||
self.assertIn("ai", tokens)
|
||||
self.assertIn("tools", tokens)
|
||||
|
||||
def test_lowercases(self):
|
||||
tokens = tokenize("Python REACT")
|
||||
self.assertIn("python", tokens)
|
||||
self.assertIn("react", tokens)
|
||||
|
||||
def test_strips_punctuation(self):
|
||||
tokens = tokenize("hello, world!")
|
||||
self.assertIn("hello", tokens)
|
||||
self.assertIn("world", tokens)
|
||||
|
||||
def test_drops_single_char(self):
|
||||
tokens = tokenize("a b c python")
|
||||
self.assertNotIn("a", tokens)
|
||||
self.assertNotIn("b", tokens)
|
||||
self.assertNotIn("c", tokens)
|
||||
self.assertIn("python", tokens)
|
||||
|
||||
def test_expands_synonyms(self):
|
||||
tokens = tokenize("ai tools")
|
||||
self.assertIn("artificial", tokens)
|
||||
self.assertIn("intelligence", tokens)
|
||||
|
||||
def test_expands_js_synonym(self):
|
||||
tokens = tokenize("js framework")
|
||||
self.assertIn("javascript", tokens)
|
||||
|
||||
def test_expands_svelte(self):
|
||||
tokens = tokenize("svelte app")
|
||||
self.assertIn("sveltejs", tokens)
|
||||
|
||||
def test_expands_vue(self):
|
||||
tokens = tokenize("vue components")
|
||||
self.assertIn("vuejs", tokens)
|
||||
|
||||
|
||||
class TestTokenOverlapRelevance(unittest.TestCase):
|
||||
"""Tests for token_overlap_relevance()."""
|
||||
|
||||
def test_high_relevance_exact_match(self):
|
||||
rel = token_overlap_relevance("claude code", "Claude Code tricks and tips")
|
||||
self.assertGreater(rel, 0.7)
|
||||
|
||||
def test_low_relevance_no_match(self):
|
||||
rel = token_overlap_relevance("claude code tips", "Best AI tools for coding")
|
||||
self.assertLess(rel, 0.5)
|
||||
|
||||
def test_empty_query_returns_neutral(self):
|
||||
rel = token_overlap_relevance("", "Some video title")
|
||||
self.assertEqual(rel, 0.5)
|
||||
|
||||
def test_floor_at_0_1(self):
|
||||
rel = token_overlap_relevance("quantum physics", "cat dancing video")
|
||||
self.assertEqual(rel, 0.0)
|
||||
|
||||
def test_full_match_returns_1(self):
|
||||
rel = token_overlap_relevance("python tutorial", "Python Tutorial for Beginners")
|
||||
self.assertEqual(rel, 1.0)
|
||||
|
||||
def test_partial_match(self):
|
||||
rel = token_overlap_relevance("react native tutorial", "React Native Guide")
|
||||
self.assertGreater(rel, 0.3)
|
||||
self.assertLess(rel, 1.0)
|
||||
|
||||
def test_synonym_boosts_relevance(self):
|
||||
# "js" should match "javascript" via synonym expansion
|
||||
rel_with_syn = token_overlap_relevance("js framework", "javascript framework comparison")
|
||||
rel_without = token_overlap_relevance("python framework", "javascript framework comparison")
|
||||
self.assertGreater(rel_with_syn, rel_without)
|
||||
|
||||
def test_stopword_only_query(self):
|
||||
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)."""
|
||||
|
||||
def test_hashtag_boost(self):
|
||||
rel_no_hash = token_overlap_relevance("claude code", "random video about stuff")
|
||||
rel_with_hash = token_overlap_relevance(
|
||||
"claude code", "random video about stuff", ["claudecode", "ai"]
|
||||
)
|
||||
self.assertGreater(rel_with_hash, rel_no_hash)
|
||||
|
||||
def test_concatenated_hashtag_splitting(self):
|
||||
# "claudecode" should match "claude" from query via substring check
|
||||
rel = token_overlap_relevance("claude", "video", ["claudecode"])
|
||||
self.assertGreater(rel, 0.5)
|
||||
|
||||
def test_none_hashtags_same_as_no_hashtags(self):
|
||||
rel1 = token_overlap_relevance("test query", "test content", None)
|
||||
rel2 = token_overlap_relevance("test query", "test content")
|
||||
self.assertEqual(rel1, rel2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -192,5 +192,160 @@ class TestInstagramEngagement(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestBlueskyEngagement(unittest.TestCase):
|
||||
"""Tests for compute_bluesky_engagement_raw()."""
|
||||
|
||||
def test_basic(self):
|
||||
eng = schema.Engagement(likes=100, reposts=25, replies=15, quotes=5)
|
||||
raw = score.compute_bluesky_engagement_raw(eng)
|
||||
self.assertIsNotNone(raw)
|
||||
self.assertGreater(raw, 0)
|
||||
|
||||
def test_likes_dominate(self):
|
||||
likes_heavy = schema.Engagement(likes=1000, reposts=0, replies=0, quotes=0)
|
||||
reposts_heavy = schema.Engagement(likes=0, reposts=1000, replies=0, quotes=0)
|
||||
self.assertGreater(
|
||||
score.compute_bluesky_engagement_raw(likes_heavy),
|
||||
score.compute_bluesky_engagement_raw(reposts_heavy),
|
||||
)
|
||||
|
||||
def test_none_engagement(self):
|
||||
self.assertIsNone(score.compute_bluesky_engagement_raw(None))
|
||||
|
||||
def test_no_likes_no_reposts(self):
|
||||
eng = schema.Engagement(replies=10)
|
||||
self.assertIsNone(score.compute_bluesky_engagement_raw(eng))
|
||||
|
||||
|
||||
class TestTruthSocialEngagement(unittest.TestCase):
|
||||
"""Tests for compute_truthsocial_engagement_raw()."""
|
||||
|
||||
def test_basic(self):
|
||||
eng = schema.Engagement(likes=100, reposts=25, replies=15)
|
||||
raw = score.compute_truthsocial_engagement_raw(eng)
|
||||
self.assertIsNotNone(raw)
|
||||
self.assertGreater(raw, 0)
|
||||
|
||||
def test_likes_dominate(self):
|
||||
likes_heavy = schema.Engagement(likes=1000, reposts=0, replies=0)
|
||||
reposts_heavy = schema.Engagement(likes=0, reposts=1000, replies=0)
|
||||
self.assertGreater(
|
||||
score.compute_truthsocial_engagement_raw(likes_heavy),
|
||||
score.compute_truthsocial_engagement_raw(reposts_heavy),
|
||||
)
|
||||
|
||||
def test_none_engagement(self):
|
||||
self.assertIsNone(score.compute_truthsocial_engagement_raw(None))
|
||||
|
||||
|
||||
class TestScoreBlueskyItems(unittest.TestCase):
|
||||
"""Tests for score_bluesky_items()."""
|
||||
|
||||
def test_scores_items(self):
|
||||
items = [
|
||||
schema.BlueskyItem(
|
||||
id="bsky1", text="Test", url="https://bsky.app/1",
|
||||
author_handle="user.bsky.social", display_name="User",
|
||||
engagement=schema.Engagement(likes=50, reposts=10, replies=5, quotes=2),
|
||||
relevance=0.8,
|
||||
),
|
||||
]
|
||||
result = score.score_bluesky_items(items)
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertGreater(result[0].score, 0)
|
||||
|
||||
def test_empty_list(self):
|
||||
self.assertEqual(score.score_bluesky_items([]), [])
|
||||
|
||||
|
||||
class TestScoreTruthSocialItems(unittest.TestCase):
|
||||
"""Tests for score_truthsocial_items()."""
|
||||
|
||||
def test_scores_items(self):
|
||||
items = [
|
||||
schema.TruthSocialItem(
|
||||
id="ts1", text="Test", url="https://truthsocial.com/1",
|
||||
author_handle="@user", display_name="User",
|
||||
engagement=schema.Engagement(likes=50, reposts=10, replies=5),
|
||||
relevance=0.8,
|
||||
),
|
||||
]
|
||||
result = score.score_truthsocial_items(items)
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertGreater(result[0].score, 0)
|
||||
|
||||
def test_empty_list(self):
|
||||
self.assertEqual(score.score_truthsocial_items([]), [])
|
||||
|
||||
|
||||
class TestSortItemsMixedSources(unittest.TestCase):
|
||||
"""Test sort_items with Bluesky and TruthSocial items."""
|
||||
|
||||
def test_bluesky_item_sorts(self):
|
||||
items = [
|
||||
schema.RedditItem(id="R1", title="Reddit", url="", subreddit="", score=30),
|
||||
schema.BlueskyItem(id="B1", text="Bluesky", url="", author_handle="u.bsky.social", display_name="U", score=90),
|
||||
]
|
||||
result = score.sort_items(items)
|
||||
self.assertEqual(result[0].id, "B1")
|
||||
|
||||
def test_truthsocial_item_sorts(self):
|
||||
items = [
|
||||
schema.RedditItem(id="R1", title="Reddit", url="", subreddit="", score=30),
|
||||
schema.TruthSocialItem(id="T1", text="TS", url="", author_handle="@u", display_name="U", score=90),
|
||||
]
|
||||
result = score.sort_items(items)
|
||||
self.assertEqual(result[0].id, "T1")
|
||||
|
||||
|
||||
class TestRelevanceFilter(unittest.TestCase):
|
||||
"""Tests for relevance_filter()."""
|
||||
|
||||
def _make_items(self, relevances):
|
||||
"""Helper: create RedditItems with given relevance values."""
|
||||
return [
|
||||
schema.RedditItem(id=f"R{i}", title=f"Item {i}", url="", subreddit="", relevance=r)
|
||||
for i, r in enumerate(relevances)
|
||||
]
|
||||
|
||||
def test_filters_below_threshold(self):
|
||||
items = self._make_items([0.8, 0.1, 0.5, 0.2])
|
||||
result = score.relevance_filter(items, "TEST", threshold=0.3)
|
||||
self.assertEqual(len(result), 2)
|
||||
self.assertTrue(all(i.relevance >= 0.3 for i in result))
|
||||
|
||||
def test_small_list_unchanged(self):
|
||||
items = self._make_items([0.1, 0.05, 0.02])
|
||||
result = score.relevance_filter(items, "TEST")
|
||||
self.assertEqual(len(result), 3)
|
||||
|
||||
def test_all_below_threshold_keeps_top_3(self):
|
||||
items = self._make_items([0.1, 0.25, 0.05, 0.2, 0.15])
|
||||
result = score.relevance_filter(items, "TEST", threshold=0.3)
|
||||
self.assertEqual(len(result), 3)
|
||||
# Should be sorted by relevance: 0.25, 0.2, 0.15
|
||||
self.assertEqual(result[0].relevance, 0.25)
|
||||
self.assertEqual(result[1].relevance, 0.2)
|
||||
|
||||
def test_empty_list(self):
|
||||
result = score.relevance_filter([], "TEST")
|
||||
self.assertEqual(result, [])
|
||||
|
||||
def test_items_without_relevance_attr_treated_as_zero(self):
|
||||
"""Objects lacking a relevance attribute get 0.0, failing the filter."""
|
||||
class BareItem:
|
||||
def __init__(self, id):
|
||||
self.id = id
|
||||
items = [
|
||||
schema.RedditItem(id="R0", title="Has relevance", url="", subreddit="", relevance=0.8),
|
||||
BareItem("B1"),
|
||||
BareItem("B2"),
|
||||
BareItem("B3"),
|
||||
]
|
||||
result = score.relevance_filter(items, "TEST", threshold=0.3)
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0].id, "R0")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -6,26 +6,27 @@ from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||
from lib import scrapecreators_x
|
||||
from lib.relevance import tokenize as _tokenize
|
||||
|
||||
|
||||
class TestTokenize(unittest.TestCase):
|
||||
def test_lowercases(self):
|
||||
tokens = scrapecreators_x._tokenize("Claude AI")
|
||||
tokens = _tokenize("Claude AI")
|
||||
self.assertIn("claude", tokens)
|
||||
|
||||
def test_strips_stopwords(self):
|
||||
tokens = scrapecreators_x._tokenize("the best AI tool")
|
||||
tokens = _tokenize("the best AI tool")
|
||||
self.assertNotIn("the", tokens)
|
||||
self.assertIn("best", tokens) # 'best' is not a stopword in tokenizer
|
||||
|
||||
def test_removes_single_char(self):
|
||||
tokens = scrapecreators_x._tokenize("a b cd ef")
|
||||
tokens = _tokenize("a b cd ef")
|
||||
self.assertNotIn("a", tokens)
|
||||
self.assertNotIn("b", tokens)
|
||||
self.assertIn("cd", tokens)
|
||||
|
||||
def test_expands_synonyms(self):
|
||||
tokens = scrapecreators_x._tokenize("ai research")
|
||||
tokens = _tokenize("ai research")
|
||||
self.assertIn("artificial", tokens)
|
||||
self.assertIn("intelligence", tokens)
|
||||
|
||||
@@ -43,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):
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
# Add lib to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "scripts"))
|
||||
|
||||
from lib.youtube_yt import _compute_relevance, _tokenize
|
||||
from lib.relevance import token_overlap_relevance as _compute_relevance, tokenize as _tokenize
|
||||
|
||||
|
||||
class TestTokenize(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