返回 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 fanout
7 from lib import http
8 from lib import pipeline
9 from lib import schema
10
11
12 class DepthSettingsOverrideTests(unittest.TestCase):
13 def test_no_overrides_returns_depth_defaults(self):
14 settings = pipeline._resolve_depth_settings("deep", {})
15 self.assertEqual(pipeline.DEPTH_SETTINGS["deep"], settings)
16
17 def test_overrides_raise_caps_and_do_not_mutate_module_defaults(self):
18 before = dict(pipeline.DEPTH_SETTINGS["deep"])
19 settings = pipeline._resolve_depth_settings(
20 "deep", {"_max_per_source": 60, "_max_results": 200}
21 )
22 self.assertEqual(60, settings["per_stream_limit"])
23 self.assertEqual(200, settings["pool_limit"])
24 self.assertEqual(200, settings["rerank_limit"])
25 # Module-level defaults must be untouched (issue #716 regression guard).
26 self.assertEqual(before, pipeline.DEPTH_SETTINGS["deep"])
27
28 def test_overrides_can_also_lower_caps(self):
29 settings = pipeline._resolve_depth_settings("deep", {"_max_results": 10})
30 self.assertEqual(10, settings["rerank_limit"])
31
32 def test_zero_override_is_honored_not_swallowed(self):
33 # 0 is a valid explicit value (e.g. disable a source), not "unset".
34 settings = pipeline._resolve_depth_settings(
35 "deep", {"_max_results": 0, "_max_per_source": 0}
36 )
37 self.assertEqual(0, settings["pool_limit"])
38 self.assertEqual(0, settings["rerank_limit"])
39 self.assertEqual(0, settings["per_stream_limit"])
40
41
42 class PipelineV3Tests(unittest.TestCase):
43 def test_mock_pipeline_report_without_live_credentials(self):
44 report = pipeline.run(
45 topic="test topic",
46 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
47 depth="quick",
48 requested_sources=["reddit", "x", "grounding"],
49 mock=True,
50 )
51 self.assertEqual("test topic", report.topic)
52 self.assertTrue(report.ranked_candidates)
53 self.assertTrue(report.clusters)
54 self.assertIn("x", report.items_by_source)
55 # Grounding items now enter the ranked pool (web search backends produce real items)
56 self.assertIn("grounding", report.items_by_source)
57 self.assertEqual("gemini", report.provider_runtime.reasoning_provider)
58
59 def test_empty_explicit_plan_is_rejected(self):
60 with self.assertRaisesRegex(ValueError, "intent"):
61 pipeline.run(
62 topic="test topic",
63 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
64 depth="quick",
65 external_plan={},
66 mock=True,
67 )
68
69 def test_external_plan_honors_per_subquery_sources_at_default_depth(self):
70 # Issue #1073: --plan sources are a contract at default/deep.
71 plan = {
72 "intent": "opinion",
73 "freshness_mode": "balanced_recent",
74 "cluster_mode": "debate",
75 "subqueries": [
76 {
77 "label": "primary",
78 "search_query": "late diagnosed autism adults",
79 "ranking_query": "What are people saying about late diagnosed autism in adults?",
80 "sources": ["reddit", "x", "youtube"],
81 "weight": 1.0,
82 }
83 ],
84 }
85 report = pipeline.run(
86 topic="late diagnosed autism adults",
87 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
88 depth="default",
89 requested_sources=["reddit", "x", "youtube", "hackernews", "polymarket", "github"],
90 mock=True,
91 web_backend="none",
92 external_plan=plan,
93 )
94 self.assertEqual(
95 ["reddit", "x", "youtube"],
96 report.query_plan.subqueries[0].sources,
97 )
98 self.assertNotIn("hackernews", report.items_by_source)
99 self.assertNotIn("polymarket", report.items_by_source)
100 self.assertNotIn("github", report.items_by_source)
101
102 def test_planner_trace_always_fires_on_mock_run(self):
103 """Unit 5: The unified planner trace emits one summary line plus one
104 line per subquery on every run, regardless of --debug. 2026-04-19
105 Hermes Agent Use Cases failure: retrieval-breadth issues were invisible
106 because the internal planner path logged nothing.
107 """
108 import io
109 import contextlib
110 buf = io.StringIO()
111 with contextlib.redirect_stderr(buf):
112 pipeline.run(
113 topic="test topic",
114 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
115 depth="quick",
116 requested_sources=["reddit", "x", "grounding"],
117 mock=True,
118 )
119 output = buf.getvalue()
120 self.assertIn("[Planner] Plan: intent=", output)
121 self.assertIn("subqueries=", output)
122 self.assertIn("source=", output)
123 # At least one per-subquery line.
124 self.assertIn("[Planner] sq1 label=", output)
125
126 def test_parallel_web_backend_enables_grounding_source(self):
127 plan = {
128 "intent": "news",
129 "freshness_mode": "balanced_recent",
130 "cluster_mode": "timeline",
131 "subqueries": [
132 {
133 "label": "primary",
134 "search_query": "test topic",
135 "ranking_query": "What happened with test topic?",
136 "sources": ["grounding"],
137 }
138 ],
139 "source_weights": {"grounding": 1.0},
140 }
141 report = pipeline.run(
142 topic="test topic",
143 config={"LAST30DAYS_REASONING_PROVIDER": "auto"},
144 depth="quick",
145 requested_sources=["grounding"],
146 web_backend="parallel",
147 external_plan=plan,
148 )
149 # Anchor on the stable source key, not the exact wording of the
150 # grounding.py error message. Phrasing can shift (e.g., when the
151 # missing-key check moves or the message is reworded) without
152 # changing the contract that the grounding source registers an
153 # error when its required backend key is unset.
154 self.assertIn("grounding", report.errors_by_source)
155
156 def test_parallel_mcp_enables_grounding_without_key_on_native_host(self):
157 def mcp_response(message, _api_key, _session_id=None):
158 results = {
159 "initialize": {"protocolVersion": "2025-03-26"},
160 "notifications/initialized": {},
161 "tools/list": {"tools": [{"name": "web_search"}]},
162 "tools/call": {"structuredContent": {"results": [{
163 "url": "https://example.com/update",
164 "title": "Test topic update",
165 "publish_date": "2026-08-01",
166 "excerpts": ["New evidence about test topic"],
167 }]}},
168 }
169 return {"result": results[message["method"]]}, None
170
171 with patch("lib.parallel_mcp._request", side_effect=mcp_response):
172 report = pipeline.run(
173 topic="test topic",
174 config={"LAST30DAYS_REASONING_PROVIDER": "auto", "LAST30DAYS_NATIVE_SEARCH": "1"},
175 depth="quick",
176 requested_sources=["grounding"],
177 web_backend="parallel-mcp",
178 as_of_date="2026-08-26",
179 )
180 self.assertNotIn("grounding", report.errors_by_source)
181 self.assertEqual(1, len(report.items_by_source["grounding"]))
182 self.assertEqual("2026-08-01", report.items_by_source["grounding"][0].published_at)
183
184 def test_hiring_signals_mode_enables_jobs_source_in_mock_run(self):
185 report = pipeline.run(
186 topic="Listen Labs",
187 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
188 depth="quick",
189 requested_sources=["jobs"],
190 mock=True,
191 hiring_signals_mode=True,
192 )
193 self.assertIn("jobs", report.items_by_source)
194 self.assertIn("hiring_signals", report.artifacts)
195 self.assertTrue(report.artifacts["hiring_signals"]["include"])
196
197 def test_hiring_signals_mode_defaults_to_jobs_source(self):
198 report = pipeline.run(
199 topic="Listen Labs",
200 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
201 depth="quick",
202 mock=True,
203 hiring_signals_mode=True,
204 )
205 self.assertEqual(["jobs"], sorted(report.items_by_source))
206 self.assertTrue(report.artifacts["hiring_signals"]["include"])
207
208 def test_explicit_sources_suppress_automatic_company_jobs(self):
209 report = pipeline.run(
210 topic="Listen Labs",
211 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
212 depth="quick",
213 requested_sources=["grounding", "x"],
214 mock=True,
215 )
216 self.assertEqual({"grounding", "x"}, set(report.items_by_source))
217 self.assertTrue(
218 all("jobs" not in subquery.sources for subquery in report.query_plan.subqueries)
219 )
220
221 def test_hiring_signals_forces_jobs_with_explicit_non_jobs_sources(self):
222 report = pipeline.run(
223 topic="Listen Labs",
224 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
225 depth="quick",
226 requested_sources=["grounding", "x"],
227 mock=True,
228 hiring_signals_mode=True,
229 )
230 self.assertEqual({"grounding", "jobs", "x"}, set(report.items_by_source))
231
232 def test_standard_company_run_fetches_jobs_for_signal_gate(self):
233 report = pipeline.run(
234 topic="Listen Labs",
235 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
236 depth="quick",
237 mock=True,
238 )
239 self.assertIn("jobs", report.items_by_source)
240 self.assertIn("hiring_signals", report.artifacts)
241
242 def test_standard_mock_run_does_not_add_jobs_for_generic_topic(self):
243 report = pipeline.run(
244 topic="how to deploy on Fly.io",
245 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
246 depth="quick",
247 mock=True,
248 )
249 self.assertNotIn("jobs", report.items_by_source)
250 self.assertNotIn("hiring_signals", report.artifacts)
251
252 def test_single_word_generic_topic_does_not_add_jobs(self):
253 report = pipeline.run(
254 topic="bitcoin",
255 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
256 depth="quick",
257 mock=True,
258 )
259 self.assertNotIn("jobs", report.items_by_source)
260 self.assertNotIn("hiring_signals", report.artifacts)
261
262 def test_question_comparison_topic_does_not_add_jobs(self):
263 report = pipeline.run(
264 topic="Python vs Ruby benchmark?",
265 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
266 depth="quick",
267 mock=True,
268 )
269 self.assertNotIn("jobs", report.items_by_source)
270 self.assertNotIn("hiring_signals", report.artifacts)
271
272 def test_bare_language_comparison_topics_do_not_add_jobs(self):
273 for topic in ("python vs ruby", "Python vs Ruby"):
274 with self.subTest(topic=topic):
275 self.assertFalse(pipeline._company_topic_likely(topic))
276
277 def test_company_comparison_topics_add_jobs(self):
278 for topic in ("Stripe vs Brex", "OpenAI versus Anthropic"):
279 with self.subTest(topic=topic):
280 self.assertTrue(pipeline._company_topic_likely(topic))
281
282 def test_standard_mode_omits_weak_large_company_jobs_signal(self):
283 with patch("lib.pipeline._retrieve_stream") as mock_retrieve:
284 def fake_retrieve(**kwargs):
285 if kwargs["source"] == "jobs":
286 return (
287 [
288 {
289 "id": "J1",
290 "title": "Retail Associate",
291 "description": "Store operations",
292 "url": "https://example.com/jobs/1",
293 "department": "Retail",
294 "date": "2026-06-01",
295 "provider": "mock",
296 }
297 ],
298 {},
299 )
300 return pipeline._mock_stream_results(kwargs["source"], kwargs["subquery"])
301
302 mock_retrieve.side_effect = fake_retrieve
303 report = pipeline.run(
304 topic="Apple",
305 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
306 depth="quick",
307 requested_sources=["jobs"],
308 mock=True,
309 )
310 self.assertNotIn("jobs", report.items_by_source)
311 self.assertFalse(report.artifacts["hiring_signals"]["include"])
312
313
314 class TestSourceFetchCap(unittest.TestCase):
315 def test_paid_budget_rejects_non_owner_before_owner_claims(self):
316 paid_budget = pipeline.PaidSourceBudget(owner="main")
317
318 self.assertFalse(paid_budget.try_consume(1, claimant="peer"))
319 self.assertEqual(0, paid_budget.used)
320 self.assertTrue(paid_budget.try_consume(1, claimant="main"))
321 self.assertEqual(1, paid_budget.used)
322
323 """X source fetch count must be capped by MAX_SOURCE_FETCHES."""
324
325 def test_x_capped_in_max_source_fetches(self):
326 """MAX_SOURCE_FETCHES must cap X at 2 to prevent 429 cascades."""
327 self.assertIn("x", pipeline.MAX_SOURCE_FETCHES)
328 self.assertEqual(pipeline.MAX_SOURCE_FETCHES["x"], 2)
329
330 def test_jobs_capped_in_max_source_fetches(self):
331 self.assertIn("jobs", pipeline.MAX_SOURCE_FETCHES)
332 self.assertEqual(pipeline.MAX_SOURCE_FETCHES["jobs"], 1)
333
334 def test_perplexity_paid_call_cap_cannot_be_raised_by_generic_override(self):
335 self.assertEqual(
336 1,
337 pipeline._source_fetch_cap(
338 "perplexity",
339 {"_max_source_fetches": 10},
340 ),
341 )
342 self.assertEqual(
343 0,
344 pipeline._source_fetch_cap(
345 "perplexity",
346 {"_max_source_fetches": 0},
347 ),
348 )
349
350 def test_deep_research_lane_is_isolated_and_does_not_replace_primary(self):
351 primary = schema.SubQuery(
352 label="primary",
353 search_query="narrow angle",
354 ranking_query="narrow angle",
355 sources=["reddit", "grounding", "perplexity"],
356 )
357 plan = schema.QueryPlan(
358 intent="general",
359 freshness_mode="balanced_recent",
360 cluster_mode="story",
361 raw_topic="whole topic",
362 subqueries=[primary],
363 source_weights={"reddit": 1.0, "grounding": 1.0, "perplexity": 1.0},
364 )
365
366 pipeline._ensure_perplexity_in_plan(
367 plan,
368 "whole topic",
369 ["reddit", "grounding", "perplexity"],
370 force=True,
371 )
372
373 self.assertEqual("primary", plan.subqueries[0].label)
374 self.assertEqual(["reddit", "grounding"], plan.subqueries[0].sources)
375 self.assertEqual("deep-research", plan.subqueries[-1].label)
376 self.assertEqual(["perplexity"], plan.subqueries[-1].sources)
377
378 def test_cap_logic_limits_source_submissions(self):
379 """Verify the cap logic skips submissions beyond the limit."""
380 subquery_sources = [
381 ["x", "reddit", "youtube"],
382 ["x", "reddit", "youtube"],
383 ["x", "reddit", "youtube"],
384 ["x", "reddit", "youtube"],
385 ]
386 source_fetch_count: dict[str, int] = {}
387 submitted: list[str] = []
388 for sources in subquery_sources:
389 for source in sources:
390 source_cap = pipeline.MAX_SOURCE_FETCHES.get(source)
391 if source_cap is not None:
392 current = source_fetch_count.get(source, 0)
393 if current >= source_cap:
394 continue
395 source_fetch_count[source] = current + 1
396 submitted.append(source)
397
398 x_count = submitted.count("x")
399 reddit_count = submitted.count("reddit")
400 self.assertEqual(x_count, 2, f"X should be capped at 2, got {x_count}")
401 self.assertEqual(reddit_count, 4, f"Reddit should be uncapped, got {reddit_count}")
402
403 @patch("lib.pipeline._retrieve_stream")
404 def test_mock_run_caps_x_fetches(self, mock_retrieve):
405 """Pipeline.run in mock mode should call _retrieve_stream for X at most 2 times."""
406 mock_retrieve.side_effect = lambda **kwargs: pipeline._mock_stream_results(
407 kwargs["source"], kwargs["subquery"]
408 )
409 pipeline.run(
410 topic="compare iPhone vs Android vs Pixel vs Samsung",
411 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
412 depth="quick",
413 requested_sources=["reddit", "x"],
414 mock=True,
415 )
416 x_calls = [
417 call for call in mock_retrieve.call_args_list
418 if call.kwargs.get("source") == "x"
419 ]
420 self.assertLessEqual(
421 len(x_calls), 2,
422 f"X should be fetched at most 2 times, got {len(x_calls)}",
423 )
424
425 @patch("lib.pipeline._retrieve_stream")
426 def test_zero_source_fetch_override_suppresses_capped_source(self, mock_retrieve):
427 """A 0 override is explicit and should suppress capped-source submissions."""
428 mock_retrieve.side_effect = lambda **kwargs: pipeline._mock_stream_results(
429 kwargs["source"], kwargs["subquery"]
430 )
431 pipeline.run(
432 topic="compare iPhone vs Android vs Pixel vs Samsung",
433 config={
434 "LAST30DAYS_REASONING_PROVIDER": "gemini",
435 "_max_source_fetches": 0,
436 },
437 depth="quick",
438 requested_sources=["reddit", "x"],
439 mock=True,
440 )
441 x_calls = [
442 call for call in mock_retrieve.call_args_list
443 if call.kwargs.get("source") == "x"
444 ]
445 reddit_calls = [
446 call for call in mock_retrieve.call_args_list
447 if call.kwargs.get("source") == "reddit"
448 ]
449 self.assertEqual([], x_calls)
450 self.assertGreater(len(reddit_calls), 0)
451
452 @patch("lib.pipeline._retrieve_stream")
453 def test_deep_research_subquery_fanout_submits_once(self, mock_retrieve):
454 mock_retrieve.return_value = ([], {})
455 plan = {
456 "intent": "comparison",
457 "freshness_mode": "balanced_recent",
458 "cluster_mode": "debate",
459 "subqueries": [
460 {
461 "label": f"angle-{index}",
462 "search_query": f"topic angle {index}",
463 "ranking_query": f"topic angle {index}",
464 "sources": ["perplexity"],
465 }
466 for index in range(3)
467 ],
468 "source_weights": {"perplexity": 1.0},
469 }
470
471 pipeline.run(
472 topic="whole topic",
473 config={
474 "LAST30DAYS_REASONING_PROVIDER": "gemini",
475 "PERPLEXITY_API_KEY": "pplx-test",
476 "_deep_research": True,
477 "_max_source_fetches": 10,
478 },
479 depth="default",
480 requested_sources=["reddit", "perplexity"],
481 mock=True,
482 external_plan=plan,
483 )
484
485 perplexity_calls = [
486 call
487 for call in mock_retrieve.call_args_list
488 if call.kwargs.get("source") == "perplexity"
489 ]
490 self.assertEqual(1, len(perplexity_calls))
491 self.assertEqual(
492 "whole topic",
493 perplexity_calls[0].kwargs["subquery"].search_query,
494 )
495
496 @patch("lib.pipeline._retrieve_stream")
497 def test_normal_perplexity_subquery_fanout_submits_once(self, mock_retrieve):
498 mock_retrieve.return_value = ([], {})
499 plan = {
500 "intent": "comparison",
501 "freshness_mode": "balanced_recent",
502 "cluster_mode": "debate",
503 "subqueries": [
504 {
505 "label": f"angle-{index}",
506 "search_query": f"topic angle {index}",
507 "ranking_query": f"topic angle {index}",
508 "sources": ["perplexity"],
509 }
510 for index in range(4)
511 ],
512 "source_weights": {"perplexity": 1.0},
513 }
514
515 pipeline.run(
516 topic="whole topic",
517 config={
518 "LAST30DAYS_REASONING_PROVIDER": "gemini",
519 "PERPLEXITY_API_KEY": "pplx-test",
520 "_max_source_fetches": 10,
521 },
522 depth="default",
523 requested_sources=["perplexity"],
524 mock=True,
525 external_plan=plan,
526 )
527
528 perplexity_calls = [
529 call
530 for call in mock_retrieve.call_args_list
531 if call.kwargs.get("source") == "perplexity"
532 ]
533 self.assertEqual(1, len(perplexity_calls))
534 self.assertEqual(
535 "whole topic",
536 perplexity_calls[0].kwargs["subquery"].search_query,
537 )
538
539 @patch("lib.pipeline._retrieve_stream")
540 def test_shared_paid_budget_caps_competitor_subruns(self, mock_retrieve):
541 mock_retrieve.return_value = ([], {})
542 paid_budget = pipeline.PaidSourceBudget()
543 plan = {
544 "intent": "general",
545 "freshness_mode": "balanced_recent",
546 "cluster_mode": "story",
547 "subqueries": [
548 {
549 "label": "primary",
550 "search_query": "entity angle",
551 "ranking_query": "entity angle",
552 "sources": ["perplexity"],
553 }
554 ],
555 "source_weights": {"perplexity": 1.0},
556 }
557
558 for entity in ("main", "peer-a", "peer-b"):
559 pipeline.run(
560 topic=entity,
561 config={
562 "LAST30DAYS_REASONING_PROVIDER": "gemini",
563 "PERPLEXITY_API_KEY": "pplx-test",
564 "_perplexity_paid_budget": paid_budget,
565 },
566 depth="quick",
567 requested_sources=["perplexity"],
568 mock=True,
569 external_plan=plan,
570 internal_subrun=True,
571 )
572
573 perplexity_calls = [
574 call
575 for call in mock_retrieve.call_args_list
576 if call.kwargs.get("source") == "perplexity"
577 ]
578 self.assertEqual(1, len(perplexity_calls))
579 self.assertEqual(1, paid_budget.used)
580
581 @patch("lib.pipeline._retrieve_stream")
582 def test_shared_paid_budget_is_reserved_for_main_competitor_topic(
583 self,
584 mock_retrieve,
585 ):
586 mock_retrieve.return_value = ([], {})
587 paid_budget = pipeline.PaidSourceBudget(owner="main")
588 barrier = threading.Barrier(3)
589 plan = {
590 "intent": "general",
591 "freshness_mode": "balanced_recent",
592 "cluster_mode": "story",
593 "subqueries": [
594 {
595 "label": "primary",
596 "search_query": "entity angle",
597 "ranking_query": "entity angle",
598 "sources": ["perplexity"],
599 }
600 ],
601 "source_weights": {"perplexity": 1.0},
602 }
603
604 def run_entity(entity):
605 barrier.wait()
606 return pipeline.run(
607 topic=entity,
608 config={
609 "LAST30DAYS_REASONING_PROVIDER": "gemini",
610 "PERPLEXITY_API_KEY": "pplx-test",
611 "_perplexity_paid_budget": paid_budget,
612 },
613 depth="quick",
614 requested_sources=["perplexity"],
615 mock=True,
616 external_plan=plan,
617 internal_subrun=True,
618 )
619
620 results = fanout.run_competitor_fanout(
621 main_topic="main",
622 main_runner=lambda: run_entity("main"),
623 competitors=["peer-a", "peer-b"],
624 competitor_runner=run_entity,
625 )
626
627 perplexity_calls = [
628 call
629 for call in mock_retrieve.call_args_list
630 if call.kwargs.get("source") == "perplexity"
631 ]
632 self.assertEqual(1, len(perplexity_calls))
633 self.assertEqual("main", perplexity_calls[0].kwargs["topic"])
634 self.assertEqual(1, paid_budget.used)
635 for entity, report in results[1:]:
636 receipt = report.artifacts["paid_source_budget"]["perplexity"]
637 self.assertEqual("skipped-budget", receipt["state"])
638 self.assertFalse(receipt["attempted"])
639 self.assertEqual("main", receipt["owner"])
640 self.assertEqual(entity, receipt["claimant"])
641
642
643 class TestRateLimitSharing(unittest.TestCase):
644 """429 signals should be shared across subqueries."""
645
646 def test_is_rate_limit_error_detects_429_status(self):
647 exc = http.HTTPError("HTTP 429: Too Many Requests", status_code=429)
648 self.assertTrue(pipeline._is_rate_limit_error(exc))
649
650 def test_is_rate_limit_error_ignores_non_429(self):
651 exc = http.HTTPError("HTTP 400: Bad Request", status_code=400)
652 self.assertFalse(pipeline._is_rate_limit_error(exc))
653
654 def test_is_rate_limit_error_detects_429_in_string(self):
655 exc = RuntimeError("xAI returned 429 rate limit")
656 self.assertTrue(pipeline._is_rate_limit_error(exc))
657
658 def test_is_rate_limit_error_rejects_unrelated_error(self):
659 exc = RuntimeError("Connection refused")
660 self.assertFalse(pipeline._is_rate_limit_error(exc))
661
662 def test_retrieve_stream_skips_rate_limited_source(self):
663 """_retrieve_stream should return empty when source is rate-limited."""
664 from lib import schema
665 rate_limited = {"x"}
666 lock = threading.Lock()
667 subquery = schema.SubQuery(
668 label="test",
669 search_query="test query",
670 ranking_query="test query",
671 sources=["x"],
672 )
673 items, artifact = pipeline._retrieve_stream(
674 topic="test",
675 subquery=subquery,
676 source="x",
677 config={},
678 depth="quick",
679 date_range=("2026-02-15", "2026-03-17"),
680 runtime=schema.ProviderRuntime(
681 reasoning_provider="mock",
682 planner_model="mock",
683 rerank_model="mock",
684 ),
685 mock=True,
686 rate_limited_sources=rate_limited,
687 rate_limit_lock=lock,
688 )
689 self.assertEqual(items, [])
690 self.assertEqual(artifact, {})
691
692
693 class TestThinSourceRetryPlannedSource(unittest.TestCase):
694 @patch("lib.pipeline._retrieve_stream")
695 def test_retry_includes_planned_source_with_zero_initial_items(self, mock_retrieve):
696 mock_retrieve.return_value = (
697 [
698 {
699 "id": "X100",
700 "text": "OpenClaw funding update from an investor",
701 "url": "https://x.com/example/status/100",
702 "author_handle": "example",
703 "date": "2026-03-15",
704 "engagement": {"likes": 25, "reposts": 4, "replies": 2},
705 "relevance": 0.8,
706 "why_relevant": "retry result",
707 }
708 ],
709 {},
710 )
711
712 plan = schema.QueryPlan(
713 intent="breaking_news",
714 freshness_mode="strict_recent",
715 cluster_mode="story",
716 raw_topic="latest OpenClaw funding updates",
717 subqueries=[
718 schema.SubQuery(
719 label="primary",
720 search_query="latest OpenClaw funding updates",
721 ranking_query="What recent evidence matters for OpenClaw funding?",
722 sources=["x", "reddit"],
723 )
724 ],
725 source_weights={"x": 1.0, "reddit": 1.0},
726 )
727 bundle = schema.RetrievalBundle(
728 items_by_source={
729 "reddit": [
730 _make_source_item("reddit", "r1", "https://reddit.com/1"),
731 _make_source_item("reddit", "r2", "https://reddit.com/2"),
732 _make_source_item("reddit", "r3", "https://reddit.com/3"),
733 ]
734 }
735 )
736
737 pipeline._retry_thin_sources(
738 topic="latest OpenClaw funding updates",
739 bundle=bundle,
740 plan=plan,
741 config={},
742 depth="default",
743 date_range=("2026-02-15", "2026-03-17"),
744 runtime=_make_runtime("bird"),
745 mock=False,
746 rate_limited_sources=set(),
747 rate_limit_lock=threading.Lock(),
748 settings=pipeline.DEPTH_SETTINGS["default"],
749 )
750
751 self.assertEqual(["x"], [call.kwargs["source"] for call in mock_retrieve.call_args_list])
752 self.assertIn("x", bundle.items_by_source)
753 self.assertEqual("https://x.com/example/status/100", bundle.items_by_source["x"][0].url)
754
755
756 class TestPinnedGithubPersonAuthority(unittest.TestCase):
757 @patch("lib.pipeline._retrieve_stream")
758 @patch("lib.pipeline.github.search_github_person", return_value=[])
759 def test_empty_person_result_suppresses_generic_fanout_and_retry(
760 self, mock_person_search, mock_retrieve
761 ):
762 plan = {
763 "intent": "person",
764 "freshness_mode": "balanced_recent",
765 "cluster_mode": "topic",
766 "subqueries": [
767 {
768 "label": "primary",
769 "search_query": "octocat recent activity",
770 "ranking_query": "What has @octocat done on GitHub recently?",
771 "sources": ["github"],
772 }
773 ],
774 "source_weights": {"github": 1.0},
775 }
776
777 report = pipeline.run(
778 topic="octocat",
779 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
780 depth="default",
781 requested_sources=["github"],
782 mock=True,
783 external_plan=plan,
784 github_user="octocat",
785 )
786
787 mock_person_search.assert_called_once()
788 mock_retrieve.assert_not_called()
789 self.assertEqual(schema.NO_RESULTS, report.source_status["github"].state)
790 self.assertIn(
791 "Person mode found no activity for @octocat",
792 report.source_status["github"].detail,
793 )
794
795 @patch("lib.pipeline._retrieve_stream")
796 @patch(
797 "lib.pipeline.github.search_github_person",
798 side_effect=RuntimeError("GitHub API unavailable"),
799 )
800 def test_person_failure_suppresses_generic_fanout_and_retry(
801 self, mock_person_search, mock_retrieve
802 ):
803 plan = {
804 "intent": "person",
805 "freshness_mode": "balanced_recent",
806 "cluster_mode": "topic",
807 "subqueries": [
808 {
809 "label": "primary",
810 "search_query": "octocat recent activity",
811 "ranking_query": "What has @octocat done on GitHub recently?",
812 "sources": ["github"],
813 }
814 ],
815 "source_weights": {"github": 1.0},
816 }
817
818 report = pipeline.run(
819 topic="octocat",
820 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
821 depth="default",
822 requested_sources=["github"],
823 mock=True,
824 external_plan=plan,
825 github_user="octocat",
826 )
827
828 mock_person_search.assert_called_once()
829 mock_retrieve.assert_not_called()
830 self.assertEqual(health.ERROR, report.source_status["github"].state)
831 self.assertEqual(
832 "GitHub API unavailable",
833 report.source_status["github"].detail,
834 )
835 self.assertEqual(
836 "Person-mode failed: GitHub API unavailable",
837 report.errors_by_source["github"],
838 )
839
840
841
842 class TestTrustpilotNeverRetriedAsThin(unittest.TestCase):
843 @patch("lib.pipeline._retrieve_stream")
844 def test_trustpilot_excluded_from_thin_source_retry(self, mock_retrieve):
845 """Trustpilot returns at most one item by design, so the '<3 items'
846 thinness rule must never re-fetch it: a retry would bypass
847 MAX_SOURCE_FETCHES and re-resolve without the caller's
848 --trustpilot-domain (a lookalike-misattribution path)."""
849 mock_retrieve.return_value = ([], {})
850
851 plan = schema.QueryPlan(
852 intent="product",
853 freshness_mode="balanced_recent",
854 cluster_mode="none",
855 raw_topic="ThriftBooks",
856 subqueries=[
857 schema.SubQuery(
858 label="primary",
859 search_query="thriftbooks",
860 ranking_query="What matters for ThriftBooks?",
861 sources=["trustpilot", "x"],
862 )
863 ],
864 source_weights={"trustpilot": 1.0, "x": 1.0},
865 )
866 bundle = schema.RetrievalBundle(
867 items_by_source={
868 # one successful trustpilot item -- its normal success state,
869 # yet still "<3" and thus retry-eligible without the exclusion
870 "trustpilot": [
871 _make_source_item("trustpilot", "tp1", "https://www.trustpilot.com/review/x.com"),
872 ],
873 }
874 )
875
876 pipeline._retry_thin_sources(
877 topic="ThriftBooks",
878 bundle=bundle,
879 plan=plan,
880 config={},
881 depth="default",
882 date_range=("2026-06-04", "2026-07-04"),
883 runtime=_make_runtime("bird"),
884 mock=False,
885 rate_limited_sources=set(),
886 rate_limit_lock=threading.Lock(),
887 settings=pipeline.DEPTH_SETTINGS["default"],
888 )
889
890 retried = [call.kwargs["source"] for call in mock_retrieve.call_args_list]
891 self.assertNotIn("trustpilot", retried)
892 self.assertIn("x", retried) # other thin sources still retry
893
894
895 def _make_runtime(x_backend="bird"):
896 return schema.ProviderRuntime(
897 reasoning_provider="mock",
898 planner_model="mock",
899 rerank_model="mock",
900 x_search_backend=x_backend,
901 )
902
903
904 def _make_plan(topic="test topic"):
905 return schema.QueryPlan(
906 intent="exploration",
907 freshness_mode="balanced_recent",
908 cluster_mode="topic",
909 raw_topic=topic,
910 subqueries=[
911 schema.SubQuery(
912 label="primary",
913 search_query=topic,
914 ranking_query=f"What recent evidence matters for {topic}?",
915 sources=["x", "reddit"],
916 )
917 ],
918 source_weights={"x": 1.0, "reddit": 1.0},
919 )
920
921
922 def _make_source_item(source, item_id, url, author=None, body="", container=None, metadata=None):
923 return schema.SourceItem(
924 item_id=item_id,
925 source=source,
926 title=f"Item {item_id}",
927 body=body,
928 url=url,
929 author=author,
930 container=container,
931 metadata=metadata or {},
932 )
933
934
935 class TestXBackendChainAndFailover(unittest.TestCase):
936 """One X source, an ordered backend chain with failover; never parallel."""
937
938 @patch("lib.xurl_x.is_available", return_value=False)
939 def test_chain_orders_by_priority(self, _xurl):
940 from lib import env
941 chain = env.x_backend_chain({"XAI_API_KEY": "k", "XQUIK_API_KEY": "q"})
942 self.assertEqual(["xai", "xquik"], chain) # xai primary, xquik backup
943
944 @patch("lib.xurl_x.is_available", return_value=False)
945 def test_pin_forces_single_backend(self, _xurl):
946 from lib import env
947 chain = env.x_backend_chain(
948 {"XAI_API_KEY": "k", "XQUIK_API_KEY": "q", "LAST30DAYS_X_BACKEND": "xquik"}
949 )
950 self.assertEqual(["xquik"], chain) # pin = no failover
951
952 @patch("lib.xurl_x.is_available", return_value=False)
953 def test_chain_empty_when_nothing_configured(self, _xurl):
954 from lib import env
955 self.assertEqual([], env.x_backend_chain({}))
956
957 @patch("lib.env.x_backend_chain", return_value=["bird", "xquik"])
958 def test_failover_to_next_backend_on_empty(self, _chain):
959 sq = schema.SubQuery(label="primary", search_query="q", ranking_query="q?", sources=["x"])
960
961 def fake_fetch(backend, *a, **k):
962 if backend == "xquik":
963 return ([{"id": "XQ1", "url": "https://x.com/a/status/1"}], "")
964 return ([], "") # bird returns nothing
965
966 with patch("lib.pipeline._fetch_x_backend", side_effect=fake_fetch):
967 items, _ = pipeline._retrieve_stream(
968 topic="q", subquery=sq, source="x", config={}, depth="default",
969 date_range=("2026-05-19", "2026-06-18"), runtime=_make_runtime(None), mock=False,
970 )
971 self.assertEqual(1, len(items))
972 self.assertEqual("XQ1", items[0]["id"])
973
974 @patch("lib.env.x_backend_chain", return_value=["xquik"])
975 def test_sole_backend_error_raises_honestly(self, _chain):
976 sq = schema.SubQuery(label="primary", search_query="q", ranking_query="q?", sources=["x"])
977 with patch("lib.pipeline._fetch_x_backend", return_value=([], "Xquik key unpaid (402)")):
978 with self.assertRaises(RuntimeError):
979 pipeline._retrieve_stream(
980 topic="q", subquery=sq, source="x", config={}, depth="default",
981 date_range=("2026-05-19", "2026-06-18"), runtime=_make_runtime(None), mock=False,
982 )
983
984 def test_xquik_is_not_a_separate_source(self):
985 # xquik registers only as a backend of "x", never its own source.
986 avail = pipeline.available_sources({"XQUIK_API_KEY": "k"})
987 self.assertIn("x", avail)
988 self.assertNotIn("xquik", avail)
989
990
991 class TestMixedResultRevocation(unittest.TestCase):
992 """Mixed-result revocation: when grok returns items AND an error, preserve both."""
993
994 @patch("lib.env.x_backend_chain", return_value=["grok"])
995 def test_items_with_auth_error_surfaces_error(self, _chain):
996 """Grok returns some items but then auth is revoked → error is surfaced."""
997 sq = schema.SubQuery(label="primary", search_query="q", ranking_query="q?", sources=["x"])
998
999 def fake_fetch(backend, *a, **k):
1000 if backend == "grok":
1001 # Mixed result: got some items, but also hit auth revocation
1002 return (
1003 [{"id": "G1", "url": "https://x.com/a/status/1"}],
1004 "grok: grok session expired or was revoked",
1005 )
1006 return ([], "")
1007
1008 with patch("lib.pipeline._fetch_x_backend", side_effect=fake_fetch):
1009 items, artifact = pipeline._retrieve_stream(
1010 topic="q", subquery=sq, source="x", config={}, depth="default",
1011 date_range=("2026-05-19", "2026-06-18"), runtime=_make_runtime(None), mock=False,
1012 )
1013 self.assertEqual(1, len(items))
1014 # The artifact should have _source_outcome with the error info
1015 outcome = artifact.get("_source_outcome", {})
1016 self.assertIn("grok session expired", outcome.get("detail", "").lower())
1017 # State should be AUTH_FAILED since the error contains auth markers
1018 self.assertEqual(schema.AUTH_FAILED, outcome.get("state"))
1019
1020 @patch("lib.env.x_backend_chain", return_value=["grok", "bird"])
1021 def test_fallback_after_auth_failed_keeps_auth_failed_state(self, _chain):
1022 """Grok auth fails → bird succeeds → state is AUTH_FAILED (not OK).
1023
1024 The user needs re-login guidance for grok even though fallback served items.
1025 """
1026 sq = schema.SubQuery(label="primary", search_query="q", ranking_query="q?", sources=["x"])
1027
1028 def fake_fetch(backend, *a, **k):
1029 if backend == "grok":
1030 return ([], "grok: grok session expired or was revoked")
1031 if backend == "bird":
1032 return ([{"id": "B1", "url": "https://x.com/a/status/1"}], "")
1033 return ([], "")
1034
1035 with patch("lib.pipeline._fetch_x_backend", side_effect=fake_fetch):
1036 items, artifact = pipeline._retrieve_stream(
1037 topic="q", subquery=sq, source="x", config={}, depth="default",
1038 date_range=("2026-05-19", "2026-06-18"), runtime=_make_runtime(None), mock=False,
1039 )
1040 self.assertEqual(1, len(items))
1041 outcome = artifact.get("_source_outcome", {})
1042 # State should be AUTH_FAILED so user gets re-login guidance
1043 self.assertEqual(schema.AUTH_FAILED, outcome.get("state"))
1044 self.assertIn("re-login", outcome.get("detail", "").lower())
1045
1046 @patch("lib.env.x_backend_chain", return_value=["grok", "bird"])
1047 def test_fallback_after_non_auth_error_is_ok(self, _chain):
1048 """Grok fails with non-auth error → bird succeeds → state is OK."""
1049 sq = schema.SubQuery(label="primary", search_query="q", ranking_query="q?", sources=["x"])
1050
1051 def fake_fetch(backend, *a, **k):
1052 if backend == "grok":
1053 return ([], "grok: network timeout")
1054 if backend == "bird":
1055 return ([{"id": "B1", "url": "https://x.com/a/status/1"}], "")
1056 return ([], "")
1057
1058 with patch("lib.pipeline._fetch_x_backend", side_effect=fake_fetch):
1059 items, artifact = pipeline._retrieve_stream(
1060 topic="q", subquery=sq, source="x", config={}, depth="default",
1061 date_range=("2026-05-19", "2026-06-18"), runtime=_make_runtime(None), mock=False,
1062 )
1063 self.assertEqual(1, len(items))
1064 outcome = artifact.get("_source_outcome", {})
1065 # Non-auth error followed by fallback success is OK
1066 self.assertEqual("ok", outcome.get("state"))
1067
1068 @patch("lib.env.x_backend_chain", return_value=["bird", "grok"])
1069 def test_prior_non_auth_then_current_auth_fail_yields_auth_failed(self, _chain):
1070 """Bird non-auth fail → Grok items + revocation → AUTH_FAILED, items kept.
1071
1072 If an earlier backend fails for a non-auth reason and the current backend
1073 returns items plus an auth revocation error, the outcome must be AUTH_FAILED
1074 (not OK) so the user gets re-login guidance.
1075 """
1076 sq = schema.SubQuery(label="primary", search_query="q", ranking_query="q?", sources=["x"])
1077
1078 def fake_fetch(backend, *a, **k):
1079 if backend == "bird":
1080 # Bird fails with a non-auth error
1081 return ([], "bird: network timeout")
1082 if backend == "grok":
1083 # Grok returns items but also signals auth revocation
1084 return (
1085 [{"id": "G1", "url": "https://x.com/a/status/1"}],
1086 "grok: grok session expired or was revoked",
1087 )
1088 return ([], "")
1089
1090 with patch("lib.pipeline._fetch_x_backend", side_effect=fake_fetch):
1091 items, artifact = pipeline._retrieve_stream(
1092 topic="q", subquery=sq, source="x", config={}, depth="default",
1093 date_range=("2026-05-19", "2026-06-18"), runtime=_make_runtime(None), mock=False,
1094 )
1095 # Items should be preserved
1096 self.assertEqual(1, len(items))
1097 outcome = artifact.get("_source_outcome", {})
1098 # State must be AUTH_FAILED, not OK
1099 self.assertEqual(schema.AUTH_FAILED, outcome.get("state"))
1100 # Re-login guidance must be present
1101 self.assertIn("re-login", outcome.get("detail", "").lower())
1102
1103 @patch("lib.env.x_backend_chain", return_value=["grok", "bird"])
1104 def test_not_logged_in_triggers_auth_failed_fallback(self, _chain):
1105 """Grok 'not logged in' → bird fallback → AUTH_FAILED state preserved.
1106
1107 The 'not logged in' message from Grok is a recognized revocation marker.
1108 If Grok fails with this message and bird fallback succeeds, the outcome
1109 must be AUTH_FAILED so the user gets re-login guidance.
1110 """
1111 sq = schema.SubQuery(label="primary", search_query="q", ranking_query="q?", sources=["x"])
1112
1113 def fake_fetch(backend, *a, **k):
1114 if backend == "grok":
1115 # Grok fails with 'not logged in' error
1116 return ([], "grok: not logged in")
1117 if backend == "bird":
1118 return ([{"id": "B1", "url": "https://x.com/a/status/1"}], "")
1119 return ([], "")
1120
1121 with patch("lib.pipeline._fetch_x_backend", side_effect=fake_fetch):
1122 items, artifact = pipeline._retrieve_stream(
1123 topic="q", subquery=sq, source="x", config={}, depth="default",
1124 date_range=("2026-05-19", "2026-06-18"), runtime=_make_runtime(None), mock=False,
1125 )
1126 self.assertEqual(1, len(items))
1127 outcome = artifact.get("_source_outcome", {})
1128 # State must be AUTH_FAILED because 'not logged in' is an auth marker
1129 self.assertEqual(schema.AUTH_FAILED, outcome.get("state"))
1130 self.assertIn("re-login", outcome.get("detail", "").lower())
1131
1132
1133 class TestRetrievalBundleAuthPreservation(unittest.TestCase):
1134 """AUTH_FAILED state must be preserved through add_items.
1135
1136 When a lane records AUTH_FAILED before items are added, the state
1137 must not be downgraded to PARTIAL by subsequent add_items calls.
1138 """
1139
1140 def test_add_items_preserves_auth_failed_state(self):
1141 """add_items should preserve AUTH_FAILED state, not downgrade to PARTIAL."""
1142 bundle = schema.RetrievalBundle()
1143 bundle.mark_attempted("x")
1144 # First: record auth failure (no items yet)
1145 bundle.record_failure("x", schema.AUTH_FAILED, "grok session expired", attempted=True)
1146 # At this point, state should be AUTH_FAILED
1147 self.assertEqual(schema.AUTH_FAILED, bundle.source_status["x"].state)
1148
1149 # Now add items
1150 items = [_make_source_item("x", "X1", "https://x.com/a/status/1")]
1151 bundle.add_items("primary", "x", items)
1152
1153 # State must still be AUTH_FAILED, not PARTIAL
1154 self.assertEqual(schema.AUTH_FAILED, bundle.source_status["x"].state)
1155 # Detail should be preserved
1156 self.assertEqual("grok session expired", bundle.source_status["x"].detail)
1157 # Items should be recorded
1158 self.assertEqual(1, len(bundle.items_by_source["x"]))
1159
1160 def test_add_items_keeps_partial_for_non_auth_failures(self):
1161 """For non-auth failures, add_items should still produce PARTIAL."""
1162 bundle = schema.RetrievalBundle()
1163 bundle.mark_attempted("x")
1164 # Record a non-auth failure
1165 bundle.record_failure("x", health.TIMEOUT, "request timed out", attempted=True)
1166 self.assertEqual(health.TIMEOUT, bundle.source_status["x"].state)
1167
1168 # Add items
1169 items = [_make_source_item("x", "X1", "https://x.com/a/status/1")]
1170 bundle.add_items("primary", "x", items)
1171
1172 # State should become PARTIAL (not TIMEOUT)
1173 self.assertEqual(schema.PARTIAL, bundle.source_status["x"].state)
1174 # Detail should be preserved
1175 self.assertEqual("request timed out", bundle.source_status["x"].detail)
1176
1177 def test_record_failure_preserves_auth_failed_when_items_exist(self):
1178 """record_failure should preserve AUTH_FAILED even when items already exist.
1179
1180 When Phase 1 has already collected X posts and a supplemental Grok lane
1181 reports revocation, record_failure must not convert AUTH_FAILED to PARTIAL.
1182 """
1183 bundle = schema.RetrievalBundle()
1184 bundle.mark_attempted("x")
1185
1186 # Phase 1 already collected items
1187 phase1_items = [_make_source_item("x", "X1", "https://x.com/a/status/1")]
1188 bundle.add_items("primary", "x", phase1_items)
1189 self.assertEqual(health.OK, bundle.source_status["x"].state)
1190
1191 # Supplemental lane reports auth failure
1192 bundle.record_failure("x", schema.AUTH_FAILED, "grok session revoked", attempted=True)
1193
1194 # State must be AUTH_FAILED, not PARTIAL
1195 self.assertEqual(schema.AUTH_FAILED, bundle.source_status["x"].state)
1196 # Detail should be set
1197 self.assertEqual("grok session revoked", bundle.source_status["x"].detail)
1198 # Items should still be there
1199 self.assertEqual(1, len(bundle.items_by_source["x"]))
1200
1201
1202 class TestSupplementalSearches(unittest.TestCase):
1203 """R1: Phase 2 entity drilling should be wired into the pipeline."""
1204
1205 def test_run_supplemental_searches_exists(self):
1206 """_run_supplemental_searches must be a callable in pipeline module."""
1207 self.assertTrue(
1208 hasattr(pipeline, "_run_supplemental_searches"),
1209 "_run_supplemental_searches function not found in pipeline module",
1210 )
1211 self.assertTrue(callable(pipeline._run_supplemental_searches))
1212
1213 @patch("lib.bird_x.search_handles")
1214 @patch("lib.entity_extract.extract_entities")
1215 def test_entity_extract_called_after_phase1(self, mock_extract, mock_handles):
1216 """Phase 2 should call entity_extract on Phase 1 X results, then search_handles."""
1217 mock_extract.return_value = {"x_handles": ["analyst1", "reporter2"], "x_hashtags": [], "reddit_subreddits": []}
1218 mock_handles.return_value = [
1219 {
1220 "id": "supp1",
1221 "text": "Supplemental tweet from analyst1",
1222 "url": "https://x.com/analyst1/status/999",
1223 "author_handle": "analyst1",
1224 "date": "2026-03-15",
1225 "engagement": {"likes": 50},
1226 "relevance": 0.8,
1227 "why_relevant": "direct handle search",
1228 }
1229 ]
1230
1231 bundle = schema.RetrievalBundle()
1232 # Handles need ≥2 on-topic posts to be promotable per x_judge.MIN_ON_TOPIC_HITS
1233 bundle.items_by_source["x"] = [
1234 _make_source_item("x", "X1", "https://x.com/analyst1/status/1", author="analyst1", body="AI safety analysis"),
1235 _make_source_item("x", "X2", "https://x.com/analyst1/status/2", author="analyst1", body="AI safety research"),
1236 _make_source_item("x", "X3", "https://x.com/reporter2/status/3", author="reporter2", body="AI safety report"),
1237 _make_source_item("x", "X4", "https://x.com/reporter2/status/4", author="reporter2", body="AI safety news"),
1238 ]
1239
1240 plan = _make_plan("AI safety")
1241 config = {}
1242
1243 pipeline._run_supplemental_searches(
1244 topic="AI safety",
1245 bundle=bundle,
1246 plan=plan,
1247 config=config,
1248 depth="default",
1249 date_range=("2026-02-15", "2026-03-17"),
1250 runtime=_make_runtime("bird"),
1251 mock=False,
1252 rate_limited_sources=set(),
1253 rate_limit_lock=threading.Lock(),
1254 )
1255
1256 mock_extract.assert_called_once()
1257 mock_handles.assert_called_once()
1258 # Supplemental items should be merged into bundle
1259 x_urls = {item.url for item in bundle.items_by_source.get("x", [])}
1260 self.assertIn("https://x.com/analyst1/status/999", x_urls)
1261
1262 @patch("lib.env.x_backend_chain", return_value=["xquik"])
1263 @patch("lib.xquik.search_xquik", return_value={"items": []})
1264 def test_x_topic_lane_uses_raw_topic_via_xquik(self, mock_search, _chain):
1265 """The X topic lane uses raw_topic (like Reddit/YouTube), not the
1266 planner's search_query. This avoids phrase-quoting issues like "Rome Italy"
1267 returning off-topic results. Disambiguation lives in ranking_query."""
1268 anchored = schema.SubQuery(
1269 label="primary", search_query="kevin rose digg founder",
1270 ranking_query="What has Kevin Rose, founder of Digg, been doing?",
1271 sources=["x"],
1272 )
1273 pipeline._retrieve_stream(
1274 topic="kevin rose digg founder", subquery=anchored, source="x",
1275 config={"XQUIK_API_KEY": "k"}, depth="default",
1276 date_range=("2026-05-19", "2026-06-18"), runtime=_make_runtime(None),
1277 mock=False, raw_topic="kevin rose",
1278 )
1279 mock_search.assert_called_once()
1280 # X now uses raw_topic, not search_query (like Reddit/YouTube)
1281 self.assertEqual("kevin rose", mock_search.call_args[0][0])
1282
1283 @patch("lib.env.get_xquik_token", return_value="k")
1284 @patch("lib.env.x_backend_chain", return_value=["xquik"])
1285 @patch("lib.xquik.search_mentions", return_value=[])
1286 @patch("lib.xquik.search_handles")
1287 @patch("lib.entity_extract.extract_entities")
1288 def test_handle_lanes_route_to_xquik_when_primary(
1289 self, mock_extract, mock_xq_handles, mock_xq_mentions, *_patches
1290 ):
1291 """When xquik is the primary X backend, the FROM/ABOUT handle lanes run
1292 via xquik and items land under the single 'x' slug."""
1293 mock_extract.return_value = {"x_handles": ["analyst1"], "x_hashtags": [], "reddit_subreddits": []}
1294 mock_xq_handles.return_value = [{
1295 "id": "XF1", "text": "from analyst1", "url": "https://x.com/analyst1/status/777",
1296 "author_handle": "analyst1", "date": "2026-03-15",
1297 "engagement": {"likes": 30}, "relevance": 0.8, "why_relevant": "",
1298 }]
1299
1300 bundle = schema.RetrievalBundle()
1301 # Handles need ≥2 on-topic posts to be promotable per x_judge.MIN_ON_TOPIC_HITS
1302 bundle.items_by_source["x"] = [
1303 _make_source_item("x", "X1", "https://x.com/analyst1/status/1", author="analyst1", body="AI safety analysis"),
1304 _make_source_item("x", "X2", "https://x.com/analyst1/status/2", author="analyst1", body="AI safety research"),
1305 ]
1306
1307 pipeline._run_supplemental_searches(
1308 topic="AI safety", bundle=bundle, plan=_make_plan("AI safety"), config={},
1309 depth="default", date_range=("2026-02-15", "2026-03-17"),
1310 runtime=_make_runtime(None), mock=False,
1311 rate_limited_sources=set(), rate_limit_lock=threading.Lock(),
1312 )
1313
1314 mock_xq_handles.assert_called_once()
1315 x_urls = {item.url for item in bundle.items_by_source.get("x", [])}
1316 self.assertIn("https://x.com/analyst1/status/777", x_urls)
1317 # There is no separate 'xquik' source — everything is under 'x'.
1318 self.assertNotIn("xquik", bundle.items_by_source)
1319
1320 @patch("lib.env.get_xquik_token", return_value="k")
1321 @patch("lib.env.x_backend_chain", return_value=["xai", "xquik"])
1322 @patch("lib.xquik.search_mentions", return_value=[])
1323 @patch("lib.xquik.search_handles")
1324 @patch("lib.entity_extract.extract_entities")
1325 def test_handle_lanes_use_xquik_when_xai_is_primary(
1326 self, mock_extract, mock_xq_handles, *_patches
1327 ):
1328 """When xAI is the topic primary but xquik is in the chain, the
1329 supplemental handle lanes still run via xquik (first handle-capable
1330 backend) rather than being skipped."""
1331 mock_extract.return_value = {"x_handles": ["analyst1"], "x_hashtags": [], "reddit_subreddits": []}
1332 mock_xq_handles.return_value = [{
1333 "id": "XF1", "text": "from analyst1", "url": "https://x.com/analyst1/status/888",
1334 "author_handle": "analyst1", "date": "2026-03-15",
1335 "engagement": {"likes": 5}, "relevance": 0.8, "why_relevant": "",
1336 }]
1337 bundle = schema.RetrievalBundle()
1338 # Handles need ≥2 on-topic posts to be promotable per x_judge.MIN_ON_TOPIC_HITS
1339 bundle.items_by_source["x"] = [
1340 _make_source_item("x", "X1", "https://x.com/analyst1/status/1", author="analyst1", body="AI safety analysis"),
1341 _make_source_item("x", "X2", "https://x.com/analyst1/status/2", author="analyst1", body="AI safety research"),
1342 ]
1343 pipeline._run_supplemental_searches(
1344 topic="AI safety", bundle=bundle, plan=_make_plan("AI safety"), config={},
1345 depth="default", date_range=("2026-02-15", "2026-03-17"),
1346 runtime=_make_runtime("xai"), mock=False,
1347 rate_limited_sources=set(), rate_limit_lock=threading.Lock(),
1348 )
1349 mock_xq_handles.assert_called_once()
1350 x_urls = {item.url for item in bundle.items_by_source.get("x", [])}
1351 self.assertIn("https://x.com/analyst1/status/888", x_urls)
1352
1353 @patch("lib.bird_x.search_handles")
1354 @patch("lib.entity_extract.extract_entities")
1355 def test_supplemental_items_deduplicated_by_url(self, mock_extract, mock_handles):
1356 """Supplemental items with same URL as Phase 1 should not be duplicated."""
1357 mock_extract.return_value = {"x_handles": ["analyst1"], "x_hashtags": [], "reddit_subreddits": []}
1358 # Return item with same URL as Phase 1
1359 mock_handles.return_value = [
1360 {
1361 "id": "dup1",
1362 "text": "Same tweet",
1363 "url": "https://x.com/analyst1/status/1",
1364 "author_handle": "analyst1",
1365 "date": "2026-03-15",
1366 "engagement": {"likes": 50},
1367 "relevance": 0.8,
1368 "why_relevant": "duplicate",
1369 }
1370 ]
1371
1372 bundle = schema.RetrievalBundle()
1373 original = _make_source_item("x", "X1", "https://x.com/analyst1/status/1", author="analyst1")
1374 bundle.items_by_source["x"] = [original]
1375
1376 plan = _make_plan("AI safety")
1377
1378 pipeline._run_supplemental_searches(
1379 topic="AI safety",
1380 bundle=bundle,
1381 plan=plan,
1382 config={},
1383 depth="default",
1384 date_range=("2026-02-15", "2026-03-17"),
1385 runtime=_make_runtime("bird"),
1386 mock=False,
1387 rate_limited_sources=set(),
1388 rate_limit_lock=threading.Lock(),
1389 )
1390
1391 # Should still have only 1 item (no duplicates)
1392 x_items = bundle.items_by_source.get("x", [])
1393 urls = [item.url for item in x_items]
1394 self.assertEqual(
1395 urls.count("https://x.com/analyst1/status/1"), 1,
1396 f"Duplicate URL found: {urls}",
1397 )
1398
1399 @patch("lib.bird_x.search_mentions")
1400 @patch("lib.bird_x.search_handles")
1401 @patch("lib.entity_extract.extract_entities")
1402 def test_from_lane_uses_raised_cap_mention_lane_modest(self, mock_extract, mock_handles, mock_mentions):
1403 """U4: the FROM lane (subject's own timeline) uses the raised per-handle
1404 cap; the mention lane stays modest."""
1405 mock_extract.return_value = {"x_handles": ["subject1"], "x_hashtags": [], "reddit_subreddits": []}
1406 mock_handles.return_value = []
1407 mock_mentions.return_value = []
1408 bundle = schema.RetrievalBundle()
1409 # Handles need ≥2 on-topic posts to be promotable per x_judge.MIN_ON_TOPIC_HITS
1410 bundle.items_by_source["x"] = [
1411 _make_source_item("x", "X1", "https://x.com/subject1/status/1", author="subject1", body="subject1 discussion"),
1412 _make_source_item("x", "X2", "https://x.com/subject1/status/2", author="subject1", body="subject1 update"),
1413 ]
1414 pipeline._run_supplemental_searches(
1415 topic="subject1",
1416 bundle=bundle,
1417 plan=_make_plan("subject1"),
1418 config={},
1419 depth="default",
1420 date_range=("2026-02-15", "2026-03-17"),
1421 runtime=_make_runtime("bird"),
1422 mock=False,
1423 rate_limited_sources=set(),
1424 rate_limit_lock=threading.Lock(),
1425 )
1426 from_call = mock_handles.call_args_list[0]
1427 self.assertEqual(pipeline.FROM_LANE_COUNT_PER, from_call.kwargs.get("count_per"))
1428 self.assertEqual(pipeline.MENTION_LANE_COUNT_PER, mock_mentions.call_args.kwargs.get("count_per"))
1429
1430 def test_phase2_skipped_in_quick_mode(self):
1431 """_run_supplemental_searches should return immediately when depth='quick'."""
1432 bundle = schema.RetrievalBundle()
1433 bundle.items_by_source["x"] = [
1434 _make_source_item("x", "X1", "https://x.com/a/1", author="someone"),
1435 ]
1436
1437 # If it tries to import entity_extract, that's fine -- it should return before calling it
1438 pipeline._run_supplemental_searches(
1439 topic="test",
1440 bundle=bundle,
1441 plan=_make_plan(),
1442 config={},
1443 depth="quick",
1444 date_range=("2026-02-15", "2026-03-17"),
1445 runtime=_make_runtime("bird"),
1446 mock=False,
1447 rate_limited_sources=set(),
1448 rate_limit_lock=threading.Lock(),
1449 )
1450 # Bundle should be unchanged (only original item)
1451 self.assertEqual(len(bundle.items_by_source["x"]), 1)
1452
1453 def test_phase2_skipped_in_mock_mode(self):
1454 """_run_supplemental_searches should return immediately when mock=True."""
1455 bundle = schema.RetrievalBundle()
1456 bundle.items_by_source["x"] = [
1457 _make_source_item("x", "X1", "https://x.com/a/1", author="someone"),
1458 ]
1459
1460 pipeline._run_supplemental_searches(
1461 topic="test",
1462 bundle=bundle,
1463 plan=_make_plan(),
1464 config={},
1465 depth="default",
1466 date_range=("2026-02-15", "2026-03-17"),
1467 runtime=_make_runtime("bird"),
1468 mock=True,
1469 rate_limited_sources=set(),
1470 rate_limit_lock=threading.Lock(),
1471 )
1472 self.assertEqual(len(bundle.items_by_source["x"]), 1)
1473
1474 def test_phase2_skipped_when_x_rate_limited(self):
1475 """_run_supplemental_searches should skip when X is rate-limited."""
1476 bundle = schema.RetrievalBundle()
1477 bundle.items_by_source["x"] = [
1478 _make_source_item("x", "X1", "https://x.com/a/1", author="someone"),
1479 ]
1480
1481 pipeline._run_supplemental_searches(
1482 topic="test",
1483 bundle=bundle,
1484 plan=_make_plan(),
1485 config={},
1486 depth="default",
1487 date_range=("2026-02-15", "2026-03-17"),
1488 runtime=_make_runtime("bird"),
1489 mock=False,
1490 rate_limited_sources={"x"},
1491 rate_limit_lock=threading.Lock(),
1492 )
1493 self.assertEqual(len(bundle.items_by_source["x"]), 1)
1494
1495 def test_phase2_skipped_when_backend_not_bird(self):
1496 """_run_supplemental_searches should skip when X backend is not bird."""
1497 bundle = schema.RetrievalBundle()
1498 bundle.items_by_source["x"] = [
1499 _make_source_item("x", "X1", "https://x.com/a/1", author="someone"),
1500 ]
1501
1502 pipeline._run_supplemental_searches(
1503 topic="test",
1504 bundle=bundle,
1505 plan=_make_plan(),
1506 config={},
1507 depth="default",
1508 date_range=("2026-02-15", "2026-03-17"),
1509 runtime=_make_runtime("xai"),
1510 mock=False,
1511 rate_limited_sources=set(),
1512 rate_limit_lock=threading.Lock(),
1513 )
1514 self.assertEqual(len(bundle.items_by_source["x"]), 1)
1515
1516
1517 class TestThinSourceRetry(unittest.TestCase):
1518 """R2: Dynamic query refinement on thin results."""
1519
1520 def test_retry_thin_sources_exists(self):
1521 """_retry_thin_sources must be a callable in pipeline module."""
1522 self.assertTrue(
1523 hasattr(pipeline, "_retry_thin_sources"),
1524 "_retry_thin_sources function not found in pipeline module",
1525 )
1526 self.assertTrue(callable(pipeline._retry_thin_sources))
1527
1528 @patch("lib.pipeline._retrieve_stream")
1529 def test_thin_source_retried_with_core_subject(self, mock_retrieve):
1530 """Sources with < 3 items and no errors should be retried."""
1531 mock_retrieve.return_value = (
1532 [
1533 {
1534 "id": "retry1",
1535 "title": "Retry result",
1536 "url": "https://reddit.com/r/test/2",
1537 "subreddit": "test",
1538 "date": "2026-03-15",
1539 "engagement": {"score": 10},
1540 "selftext": "Retry content",
1541 "relevance": 0.7,
1542 "why_relevant": "retry",
1543 }
1544 ],
1545 {},
1546 )
1547
1548 bundle = schema.RetrievalBundle()
1549 # Only 1 reddit item (thin)
1550 bundle.items_by_source["reddit"] = [
1551 _make_source_item("reddit", "R1", "https://reddit.com/r/test/1", container="test"),
1552 ]
1553 # 5 X items (not thin)
1554 bundle.items_by_source["x"] = [
1555 _make_source_item("x", f"X{i}", f"https://x.com/a/{i}") for i in range(5)
1556 ]
1557
1558 plan = _make_plan("advanced AI safety techniques")
1559 settings = pipeline.DEPTH_SETTINGS["default"]
1560
1561 pipeline._retry_thin_sources(
1562 topic="advanced AI safety techniques",
1563 bundle=bundle,
1564 plan=plan,
1565 config={},
1566 depth="default",
1567 date_range=("2026-02-15", "2026-03-17"),
1568 runtime=_make_runtime(),
1569 mock=False,
1570 rate_limited_sources=set(),
1571 rate_limit_lock=threading.Lock(),
1572 settings=settings,
1573 )
1574
1575 # _retrieve_stream should have been called for reddit (thin source)
1576 mock_retrieve.assert_called()
1577 call_sources = [c.kwargs.get("source") for c in mock_retrieve.call_args_list]
1578 self.assertIn("reddit", call_sources)
1579 # X should NOT have been retried
1580 self.assertNotIn("x", call_sources)
1581
1582 def test_sources_with_enough_items_not_retried(self):
1583 """Sources with >= 3 items should not be retried."""
1584 bundle = schema.RetrievalBundle()
1585 bundle.items_by_source["reddit"] = [
1586 _make_source_item("reddit", f"R{i}", f"https://reddit.com/r/test/{i}") for i in range(5)
1587 ]
1588 bundle.items_by_source["x"] = [
1589 _make_source_item("x", f"X{i}", f"https://x.com/a/{i}") for i in range(5)
1590 ]
1591
1592 plan = _make_plan("AI safety")
1593 settings = pipeline.DEPTH_SETTINGS["default"]
1594
1595 with patch("lib.pipeline._retrieve_stream") as mock_retrieve:
1596 pipeline._retry_thin_sources(
1597 topic="AI safety",
1598 bundle=bundle,
1599 plan=plan,
1600 config={},
1601 depth="default",
1602 date_range=("2026-02-15", "2026-03-17"),
1603 runtime=_make_runtime(),
1604 mock=False,
1605 rate_limited_sources=set(),
1606 rate_limit_lock=threading.Lock(),
1607 settings=settings,
1608 )
1609 mock_retrieve.assert_not_called()
1610
1611 def test_errored_sources_not_retried(self):
1612 """Sources in errors_by_source should not be retried even if thin.
1613 Non-errored thin sources SHOULD still be retried."""
1614 bundle = schema.RetrievalBundle()
1615 bundle.items_by_source["reddit"] = [
1616 _make_source_item("reddit", "R1", "https://reddit.com/r/test/1"),
1617 ]
1618 bundle.errors_by_source["reddit"] = "API error"
1619
1620 plan = _make_plan("AI safety")
1621 settings = pipeline.DEPTH_SETTINGS["default"]
1622
1623 mock_items = [{"id": "X1", "title": "test", "url": "https://x.com/1", "text": "test"}]
1624 with patch("lib.pipeline._retrieve_stream", return_value=(mock_items, {})) as mock_retrieve:
1625 pipeline._retry_thin_sources(
1626 topic="AI safety",
1627 bundle=bundle,
1628 plan=plan,
1629 config={},
1630 depth="default",
1631 date_range=("2026-02-15", "2026-03-17"),
1632 runtime=_make_runtime(),
1633 mock=False,
1634 rate_limited_sources=set(),
1635 rate_limit_lock=threading.Lock(),
1636 settings=settings,
1637 )
1638 # x (non-errored, thin) should be retried; reddit (errored) should not
1639 if mock_retrieve.call_count > 0:
1640 self.assertNotIn("reddit", [c.kwargs.get("source") for c in mock_retrieve.call_args_list])
1641
1642 def test_retry_skipped_in_quick_mode(self):
1643 """_retry_thin_sources should return immediately in quick mode."""
1644 bundle = schema.RetrievalBundle()
1645 bundle.items_by_source["reddit"] = [
1646 _make_source_item("reddit", "R1", "https://reddit.com/r/test/1"),
1647 ]
1648
1649 plan = _make_plan("AI safety")
1650 settings = pipeline.DEPTH_SETTINGS["quick"]
1651
1652 with patch("lib.pipeline._retrieve_stream") as mock_retrieve:
1653 pipeline._retry_thin_sources(
1654 topic="AI safety",
1655 bundle=bundle,
1656 plan=plan,
1657 config={},
1658 depth="quick",
1659 date_range=("2026-02-15", "2026-03-17"),
1660 runtime=_make_runtime(),
1661 mock=False,
1662 rate_limited_sources=set(),
1663 rate_limit_lock=threading.Lock(),
1664 settings=settings,
1665 )
1666 mock_retrieve.assert_not_called()
1667
1668 def test_perplexity_is_not_retried_when_results_are_thin(self):
1669 plan = schema.QueryPlan(
1670 intent="general",
1671 freshness_mode="balanced_recent",
1672 cluster_mode="story",
1673 raw_topic="AI safety",
1674 subqueries=[
1675 schema.SubQuery(
1676 label="primary",
1677 search_query="AI safety",
1678 ranking_query="AI safety",
1679 sources=["perplexity"],
1680 )
1681 ],
1682 source_weights={"perplexity": 1.0},
1683 )
1684 bundle = schema.RetrievalBundle()
1685
1686 with patch("lib.pipeline._retrieve_stream") as mock_retrieve:
1687 pipeline._retry_thin_sources(
1688 topic="AI safety",
1689 bundle=bundle,
1690 plan=plan,
1691 config={},
1692 depth="default",
1693 date_range=("2026-02-15", "2026-03-17"),
1694 runtime=_make_runtime(),
1695 mock=False,
1696 rate_limited_sources=set(),
1697 rate_limit_lock=threading.Lock(),
1698 settings=pipeline.DEPTH_SETTINGS["default"],
1699 )
1700
1701 mock_retrieve.assert_not_called()
1702
1703
1704 class TestErrorCleanup(unittest.TestCase):
1705 """Source errors should be cleared when the source has items from other subqueries."""
1706
1707 def test_error_cleared_when_source_has_items(self):
1708 """A source that 429'd on one subquery but succeeded on another is not errored."""
1709 bundle = schema.RetrievalBundle(artifacts={})
1710 item = schema.SourceItem(
1711 item_id="x1", source="x", title="A tweet", body="content",
1712 url="https://x.com/user/status/1",
1713 )
1714 bundle.items_by_source["x"] = [item]
1715 bundle.errors_by_source["x"] = "HTTP 429: Too Many Requests"
1716
1717 # Simulate the cleanup logic from pipeline.run()
1718 for source in list(bundle.errors_by_source):
1719 if bundle.items_by_source.get(source):
1720 del bundle.errors_by_source[source]
1721
1722 self.assertNotIn("x", bundle.errors_by_source,
1723 "X should not be errored when it has items")
1724
1725 def test_error_kept_when_source_has_no_items(self):
1726 """A source with zero items should remain in errors_by_source."""
1727 bundle = schema.RetrievalBundle(artifacts={})
1728 bundle.errors_by_source["x"] = "HTTP 429: Too Many Requests"
1729
1730 for source in list(bundle.errors_by_source):
1731 if bundle.items_by_source.get(source):
1732 del bundle.errors_by_source[source]
1733
1734 self.assertIn("x", bundle.errors_by_source,
1735 "X should remain errored when it has no items")
1736
1737
1738 class TestXHandleFlag(unittest.TestCase):
1739 """R3: --x-handle CLI flag and pipeline parameter."""
1740
1741 def test_cli_accepts_x_handle_flag(self):
1742 """build_parser() should accept --x-handle."""
1743 import last30days as cli
1744
1745 parser = cli.build_parser()
1746 args = parser.parse_args(["test topic", "--x-handle", "elonmusk"])
1747 self.assertEqual(args.x_handle, "elonmusk")
1748
1749 def test_cli_x_handle_default_is_none(self):
1750 """--x-handle should default to None."""
1751 import last30days as cli
1752
1753 parser = cli.build_parser()
1754 args = parser.parse_args(["test topic"])
1755 self.assertIsNone(args.x_handle)
1756
1757 def test_pipeline_run_accepts_x_handle(self):
1758 """pipeline.run() should accept x_handle keyword argument."""
1759 import inspect
1760 sig = inspect.signature(pipeline.run)
1761 self.assertIn("x_handle", sig.parameters, "pipeline.run() must accept x_handle parameter")
1762
1763 def test_x_handle_passed_to_supplemental_searches(self):
1764 """When x_handle is provided, it should trigger targeted handle search."""
1765 # Run pipeline in mock mode with x_handle -- should not raise
1766 report = pipeline.run(
1767 topic="test topic",
1768 config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
1769 depth="quick",
1770 requested_sources=["reddit", "x", "grounding"],
1771 mock=True,
1772 x_handle="testuser",
1773 )
1774 self.assertEqual("test topic", report.topic)
1775
1776
1777 class TestWarnings(unittest.TestCase):
1778 def _item(self, source="reddit"):
1779 return schema.SourceItem(item_id="1", source=source, title="t", body="b", url="u")
1780
1781 def _candidate(self, source="reddit", score=50.0):
1782 c = schema.Candidate(
1783 candidate_id="c1", item_id="1", source=source, title="t", url="u",
1784 snippet="s", subquery_labels=["main"], native_ranks={"main:reddit": 1},
1785 local_relevance=0.5, freshness=50, engagement=10, source_quality=0.7,
1786 rrf_score=0.01, sources=[source],
1787 )
1788 c.final_score = score
1789 return c
1790
1791 def test_no_candidates_warning(self):
1792 w = pipeline._warnings({"reddit": [self._item()]}, [], {})
1793 self.assertTrue(any("No candidates" in msg for msg in w))
1794
1795 def test_thin_evidence_warning(self):
1796 candidates = [self._candidate() for _ in range(3)]
1797 w = pipeline._warnings({"reddit": [self._item()]}, candidates, {})
1798 self.assertTrue(any("thin" in msg.lower() for msg in w))
1799
1800 def test_single_source_concentration(self):
1801 candidates = [self._candidate() for _ in range(5)]
1802 w = pipeline._warnings({"reddit": [self._item()]}, candidates, {})
1803 self.assertTrue(any("concentrated" in msg.lower() for msg in w))
1804
1805 def test_source_errors_listed(self):
1806 w = pipeline._warnings({}, [self._candidate()], {"x": "timeout"})
1807 self.assertTrue(any("x" in msg for msg in w))
1808
1809 def test_no_items_warning(self):
1810 w = pipeline._warnings({}, [], {})
1811 self.assertTrue(any("No source returned" in msg for msg in w))
1812
1813
1814 class TestXRelatedSupplementalSearch(unittest.TestCase):
1815 """Tests for --x-related weighted supplemental search."""
1816
1817 @patch("lib.bird_x.search_handles")
1818 @patch("lib.entity_extract.extract_entities")
1819 def test_x_related_triggers_supplemental_related_label(self, mock_extract, mock_handles):
1820 """x_related handles should be searched and added with supplemental-related label."""
1821 mock_extract.return_value = {"x_handles": [], "x_hashtags": [], "reddit_subreddits": []}
1822 mock_handles.return_value = [
1823 {
1824 "id": "rel1",
1825 "text": "Related tweet from biancacensori",
1826 "url": "https://x.com/biancacensori/status/555",
1827 "author_handle": "biancacensori",
1828 "date": "2026-03-15",
1829 "engagement": {"likes": 30},
1830 "relevance": 0.7,
1831 "why_relevant": "related handle search",
1832 }
1833 ]
1834
1835 bundle = schema.RetrievalBundle()
1836 bundle.items_by_source["x"] = [
1837 _make_source_item("x", "X1", "https://x.com/kanyewest/status/1", author="kanyewest"),
1838 ]
1839
1840 plan = _make_plan("Kanye West")
1841
1842 pipeline._run_supplemental_searches(
1843 topic="Kanye West",
1844 bundle=bundle,
1845 plan=plan,
1846 config={},
1847 depth="default",
1848 date_range=("2026-02-15", "2026-03-17"),
1849 runtime=_make_runtime("bird"),
1850 mock=False,
1851 rate_limited_sources=set(),
1852 rate_limit_lock=threading.Lock(),
1853 x_related=["biancacensori"],
1854 )
1855
1856 # search_handles should have been called for the related handle
1857 mock_handles.assert_called()
1858 # The supplemental-related subquery label should exist in the plan
1859 labels = [sq.label for sq in plan.subqueries]
1860 self.assertIn("supplemental-related", labels)
1861 # The supplemental-related subquery should have weight 0.3
1862 related_sq = [sq for sq in plan.subqueries if sq.label == "supplemental-related"][0]
1863 self.assertAlmostEqual(related_sq.weight, 0.3)
1864
1865 @patch("lib.bird_x.search_handles")
1866 @patch("lib.entity_extract.extract_entities")
1867 def test_no_x_related_no_supplemental_related_label(self, mock_extract, mock_handles):
1868 """Without x_related, supplemental-related label should not appear."""
1869 mock_extract.return_value = {"x_handles": ["analyst1"], "x_hashtags": [], "reddit_subreddits": []}
1870 mock_handles.return_value = [
1871 {
1872 "id": "supp1",
1873 "text": "Supplemental tweet",
1874 "url": "https://x.com/analyst1/status/999",
1875 "author_handle": "analyst1",
1876 "date": "2026-03-15",
1877 "engagement": {"likes": 50},
1878 "relevance": 0.8,
1879 "why_relevant": "direct handle search",
1880 }
1881 ]
1882
1883 bundle = schema.RetrievalBundle()
1884 bundle.items_by_source["x"] = [
1885 _make_source_item("x", "X1", "https://x.com/analyst1/status/1", author="analyst1"),
1886 ]
1887
1888 plan = _make_plan("AI safety")
1889
1890 pipeline._run_supplemental_searches(
1891 topic="AI safety",
1892 bundle=bundle,
1893 plan=plan,
1894 config={},
1895 depth="default",
1896 date_range=("2026-02-15", "2026-03-17"),
1897 runtime=_make_runtime("bird"),
1898 mock=False,
1899 rate_limited_sources=set(),
1900 rate_limit_lock=threading.Lock(),
1901 )
1902
1903 # supplemental-related label should NOT exist (no x_related provided)
1904 labels = [sq.label for sq in plan.subqueries]
1905 self.assertNotIn("supplemental-related", labels)
1906
1907
1908 class TestRetryThinSourcesCoreEqualsTopic(unittest.TestCase):
1909 """Test that _retry_thin_sources fires even when core == topic (the fix)."""
1910
1911 @patch("lib.pipeline._retrieve_stream")
1912 def test_retry_fires_when_core_equals_topic(self, mock_retrieve):
1913 """Topic 'Kanye West' with 0 YouTube items should trigger retry.
1914
1915 Previously this was skipped because core 'kanye west' == topic.
1916 The fix ensures retry still fires for short topics.
1917 """
1918 mock_retrieve.return_value = (
1919 [
1920 {
1921 "id": "YT1",
1922 "title": "Kanye West new album leak",
1923 "url": "https://www.youtube.com/watch?v=abc123",
1924 "date": "2026-03-15",
1925 "engagement": {"views": 1000},
1926 "relevance": 0.8,
1927 "why_relevant": "retry result",
1928 }
1929 ],
1930 {},
1931 )
1932
1933 plan = schema.QueryPlan(
1934 intent="breaking_news",
1935 freshness_mode="strict_recent",
1936 cluster_mode="story",
1937 raw_topic="Kanye West",
1938 subqueries=[
1939 schema.SubQuery(
1940 label="primary",
1941 search_query="Kanye West",
1942 ranking_query="What recent evidence matters for Kanye West?",
1943 sources=["youtube", "x"],
1944 )
1945 ],
1946 source_weights={"youtube": 1.0, "x": 1.0},
1947 )
1948 bundle = schema.RetrievalBundle()
1949 # YouTube has 0 items (thin), X has enough
1950 bundle.items_by_source["x"] = [
1951 _make_source_item("x", f"X{i}", f"https://x.com/a/{i}") for i in range(5)
1952 ]
1953
1954 pipeline._retry_thin_sources(
1955 topic="Kanye West",
1956 bundle=bundle,
1957 plan=plan,
1958 config={},
1959 depth="default",
1960 date_range=("2026-02-15", "2026-03-17"),
1961 runtime=_make_runtime(),
1962 mock=False,
1963 rate_limited_sources=set(),
1964 rate_limit_lock=threading.Lock(),
1965 settings=pipeline.DEPTH_SETTINGS["default"],
1966 )
1967
1968 # _retrieve_stream should have been called for youtube
1969 mock_retrieve.assert_called()
1970 retried_sources = [c.kwargs["source"] for c in mock_retrieve.call_args_list]
1971 self.assertIn("youtube", retried_sources)
1972 # YouTube should now have items in the bundle
1973 self.assertIn("youtube", bundle.items_by_source)
1974
1975
1976 class TestZeroKeyPipelineRun(unittest.TestCase):
1977 """Pipeline should complete with local fallbacks when no reasoning keys are configured."""
1978
1979 @patch("lib.pipeline._retrieve_stream")
1980 def test_zero_key_run_produces_report(self, mock_retrieve):
1981 mock_retrieve.side_effect = lambda **kwargs: pipeline._mock_stream_results(
1982 kwargs["source"], kwargs["subquery"]
1983 )
1984 config = {"LAST30DAYS_REASONING_PROVIDER": "auto"}
1985 report = pipeline.run(
1986 topic="test zero key topic",
1987 config=config,
1988 depth="quick",
1989 requested_sources=["hackernews"],
1990 )
1991 self.assertEqual("test zero key topic", report.topic)
1992 self.assertEqual("local", report.provider_runtime.reasoning_provider)
1993 self.assertEqual("deterministic", report.provider_runtime.planner_model)
1994 self.assertTrue(
1995 any("fallback" in note for note in report.query_plan.notes),
1996 f"Expected fallback plan, got notes: {report.query_plan.notes}",
1997 )
1998 for candidate in report.ranked_candidates:
1999 self.assertEqual("fallback-local-score", candidate.explanation)
2000
2001
2002 class TestExcludeSources(unittest.TestCase):
2003 """EXCLUDE_SOURCES env var filters sources out of available_sources().
2004
2005 The existing INCLUDE_SOURCES allowlist (used by Perplexity opt-in) does
2006 not cover this case — tiktok and instagram are added unconditionally
2007 when SCRAPECREATORS_API_KEY is set, with no way to opt out short of
2008 unsetting the key. EXCLUDE_SOURCES gives runs a per-invocation denylist.
2009 """
2010
2011 def test_excludes_tiktok_and_instagram(self):
2012 config = {
2013 "SCRAPECREATORS_API_KEY": "test-key",
2014 "EXCLUDE_SOURCES": "tiktok,instagram",
2015 }
2016 sources = pipeline.available_sources(config)
2017 self.assertNotIn("tiktok", sources)
2018 self.assertNotIn("instagram", sources)
2019 self.assertIn("reddit", sources)
2020 self.assertIn("hackernews", sources)
2021
2022 def test_no_exclusion_when_unset(self):
2023 config = {"SCRAPECREATORS_API_KEY": "test-key"}
2024 sources = pipeline.available_sources(config)
2025 self.assertIn("tiktok", sources)
2026 self.assertIn("instagram", sources)
2027
2028 def test_empty_exclude_sources_is_noop(self):
2029 config = {
2030 "SCRAPECREATORS_API_KEY": "test-key",
2031 "EXCLUDE_SOURCES": "",
2032 }
2033 sources = pipeline.available_sources(config)
2034 self.assertIn("tiktok", sources)
2035 self.assertIn("instagram", sources)
2036
2037 def test_whitespace_and_case_insensitive(self):
2038 config = {
2039 "SCRAPECREATORS_API_KEY": "test-key",
2040 "EXCLUDE_SOURCES": " TikTok , INSTAGRAM ",
2041 }
2042 sources = pipeline.available_sources(config)
2043 self.assertNotIn("tiktok", sources)
2044 self.assertNotIn("instagram", sources)
2045
2046 def test_excludes_non_scrapecreators_source(self):
2047 """EXCLUDE_SOURCES applies to any source, not just SC-backed ones."""
2048 config = {"EXCLUDE_SOURCES": "hackernews"}
2049 sources = pipeline.available_sources(config)
2050 self.assertNotIn("hackernews", sources)
2051 self.assertIn("reddit", sources)
2052
2053
2054 class TestPerplexityAvailability(unittest.TestCase):
2055 def test_agent_background_failure_uses_safe_provider_detail(self):
2056 outcome = pipeline._legacy_artifact_outcome(
2057 "perplexity",
2058 {
2059 "error": "failed",
2060 "backgroundErrorMessage": "Provider reported an incomplete run",
2061 },
2062 )
2063
2064 self.assertEqual(health.ERROR, outcome["state"])
2065 self.assertEqual("Provider reported an incomplete run", outcome["detail"])
2066
2067 def test_agent_background_poll_429_is_rate_limited(self):
2068 outcome = pipeline._legacy_artifact_outcome(
2069 "perplexity",
2070 {
2071 "error": "poll_error",
2072 "backgroundPollError": "HTTP 429: Too Many Requests",
2073 "backgroundPollStatusCode": 429,
2074 },
2075 )
2076
2077 self.assertEqual(health.RATE_LIMITED, outcome["state"])
2078 self.assertEqual("HTTP 429: Too Many Requests", outcome["detail"])
2079
2080 def test_perplexity_source_available_with_openrouter_fallback(self):
2081 sources = pipeline.available_sources(
2082 {"OPENROUTER_API_KEY": "test-key", "INCLUDE_SOURCES": "perplexity"}
2083 )
2084 self.assertIn("perplexity", sources)
2085
2086 def test_perplexity_source_not_available_with_direct_key_without_opt_in(self):
2087 sources = pipeline.available_sources({"PERPLEXITY_API_KEY": "test-key"})
2088 self.assertNotIn("perplexity", sources)
2089
2090 def test_perplexity_source_available_with_direct_key(self):
2091 sources = pipeline.available_sources(
2092 {"PERPLEXITY_API_KEY": "test-key", "INCLUDE_SOURCES": "perplexity"}
2093 )
2094 self.assertIn("perplexity", sources)
2095
2096 def test_perplexity_diagnose_reports_direct_provider(self):
2097 diag = pipeline.diagnose({"PERPLEXITY_API_KEY": "test-key"})
2098 self.assertTrue(diag["providers"]["perplexity"])
2099 self.assertTrue(diag["local_mode"])
2100
2101
2102 class TestLinkedinAvailability(unittest.TestCase):
2103 """LinkedIn is power-user opt-in (INCLUDE_SOURCES=linkedin), unlike
2104 tiktok/instagram which activate on SCRAPECREATORS_API_KEY alone. This
2105 keeps existing SCRAPECREATORS_API_KEY holders from silently picking up a
2106 new source — and spending new credits — on their next run."""
2107
2108 def test_not_available_with_key_alone(self):
2109 sources = pipeline.available_sources({"SCRAPECREATORS_API_KEY": "test-key"})
2110 self.assertNotIn("linkedin", sources)
2111 # tiktok/instagram remain unconditional with just the key
2112 self.assertIn("tiktok", sources)
2113 self.assertIn("instagram", sources)
2114
2115 def test_available_with_key_and_include_sources(self):
2116 sources = pipeline.available_sources(
2117 {"SCRAPECREATORS_API_KEY": "test-key", "INCLUDE_SOURCES": "linkedin"}
2118 )
2119 self.assertIn("linkedin", sources)
2120
2121 def test_available_with_key_and_requested_sources(self):
2122 sources = pipeline.available_sources(
2123 {"SCRAPECREATORS_API_KEY": "test-key"}, requested_sources=["linkedin"]
2124 )
2125 self.assertIn("linkedin", sources)
2126
2127 def test_not_available_with_include_sources_but_no_key(self):
2128 sources = pipeline.available_sources({"INCLUDE_SOURCES": "linkedin"})
2129 self.assertNotIn("linkedin", sources)
2130
2131
2132 class TestKeylessGroundingAvailability(unittest.TestCase):
2133 """Grounding (general web) availability is host-aware.
2134
2135 Non-native hosts get the keyless floor by default; native-search hosts leave
2136 general web to the model's own search unless a paid key is configured.
2137 """
2138
2139 def test_grounding_available_without_key_on_non_native_host(self):
2140 sources = pipeline.available_sources({})
2141 self.assertIn("grounding", sources)
2142
2143 def test_grounding_suppressed_without_key_on_native_host(self):
2144 config = {"LAST30DAYS_NATIVE_SEARCH": "1"}
2145 sources = pipeline.available_sources(config)
2146 self.assertNotIn("grounding", sources)
2147
2148 def test_grounding_available_with_paid_key_even_on_native_host(self):
2149 config = {"LAST30DAYS_NATIVE_SEARCH": "1", "BRAVE_API_KEY": "k"}
2150 sources = pipeline.available_sources(config)
2151 self.assertIn("grounding", sources)
2152
2153
2154 class TestExcludeSourcesEndToEnd(unittest.TestCase):
2155 """Wiring regression: EXCLUDE_SOURCES from the process environment must
2156 reach available_sources() via env.get_config(). The unit tests above
2157 construct config dicts directly; this one exercises the env-to-config
2158 path so a missing entry in env.py's keys list is caught immediately."""
2159
2160 def test_exclude_sources_from_env_propagates_through_get_config(self):
2161 import os
2162 from unittest.mock import patch as _patch
2163 from lib import env as env_mod
2164 from importlib import reload
2165 with _patch.dict(os.environ, {
2166 "LAST30DAYS_CONFIG_DIR": "",
2167 "EXCLUDE_SOURCES": "tiktok,instagram",
2168 "SCRAPECREATORS_API_KEY": "fake",
2169 }, clear=False):
2170 reload(env_mod)
2171 cfg = env_mod.get_config()
2172 self.assertEqual(cfg.get("EXCLUDE_SOURCES"), "tiktok,instagram")
2173 sources = pipeline.available_sources(cfg)
2174 self.assertNotIn("tiktok", sources)
2175 self.assertNotIn("instagram", sources)
2176
2177
2178 class TestInnerMaxWorkers(unittest.TestCase):
2179 """Cap inner ThreadPoolExecutor concurrency under competitor fanout.
2180
2181 Without the cap, six competitor sub-runs each open their own
2182 ``ThreadPoolExecutor(max_workers=16)``, peaking around 96 worker threads
2183 that all hammer the same upstream APIs. ``internal_subrun=True`` should
2184 reduce the inner pool so the nested fanout stays bounded.
2185 """
2186
2187 def test_normal_run_uses_full_ceiling(self):
2188 self.assertEqual(pipeline._inner_max_workers(20, internal_subrun=False), 16)
2189 self.assertEqual(pipeline._inner_max_workers(10, internal_subrun=False), 10)
2190 self.assertEqual(pipeline._inner_max_workers(1, internal_subrun=False), 4)
2191
2192 def test_subrun_caps_at_four(self):
2193 self.assertEqual(pipeline._inner_max_workers(20, internal_subrun=True), 4)
2194 self.assertEqual(pipeline._inner_max_workers(10, internal_subrun=True), 4)
2195 self.assertEqual(pipeline._inner_max_workers(3, internal_subrun=True), 3)
2196 self.assertEqual(pipeline._inner_max_workers(1, internal_subrun=True), 2)
2197
2198 def test_subrun_caps_total_concurrency_below_uncapped(self):
2199 # Derive the outer cap from fanout so this test stays meaningful if
2200 # MAX_PARALLEL_SUBRUNS is bumped. The contract under test is "subrun
2201 # mode meaningfully reduces total inner-thread count", not a magic
2202 # number tied to today's value of MAX_PARALLEL_SUBRUNS=6.
2203 from lib import fanout
2204 max_subruns = fanout.MAX_PARALLEL_SUBRUNS
2205 capped = pipeline._inner_max_workers(20, internal_subrun=True) * max_subruns
2206 uncapped = pipeline._inner_max_workers(20, internal_subrun=False) * max_subruns
2207 self.assertLess(capped, uncapped, f"capped={capped} not < uncapped={uncapped}")
2208 # The cap must cut total concurrency to at most half of the un-capped
2209 # value; otherwise the cap is doing real work.
2210 self.assertLessEqual(
2211 capped,
2212 uncapped // 2,
2213 f"capped {capped} should be at most half of uncapped {uncapped}",
2214 )
2215
2216
2217 class TestScrapeCreatorsTierGating(unittest.TestCase):
2218 """The onboarding Recommended vs Everything tiers must be real.
2219
2220 Recommended (key, no INCLUDE_SOURCES) = TikTok + Instagram only.
2221 Everything (INCLUDE_SOURCES lists them) = also Threads, Pinterest, ...
2222 """
2223
2224 KEY = {"SCRAPECREATORS_API_KEY": "k"}
2225
2226 def test_recommended_tier_runs_tiktok_instagram(self):
2227 avail = pipeline.available_sources(dict(self.KEY))
2228 self.assertIn("tiktok", avail)
2229 self.assertIn("instagram", avail)
2230
2231 def test_threads_off_without_include_sources(self):
2232 self.assertNotIn("threads", pipeline.available_sources(dict(self.KEY)))
2233
2234 def test_threads_on_with_include_sources(self):
2235 cfg = {**self.KEY, "INCLUDE_SOURCES": "threads"}
2236 self.assertIn("threads", pipeline.available_sources(cfg))
2237
2238 def test_pinterest_off_without_include_sources(self):
2239 self.assertNotIn("pinterest", pipeline.available_sources(dict(self.KEY)))
2240
2241 def test_pinterest_on_with_persisted_include_sources(self):
2242 # Regression: this failed before U6 because the pinterest gate read
2243 # requested_sources only and ignored a persisted INCLUDE_SOURCES.
2244 cfg = {**self.KEY, "INCLUDE_SOURCES": "pinterest"}
2245 self.assertIn("pinterest", pipeline.available_sources(cfg))
2246
2247 def test_pinterest_on_via_requested_sources(self):
2248 # The per-run --sources path must still work.
2249 avail = pipeline.available_sources(dict(self.KEY), requested_sources=["pinterest"])
2250 self.assertIn("pinterest", avail)
2251
2252 def test_everything_tier_enables_all(self):
2253 cfg = {
2254 **self.KEY,
2255 "INCLUDE_SOURCES": "tiktok,instagram,threads,pinterest,youtube_comments,tiktok_comments",
2256 }
2257 avail = pipeline.available_sources(cfg)
2258 self.assertIn("threads", avail)
2259 self.assertIn("pinterest", avail)
2260
2261
2262 class TestAmazonSourceGating:
2263 """U2: the amazon source is dual-gated -- CLI available AND requested."""
2264
2265 def _config(self, include=""):
2266 return {"INCLUDE_SOURCES": include}
2267
2268 def test_unavailable_without_the_cli_even_when_requested(self):
2269 with patch.object(pipeline.brightdata, "is_available", return_value=False):
2270 available = pipeline.available_sources(self._config(), ["amazon"])
2271 assert "amazon" not in available
2272
2273 def test_unavailable_with_the_cli_when_not_requested(self):
2274 """Installing the CLI for other work must not start spending credits."""
2275 with patch.object(pipeline.brightdata, "is_available", return_value=True):
2276 available = pipeline.available_sources(self._config(), None)
2277 assert "amazon" not in available
2278
2279 def test_available_via_per_run_request(self):
2280 with patch.object(pipeline.brightdata, "is_available", return_value=True):
2281 available = pipeline.available_sources(self._config(), ["amazon"])
2282 assert "amazon" in available
2283
2284 def test_available_via_durable_include_sources(self):
2285 with patch.object(pipeline.brightdata, "is_available", return_value=True):
2286 available = pipeline.available_sources(self._config("amazon"), None)
2287 assert "amazon" in available
2288
2289 def test_search_flag_accepts_the_amazon_token(self):
2290 import last30days
2291 assert "amazon" in last30days.parse_search_flag("reddit,x,amazon")
2292
2293 def test_capped_at_one_fetch_per_run(self):
2294 """One model-supplied keyword per run: extra streams are pure cost."""
2295 assert pipeline.MAX_SOURCE_FETCHES["amazon"] == 1
2296
2297
2298 if __name__ == "__main__":
2299 unittest.main()
2300
2300 lines PYTHON