diff --git a/scripts/lib/bird_x.py b/scripts/lib/bird_x.py index be01d46..66dc13c 100644 --- a/scripts/lib/bird_x.py +++ b/scripts/lib/bird_x.py @@ -89,9 +89,11 @@ def _extract_core_subject(topic: str) -> str: # Research/meta descriptors 'best', 'top', 'good', 'great', 'awesome', 'killer', 'latest', 'new', 'news', 'update', 'updates', + 'trendiest', 'trending', 'hottest', 'hot', 'popular', 'viral', 'practices', 'features', 'guide', 'tutorial', 'recommendations', 'advice', 'review', 'reviews', 'usecases', 'examples', 'comparison', 'versus', 'vs', + 'plugin', 'plugins', 'skill', 'skills', 'tool', 'tools', # Prompting meta words 'prompt', 'prompts', 'prompting', 'techniques', 'tips', 'tricks', 'methods', 'strategies', 'approaches', @@ -286,6 +288,21 @@ def search_x( _log(f"0 results for '{core_topic}', retrying with '{shorter}'") query = f"{shorter} since:{from_date}" response = _run_bird_search(query, count, timeout) + items = parse_bird_response(response) + + # Last-chance retry: use strongest remaining token (often the product name) + if not items and core_words: + low_signal = { + 'trendiest', 'trending', 'hottest', 'hot', 'popular', 'viral', + 'best', 'top', 'latest', 'new', 'plugin', 'plugins', + 'skill', 'skills', 'tool', 'tools', + } + candidates = [w for w in core_words if w not in low_signal] + if candidates: + strongest = max(candidates, key=len) + _log(f"0 results for '{core_topic}', retrying with strongest token '{strongest}'") + query = f"{strongest} since:{from_date}" + response = _run_bird_search(query, count, timeout) return response diff --git a/tests/test_bird_x.py b/tests/test_bird_x.py new file mode 100644 index 0000000..3d78bd8 --- /dev/null +++ b/tests/test_bird_x.py @@ -0,0 +1,57 @@ +"""Tests for bird_x module.""" + +import sys +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) + +from lib import bird_x + + +class TestExtractCoreSubject(unittest.TestCase): + def test_strips_trending_noise(self): + result = bird_x._extract_core_subject("trendiest Claude Code skills") + self.assertNotIn("trendiest", result) + self.assertIn("claude", result.lower()) + + def test_strips_tool_noise(self): + result = bird_x._extract_core_subject("best AI tools for coding") + self.assertNotIn("tools", result) + self.assertNotIn("best", result) + + def test_strips_skill_noise(self): + result = bird_x._extract_core_subject("top claude code skills") + self.assertNotIn("skills", result) + self.assertNotIn("top", result) + + +class TestBirdSearchRetries(unittest.TestCase): + def test_last_chance_retry_uses_strongest_token(self): + """When shorter retry also returns 0, uses longest non-noise token.""" + empty = {"items": []} + with mock.patch.object(bird_x, "_extract_core_subject", return_value="best codex skill plugin"), \ + mock.patch.object(bird_x, "parse_bird_response", return_value=[]), \ + mock.patch.object(bird_x, "_run_bird_search", return_value=empty) as run_mock: + bird_x.search_x("best codex skill plugin", "2026-01-01", "2026-01-31", depth="quick") + + # Should try: original, shorter (2-word), last-chance (strongest token) + self.assertEqual(run_mock.call_count, 3) + queries = [call.args[0] for call in run_mock.call_args_list] + # Last call should use "codex" (longest non-noise word) + self.assertIn("codex", queries[2]) + + def test_no_retry_when_first_query_has_results(self): + """No retry when first query succeeds.""" + result = {"items": [{"id": "1"}]} + with mock.patch.object(bird_x, "_extract_core_subject", return_value="nano banana"), \ + mock.patch.object(bird_x, "parse_bird_response", return_value=[{"id": "1"}]), \ + mock.patch.object(bird_x, "_run_bird_search", return_value=result) as run_mock: + bird_x.search_x("nano banana prompting", "2026-01-01", "2026-01-31") + + self.assertEqual(run_mock.call_count, 1) + + +if __name__ == "__main__": + unittest.main()