Files
last30days-skill/tests/test_pipeline_v3.py
T
Tobi 4f6b86c456 feat: honor EXCLUDE_SOURCES env var in source count + pipeline filter
Adds a per-run denylist via the existing-but-unused EXCLUDE_SOURCES
config key. Two coupled changes:

1. pipeline.available_sources() filters out any source listed in
   config["EXCLUDE_SOURCES"] (comma-separated, case-insensitive,
   whitespace-tolerant) before returning.
2. hooks/scripts/check-config.sh "Ready — N sources active" banner
   subtracts excluded sources from the ScrapeCreators +3 (Reddit
   comments + TikTok + Instagram) so the count matches what the
   pipeline actually runs.

Use case: skip TikTok/Instagram on runs where you only want
text-substantive sources, without unsetting SCRAPECREATORS_API_KEY
(which would also kill Reddit comments). The existing INCLUDE_SOURCES
allowlist covers Perplexity opt-in but doesn't cover this denylist case
— tiktok and instagram are added unconditionally when
SCRAPECREATORS_API_KEY is set, with no opt-out short of removing the key.

Tests (tests/test_pipeline_v3.py::TestExcludeSources):
- excludes tiktok+instagram when listed
- no exclusion when env unset or empty string
- case-insensitive + whitespace-tolerant parsing
- works for any source (e.g. EXCLUDE_SOURCES=hackernews), not just SC-backed
2026-05-16 00:54:18 +02:00

961 lines
37 KiB
Python

