返回 last30days-skill
test_pipeline_v3.py
根目录 / tests / test_pipeline_v3.py
1 import threading
2 import unittest
3 from unittest.mock import patch
4
5 from lib import health
6 from lib import http
7 from lib import pipeline
8 from lib import schema
9
10
11 class DepthSettingsOverrideTests(unittest.TestCase):
12 def test_no_overrides_returns_depth_defaults(self):
13 settings = pipeline._resolve_depth_settings("deep", {})
14 self.assertEqual(pipeline.DEPTH_SETTINGS["deep"], settings)
15
16 def test_overrides_raise_caps_and_do_not_mutate_module_defaults(self):
17 before = dict(pipeline.DEPTH_SETTINGS["deep"])
18 settings = pipeline._resolve_depth_settings(
19 "deep", {"_max_per_source": 60, "_max_results": 200}
20 )
21 self.assertEqual(60, settings["per_stream_limit"])
22 self.assertEqual(200, settings["pool_limit"])
23 self.assertEqual(200, settings["rerank_limit"])
24 # Module-level defaults must be untouched (issue #716 regression guard).
25 self.assertEqual(before, pipeline.DEPTH_SETTINGS["deep"])
26
27 def test_overrides_can_also_lower_caps(self):
28 settings = pipeline._resolve_depth_settings("deep", {"_max_results": 10})
29 self.assertEqual(10, settings["rerank_limit"])
30
31 def test_zero_override_is_honored_not_swallowed(self):
32 # 0 is a valid explicit value (e.g. disable a source), not "unset".
33 settings = pipeline._resolve_depth_settings(
34 "deep", {"_max_results": 0, "_max_per_source": 0}
35 )
36 self.assertEqual(0, settings["pool_limit"])
37 self.assertEqual(0, settings["rerank_limit"])
38 self.assertEqual(0, settings["per_stream_limit"])
39
40
41 class PipelineV3Tests(unittest.TestCase):
42 def test_mock_pipeline_report_without_live_credentials(self):
43 report = pipeline.run(
44 topic="test topic",
45 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
46 depth="quick",
47 requested_sources=["reddit", "x", "grounding"],
48 mock=True,
49 )
50 self.assertEqual("test topic", report.topic)
51 self.assertTrue(report.ranked_candidates)
52 self.assertTrue(report.clusters)
53 self.assertIn("x", report.items_by_source)
54 # Grounding items now enter the ranked pool (web search backends produce real items)
55 self.assertIn("grounding", report.items_by_source)
56 self.assertEqual("gemini", report.provider_runtime.reasoning_provider)
57
58 def test_empty_explicit_plan_is_rejected(self):
59 with self.assertRaisesRegex(ValueError, "intent"):
60 pipeline.run(
61 topic="test topic",
62 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
63 depth="quick",
64 external_plan={},
65 mock=True,
66 )
67
68 def test_planner_trace_always_fires_on_mock_run(self):
69 """Unit 5: The unified planner trace emits one summary line plus one
70 line per subquery on every run, regardless of --debug. 2026-04-19
71 Hermes Agent Use Cases failure: retrieval-breadth issues were invisible
72 because the internal planner path logged nothing.
73 """
74 import io
75 import contextlib
76 buf = io.StringIO()
77 with contextlib.redirect_stderr(buf):
78 pipeline.run(
79 topic="test topic",
80 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
81 depth="quick",
82 requested_sources=["reddit", "x", "grounding"],
83 mock=True,
84 )
85 output = buf.getvalue()
86 self.assertIn("[Planner] Plan: intent=", output)
87 self.assertIn("subqueries=", output)
88 self.assertIn("source=", output)
89 # At least one per-subquery line.
90 self.assertIn("[Planner] sq1 label=", output)
91
92 def test_parallel_web_backend_enables_grounding_source(self):
93 plan = {
94 "intent": "news",
95 "freshness_mode": "balanced_recent",
96 "cluster_mode": "timeline",
97 "subqueries": [
98 {
99 "label": "primary",
100 "search_query": "test topic",
101 "ranking_query": "What happened with test topic?",
102 "sources": ["grounding"],
103 }
104 ],
105 "source_weights": {"grounding": 1.0},
106 }
107 report = pipeline.run(
108 topic="test topic",
109 config={"LAST30DAYS_REASONING_PROVIDER": "auto"},
110 depth="quick",
111 requested_sources=["grounding"],
112 web_backend="parallel",
113 external_plan=plan,
114 )
115 # Anchor on the stable source key, not the exact wording of the
116 # grounding.py error message. Phrasing can shift (e.g., when the
117 # missing-key check moves or the message is reworded) without
118 # changing the contract that the grounding source registers an
119 # error when its required backend key is unset.
120 self.assertIn("grounding", report.errors_by_source)
121
122 def test_hiring_signals_mode_enables_jobs_source_in_mock_run(self):
123 report = pipeline.run(
124 topic="Listen Labs",
125 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
126 depth="quick",
127 requested_sources=["jobs"],
128 mock=True,
129 hiring_signals_mode=True,
130 )
131 self.assertIn("jobs", report.items_by_source)
132 self.assertIn("hiring_signals", report.artifacts)
133 self.assertTrue(report.artifacts["hiring_signals"]["include"])
134
135 def test_hiring_signals_mode_defaults_to_jobs_source(self):
136 report = pipeline.run(
137 topic="Listen Labs",
138 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
139 depth="quick",
140 mock=True,
141 hiring_signals_mode=True,
142 )
143 self.assertEqual(["jobs"], sorted(report.items_by_source))
144 self.assertTrue(report.artifacts["hiring_signals"]["include"])
145
146 def test_explicit_sources_suppress_automatic_company_jobs(self):
147 report = pipeline.run(
148 topic="Listen Labs",
149 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
150 depth="quick",
151 requested_sources=["grounding", "x"],
152 mock=True,
153 )
154 self.assertEqual({"grounding", "x"}, set(report.items_by_source))
155 self.assertTrue(
156 all("jobs" not in subquery.sources for subquery in report.query_plan.subqueries)
157 )
158
159 def test_hiring_signals_forces_jobs_with_explicit_non_jobs_sources(self):
160 report = pipeline.run(
161 topic="Listen Labs",
162 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
163 depth="quick",
164 requested_sources=["grounding", "x"],
165 mock=True,
166 hiring_signals_mode=True,
167 )
168 self.assertEqual({"grounding", "jobs", "x"}, set(report.items_by_source))
169
170 def test_standard_company_run_fetches_jobs_for_signal_gate(self):
171 report = pipeline.run(
172 topic="Listen Labs",
173 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
174 depth="quick",
175 mock=True,
176 )
177 self.assertIn("jobs", report.items_by_source)
178 self.assertIn("hiring_signals", report.artifacts)
179
180 def test_standard_mock_run_does_not_add_jobs_for_generic_topic(self):
181 report = pipeline.run(
182 topic="how to deploy on Fly.io",
183 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
184 depth="quick",
185 mock=True,
186 )
187 self.assertNotIn("jobs", report.items_by_source)
188 self.assertNotIn("hiring_signals", report.artifacts)
189
190 def test_single_word_generic_topic_does_not_add_jobs(self):
191 report = pipeline.run(
192 topic="bitcoin",
193 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
194 depth="quick",
195 mock=True,
196 )
197 self.assertNotIn("jobs", report.items_by_source)
198 self.assertNotIn("hiring_signals", report.artifacts)
199
200 def test_question_comparison_topic_does_not_add_jobs(self):
201 report = pipeline.run(
202 topic="Python vs Ruby benchmark?",
203 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
204 depth="quick",
205 mock=True,
206 )
207 self.assertNotIn("jobs", report.items_by_source)
208 self.assertNotIn("hiring_signals", report.artifacts)
209
210 def test_bare_language_comparison_topics_do_not_add_jobs(self):
211 for topic in ("python vs ruby", "Python vs Ruby"):
212 with self.subTest(topic=topic):
213 self.assertFalse(pipeline._company_topic_likely(topic))
214
215 def test_company_comparison_topics_add_jobs(self):
216 for topic in ("Stripe vs Brex", "OpenAI versus Anthropic"):
217 with self.subTest(topic=topic):
218 self.assertTrue(pipeline._company_topic_likely(topic))
219
220 def test_standard_mode_omits_weak_large_company_jobs_signal(self):
221 with patch("lib.pipeline._retrieve_stream") as mock_retrieve:
222 def fake_retrieve(**kwargs):
223 if kwargs["source"] == "jobs":
224 return (
225 [
226 {
227 "id": "J1",
228 "title": "Retail Associate",
229 "description": "Store operations",
230 "url": "https://example.com/jobs/1",
231 "department": "Retail",
232 "date": "2026-06-01",
233 "provider": "mock",
234 }
235 ],
236 {},
237 )
238 return pipeline._mock_stream_results(kwargs["source"], kwargs["subquery"])
239
240 mock_retrieve.side_effect = fake_retrieve
241 report = pipeline.run(
242 topic="Apple",
243 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
244 depth="quick",
245 requested_sources=["jobs"],
246 mock=True,
247 )
248 self.assertNotIn("jobs", report.items_by_source)
249 self.assertFalse(report.artifacts["hiring_signals"]["include"])
250
251
252 class TestSourceFetchCap(unittest.TestCase):
253 """X source fetch count must be capped by MAX_SOURCE_FETCHES."""
254
255 def test_x_capped_in_max_source_fetches(self):
256 """MAX_SOURCE_FETCHES must cap X at 2 to prevent 429 cascades."""
257 self.assertIn("x", pipeline.MAX_SOURCE_FETCHES)
258 self.assertEqual(pipeline.MAX_SOURCE_FETCHES["x"], 2)
259
260 def test_jobs_capped_in_max_source_fetches(self):
261 self.assertIn("jobs", pipeline.MAX_SOURCE_FETCHES)
262 self.assertEqual(pipeline.MAX_SOURCE_FETCHES["jobs"], 1)
263
264 def test_cap_logic_limits_source_submissions(self):
265 """Verify the cap logic skips submissions beyond the limit."""
266 subquery_sources = [
267 ["x", "reddit", "youtube"],
268 ["x", "reddit", "youtube"],
269 ["x", "reddit", "youtube"],
270 ["x", "reddit", "youtube"],
271 ]
272 source_fetch_count: dict[str, int] = {}
273 submitted: list[str] = []
274 for sources in subquery_sources:
275 for source in sources:
276 source_cap = pipeline.MAX_SOURCE_FETCHES.get(source)
277 if source_cap is not None:
278 current = source_fetch_count.get(source, 0)
279 if current >= source_cap:
280 continue
281 source_fetch_count[source] = current + 1
282 submitted.append(source)
283
284 x_count = submitted.count("x")
285 reddit_count = submitted.count("reddit")
286 self.assertEqual(x_count, 2, f"X should be capped at 2, got {x_count}")
287 self.assertEqual(reddit_count, 4, f"Reddit should be uncapped, got {reddit_count}")
288
289 @patch("lib.pipeline._retrieve_stream")
290 def test_mock_run_caps_x_fetches(self, mock_retrieve):
291 """Pipeline.run in mock mode should call _retrieve_stream for X at most 2 times."""
292 mock_retrieve.side_effect = lambda **kwargs: pipeline._mock_stream_results(
293 kwargs["source"], kwargs["subquery"]
294 )
295 pipeline.run(
296 topic="compare iPhone vs Android vs Pixel vs Samsung",
297 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
298 depth="quick",
299 requested_sources=["reddit", "x"],
300 mock=True,
301 )
302 x_calls = [
303 call for call in mock_retrieve.call_args_list
304 if call.kwargs.get("source") == "x"
305 ]
306 self.assertLessEqual(
307 len(x_calls), 2,
308 f"X should be fetched at most 2 times, got {len(x_calls)}",
309 )
310
311 @patch("lib.pipeline._retrieve_stream")
312 def test_zero_source_fetch_override_suppresses_capped_source(self, mock_retrieve):
313 """A 0 override is explicit and should suppress capped-source submissions."""
314 mock_retrieve.side_effect = lambda **kwargs: pipeline._mock_stream_results(
315 kwargs["source"], kwargs["subquery"]
316 )
317 pipeline.run(
318 topic="compare iPhone vs Android vs Pixel vs Samsung",
319 config={
320 "LAST30DAYS_REASONING_PROVIDER": "gemini",
321 "_max_source_fetches": 0,
322 },
323 depth="quick",
324 requested_sources=["reddit", "x"],
325 mock=True,
326 )
327 x_calls = [
328 call for call in mock_retrieve.call_args_list
329 if call.kwargs.get("source") == "x"
330 ]
331 reddit_calls = [
332 call for call in mock_retrieve.call_args_list
333 if call.kwargs.get("source") == "reddit"
334 ]
335 self.assertEqual([], x_calls)
336 self.assertGreater(len(reddit_calls), 0)
337
338
339 class TestRateLimitSharing(unittest.TestCase):
340 """429 signals should be shared across subqueries."""
341
342 def test_is_rate_limit_error_detects_429_status(self):
343 exc = http.HTTPError("HTTP 429: Too Many Requests", status_code=429)
344 self.assertTrue(pipeline._is_rate_limit_error(exc))
345
346 def test_is_rate_limit_error_ignores_non_429(self):
347 exc = http.HTTPError("HTTP 400: Bad Request", status_code=400)
348 self.assertFalse(pipeline._is_rate_limit_error(exc))
349
350 def test_is_rate_limit_error_detects_429_in_string(self):
351 exc = RuntimeError("xAI returned 429 rate limit")
352 self.assertTrue(pipeline._is_rate_limit_error(exc))
353
354 def test_is_rate_limit_error_rejects_unrelated_error(self):
355 exc = RuntimeError("Connection refused")
356 self.assertFalse(pipeline._is_rate_limit_error(exc))
357
358 def test_retrieve_stream_skips_rate_limited_source(self):
359 """_retrieve_stream should return empty when source is rate-limited."""
360 from lib import schema
361 rate_limited = {"x"}
362 lock = threading.Lock()
363 subquery = schema.SubQuery(
364 label="test",
365 search_query="test query",
366 ranking_query="test query",
367 sources=["x"],
368 )
369 items, artifact = pipeline._retrieve_stream(
370 topic="test",
371 subquery=subquery,
372 source="x",
373 config={},
374 depth="quick",
375 date_range=("2026-02-15", "2026-03-17"),
376 runtime=schema.ProviderRuntime(
377 reasoning_provider="mock",
378 planner_model="mock",
379 rerank_model="mock",
380 ),
381 mock=True,
382 rate_limited_sources=rate_limited,
383 rate_limit_lock=lock,
384 )
385 self.assertEqual(items, [])
386 self.assertEqual(artifact, {})
387
388
389 class TestThinSourceRetryPlannedSource(unittest.TestCase):
390 @patch("lib.pipeline._retrieve_stream")
391 def test_retry_includes_planned_source_with_zero_initial_items(self, mock_retrieve):
392 mock_retrieve.return_value = (
393 [
394 {
395 "id": "X100",
396 "text": "OpenClaw funding update from an investor",
397 "url": "https://x.com/example/status/100",
398 "author_handle": "example",
399 "date": "2026-03-15",
400 "engagement": {"likes": 25, "reposts": 4, "replies": 2},
401 "relevance": 0.8,
402 "why_relevant": "retry result",
403 }
404 ],
405 {},
406 )
407
408 plan = schema.QueryPlan(
409 intent="breaking_news",
410 freshness_mode="strict_recent",
411 cluster_mode="story",
412 raw_topic="latest OpenClaw funding updates",
413 subqueries=[
414 schema.SubQuery(
415 label="primary",
416 search_query="latest OpenClaw funding updates",
417 ranking_query="What recent evidence matters for OpenClaw funding?",
418 sources=["x", "reddit"],
419 )
420 ],
421 source_weights={"x": 1.0, "reddit": 1.0},
422 )
423 bundle = schema.RetrievalBundle(
424 items_by_source={
425 "reddit": [
426 _make_source_item("reddit", "r1", "https://reddit.com/1"),
427 _make_source_item("reddit", "r2", "https://reddit.com/2"),
428 _make_source_item("reddit", "r3", "https://reddit.com/3"),
429 ]
430 }
431 )
432
433 pipeline._retry_thin_sources(
434 topic="latest OpenClaw funding updates",
435 bundle=bundle,
436 plan=plan,
437 config={},
438 depth="default",
439 date_range=("2026-02-15", "2026-03-17"),
440 runtime=_make_runtime("bird"),
441 mock=False,
442 rate_limited_sources=set(),
443 rate_limit_lock=threading.Lock(),
444 settings=pipeline.DEPTH_SETTINGS["default"],
445 )
446
447 self.assertEqual(["x"], [call.kwargs["source"] for call in mock_retrieve.call_args_list])
448 self.assertIn("x", bundle.items_by_source)
449 self.assertEqual("https://x.com/example/status/100", bundle.items_by_source["x"][0].url)
450
451
452 class TestPinnedGithubPersonAuthority(unittest.TestCase):
453 @patch("lib.pipeline._retrieve_stream")
454 @patch("lib.pipeline.github.search_github_person", return_value=[])
455 def test_empty_person_result_suppresses_generic_fanout_and_retry(
456 self, mock_person_search, mock_retrieve
457 ):
458 plan = {
459 "intent": "person",
460 "freshness_mode": "balanced_recent",
461 "cluster_mode": "topic",
462 "subqueries": [
463 {
464 "label": "primary",
465 "search_query": "octocat recent activity",
466 "ranking_query": "What has @octocat done on GitHub recently?",
467 "sources": ["github"],
468 }
469 ],
470 "source_weights": {"github": 1.0},
471 }
472
473 report = pipeline.run(
474 topic="octocat",
475 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
476 depth="default",
477 requested_sources=["github"],
478 mock=True,
479 external_plan=plan,
480 github_user="octocat",
481 )
482
483 mock_person_search.assert_called_once()
484 mock_retrieve.assert_not_called()
485 self.assertEqual(schema.NO_RESULTS, report.source_status["github"].state)
486 self.assertIn(
487 "Person mode found no activity for @octocat",
488 report.source_status["github"].detail,
489 )
490
491 @patch("lib.pipeline._retrieve_stream")
492 @patch(
493 "lib.pipeline.github.search_github_person",
494 side_effect=RuntimeError("GitHub API unavailable"),
495 )
496 def test_person_failure_suppresses_generic_fanout_and_retry(
497 self, mock_person_search, mock_retrieve
498 ):
499 plan = {
500 "intent": "person",
501 "freshness_mode": "balanced_recent",
502 "cluster_mode": "topic",
503 "subqueries": [
504 {
505 "label": "primary",
506 "search_query": "octocat recent activity",
507 "ranking_query": "What has @octocat done on GitHub recently?",
508 "sources": ["github"],
509 }
510 ],
511 "source_weights": {"github": 1.0},
512 }
513
514 report = pipeline.run(
515 topic="octocat",
516 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
517 depth="default",
518 requested_sources=["github"],
519 mock=True,
520 external_plan=plan,
521 github_user="octocat",
522 )
523
524 mock_person_search.assert_called_once()
525 mock_retrieve.assert_not_called()
526 self.assertEqual(health.ERROR, report.source_status["github"].state)
527 self.assertEqual(
528 "GitHub API unavailable",
529 report.source_status["github"].detail,
530 )
531 self.assertEqual(
532 "Person-mode failed: GitHub API unavailable",
533 report.errors_by_source["github"],
534 )
535
536
537
538 class TestTrustpilotNeverRetriedAsThin(unittest.TestCase):
539 @patch("lib.pipeline._retrieve_stream")
540 def test_trustpilot_excluded_from_thin_source_retry(self, mock_retrieve):
541 """Trustpilot returns at most one item by design, so the '<3 items'
542 thinness rule must never re-fetch it: a retry would bypass
543 MAX_SOURCE_FETCHES and re-resolve without the caller's
544 --trustpilot-domain (a lookalike-misattribution path)."""
545 mock_retrieve.return_value = ([], {})
546
547 plan = schema.QueryPlan(
548 intent="product",
549 freshness_mode="balanced_recent",
550 cluster_mode="none",
551 raw_topic="ThriftBooks",
552 subqueries=[
553 schema.SubQuery(
554 label="primary",
555 search_query="thriftbooks",
556 ranking_query="What matters for ThriftBooks?",
557 sources=["trustpilot", "x"],
558 )
559 ],
560 source_weights={"trustpilot": 1.0, "x": 1.0},
561 )
562 bundle = schema.RetrievalBundle(
563 items_by_source={
564 # one successful trustpilot item -- its normal success state,
565 # yet still "<3" and thus retry-eligible without the exclusion
566 "trustpilot": [
567 _make_source_item("trustpilot", "tp1", "https://www.trustpilot.com/review/x.com"),
568 ],
569 }
570 )
571
572 pipeline._retry_thin_sources(
573 topic="ThriftBooks",
574 bundle=bundle,
575 plan=plan,
576 config={},
577 depth="default",
578 date_range=("2026-06-04", "2026-07-04"),
579 runtime=_make_runtime("bird"),
580 mock=False,
581 rate_limited_sources=set(),
582 rate_limit_lock=threading.Lock(),
583 settings=pipeline.DEPTH_SETTINGS["default"],
584 )
585
586 retried = [call.kwargs["source"] for call in mock_retrieve.call_args_list]
587 self.assertNotIn("trustpilot", retried)
588 self.assertIn("x", retried) # other thin sources still retry
589
590
591 def _make_runtime(x_backend="bird"):
592 return schema.ProviderRuntime(
593 reasoning_provider="mock",
594 planner_model="mock",
595 rerank_model="mock",
596 x_search_backend=x_backend,
597 )
598
599
600 def _make_plan(topic="test topic"):
601 return schema.QueryPlan(
602 intent="exploration",
603 freshness_mode="balanced_recent",
604 cluster_mode="topic",
605 raw_topic=topic,
606 subqueries=[
607 schema.SubQuery(
608 label="primary",
609 search_query=topic,
610 ranking_query=f"What recent evidence matters for {topic}?",
611 sources=["x", "reddit"],
612 )
613 ],
614 source_weights={"x": 1.0, "reddit": 1.0},
615 )
616
617
618 def _make_source_item(source, item_id, url, author=None, body="", container=None, metadata=None):
619 return schema.SourceItem(
620 item_id=item_id,
621 source=source,
622 title=f"Item {item_id}",
623 body=body,
624 url=url,
625 author=author,
626 container=container,
627 metadata=metadata or {},
628 )
629
630
631 class TestXBackendChainAndFailover(unittest.TestCase):
632 """One X source, an ordered backend chain with failover; never parallel."""
633
634 @patch("lib.xurl_x.is_available", return_value=False)
635 def test_chain_orders_by_priority(self, _xurl):
636 from lib import env
637 chain = env.x_backend_chain({"XAI_API_KEY": "k", "XQUIK_API_KEY": "q"})
638 self.assertEqual(["xai", "xquik"], chain) # xai primary, xquik backup
639
640 @patch("lib.xurl_x.is_available", return_value=False)
641 def test_pin_forces_single_backend(self, _xurl):
642 from lib import env
643 chain = env.x_backend_chain(
644 {"XAI_API_KEY": "k", "XQUIK_API_KEY": "q", "LAST30DAYS_X_BACKEND": "xquik"}
645 )
646 self.assertEqual(["xquik"], chain) # pin = no failover
647
648 @patch("lib.xurl_x.is_available", return_value=False)
649 def test_chain_empty_when_nothing_configured(self, _xurl):
650 from lib import env
651 self.assertEqual([], env.x_backend_chain({}))
652
653 @patch("lib.env.x_backend_chain", return_value=["bird", "xquik"])
654 def test_failover_to_next_backend_on_empty(self, _chain):
655 sq = schema.SubQuery(label="primary", search_query="q", ranking_query="q?", sources=["x"])
656
657 def fake_fetch(backend, *a, **k):
658 if backend == "xquik":
659 return ([{"id": "XQ1", "url": "https://x.com/a/status/1"}], "")
660 return ([], "") # bird returns nothing
661
662 with patch("lib.pipeline._fetch_x_backend", side_effect=fake_fetch):
663 items, _ = pipeline._retrieve_stream(
664 topic="q", subquery=sq, source="x", config={}, depth="default",
665 date_range=("2026-05-19", "2026-06-18"), runtime=_make_runtime(None), mock=False,
666 )
667 self.assertEqual(1, len(items))
668 self.assertEqual("XQ1", items[0]["id"])
669
670 @patch("lib.env.x_backend_chain", return_value=["xquik"])
671 def test_sole_backend_error_raises_honestly(self, _chain):
672 sq = schema.SubQuery(label="primary", search_query="q", ranking_query="q?", sources=["x"])
673 with patch("lib.pipeline._fetch_x_backend", return_value=([], "Xquik key unpaid (402)")):
674 with self.assertRaises(RuntimeError):
675 pipeline._retrieve_stream(
676 topic="q", subquery=sq, source="x", config={}, depth="default",
677 date_range=("2026-05-19", "2026-06-18"), runtime=_make_runtime(None), mock=False,
678 )
679
680 def test_xquik_is_not_a_separate_source(self):
681 # xquik registers only as a backend of "x", never its own source.
682 avail = pipeline.available_sources({"XQUIK_API_KEY": "k"})
683 self.assertIn("x", avail)
684 self.assertNotIn("xquik", avail)
685
686
687 class TestSupplementalSearches(unittest.TestCase):
688 """R1: Phase 2 entity drilling should be wired into the pipeline."""
689
690 def test_run_supplemental_searches_exists(self):
691 """_run_supplemental_searches must be a callable in pipeline module."""
692 self.assertTrue(
693 hasattr(pipeline, "_run_supplemental_searches"),
694 "_run_supplemental_searches function not found in pipeline module",
695 )
696 self.assertTrue(callable(pipeline._run_supplemental_searches))
697
698 @patch("lib.bird_x.search_handles")
699 @patch("lib.entity_extract.extract_entities")
700 def test_entity_extract_called_after_phase1(self, mock_extract, mock_handles):
701 """Phase 2 should call entity_extract on Phase 1 X results, then search_handles."""
702 mock_extract.return_value = {"x_handles": ["analyst1", "reporter2"], "x_hashtags": [], "reddit_subreddits": []}
703 mock_handles.return_value = [
704 {
705 "id": "supp1",
706 "text": "Supplemental tweet from analyst1",
707 "url": "https://x.com/analyst1/status/999",
708 "author_handle": "analyst1",
709 "date": "2026-03-15",
710 "engagement": {"likes": 50},
711 "relevance": 0.8,
712 "why_relevant": "direct handle search",
713 }
714 ]
715
716 bundle = schema.RetrievalBundle()
717 bundle.items_by_source["x"] = [
718 _make_source_item("x", "X1", "https://x.com/analyst1/status/1", author="analyst1", body="Some tweet about AI"),
719 _make_source_item("x", "X2", "https://x.com/reporter2/status/2", author="reporter2", body="AI analysis @expert3"),
720 ]
721
722 plan = _make_plan("AI safety")
723 config = {}
724
725 pipeline._run_supplemental_searches(
726 topic="AI safety",
727 bundle=bundle,
728 plan=plan,
729 config=config,
730 depth="default",
731 date_range=("2026-02-15", "2026-03-17"),
732 runtime=_make_runtime("bird"),
733 mock=False,
734 rate_limited_sources=set(),
735 rate_limit_lock=threading.Lock(),
736 )
737
738 mock_extract.assert_called_once()
739 mock_handles.assert_called_once()
740 # Supplemental items should be merged into bundle
741 x_urls = {item.url for item in bundle.items_by_source.get("x", [])}
742 self.assertIn("https://x.com/analyst1/status/999", x_urls)
743
744 @patch("lib.env.x_backend_chain", return_value=["xquik"])
745 @patch("lib.xquik.search_xquik", return_value={"items": []})
746 def test_x_topic_lane_uses_anchored_query_via_xquik(self, mock_search, _chain):
747 """The X topic lane (here resolved to the xquik backend) consumes the
748 anchored subquery.search_query (#611), not the bare raw_topic."""
749 anchored = schema.SubQuery(
750 label="primary", search_query="kevin rose digg founder",
751 ranking_query="What has Kevin Rose, founder of Digg, been doing?",
752 sources=["x"],
753 )
754 pipeline._retrieve_stream(
755 topic="kevin rose digg founder", subquery=anchored, source="x",
756 config={"XQUIK_API_KEY": "k"}, depth="default",
757 date_range=("2026-05-19", "2026-06-18"), runtime=_make_runtime(None),
758 mock=False, raw_topic="kevin rose",
759 )
760 mock_search.assert_called_once()
761 self.assertEqual("kevin rose digg founder", mock_search.call_args[0][0])
762
763 @patch("lib.env.get_xquik_token", return_value="k")
764 @patch("lib.env.x_backend_chain", return_value=["xquik"])
765 @patch("lib.xquik.search_mentions", return_value=[])
766 @patch("lib.xquik.search_handles")
767 @patch("lib.entity_extract.extract_entities")
768 def test_handle_lanes_route_to_xquik_when_primary(
769 self, mock_extract, mock_xq_handles, mock_xq_mentions, *_patches
770 ):
771 """When xquik is the primary X backend, the FROM/ABOUT handle lanes run
772 via xquik and items land under the single 'x' slug."""
773 mock_extract.return_value = {"x_handles": ["analyst1"], "x_hashtags": [], "reddit_subreddits": []}
774 mock_xq_handles.return_value = [{
775 "id": "XF1", "text": "from analyst1", "url": "https://x.com/analyst1/status/777",
776 "author_handle": "analyst1", "date": "2026-03-15",
777 "engagement": {"likes": 30}, "relevance": 0.8, "why_relevant": "",
778 }]
779
780 bundle = schema.RetrievalBundle()
781 bundle.items_by_source["x"] = [
782 _make_source_item("x", "X1", "https://x.com/analyst1/status/1", author="analyst1", body="tweet about AI"),
783 ]
784
785 pipeline._run_supplemental_searches(
786 topic="AI safety", bundle=bundle, plan=_make_plan("AI safety"), config={},
787 depth="default", date_range=("2026-02-15", "2026-03-17"),
788 runtime=_make_runtime(None), mock=False,
789 rate_limited_sources=set(), rate_limit_lock=threading.Lock(),
790 )
791
792 mock_xq_handles.assert_called_once()
793 x_urls = {item.url for item in bundle.items_by_source.get("x", [])}
794 self.assertIn("https://x.com/analyst1/status/777", x_urls)
795 # There is no separate 'xquik' source — everything is under 'x'.
796 self.assertNotIn("xquik", bundle.items_by_source)
797
798 @patch("lib.env.get_xquik_token", return_value="k")
799 @patch("lib.env.x_backend_chain", return_value=["xai", "xquik"])
800 @patch("lib.xquik.search_mentions", return_value=[])
801 @patch("lib.xquik.search_handles")
802 @patch("lib.entity_extract.extract_entities")
803 def test_handle_lanes_use_xquik_when_xai_is_primary(
804 self, mock_extract, mock_xq_handles, *_patches
805 ):
806 """When xAI is the topic primary but xquik is in the chain, the
807 supplemental handle lanes still run via xquik (first handle-capable
808 backend) rather than being skipped."""
809 mock_extract.return_value = {"x_handles": ["analyst1"], "x_hashtags": [], "reddit_subreddits": []}
810 mock_xq_handles.return_value = [{
811 "id": "XF1", "text": "from analyst1", "url": "https://x.com/analyst1/status/888",
812 "author_handle": "analyst1", "date": "2026-03-15",
813 "engagement": {"likes": 5}, "relevance": 0.8, "why_relevant": "",
814 }]
815 bundle = schema.RetrievalBundle()
816 bundle.items_by_source["x"] = [
817 _make_source_item("x", "X1", "https://x.com/analyst1/status/1", author="analyst1", body="tweet"),
818 ]
819 pipeline._run_supplemental_searches(
820 topic="AI safety", bundle=bundle, plan=_make_plan("AI safety"), config={},
821 depth="default", date_range=("2026-02-15", "2026-03-17"),
822 runtime=_make_runtime("xai"), mock=False,
823 rate_limited_sources=set(), rate_limit_lock=threading.Lock(),
824 )
825 mock_xq_handles.assert_called_once()
826 x_urls = {item.url for item in bundle.items_by_source.get("x", [])}
827 self.assertIn("https://x.com/analyst1/status/888", x_urls)
828
829 @patch("lib.bird_x.search_handles")
830 @patch("lib.entity_extract.extract_entities")
831 def test_supplemental_items_deduplicated_by_url(self, mock_extract, mock_handles):
832 """Supplemental items with same URL as Phase 1 should not be duplicated."""
833 mock_extract.return_value = {"x_handles": ["analyst1"], "x_hashtags": [], "reddit_subreddits": []}
834 # Return item with same URL as Phase 1
835 mock_handles.return_value = [
836 {
837 "id": "dup1",
838 "text": "Same tweet",
839 "url": "https://x.com/analyst1/status/1",
840 "author_handle": "analyst1",
841 "date": "2026-03-15",
842 "engagement": {"likes": 50},
843 "relevance": 0.8,
844 "why_relevant": "duplicate",
845 }
846 ]
847
848 bundle = schema.RetrievalBundle()
849 original = _make_source_item("x", "X1", "https://x.com/analyst1/status/1", author="analyst1")
850 bundle.items_by_source["x"] = [original]
851
852 plan = _make_plan("AI safety")
853
854 pipeline._run_supplemental_searches(
855 topic="AI safety",
856 bundle=bundle,
857 plan=plan,
858 config={},
859 depth="default",
860 date_range=("2026-02-15", "2026-03-17"),
861 runtime=_make_runtime("bird"),
862 mock=False,
863 rate_limited_sources=set(),
864 rate_limit_lock=threading.Lock(),
865 )
866
867 # Should still have only 1 item (no duplicates)
868 x_items = bundle.items_by_source.get("x", [])
869 urls = [item.url for item in x_items]
870 self.assertEqual(
871 urls.count("https://x.com/analyst1/status/1"), 1,
872 f"Duplicate URL found: {urls}",
873 )
874
875 @patch("lib.bird_x.search_mentions")
876 @patch("lib.bird_x.search_handles")
877 @patch("lib.entity_extract.extract_entities")
878 def test_from_lane_uses_raised_cap_mention_lane_modest(self, mock_extract, mock_handles, mock_mentions):
879 """U4: the FROM lane (subject's own timeline) uses the raised per-handle
880 cap; the mention lane stays modest."""
881 mock_extract.return_value = {"x_handles": ["subject1"], "x_hashtags": [], "reddit_subreddits": []}
882 mock_handles.return_value = []
883 mock_mentions.return_value = []
884 bundle = schema.RetrievalBundle()
885 bundle.items_by_source["x"] = [
886 _make_source_item("x", "X1", "https://x.com/subject1/status/1", author="subject1", body="hi"),
887 ]
888 pipeline._run_supplemental_searches(
889 topic="subject1",
890 bundle=bundle,
891 plan=_make_plan("subject1"),
892 config={},
893 depth="default",
894 date_range=("2026-02-15", "2026-03-17"),
895 runtime=_make_runtime("bird"),
896 mock=False,
897 rate_limited_sources=set(),
898 rate_limit_lock=threading.Lock(),
899 )
900 from_call = mock_handles.call_args_list[0]
901 self.assertEqual(pipeline.FROM_LANE_COUNT_PER, from_call.kwargs.get("count_per"))
902 self.assertEqual(pipeline.MENTION_LANE_COUNT_PER, mock_mentions.call_args.kwargs.get("count_per"))
903
904 def test_phase2_skipped_in_quick_mode(self):
905 """_run_supplemental_searches should return immediately when depth='quick'."""
906 bundle = schema.RetrievalBundle()
907 bundle.items_by_source["x"] = [
908 _make_source_item("x", "X1", "https://x.com/a/1", author="someone"),
909 ]
910
911 # If it tries to import entity_extract, that's fine -- it should return before calling it
912 pipeline._run_supplemental_searches(
913 topic="test",
914 bundle=bundle,
915 plan=_make_plan(),
916 config={},
917 depth="quick",
918 date_range=("2026-02-15", "2026-03-17"),
919 runtime=_make_runtime("bird"),
920 mock=False,
921 rate_limited_sources=set(),
922 rate_limit_lock=threading.Lock(),
923 )
924 # Bundle should be unchanged (only original item)
925 self.assertEqual(len(bundle.items_by_source["x"]), 1)
926
927 def test_phase2_skipped_in_mock_mode(self):
928 """_run_supplemental_searches should return immediately when mock=True."""
929 bundle = schema.RetrievalBundle()
930 bundle.items_by_source["x"] = [
931 _make_source_item("x", "X1", "https://x.com/a/1", author="someone"),
932 ]
933
934 pipeline._run_supplemental_searches(
935 topic="test",
936 bundle=bundle,
937 plan=_make_plan(),
938 config={},
939 depth="default",
940 date_range=("2026-02-15", "2026-03-17"),
941 runtime=_make_runtime("bird"),
942 mock=True,
943 rate_limited_sources=set(),
944 rate_limit_lock=threading.Lock(),
945 )
946 self.assertEqual(len(bundle.items_by_source["x"]), 1)
947
948 def test_phase2_skipped_when_x_rate_limited(self):
949 """_run_supplemental_searches should skip when X is rate-limited."""
950 bundle = schema.RetrievalBundle()
951 bundle.items_by_source["x"] = [
952 _make_source_item("x", "X1", "https://x.com/a/1", author="someone"),
953 ]
954
955 pipeline._run_supplemental_searches(
956 topic="test",
957 bundle=bundle,
958 plan=_make_plan(),
959 config={},
960 depth="default",
961 date_range=("2026-02-15", "2026-03-17"),
962 runtime=_make_runtime("bird"),
963 mock=False,
964 rate_limited_sources={"x"},
965 rate_limit_lock=threading.Lock(),
966 )
967 self.assertEqual(len(bundle.items_by_source["x"]), 1)
968
969 def test_phase2_skipped_when_backend_not_bird(self):
970 """_run_supplemental_searches should skip when X backend is not bird."""
971 bundle = schema.RetrievalBundle()
972 bundle.items_by_source["x"] = [
973 _make_source_item("x", "X1", "https://x.com/a/1", author="someone"),
974 ]
975
976 pipeline._run_supplemental_searches(
977 topic="test",
978 bundle=bundle,
979 plan=_make_plan(),
980 config={},
981 depth="default",
982 date_range=("2026-02-15", "2026-03-17"),
983 runtime=_make_runtime("xai"),
984 mock=False,
985 rate_limited_sources=set(),
986 rate_limit_lock=threading.Lock(),
987 )
988 self.assertEqual(len(bundle.items_by_source["x"]), 1)
989
990
991 class TestThinSourceRetry(unittest.TestCase):
992 """R2: Dynamic query refinement on thin results."""
993
994 def test_retry_thin_sources_exists(self):
995 """_retry_thin_sources must be a callable in pipeline module."""
996 self.assertTrue(
997 hasattr(pipeline, "_retry_thin_sources"),
998 "_retry_thin_sources function not found in pipeline module",
999 )
1000 self.assertTrue(callable(pipeline._retry_thin_sources))
1001
1002 @patch("lib.pipeline._retrieve_stream")
1003 def test_thin_source_retried_with_core_subject(self, mock_retrieve):
1004 """Sources with < 3 items and no errors should be retried."""
1005 mock_retrieve.return_value = (
1006 [
1007 {
1008 "id": "retry1",
1009 "title": "Retry result",
1010 "url": "https://reddit.com/r/test/2",
1011 "subreddit": "test",
1012 "date": "2026-03-15",
1013 "engagement": {"score": 10},
1014 "selftext": "Retry content",
1015 "relevance": 0.7,
1016 "why_relevant": "retry",
1017 }
1018 ],
1019 {},
1020 )
1021
1022 bundle = schema.RetrievalBundle()
1023 # Only 1 reddit item (thin)
1024 bundle.items_by_source["reddit"] = [
1025 _make_source_item("reddit", "R1", "https://reddit.com/r/test/1", container="test"),
1026 ]
1027 # 5 X items (not thin)
1028 bundle.items_by_source["x"] = [
1029 _make_source_item("x", f"X{i}", f"https://x.com/a/{i}") for i in range(5)
1030 ]
1031
1032 plan = _make_plan("advanced AI safety techniques")
1033 settings = pipeline.DEPTH_SETTINGS["default"]
1034
1035 pipeline._retry_thin_sources(
1036 topic="advanced AI safety techniques",
1037 bundle=bundle,
1038 plan=plan,
1039 config={},
1040 depth="default",
1041 date_range=("2026-02-15", "2026-03-17"),
1042 runtime=_make_runtime(),
1043 mock=False,
1044 rate_limited_sources=set(),
1045 rate_limit_lock=threading.Lock(),
1046 settings=settings,
1047 )
1048
1049 # _retrieve_stream should have been called for reddit (thin source)
1050 mock_retrieve.assert_called()
1051 call_sources = [c.kwargs.get("source") for c in mock_retrieve.call_args_list]
1052 self.assertIn("reddit", call_sources)
1053 # X should NOT have been retried
1054 self.assertNotIn("x", call_sources)
1055
1056 def test_sources_with_enough_items_not_retried(self):
1057 """Sources with >= 3 items should not be retried."""
1058 bundle = schema.RetrievalBundle()
1059 bundle.items_by_source["reddit"] = [
1060 _make_source_item("reddit", f"R{i}", f"https://reddit.com/r/test/{i}") for i in range(5)
1061 ]
1062 bundle.items_by_source["x"] = [
1063 _make_source_item("x", f"X{i}", f"https://x.com/a/{i}") for i in range(5)
1064 ]
1065
1066 plan = _make_plan("AI safety")
1067 settings = pipeline.DEPTH_SETTINGS["default"]
1068
1069 with patch("lib.pipeline._retrieve_stream") as mock_retrieve:
1070 pipeline._retry_thin_sources(
1071 topic="AI safety",
1072 bundle=bundle,
1073 plan=plan,
1074 config={},
1075 depth="default",
1076 date_range=("2026-02-15", "2026-03-17"),
1077 runtime=_make_runtime(),
1078 mock=False,
1079 rate_limited_sources=set(),
1080 rate_limit_lock=threading.Lock(),
1081 settings=settings,
1082 )
1083 mock_retrieve.assert_not_called()
1084
1085 def test_errored_sources_not_retried(self):
1086 """Sources in errors_by_source should not be retried even if thin.
1087 Non-errored thin sources SHOULD still be retried."""
1088 bundle = schema.RetrievalBundle()
1089 bundle.items_by_source["reddit"] = [
1090 _make_source_item("reddit", "R1", "https://reddit.com/r/test/1"),
1091 ]
1092 bundle.errors_by_source["reddit"] = "API error"
1093
1094 plan = _make_plan("AI safety")
1095 settings = pipeline.DEPTH_SETTINGS["default"]
1096
1097 mock_items = [{"id": "X1", "title": "test", "url": "https://x.com/1", "text": "test"}]
1098 with patch("lib.pipeline._retrieve_stream", return_value=(mock_items, {})) as mock_retrieve:
1099 pipeline._retry_thin_sources(
1100 topic="AI safety",
1101 bundle=bundle,
1102 plan=plan,
1103 config={},
1104 depth="default",
1105 date_range=("2026-02-15", "2026-03-17"),
1106 runtime=_make_runtime(),
1107 mock=False,
1108 rate_limited_sources=set(),
1109 rate_limit_lock=threading.Lock(),
1110 settings=settings,
1111 )
1112 # x (non-errored, thin) should be retried; reddit (errored) should not
1113 if mock_retrieve.call_count > 0:
1114 self.assertNotIn("reddit", [c.kwargs.get("source") for c in mock_retrieve.call_args_list])
1115
1116 def test_retry_skipped_in_quick_mode(self):
1117 """_retry_thin_sources should return immediately in quick mode."""
1118 bundle = schema.RetrievalBundle()
1119 bundle.items_by_source["reddit"] = [
1120 _make_source_item("reddit", "R1", "https://reddit.com/r/test/1"),
1121 ]
1122
1123 plan = _make_plan("AI safety")
1124 settings = pipeline.DEPTH_SETTINGS["quick"]
1125
1126 with patch("lib.pipeline._retrieve_stream") as mock_retrieve:
1127 pipeline._retry_thin_sources(
1128 topic="AI safety",
1129 bundle=bundle,
1130 plan=plan,
1131 config={},
1132 depth="quick",
1133 date_range=("2026-02-15", "2026-03-17"),
1134 runtime=_make_runtime(),
1135 mock=False,
1136 rate_limited_sources=set(),
1137 rate_limit_lock=threading.Lock(),
1138 settings=settings,
1139 )
1140 mock_retrieve.assert_not_called()
1141
1142
1143 class TestErrorCleanup(unittest.TestCase):
1144 """Source errors should be cleared when the source has items from other subqueries."""
1145
1146 def test_error_cleared_when_source_has_items(self):
1147 """A source that 429'd on one subquery but succeeded on another is not errored."""
1148 bundle = schema.RetrievalBundle(artifacts={})
1149 item = schema.SourceItem(
1150 item_id="x1", source="x", title="A tweet", body="content",
1151 url="https://x.com/user/status/1",
1152 )
1153 bundle.items_by_source["x"] = [item]
1154 bundle.errors_by_source["x"] = "HTTP 429: Too Many Requests"
1155
1156 # Simulate the cleanup logic from pipeline.run()
1157 for source in list(bundle.errors_by_source):
1158 if bundle.items_by_source.get(source):
1159 del bundle.errors_by_source[source]
1160
1161 self.assertNotIn("x", bundle.errors_by_source,
1162 "X should not be errored when it has items")
1163
1164 def test_error_kept_when_source_has_no_items(self):
1165 """A source with zero items should remain in errors_by_source."""
1166 bundle = schema.RetrievalBundle(artifacts={})
1167 bundle.errors_by_source["x"] = "HTTP 429: Too Many Requests"
1168
1169 for source in list(bundle.errors_by_source):
1170 if bundle.items_by_source.get(source):
1171 del bundle.errors_by_source[source]
1172
1173 self.assertIn("x", bundle.errors_by_source,
1174 "X should remain errored when it has no items")
1175
1176
1177 class TestXHandleFlag(unittest.TestCase):
1178 """R3: --x-handle CLI flag and pipeline parameter."""
1179
1180 def test_cli_accepts_x_handle_flag(self):
1181 """build_parser() should accept --x-handle."""
1182 import last30days as cli
1183
1184 parser = cli.build_parser()
1185 args = parser.parse_args(["test topic", "--x-handle", "elonmusk"])
1186 self.assertEqual(args.x_handle, "elonmusk")
1187
1188 def test_cli_x_handle_default_is_none(self):
1189 """--x-handle should default to None."""
1190 import last30days as cli
1191
1192 parser = cli.build_parser()
1193 args = parser.parse_args(["test topic"])
1194 self.assertIsNone(args.x_handle)
1195
1196 def test_pipeline_run_accepts_x_handle(self):
1197 """pipeline.run() should accept x_handle keyword argument."""
1198 import inspect
1199 sig = inspect.signature(pipeline.run)
1200 self.assertIn("x_handle", sig.parameters, "pipeline.run() must accept x_handle parameter")
1201
1202 def test_x_handle_passed_to_supplemental_searches(self):
1203 """When x_handle is provided, it should trigger targeted handle search."""
1204 # Run pipeline in mock mode with x_handle -- should not raise
1205 report = pipeline.run(
1206 topic="test topic",
1207 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
1208 depth="quick",
1209 requested_sources=["reddit", "x", "grounding"],
1210 mock=True,
1211 x_handle="testuser",
1212 )
1213 self.assertEqual("test topic", report.topic)
1214
1215
1216 class TestWarnings(unittest.TestCase):
1217 def _item(self, source="reddit"):
1218 return schema.SourceItem(item_id="1", source=source, title="t", body="b", url="u")
1219
1220 def _candidate(self, source="reddit", score=50.0):
1221 c = schema.Candidate(
1222 candidate_id="c1", item_id="1", source=source, title="t", url="u",
1223 snippet="s", subquery_labels=["main"], native_ranks={"main:reddit": 1},
1224 local_relevance=0.5, freshness=50, engagement=10, source_quality=0.7,
1225 rrf_score=0.01, sources=[source],
1226 )
1227 c.final_score = score
1228 return c
1229
1230 def test_no_candidates_warning(self):
1231 w = pipeline._warnings({"reddit": [self._item()]}, [], {})
1232 self.assertTrue(any("No candidates" in msg for msg in w))
1233
1234 def test_thin_evidence_warning(self):
1235 candidates = [self._candidate() for _ in range(3)]
1236 w = pipeline._warnings({"reddit": [self._item()]}, candidates, {})
1237 self.assertTrue(any("thin" in msg.lower() for msg in w))
1238
1239 def test_single_source_concentration(self):
1240 candidates = [self._candidate() for _ in range(5)]
1241 w = pipeline._warnings({"reddit": [self._item()]}, candidates, {})
1242 self.assertTrue(any("concentrated" in msg.lower() for msg in w))
1243
1244 def test_source_errors_listed(self):
1245 w = pipeline._warnings({}, [self._candidate()], {"x": "timeout"})
1246 self.assertTrue(any("x" in msg for msg in w))
1247
1248 def test_no_items_warning(self):
1249 w = pipeline._warnings({}, [], {})
1250 self.assertTrue(any("No source returned" in msg for msg in w))
1251
1252
1253 class TestXRelatedSupplementalSearch(unittest.TestCase):
1254 """Tests for --x-related weighted supplemental search."""
1255
1256 @patch("lib.bird_x.search_handles")
1257 @patch("lib.entity_extract.extract_entities")
1258 def test_x_related_triggers_supplemental_related_label(self, mock_extract, mock_handles):
1259 """x_related handles should be searched and added with supplemental-related label."""
1260 mock_extract.return_value = {"x_handles": [], "x_hashtags": [], "reddit_subreddits": []}
1261 mock_handles.return_value = [
1262 {
1263 "id": "rel1",
1264 "text": "Related tweet from biancacensori",
1265 "url": "https://x.com/biancacensori/status/555",
1266 "author_handle": "biancacensori",
1267 "date": "2026-03-15",
1268 "engagement": {"likes": 30},
1269 "relevance": 0.7,
1270 "why_relevant": "related handle search",
1271 }
1272 ]
1273
1274 bundle = schema.RetrievalBundle()
1275 bundle.items_by_source["x"] = [
1276 _make_source_item("x", "X1", "https://x.com/kanyewest/status/1", author="kanyewest"),
1277 ]
1278
1279 plan = _make_plan("Kanye West")
1280
1281 pipeline._run_supplemental_searches(
1282 topic="Kanye West",
1283 bundle=bundle,
1284 plan=plan,
1285 config={},
1286 depth="default",
1287 date_range=("2026-02-15", "2026-03-17"),
1288 runtime=_make_runtime("bird"),
1289 mock=False,
1290 rate_limited_sources=set(),
1291 rate_limit_lock=threading.Lock(),
1292 x_related=["biancacensori"],
1293 )
1294
1295 # search_handles should have been called for the related handle
1296 mock_handles.assert_called()
1297 # The supplemental-related subquery label should exist in the plan
1298 labels = [sq.label for sq in plan.subqueries]
1299 self.assertIn("supplemental-related", labels)
1300 # The supplemental-related subquery should have weight 0.3
1301 related_sq = [sq for sq in plan.subqueries if sq.label == "supplemental-related"][0]
1302 self.assertAlmostEqual(related_sq.weight, 0.3)
1303
1304 @patch("lib.bird_x.search_handles")
1305 @patch("lib.entity_extract.extract_entities")
1306 def test_no_x_related_no_supplemental_related_label(self, mock_extract, mock_handles):
1307 """Without x_related, supplemental-related label should not appear."""
1308 mock_extract.return_value = {"x_handles": ["analyst1"], "x_hashtags": [], "reddit_subreddits": []}
1309 mock_handles.return_value = [
1310 {
1311 "id": "supp1",
1312 "text": "Supplemental tweet",
1313 "url": "https://x.com/analyst1/status/999",
1314 "author_handle": "analyst1",
1315 "date": "2026-03-15",
1316 "engagement": {"likes": 50},
1317 "relevance": 0.8,
1318 "why_relevant": "direct handle search",
1319 }
1320 ]
1321
1322 bundle = schema.RetrievalBundle()
1323 bundle.items_by_source["x"] = [
1324 _make_source_item("x", "X1", "https://x.com/analyst1/status/1", author="analyst1"),
1325 ]
1326
1327 plan = _make_plan("AI safety")
1328
1329 pipeline._run_supplemental_searches(
1330 topic="AI safety",
1331 bundle=bundle,
1332 plan=plan,
1333 config={},
1334 depth="default",
1335 date_range=("2026-02-15", "2026-03-17"),
1336 runtime=_make_runtime("bird"),
1337 mock=False,
1338 rate_limited_sources=set(),
1339 rate_limit_lock=threading.Lock(),
1340 )
1341
1342 # supplemental-related label should NOT exist (no x_related provided)
1343 labels = [sq.label for sq in plan.subqueries]
1344 self.assertNotIn("supplemental-related", labels)
1345
1346
1347 class TestRetryThinSourcesCoreEqualsTopic(unittest.TestCase):
1348 """Test that _retry_thin_sources fires even when core == topic (the fix)."""
1349
1350 @patch("lib.pipeline._retrieve_stream")
1351 def test_retry_fires_when_core_equals_topic(self, mock_retrieve):
1352 """Topic 'Kanye West' with 0 YouTube items should trigger retry.
1353
1354 Previously this was skipped because core 'kanye west' == topic.
1355 The fix ensures retry still fires for short topics.
1356 """
1357 mock_retrieve.return_value = (
1358 [
1359 {
1360 "id": "YT1",
1361 "title": "Kanye West new album leak",
1362 "url": "https://www.youtube.com/watch?v=abc123",
1363 "date": "2026-03-15",
1364 "engagement": {"views": 1000},
1365 "relevance": 0.8,
1366 "why_relevant": "retry result",
1367 }
1368 ],
1369 {},
1370 )
1371
1372 plan = schema.QueryPlan(
1373 intent="breaking_news",
1374 freshness_mode="strict_recent",
1375 cluster_mode="story",
1376 raw_topic="Kanye West",
1377 subqueries=[
1378 schema.SubQuery(
1379 label="primary",
1380 search_query="Kanye West",
1381 ranking_query="What recent evidence matters for Kanye West?",
1382 sources=["youtube", "x"],
1383 )
1384 ],
1385 source_weights={"youtube": 1.0, "x": 1.0},
1386 )
1387 bundle = schema.RetrievalBundle()
1388 # YouTube has 0 items (thin), X has enough
1389 bundle.items_by_source["x"] = [
1390 _make_source_item("x", f"X{i}", f"https://x.com/a/{i}") for i in range(5)
1391 ]
1392
1393 pipeline._retry_thin_sources(
1394 topic="Kanye West",
1395 bundle=bundle,
1396 plan=plan,
1397 config={},
1398 depth="default",
1399 date_range=("2026-02-15", "2026-03-17"),
1400 runtime=_make_runtime(),
1401 mock=False,
1402 rate_limited_sources=set(),
1403 rate_limit_lock=threading.Lock(),
1404 settings=pipeline.DEPTH_SETTINGS["default"],
1405 )
1406
1407 # _retrieve_stream should have been called for youtube
1408 mock_retrieve.assert_called()
1409 retried_sources = [c.kwargs["source"] for c in mock_retrieve.call_args_list]
1410 self.assertIn("youtube", retried_sources)
1411 # YouTube should now have items in the bundle
1412 self.assertIn("youtube", bundle.items_by_source)
1413
1414
1415 class TestZeroKeyPipelineRun(unittest.TestCase):
1416 """Pipeline should complete with local fallbacks when no reasoning keys are configured."""
1417
1418 @patch("lib.pipeline._retrieve_stream")
1419 def test_zero_key_run_produces_report(self, mock_retrieve):
1420 mock_retrieve.side_effect = lambda **kwargs: pipeline._mock_stream_results(
1421 kwargs["source"], kwargs["subquery"]
1422 )
1423 config = {"LAST30DAYS_REASONING_PROVIDER": "auto"}
1424 report = pipeline.run(
1425 topic="test zero key topic",
1426 config=config,
1427 depth="quick",
1428 requested_sources=["hackernews"],
1429 )
1430 self.assertEqual("test zero key topic", report.topic)
1431 self.assertEqual("local", report.provider_runtime.reasoning_provider)
1432 self.assertEqual("deterministic", report.provider_runtime.planner_model)
1433 self.assertTrue(
1434 any("fallback" in note for note in report.query_plan.notes),
1435 f"Expected fallback plan, got notes: {report.query_plan.notes}",
1436 )
1437 for candidate in report.ranked_candidates:
1438 self.assertEqual("fallback-local-score", candidate.explanation)
1439
1440
1441 class TestExcludeSources(unittest.TestCase):
1442 """EXCLUDE_SOURCES env var filters sources out of available_sources().
1443
1444 The existing INCLUDE_SOURCES allowlist (used by Perplexity opt-in) does
1445 not cover this case — tiktok and instagram are added unconditionally
1446 when SCRAPECREATORS_API_KEY is set, with no way to opt out short of
1447 unsetting the key. EXCLUDE_SOURCES gives runs a per-invocation denylist.
1448 """
1449
1450 def test_excludes_tiktok_and_instagram(self):
1451 config = {
1452 "SCRAPECREATORS_API_KEY": "test-key",
1453 "EXCLUDE_SOURCES": "tiktok,instagram",
1454 }
1455 sources = pipeline.available_sources(config)
1456 self.assertNotIn("tiktok", sources)
1457 self.assertNotIn("instagram", sources)
1458 self.assertIn("reddit", sources)
1459 self.assertIn("hackernews", sources)
1460
1461 def test_no_exclusion_when_unset(self):
1462 config = {"SCRAPECREATORS_API_KEY": "test-key"}
1463 sources = pipeline.available_sources(config)
1464 self.assertIn("tiktok", sources)
1465 self.assertIn("instagram", sources)
1466
1467 def test_empty_exclude_sources_is_noop(self):
1468 config = {
1469 "SCRAPECREATORS_API_KEY": "test-key",
1470 "EXCLUDE_SOURCES": "",
1471 }
1472 sources = pipeline.available_sources(config)
1473 self.assertIn("tiktok", sources)
1474 self.assertIn("instagram", sources)
1475
1476 def test_whitespace_and_case_insensitive(self):
1477 config = {
1478 "SCRAPECREATORS_API_KEY": "test-key",
1479 "EXCLUDE_SOURCES": " TikTok , INSTAGRAM ",
1480 }
1481 sources = pipeline.available_sources(config)
1482 self.assertNotIn("tiktok", sources)
1483 self.assertNotIn("instagram", sources)
1484
1485 def test_excludes_non_scrapecreators_source(self):
1486 """EXCLUDE_SOURCES applies to any source, not just SC-backed ones."""
1487 config = {"EXCLUDE_SOURCES": "hackernews"}
1488 sources = pipeline.available_sources(config)
1489 self.assertNotIn("hackernews", sources)
1490 self.assertIn("reddit", sources)
1491
1492
1493 class TestPerplexityAvailability(unittest.TestCase):
1494 def test_perplexity_source_not_available_with_direct_key_without_opt_in(self):
1495 sources = pipeline.available_sources({"PERPLEXITY_API_KEY": "test-key"})
1496 self.assertNotIn("perplexity", sources)
1497
1498 def test_perplexity_source_available_with_direct_key(self):
1499 sources = pipeline.available_sources(
1500 {"PERPLEXITY_API_KEY": "test-key", "INCLUDE_SOURCES": "perplexity"}
1501 )
1502 self.assertIn("perplexity", sources)
1503
1504 def test_perplexity_diagnose_reports_direct_provider(self):
1505 diag = pipeline.diagnose({"PERPLEXITY_API_KEY": "test-key"})
1506 self.assertTrue(diag["providers"]["perplexity"])
1507 self.assertTrue(diag["local_mode"])
1508
1509
1510 class TestLinkedinAvailability(unittest.TestCase):
1511 """LinkedIn is power-user opt-in (INCLUDE_SOURCES=linkedin), unlike
1512 tiktok/instagram which activate on SCRAPECREATORS_API_KEY alone. This
1513 keeps existing SCRAPECREATORS_API_KEY holders from silently picking up a
1514 new source — and spending new credits — on their next run."""
1515
1516 def test_not_available_with_key_alone(self):
1517 sources = pipeline.available_sources({"SCRAPECREATORS_API_KEY": "test-key"})
1518 self.assertNotIn("linkedin", sources)
1519 # tiktok/instagram remain unconditional with just the key
1520 self.assertIn("tiktok", sources)
1521 self.assertIn("instagram", sources)
1522
1523 def test_available_with_key_and_include_sources(self):
1524 sources = pipeline.available_sources(
1525 {"SCRAPECREATORS_API_KEY": "test-key", "INCLUDE_SOURCES": "linkedin"}
1526 )
1527 self.assertIn("linkedin", sources)
1528
1529 def test_available_with_key_and_requested_sources(self):
1530 sources = pipeline.available_sources(
1531 {"SCRAPECREATORS_API_KEY": "test-key"}, requested_sources=["linkedin"]
1532 )
1533 self.assertIn("linkedin", sources)
1534
1535 def test_not_available_with_include_sources_but_no_key(self):
1536 sources = pipeline.available_sources({"INCLUDE_SOURCES": "linkedin"})
1537 self.assertNotIn("linkedin", sources)
1538
1539
1540 class TestKeylessGroundingAvailability(unittest.TestCase):
1541 """Grounding (general web) availability is host-aware.
1542
1543 Non-native hosts get the keyless floor by default; native-search hosts leave
1544 general web to the model's own search unless a paid key is configured.
1545 """
1546
1547 def test_grounding_available_without_key_on_non_native_host(self):
1548 sources = pipeline.available_sources({})
1549 self.assertIn("grounding", sources)
1550
1551 def test_grounding_suppressed_without_key_on_native_host(self):
1552 config = {"LAST30DAYS_NATIVE_SEARCH": "1"}
1553 sources = pipeline.available_sources(config)
1554 self.assertNotIn("grounding", sources)
1555
1556 def test_grounding_available_with_paid_key_even_on_native_host(self):
1557 config = {"LAST30DAYS_NATIVE_SEARCH": "1", "BRAVE_API_KEY": "k"}
1558 sources = pipeline.available_sources(config)
1559 self.assertIn("grounding", sources)
1560
1561
1562 class TestExcludeSourcesEndToEnd(unittest.TestCase):
1563 """Wiring regression: EXCLUDE_SOURCES from the process environment must
1564 reach available_sources() via env.get_config(). The unit tests above
1565 construct config dicts directly; this one exercises the env-to-config
1566 path so a missing entry in env.py's keys list is caught immediately."""
1567
1568 def test_exclude_sources_from_env_propagates_through_get_config(self):
1569 import os
1570 from unittest.mock import patch as _patch
1571 from lib import env as env_mod
1572 from importlib import reload
1573 with _patch.dict(os.environ, {
1574 "LAST30DAYS_CONFIG_DIR": "",
1575 "EXCLUDE_SOURCES": "tiktok,instagram",
1576 "SCRAPECREATORS_API_KEY": "fake",
1577 }, clear=False):
1578 reload(env_mod)
1579 cfg = env_mod.get_config()
1580 self.assertEqual(cfg.get("EXCLUDE_SOURCES"), "tiktok,instagram")
1581 sources = pipeline.available_sources(cfg)
1582 self.assertNotIn("tiktok", sources)
1583 self.assertNotIn("instagram", sources)
1584
1585
1586 class TestInnerMaxWorkers(unittest.TestCase):
1587 """Cap inner ThreadPoolExecutor concurrency under competitor fanout.
1588
1589 Without the cap, six competitor sub-runs each open their own
1590 ``ThreadPoolExecutor(max_workers=16)``, peaking around 96 worker threads
1591 that all hammer the same upstream APIs. ``internal_subrun=True`` should
1592 reduce the inner pool so the nested fanout stays bounded.
1593 """
1594
1595 def test_normal_run_uses_full_ceiling(self):
1596 self.assertEqual(pipeline._inner_max_workers(20, internal_subrun=False), 16)
1597 self.assertEqual(pipeline._inner_max_workers(10, internal_subrun=False), 10)
1598 self.assertEqual(pipeline._inner_max_workers(1, internal_subrun=False), 4)
1599
1600 def test_subrun_caps_at_four(self):
1601 self.assertEqual(pipeline._inner_max_workers(20, internal_subrun=True), 4)
1602 self.assertEqual(pipeline._inner_max_workers(10, internal_subrun=True), 4)
1603 self.assertEqual(pipeline._inner_max_workers(3, internal_subrun=True), 3)
1604 self.assertEqual(pipeline._inner_max_workers(1, internal_subrun=True), 2)
1605
1606 def test_subrun_caps_total_concurrency_below_uncapped(self):
1607 # Derive the outer cap from fanout so this test stays meaningful if
1608 # MAX_PARALLEL_SUBRUNS is bumped. The contract under test is "subrun
1609 # mode meaningfully reduces total inner-thread count", not a magic
1610 # number tied to today's value of MAX_PARALLEL_SUBRUNS=6.
1611 from lib import fanout
1612 max_subruns = fanout.MAX_PARALLEL_SUBRUNS
1613 capped = pipeline._inner_max_workers(20, internal_subrun=True) * max_subruns
1614 uncapped = pipeline._inner_max_workers(20, internal_subrun=False) * max_subruns
1615 self.assertLess(capped, uncapped, f"capped={capped} not < uncapped={uncapped}")
1616 # The cap must cut total concurrency to at most half of the un-capped
1617 # value; otherwise the cap is doing real work.
1618 self.assertLessEqual(
1619 capped,
1620 uncapped // 2,
1621 f"capped {capped} should be at most half of uncapped {uncapped}",
1622 )
1623
1624
1625 class TestScrapeCreatorsTierGating(unittest.TestCase):
1626 """The onboarding Recommended vs Everything tiers must be real.
1627
1628 Recommended (key, no INCLUDE_SOURCES) = TikTok + Instagram only.
1629 Everything (INCLUDE_SOURCES lists them) = also Threads, Pinterest, ...
1630 """
1631
1632 KEY = {"SCRAPECREATORS_API_KEY": "k"}
1633
1634 def test_recommended_tier_runs_tiktok_instagram(self):
1635 avail = pipeline.available_sources(dict(self.KEY))
1636 self.assertIn("tiktok", avail)
1637 self.assertIn("instagram", avail)
1638
1639 def test_threads_off_without_include_sources(self):
1640 self.assertNotIn("threads", pipeline.available_sources(dict(self.KEY)))
1641
1642 def test_threads_on_with_include_sources(self):
1643 cfg = {**self.KEY, "INCLUDE_SOURCES": "threads"}
1644 self.assertIn("threads", pipeline.available_sources(cfg))
1645
1646 def test_pinterest_off_without_include_sources(self):
1647 self.assertNotIn("pinterest", pipeline.available_sources(dict(self.KEY)))
1648
1649 def test_pinterest_on_with_persisted_include_sources(self):
1650 # Regression: this failed before U6 because the pinterest gate read
1651 # requested_sources only and ignored a persisted INCLUDE_SOURCES.
1652 cfg = {**self.KEY, "INCLUDE_SOURCES": "pinterest"}
1653 self.assertIn("pinterest", pipeline.available_sources(cfg))
1654
1655 def test_pinterest_on_via_requested_sources(self):
1656 # The per-run --sources path must still work.
1657 avail = pipeline.available_sources(dict(self.KEY), requested_sources=["pinterest"])
1658 self.assertIn("pinterest", avail)
1659
1660 def test_everything_tier_enables_all(self):
1661 cfg = {
1662 **self.KEY,
1663 "INCLUDE_SOURCES": "tiktok,instagram,threads,pinterest,youtube_comments,tiktok_comments",
1664 }
1665 avail = pipeline.available_sources(cfg)
1666 self.assertIn("threads", avail)
1667 self.assertIn("pinterest", avail)
1668
1669
1670 if __name__ == "__main__":
1671 unittest.main()
1672
1672 lines PYTHON