import sys
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "skills" / "last30days" / "scripts"))
from lib import pipeline
from lib import http
from lib import schema
class PipelineV3Tests(unittest.TestCase):
def test_mock_pipeline_report_without_live_credentials(self):
report = pipeline.run(
topic="test topic",
config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
depth="quick",
requested_sources=["reddit", "x", "grounding"],
mock=True,
)
self.assertEqual("test topic", report.topic)
self.assertTrue(report.ranked_candidates)
self.assertTrue(report.clusters)
self.assertIn("x", report.items_by_source)
# Grounding items now enter the ranked pool (web search backends produce real items)
self.assertIn("grounding", report.items_by_source)
self.assertEqual("gemini", report.provider_runtime.reasoning_provider)
def test_planner_trace_always_fires_on_mock_run(self):
"""Unit 5: The unified planner trace emits one summary line plus one
line per subquery on every run, regardless of --debug. 2026-04-19
Hermes Agent Use Cases failure: retrieval-breadth issues were invisible
because the internal planner path logged nothing.
"""
import io
import contextlib
buf = io.StringIO()
with contextlib.redirect_stderr(buf):
pipeline.run(
topic="test topic",
config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
depth="quick",
requested_sources=["reddit", "x", "grounding"],
mock=True,
)
output = buf.getvalue()
self.assertIn("[Planner] Plan: intent=", output)
self.assertIn("subqueries=", output)
self.assertIn("source=", output)
# At least one per-subquery line.
self.assertIn("[Planner] sq1 label=", output)
class TestSourceFetchCap(unittest.TestCase):
"""X source fetch count must be capped by MAX_SOURCE_FETCHES."""
def test_x_capped_in_max_source_fetches(self):
"""MAX_SOURCE_FETCHES must cap X at 2 to prevent 429 cascades."""
self.assertIn("x", pipeline.MAX_SOURCE_FETCHES)
self.assertEqual(pipeline.MAX_SOURCE_FETCHES["x"], 2)
def test_cap_logic_limits_source_submissions(self):
"""Verify the cap logic skips submissions beyond the limit."""
cap = pipeline.MAX_SOURCE_FETCHES.get("x", float("inf"))
subquery_sources = [
["x", "reddit", "youtube"],
["x", "reddit", "youtube"],
["x", "reddit", "youtube"],
["x", "reddit", "youtube"],
]
source_fetch_count: dict[str, int] = {}
submitted: list[str] = []
for sources in subquery_sources:
for source in sources:
source_cap = pipeline.MAX_SOURCE_FETCHES.get(source)
if source_cap is not None:
current = source_fetch_count.get(source, 0)
if current >= source_cap:
continue
source_fetch_count[source] = current + 1
submitted.append(source)
x_count = submitted.count("x")
reddit_count = submitted.count("reddit")
self.assertEqual(x_count, 2, f"X should be capped at 2, got {x_count}")
self.assertEqual(reddit_count, 4, f"Reddit should be uncapped, got {reddit_count}")
@patch("lib.pipeline._retrieve_stream")
def test_mock_run_caps_x_fetches(self, mock_retrieve):
"""Pipeline.run in mock mode should call _retrieve_stream for X at most 2 times."""
mock_retrieve.side_effect = lambda **kwargs: pipeline._mock_stream_results(
kwargs["source"], kwargs["subquery"]
)
report = pipeline.run(
topic="compare iPhone vs Android vs Pixel vs Samsung",
config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
depth="quick",
requested_sources=["reddit", "x"],
mock=True,
)
x_calls = [
call for call in mock_retrieve.call_args_list
if call.kwargs.get("source") == "x"
]
self.assertLessEqual(
len(x_calls), 2,
f"X should be fetched at most 2 times, got {len(x_calls)}",
)
class TestRateLimitSharing(unittest.TestCase):
"""429 signals should be shared across subqueries."""
def test_is_rate_limit_error_detects_429_status(self):
exc = http.HTTPError("HTTP 429: Too Many Requests", status_code=429)
self.assertTrue(pipeline._is_rate_limit_error(exc))
def test_is_rate_limit_error_ignores_non_429(self):
exc = http.HTTPError("HTTP 400: Bad Request", status_code=400)
self.assertFalse(pipeline._is_rate_limit_error(exc))
def test_is_rate_limit_error_detects_429_in_string(self):
exc = RuntimeError("xAI returned 429 rate limit")
self.assertTrue(pipeline._is_rate_limit_error(exc))
def test_is_rate_limit_error_rejects_unrelated_error(self):
exc = RuntimeError("Connection refused")
self.assertFalse(pipeline._is_rate_limit_error(exc))
def test_retrieve_stream_skips_rate_limited_source(self):
"""_retrieve_stream should return empty when source is rate-limited."""
from lib import schema
rate_limited = {"x"}
lock = threading.Lock()
subquery = schema.SubQuery(
label="test",
search_query="test query",
ranking_query="test query",
sources=["x"],
)
items, artifact = pipeline._retrieve_stream(
topic="test",
subquery=subquery,
source="x",
config={},
depth="quick",
date_range=("2026-02-15", "2026-03-17"),
runtime=schema.ProviderRuntime(
reasoning_provider="mock",
planner_model="mock",
rerank_model="mock",
),
mock=True,
rate_limited_sources=rate_limited,
rate_limit_lock=lock,
)
self.assertEqual(items, [])
self.assertEqual(artifact, {})
class TestThinSourceRetry(unittest.TestCase):
@patch("lib.pipeline._retrieve_stream")
def test_retry_includes_planned_source_with_zero_initial_items(self, mock_retrieve):
mock_retrieve.return_value = (
[
{
"id": "X100",
"text": "OpenClaw funding update from an investor",
"url": "https://x.com/example/status/100",
"author_handle": "example",
"date": "2026-03-15",
"engagement": {"likes": 25, "reposts": 4, "replies": 2},
"relevance": 0.8,
"why_relevant": "retry result",
}
],
{},
)
plan = schema.QueryPlan(
intent="breaking_news",
freshness_mode="strict_recent",
cluster_mode="story",
raw_topic="latest OpenClaw funding updates",
subqueries=[
schema.SubQuery(
label="primary",
search_query="latest OpenClaw funding updates",
ranking_query="What recent evidence matters for OpenClaw funding?",
sources=["x", "reddit"],
)
],
source_weights={"x": 1.0, "reddit": 1.0},
)
bundle = schema.RetrievalBundle(
items_by_source={
"reddit": [
_make_source_item("reddit", "r1", "https://reddit.com/1"),
_make_source_item("reddit", "r2", "https://reddit.com/2"),
_make_source_item("reddit", "r3", "https://reddit.com/3"),
]
}
)
pipeline._retry_thin_sources(
topic="latest OpenClaw funding updates",
bundle=bundle,
plan=plan,
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime("bird"),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
settings=pipeline.DEPTH_SETTINGS["default"],
)
self.assertEqual(["x"], [call.kwargs["source"] for call in mock_retrieve.call_args_list])
self.assertIn("x", bundle.items_by_source)
self.assertEqual("https://x.com/example/status/100", bundle.items_by_source["x"][0].url)
def _make_runtime(x_backend="bird"):
return schema.ProviderRuntime(
reasoning_provider="mock",
planner_model="mock",
rerank_model="mock",
x_search_backend=x_backend,
)
def _make_plan(topic="test topic"):
return schema.QueryPlan(
intent="exploration",
freshness_mode="balanced_recent",
cluster_mode="topic",
raw_topic=topic,
subqueries=[
schema.SubQuery(
label="primary",
search_query=topic,
ranking_query=f"What recent evidence matters for {topic}?",
sources=["x", "reddit"],
)
],
source_weights={"x": 1.0, "reddit": 1.0},
)
def _make_source_item(source, item_id, url, author=None, body="", container=None, metadata=None):
return schema.SourceItem(
item_id=item_id,
source=source,
title=f"Item {item_id}",
body=body,
url=url,
author=author,
container=container,
metadata=metadata or {},
)
class TestSupplementalSearches(unittest.TestCase):
"""R1: Phase 2 entity drilling should be wired into the pipeline."""
def test_run_supplemental_searches_exists(self):
"""_run_supplemental_searches must be a callable in pipeline module."""
self.assertTrue(
hasattr(pipeline, "_run_supplemental_searches"),
"_run_supplemental_searches function not found in pipeline module",
)
self.assertTrue(callable(pipeline._run_supplemental_searches))
@patch("lib.bird_x.search_handles")
@patch("lib.entity_extract.extract_entities")
def test_entity_extract_called_after_phase1(self, mock_extract, mock_handles):
"""Phase 2 should call entity_extract on Phase 1 X results, then search_handles."""
mock_extract.return_value = {"x_handles": ["analyst1", "reporter2"], "x_hashtags": [], "reddit_subreddits": []}
mock_handles.return_value = [
{
"id": "supp1",
"text": "Supplemental tweet from analyst1",
"url": "https://x.com/analyst1/status/999",
"author_handle": "analyst1",
"date": "2026-03-15",
"engagement": {"likes": 50},
"relevance": 0.8,
"why_relevant": "direct handle search",
}
]
bundle = schema.RetrievalBundle()
bundle.items_by_source["x"] = [
_make_source_item("x", "X1", "https://x.com/analyst1/status/1", author="analyst1", body="Some tweet about AI"),
_make_source_item("x", "X2", "https://x.com/reporter2/status/2", author="reporter2", body="AI analysis @expert3"),
]
plan = _make_plan("AI safety")
config = {}
pipeline._run_supplemental_searches(
topic="AI safety",
bundle=bundle,
plan=plan,
config=config,
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime("bird"),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
)
mock_extract.assert_called_once()
mock_handles.assert_called_once()
# Supplemental items should be merged into bundle
x_urls = {item.url for item in bundle.items_by_source.get("x", [])}
self.assertIn("https://x.com/analyst1/status/999", x_urls)
@patch("lib.bird_x.search_handles")
@patch("lib.entity_extract.extract_entities")
def test_supplemental_items_deduplicated_by_url(self, mock_extract, mock_handles):
"""Supplemental items with same URL as Phase 1 should not be duplicated."""
mock_extract.return_value = {"x_handles": ["analyst1"], "x_hashtags": [], "reddit_subreddits": []}
# Return item with same URL as Phase 1
mock_handles.return_value = [
{
"id": "dup1",
"text": "Same tweet",
"url": "https://x.com/analyst1/status/1",
"author_handle": "analyst1",
"date": "2026-03-15",
"engagement": {"likes": 50},
"relevance": 0.8,
"why_relevant": "duplicate",
}
]
bundle = schema.RetrievalBundle()
original = _make_source_item("x", "X1", "https://x.com/analyst1/status/1", author="analyst1")
bundle.items_by_source["x"] = [original]
plan = _make_plan("AI safety")
pipeline._run_supplemental_searches(
topic="AI safety",
bundle=bundle,
plan=plan,
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime("bird"),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
)
# Should still have only 1 item (no duplicates)
x_items = bundle.items_by_source.get("x", [])
urls = [item.url for item in x_items]
self.assertEqual(
urls.count("https://x.com/analyst1/status/1"), 1,
f"Duplicate URL found: {urls}",
)
def test_phase2_skipped_in_quick_mode(self):
"""_run_supplemental_searches should return immediately when depth='quick'."""
bundle = schema.RetrievalBundle()
bundle.items_by_source["x"] = [
_make_source_item("x", "X1", "https://x.com/a/1", author="someone"),
]
# If it tries to import entity_extract, that's fine -- it should return before calling it
pipeline._run_supplemental_searches(
topic="test",
bundle=bundle,
plan=_make_plan(),
config={},
depth="quick",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime("bird"),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
)
# Bundle should be unchanged (only original item)
self.assertEqual(len(bundle.items_by_source["x"]), 1)
def test_phase2_skipped_in_mock_mode(self):
"""_run_supplemental_searches should return immediately when mock=True."""
bundle = schema.RetrievalBundle()
bundle.items_by_source["x"] = [
_make_source_item("x", "X1", "https://x.com/a/1", author="someone"),
]
pipeline._run_supplemental_searches(
topic="test",
bundle=bundle,
plan=_make_plan(),
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime("bird"),
mock=True,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
)
self.assertEqual(len(bundle.items_by_source["x"]), 1)
def test_phase2_skipped_when_x_rate_limited(self):
"""_run_supplemental_searches should skip when X is rate-limited."""
bundle = schema.RetrievalBundle()
bundle.items_by_source["x"] = [
_make_source_item("x", "X1", "https://x.com/a/1", author="someone"),
]
pipeline._run_supplemental_searches(
topic="test",
bundle=bundle,
plan=_make_plan(),
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime("bird"),
mock=False,
rate_limited_sources={"x"},
rate_limit_lock=threading.Lock(),
)
self.assertEqual(len(bundle.items_by_source["x"]), 1)
def test_phase2_skipped_when_backend_not_bird(self):
"""_run_supplemental_searches should skip when X backend is not bird."""
bundle = schema.RetrievalBundle()
bundle.items_by_source["x"] = [
_make_source_item("x", "X1", "https://x.com/a/1", author="someone"),
]
pipeline._run_supplemental_searches(
topic="test",
bundle=bundle,
plan=_make_plan(),
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime("xai"),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
)
self.assertEqual(len(bundle.items_by_source["x"]), 1)
class TestThinSourceRetry(unittest.TestCase):
"""R2: Dynamic query refinement on thin results."""
def test_retry_thin_sources_exists(self):
"""_retry_thin_sources must be a callable in pipeline module."""
self.assertTrue(
hasattr(pipeline, "_retry_thin_sources"),
"_retry_thin_sources function not found in pipeline module",
)
self.assertTrue(callable(pipeline._retry_thin_sources))
@patch("lib.pipeline._retrieve_stream")
def test_thin_source_retried_with_core_subject(self, mock_retrieve):
"""Sources with < 3 items and no errors should be retried."""
mock_retrieve.return_value = (
[
{
"id": "retry1",
"title": "Retry result",
"url": "https://reddit.com/r/test/2",
"subreddit": "test",
"date": "2026-03-15",
"engagement": {"score": 10},
"selftext": "Retry content",
"relevance": 0.7,
"why_relevant": "retry",
}
],
{},
)
bundle = schema.RetrievalBundle()
# Only 1 reddit item (thin)
bundle.items_by_source["reddit"] = [
_make_source_item("reddit", "R1", "https://reddit.com/r/test/1", container="test"),
]
# 5 X items (not thin)
bundle.items_by_source["x"] = [
_make_source_item("x", f"X{i}", f"https://x.com/a/{i}") for i in range(5)
]
plan = _make_plan("advanced AI safety techniques")
settings = pipeline.DEPTH_SETTINGS["default"]
pipeline._retry_thin_sources(
topic="advanced AI safety techniques",
bundle=bundle,
plan=plan,
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime(),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
settings=settings,
)
# _retrieve_stream should have been called for reddit (thin source)
mock_retrieve.assert_called()
call_sources = [c.kwargs.get("source") for c in mock_retrieve.call_args_list]
self.assertIn("reddit", call_sources)
# X should NOT have been retried
self.assertNotIn("x", call_sources)
def test_sources_with_enough_items_not_retried(self):
"""Sources with >= 3 items should not be retried."""
bundle = schema.RetrievalBundle()
bundle.items_by_source["reddit"] = [
_make_source_item("reddit", f"R{i}", f"https://reddit.com/r/test/{i}") for i in range(5)
]
bundle.items_by_source["x"] = [
_make_source_item("x", f"X{i}", f"https://x.com/a/{i}") for i in range(5)
]
plan = _make_plan("AI safety")
settings = pipeline.DEPTH_SETTINGS["default"]
with patch("lib.pipeline._retrieve_stream") as mock_retrieve:
pipeline._retry_thin_sources(
topic="AI safety",
bundle=bundle,
plan=plan,
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime(),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
settings=settings,
)
mock_retrieve.assert_not_called()
def test_errored_sources_not_retried(self):
"""Sources in errors_by_source should not be retried even if thin.
Non-errored thin sources SHOULD still be retried."""
bundle = schema.RetrievalBundle()
bundle.items_by_source["reddit"] = [
_make_source_item("reddit", "R1", "https://reddit.com/r/test/1"),
]
bundle.errors_by_source["reddit"] = "API error"
plan = _make_plan("AI safety")
settings = pipeline.DEPTH_SETTINGS["default"]
mock_items = [{"id": "X1", "title": "test", "url": "https://x.com/1", "text": "test"}]
with patch("lib.pipeline._retrieve_stream", return_value=(mock_items, {})) as mock_retrieve:
pipeline._retry_thin_sources(
topic="AI safety",
bundle=bundle,
plan=plan,
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime(),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
settings=settings,
)
# x (non-errored, thin) should be retried; reddit (errored) should not
if mock_retrieve.call_count > 0:
retried_sources = [call.kwargs.get("source") or call.args[2] for call in mock_retrieve.call_args_list if hasattr(call, 'kwargs')]
self.assertNotIn("reddit", [c.kwargs.get("source") for c in mock_retrieve.call_args_list])
def test_retry_skipped_in_quick_mode(self):
"""_retry_thin_sources should return immediately in quick mode."""
bundle = schema.RetrievalBundle()
bundle.items_by_source["reddit"] = [
_make_source_item("reddit", "R1", "https://reddit.com/r/test/1"),
]
plan = _make_plan("AI safety")
settings = pipeline.DEPTH_SETTINGS["quick"]
with patch("lib.pipeline._retrieve_stream") as mock_retrieve:
pipeline._retry_thin_sources(
topic="AI safety",
bundle=bundle,
plan=plan,
config={},
depth="quick",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime(),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
settings=settings,
)
mock_retrieve.assert_not_called()
class TestErrorCleanup(unittest.TestCase):
"""Source errors should be cleared when the source has items from other subqueries."""
def test_error_cleared_when_source_has_items(self):
"""A source that 429'd on one subquery but succeeded on another is not errored."""
bundle = schema.RetrievalBundle(artifacts={})
item = schema.SourceItem(
item_id="x1", source="x", title="A tweet", body="content",
url="https://x.com/user/status/1",
)
bundle.items_by_source["x"] = [item]
bundle.errors_by_source["x"] = "HTTP 429: Too Many Requests"
# Simulate the cleanup logic from pipeline.run()
for source in list(bundle.errors_by_source):
if bundle.items_by_source.get(source):
del bundle.errors_by_source[source]
self.assertNotIn("x", bundle.errors_by_source,
"X should not be errored when it has items")
def test_error_kept_when_source_has_no_items(self):
"""A source with zero items should remain in errors_by_source."""
bundle = schema.RetrievalBundle(artifacts={})
bundle.errors_by_source["x"] = "HTTP 429: Too Many Requests"
for source in list(bundle.errors_by_source):
if bundle.items_by_source.get(source):
del bundle.errors_by_source[source]
self.assertIn("x", bundle.errors_by_source,
"X should remain errored when it has no items")
class TestXHandleFlag(unittest.TestCase):
"""R3: --x-handle CLI flag and pipeline parameter."""
def test_cli_accepts_x_handle_flag(self):
"""build_parser() should accept --x-handle."""
import last30days as cli
parser = cli.build_parser()
args = parser.parse_args(["test topic", "--x-handle", "elonmusk"])
self.assertEqual(args.x_handle, "elonmusk")
def test_cli_x_handle_default_is_none(self):
"""--x-handle should default to None."""
import last30days as cli
parser = cli.build_parser()
args = parser.parse_args(["test topic"])
self.assertIsNone(args.x_handle)
def test_pipeline_run_accepts_x_handle(self):
"""pipeline.run() should accept x_handle keyword argument."""
import inspect
sig = inspect.signature(pipeline.run)
self.assertIn("x_handle", sig.parameters, "pipeline.run() must accept x_handle parameter")
def test_x_handle_passed_to_supplemental_searches(self):
"""When x_handle is provided, it should trigger targeted handle search."""
# Run pipeline in mock mode with x_handle -- should not raise
report = pipeline.run(
topic="test topic",
config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
depth="quick",
requested_sources=["reddit", "x", "grounding"],
mock=True,
x_handle="testuser",
)
self.assertEqual("test topic", report.topic)
class TestWarnings(unittest.TestCase):
def _item(self, source="reddit"):
return schema.SourceItem(item_id="1", source=source, title="t", body="b", url="u")
def _candidate(self, source="reddit", score=50.0):
c = schema.Candidate(
candidate_id="c1", item_id="1", source=source, title="t", url="u",
snippet="s", subquery_labels=["main"], native_ranks={"main:reddit": 1},
local_relevance=0.5, freshness=50, engagement=10, source_quality=0.7,
rrf_score=0.01, sources=[source],
)
c.final_score = score
return c
def test_no_candidates_warning(self):
w = pipeline._warnings({"reddit": [self._item()]}, [], {})
self.assertTrue(any("No candidates" in msg for msg in w))
def test_thin_evidence_warning(self):
candidates = [self._candidate() for _ in range(3)]
w = pipeline._warnings({"reddit": [self._item()]}, candidates, {})
self.assertTrue(any("thin" in msg.lower() for msg in w))
def test_single_source_concentration(self):
candidates = [self._candidate() for _ in range(5)]
w = pipeline._warnings({"reddit": [self._item()]}, candidates, {})
self.assertTrue(any("concentrated" in msg.lower() for msg in w))
def test_source_errors_listed(self):
w = pipeline._warnings({}, [self._candidate()], {"x": "timeout"})
self.assertTrue(any("x" in msg for msg in w))
def test_no_items_warning(self):
w = pipeline._warnings({}, [], {})
self.assertTrue(any("No source returned" in msg for msg in w))
class TestXRelatedSupplementalSearch(unittest.TestCase):
"""Tests for --x-related weighted supplemental search."""
@patch("lib.bird_x.search_handles")
@patch("lib.entity_extract.extract_entities")
def test_x_related_triggers_supplemental_related_label(self, mock_extract, mock_handles):
"""x_related handles should be searched and added with supplemental-related label."""
mock_extract.return_value = {"x_handles": [], "x_hashtags": [], "reddit_subreddits": []}
mock_handles.return_value = [
{
"id": "rel1",
"text": "Related tweet from biancacensori",
"url": "https://x.com/biancacensori/status/555",
"author_handle": "biancacensori",
"date": "2026-03-15",
"engagement": {"likes": 30},
"relevance": 0.7,
"why_relevant": "related handle search",
}
]
bundle = schema.RetrievalBundle()
bundle.items_by_source["x"] = [
_make_source_item("x", "X1", "https://x.com/kanyewest/status/1", author="kanyewest"),
]
plan = _make_plan("Kanye West")
pipeline._run_supplemental_searches(
topic="Kanye West",
bundle=bundle,
plan=plan,
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime("bird"),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
x_related=["biancacensori"],
)
# search_handles should have been called for the related handle
mock_handles.assert_called()
# The supplemental-related subquery label should exist in the plan
labels = [sq.label for sq in plan.subqueries]
self.assertIn("supplemental-related", labels)
# The supplemental-related subquery should have weight 0.3
related_sq = [sq for sq in plan.subqueries if sq.label == "supplemental-related"][0]
self.assertAlmostEqual(related_sq.weight, 0.3)
@patch("lib.bird_x.search_handles")
@patch("lib.entity_extract.extract_entities")
def test_no_x_related_no_supplemental_related_label(self, mock_extract, mock_handles):
"""Without x_related, supplemental-related label should not appear."""
mock_extract.return_value = {"x_handles": ["analyst1"], "x_hashtags": [], "reddit_subreddits": []}
mock_handles.return_value = [
{
"id": "supp1",
"text": "Supplemental tweet",
"url": "https://x.com/analyst1/status/999",
"author_handle": "analyst1",
"date": "2026-03-15",
"engagement": {"likes": 50},
"relevance": 0.8,
"why_relevant": "direct handle search",
}
]
bundle = schema.RetrievalBundle()
bundle.items_by_source["x"] = [
_make_source_item("x", "X1", "https://x.com/analyst1/status/1", author="analyst1"),
]
plan = _make_plan("AI safety")
pipeline._run_supplemental_searches(
topic="AI safety",
bundle=bundle,
plan=plan,
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime("bird"),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
)
# supplemental-related label should NOT exist (no x_related provided)
labels = [sq.label for sq in plan.subqueries]
self.assertNotIn("supplemental-related", labels)
class TestRetryThinSourcesCoreEqualsTopic(unittest.TestCase):
"""Test that _retry_thin_sources fires even when core == topic (the fix)."""
@patch("lib.pipeline._retrieve_stream")
def test_retry_fires_when_core_equals_topic(self, mock_retrieve):
"""Topic 'Kanye West' with 0 YouTube items should trigger retry.
Previously this was skipped because core 'kanye west' == topic.
The fix ensures retry still fires for short topics.
"""
mock_retrieve.return_value = (
[
{
"id": "YT1",
"title": "Kanye West new album leak",
"url": "https://www.youtube.com/watch?v=abc123",
"date": "2026-03-15",
"engagement": {"views": 1000},
"relevance": 0.8,
"why_relevant": "retry result",
}
],
{},
)
plan = schema.QueryPlan(
intent="breaking_news",
freshness_mode="strict_recent",
cluster_mode="story",
raw_topic="Kanye West",
subqueries=[
schema.SubQuery(
label="primary",
search_query="Kanye West",
ranking_query="What recent evidence matters for Kanye West?",
sources=["youtube", "x"],
)
],
source_weights={"youtube": 1.0, "x": 1.0},
)
bundle = schema.RetrievalBundle()
# YouTube has 0 items (thin), X has enough
bundle.items_by_source["x"] = [
_make_source_item("x", f"X{i}", f"https://x.com/a/{i}") for i in range(5)
]
pipeline._retry_thin_sources(
topic="Kanye West",
bundle=bundle,
plan=plan,
config={},
depth="default",
date_range=("2026-02-15", "2026-03-17"),
runtime=_make_runtime(),
mock=False,
rate_limited_sources=set(),
rate_limit_lock=threading.Lock(),
settings=pipeline.DEPTH_SETTINGS["default"],
)
# _retrieve_stream should have been called for youtube
mock_retrieve.assert_called()
retried_sources = [c.kwargs["source"] for c in mock_retrieve.call_args_list]
self.assertIn("youtube", retried_sources)
# YouTube should now have items in the bundle
self.assertIn("youtube", bundle.items_by_source)
class TestZeroKeyPipelineRun(unittest.TestCase):
"""Pipeline should complete with local fallbacks when no reasoning keys are configured."""
@patch("lib.pipeline._retrieve_stream")
def test_zero_key_run_produces_report(self, mock_retrieve):
mock_retrieve.side_effect = lambda **kwargs: pipeline._mock_stream_results(
kwargs["source"], kwargs["subquery"]
)
config = {"LAST30DAYS_REASONING_PROVIDER": "auto"}
report = pipeline.run(
topic="test zero key topic",
config=config,
depth="quick",
requested_sources=["hackernews"],
)
self.assertEqual("test zero key topic", report.topic)
self.assertEqual("local", report.provider_runtime.reasoning_provider)
self.assertEqual("deterministic", report.provider_runtime.planner_model)
self.assertTrue(
any("fallback" in note for note in report.query_plan.notes),
f"Expected fallback plan, got notes: {report.query_plan.notes}",
)
for candidate in report.ranked_candidates:
self.assertEqual("fallback-local-score", candidate.explanation)
class TestExcludeSources(unittest.TestCase):
"""EXCLUDE_SOURCES env var filters sources out of available_sources().
The existing INCLUDE_SOURCES allowlist (used by Perplexity opt-in) does
not cover this case — tiktok and instagram are added unconditionally
when SCRAPECREATORS_API_KEY is set, with no way to opt out short of
unsetting the key. EXCLUDE_SOURCES gives runs a per-invocation denylist.
"""
def test_excludes_tiktok_and_instagram(self):
config = {
"SCRAPECREATORS_API_KEY": "test-key",
"EXCLUDE_SOURCES": "tiktok,instagram",
}
sources = pipeline.available_sources(config)
self.assertNotIn("tiktok", sources)
self.assertNotIn("instagram", sources)
self.assertIn("reddit", sources)
self.assertIn("hackernews", sources)
def test_no_exclusion_when_unset(self):
config = {"SCRAPECREATORS_API_KEY": "test-key"}
sources = pipeline.available_sources(config)
self.assertIn("tiktok", sources)
self.assertIn("instagram", sources)
def test_empty_exclude_sources_is_noop(self):
config = {
"SCRAPECREATORS_API_KEY": "test-key",
"EXCLUDE_SOURCES": "",
}
sources = pipeline.available_sources(config)
self.assertIn("tiktok", sources)
self.assertIn("instagram", sources)
def test_whitespace_and_case_insensitive(self):
config = {
"SCRAPECREATORS_API_KEY": "test-key",
"EXCLUDE_SOURCES": " TikTok , INSTAGRAM ",
}
sources = pipeline.available_sources(config)
self.assertNotIn("tiktok", sources)
self.assertNotIn("instagram", sources)
def test_excludes_non_scrapecreators_source(self):
"""EXCLUDE_SOURCES applies to any source, not just SC-backed ones."""
config = {"EXCLUDE_SOURCES": "hackernews"}
sources = pipeline.available_sources(config)
self.assertNotIn("hackernews", sources)
self.assertIn("reddit", sources)
if __name__ == "__main__":
unittest.main()