返回 last30days-skill
pipeline.py
根目录 / skills / last30days / scripts / lib / pipeline.py
1 """v3.0.0 orchestration pipeline."""
2
3 from __future__ import annotations
4
5 import copy
6 from collections.abc import Iterable, Mapping
7 import math
8 import queue
9 import re
10 import sqlite3
11 import sys
12 import threading
13 import time
14 from collections import Counter
15 from concurrent.futures import ThreadPoolExecutor, as_completed
16 from dataclasses import dataclass, field, replace
17 from datetime import date, datetime, timedelta, timezone
18 from pathlib import Path
19 from shutil import which
20 from typing import Any
21
22 from . import (
23 amazon,
24 arxiv,
25 bird_x,
26 bluesky,
27 brightdata,
28 corpus,
29 dates,
30 dedupe,
31 digg,
32 dripstack,
33 entity_extract,
34 env,
35 github,
36 grok_x,
37 grounding,
38 hackernews,
39 health,
40 hiring_signals,
41 http,
42 instagram,
43 jobs,
44 linkedin,
45 library,
46 library_index,
47 log,
48 meta_ads,
49 normalize,
50 permission_preflight,
51 perplexity,
52 pinterest,
53 planner,
54 polymarket,
55 providers,
56 query,
57 reddit,
58 reddit_listing,
59 reddit_public,
60 relevance,
61 rerank,
62 schema,
63 signals,
64 snippet,
65 stocktwits,
66 techmeme,
67 telegram,
68 threads,
69 tiktok,
70 topic_shape,
71 truthsocial,
72 trustpilot,
73 x_api,
74 x_envelope,
75 x_judge,
76 xai_x,
77 xiaohongshu_api,
78 xquik,
79 xurl_x,
80 youtube_yt,
81 )
82 from .cluster import cluster_candidates
83 from . import fusion
84 from . import render
85 from .fusion import collapse_duplicate_urls, weighted_rrf
86
87 DISCOVERY_SOURCES = ("reddit", "hackernews", "digg", "x")
88 _DISCOVERY_GENERIC_DOMAIN_TERMS = {
89 "ai", "artificial", "intelligence", "tech", "technology", "trending", "trend",
90 }
91
92 DEPTH_SETTINGS = {
93 "quick": {"per_stream_limit": 6, "pool_limit": 15, "rerank_limit": 12},
94 "default": {"per_stream_limit": 12, "pool_limit": 40, "rerank_limit": 40},
95 "deep": {"per_stream_limit": 20, "pool_limit": 60, "rerank_limit": 60},
96 }
97
98 SEARCH_ALIAS = {
99 "hn": "hackernews",
100 "bsky": "bluesky",
101 "truth": "truthsocial",
102 "web": "grounding",
103 "xhs": "xiaohongshu",
104 "meta": "meta_ads",
105 "meta-ads": "meta_ads",
106 "xquik": "x", # xquik is a backend of the single "x" source, not its own source
107 }
108
109 # trustpilot is capped at 1: every subquery would use the identical company
110 # identifier, so N streams are pure redundancy -- and each extra stream risks
111 # its own WAF-cookie Chrome harvest.
112 # amazon is capped at 1 for the same reason as trustpilot: the model supplies
113 # one product keyword for the run, so every subquery would issue the identical
114 # product search. Extra streams would be pure redundancy at one credit each.
115 # meta_ads is capped at 1 for the same reason as amazon: one advertiser page is
116 # resolved per run, so every subquery would issue the identical page fetch.
117 MAX_SOURCE_FETCHES: dict[str, int] = {
118 "x": 2, "jobs": 1, "linkedin": 1, "stocktwits": 1, "trustpilot": 1, "amazon": 1,
119 "telegram": 1, "meta_ads": 1,
120 }
121
122 # Sources whose thin result is their normal success state, so the "<3 items"
123 # retry would re-fetch them after every success -- bypassing
124 # MAX_SOURCE_FETCHES and, for the resolved-entity sources, re-resolving
125 # WITHOUT the caller's override (a lookalike-misattribution path).
126 # trustpilot returns at most ONE item by design.
127 # perplexity answers once per run.
128 # meta_ads resolves one advertiser page per run, so a brand that genuinely
129 # ran two creatives this month is complete; a retry would re-resolve the
130 # page and re-spend the discovery credit.
131 THIN_RETRY_EXEMPT: frozenset[str] = frozenset({"trustpilot", "perplexity", "meta_ads"})
132
133 # Stream-artifact keys promoted to named top-level report artifacts. A stream
134 # artifact only ever reaches the report as an anonymous entry in the grounding
135 # list, so anything the renderer needs by name has to be lifted out of it --
136 # most importantly on a zero-item run, which is exactly when naming the
137 # resolved advertiser and its counts matters most.
138 STREAM_ARTIFACT_LIFT_KEYS: tuple[str, ...] = ("meta_ads_page", "meta_ads_tally")
139
140
141 def _lift_stream_artifacts(bundle) -> None:
142 """Promote per-stream artifacts the renderer reads by name."""
143 for stream_artifact in bundle.artifacts.get("grounding", []):
144 if not isinstance(stream_artifact, dict):
145 continue
146 for key in STREAM_ARTIFACT_LIFT_KEYS:
147 value = stream_artifact.get(key)
148 if value:
149 bundle.artifacts[key] = value
150
151
152 _FAILURE_SPECIFICITY = {
153 health.AUTH_FAILED: 0,
154 health.PAYMENT_REQUIRED: 1,
155 health.RATE_LIMITED: 2,
156 health.SCHEMA_DRIFT: 3,
157 health.TIMEOUT: 4,
158 health.UNREACHABLE: 5,
159 health.ERROR: 6,
160 }
161
162
163 @dataclass
164 class PaidSourceBudget:
165 """Command-wide, thread-safe budget for paid source adapter calls."""
166
167 used: int = 0
168 owner: str | None = None
169 _lock: Any = field(default_factory=threading.Lock, repr=False)
170
171 def try_consume(self, limit: int, *, claimant: str | None = None) -> bool:
172 with self._lock:
173 if self.owner is not None and claimant != self.owner:
174 return False
175 if self.used >= limit:
176 return False
177 self.used += 1
178 return True
179
180
181 def _source_fetch_cap(source: str, config: dict[str, Any]) -> int | None:
182 """Return the effective per-run cap for one source.
183
184 Every Perplexity adapter call is paid, and ``both`` performs two paid POSTs.
185 A generic fetch-cap override must not multiply either normal or Deep
186 Research mode across planner subqueries.
187 """
188 override = config.get("_max_source_fetches")
189 if source == "perplexity":
190 return 1 if override is None else min(1, int(override))
191 cap = MAX_SOURCE_FETCHES.get(source)
192 if cap is not None and override is not None:
193 return int(override)
194 return cap
195
196
197 def _resolve_depth_settings(depth: str, config: dict[str, Any]) -> dict[str, int]:
198 """Depth profile with optional CLI cap overrides applied (issue #716).
199
200 Returns a copy so the module-level DEPTH_SETTINGS is never mutated. Overrides
201 are set directly (not max()) so callers can also lower a cap. `--max-results`
202 raises the final ranked pool (pool_limit/rerank_limit); `--max-per-source`
203 raises the per-stream truncation applied before pooling. The per-source fetch
204 cap (`--max-source-fetches`) is applied separately at the fetch site.
205 """
206 settings = dict(DEPTH_SETTINGS[depth])
207 # `is not None` (not truthiness) so an explicit 0 is honored as a real lower
208 # bound rather than ignored as "unset" — matches how main() stashes these.
209 max_per_source = config.get("_max_per_source")
210 if max_per_source is not None:
211 settings["per_stream_limit"] = int(max_per_source)
212 max_results = config.get("_max_results")
213 if max_results is not None:
214 settings["pool_limit"] = int(max_results)
215 settings["rerank_limit"] = int(max_results)
216 return settings
217
218 # Per-handle result caps for the X handle-search lanes. The FROM lane (the
219 # subject's own timeline) is the single best source for a person topic, so it
220 # gets the highest cap; the ABOUT (mention) and related-handle lanes stay
221 # modest so total volume and request budget don't balloon.
222 FROM_LANE_COUNT_PER = 8
223 MENTION_LANE_COUNT_PER = 5
224 RELATED_HANDLE_COUNT_PER = 3
225
226
227 def _has_perplexity_provider(config: dict[str, Any]) -> bool:
228 # Prefer direct Agent/Search APIs, but preserve the synchronous OpenRouter
229 # Sonar fallback for existing installs.
230 return bool(
231 config.get("PERPLEXITY_API_KEY") or config.get("OPENROUTER_API_KEY")
232 )
233
234 MOCK_AVAILABLE_SOURCES = [
235 "reddit",
236 "x",
237 "youtube",
238 "tiktok",
239 "instagram",
240 "hackernews",
241 "bluesky",
242 "truthsocial",
243 "polymarket",
244 "grounding",
245 "xiaohongshu",
246 "github",
247 "perplexity",
248 "threads",
249 "pinterest",
250 "digg",
251 "arxiv",
252 "techmeme",
253 "trustpilot",
254 "amazon",
255 "meta_ads",
256 "jobs",
257 "linkedin",
258 "corpus",
259 "dripstack",
260 "telegram",
261 ]
262
263
264 def normalize_requested_sources(sources: list[str] | None) -> list[str] | None:
265 if not sources:
266 return None
267 normalized = []
268 for source in sources:
269 key = SEARCH_ALIAS.get(source.lower(), source.lower())
270 if key not in normalized:
271 normalized.append(key)
272 return normalized
273
274
275 def available_sources(
276 config: dict[str, Any],
277 requested_sources: list[str] | None = None,
278 *,
279 x_pending: bool | None = None,
280 local_only: bool = False,
281 x_envelope: bool = False,
282 suppress_x_host_lane: bool = False,
283 ) -> list[str]:
284 """List the sources the next run can serve.
285
286 ``local_only=True`` is the safe/diagnose flavor (doctor's permission
287 block): availability is answered from local evidence only, so the X
288 check never spawns xurl's live ``whoami`` network call. Research-time
289 callers keep the default live semantics.
290
291 X is listed when an engine backend is available, or browser auth is
292 pending, or the hosting model declared the X connector lane
293 (``env.x_host_lane_declared``), or a validated ``--x-posts`` envelope is
294 present for this run (``x_envelope``), in every cookie mode.
295 ``suppress_x_host_lane`` turns only the lane branch off (discovery
296 enrichment passes); an envelope still counts.
297 """
298 available: list[str] = []
299 # reddit_public needs no API key - always available
300 available.append("reddit")
301 if corpus.resolve_directories(
302 config.get("_CORPUS_DIRS"), config.get("LAST30DAYS_CORPUS_DIRS")
303 ):
304 available.append("corpus")
305 if config.get("SCRAPECREATORS_API_KEY"):
306 available.extend(["tiktok", "instagram"])
307 if env.get_x_source(config, local_only=local_only):
308 available.append("x")
309 elif x_envelope or (
310 not suppress_x_host_lane and env.x_host_lane_declared(config)
311 ):
312 # Host-fetched X lane: the model passes connector results through
313 # --x-posts, so X is served without an engine backend.
314 available.append("x")
315 else:
316 # Safe inspection (--diagnose/--preflight) skips browser-cookie
317 # extraction, so get_x_source is None even though a real run would
318 # authenticate X via FROM_BROWSER. Report it as available so consumers
319 # of available_sources (SKILL.md ACTIVE_SOURCES_LIST) don't under-report.
320 # diagnose() precomputes the predicate and passes it via x_pending to
321 # avoid evaluating it twice in one diagnose() call.
322 if x_pending is None:
323 x_pending = env.x_pending_browser_auth(config)
324 if x_pending:
325 available.append("x")
326 if which("yt-dlp") or env.is_youtube_sc_available(config):
327 available.append("youtube")
328 available.extend(["hackernews", "polymarket"])
329 # StockTwits is gated to ticker/crypto topics only (flag set in run()).
330 if config.get("_financial_topic"):
331 available.append("stocktwits")
332 # GitHub is reachable via the unauthenticated REST tier too, so it is
333 # available even without a token/gh CLI (a token only raises rate limits).
334 available.append("github")
335 # DripStack is opt-in only (owner decision, #791): a commercial
336 # third-party API must never receive default-run traffic. Opt in per run
337 # (--search dripstack) or persistently (INCLUDE_SOURCES=dripstack in
338 # .env, the LinkedIn/Perplexity pattern); the search API is free and
339 # public (no key), so the opt-in itself is the gate.
340 include_sources = {
341 token.strip()
342 for token in (config.get("INCLUDE_SOURCES") or "").lower().split(",")
343 if token.strip()
344 }
345 if "dripstack" in include_sources or (
346 requested_sources and "dripstack" in requested_sources
347 ):
348 available.append("dripstack")
349 if which("digg-pp-cli"):
350 available.append("digg")
351 # arXiv is default-on when its Printing Press CLI is installed (zero auth).
352 # The adapter relevance-and-recency gates so it stays quiet off-topic.
353 if which("arxiv-pp-cli"):
354 available.append("arxiv")
355 # Techmeme is default-on when its CLI is installed (zero auth; sub-second
356 # local sync before each run's first search).
357 if which("techmeme-pp-cli"):
358 available.append("techmeme")
359 if env.is_bluesky_available(config):
360 available.append("bluesky")
361 if env.is_truthsocial_available(config):
362 available.append("truthsocial")
363 # Grounding (general web) is available when a paid backend is configured OR
364 # the keyless floor is permitted (i.e. the host has no native search). On a
365 # native-search host with no paid key, keyless_web_allowed is False and the
366 # engine leaves general web to the model's own search.
367 if (config.get("BRAVE_API_KEY") or config.get("EXA_API_KEY")
368 or config.get("SERPER_API_KEY") or config.get("PARALLEL_API_KEY")
369 or env.keyless_web_allowed(config)):
370 available.append("grounding")
371 if requested_sources and "jobs" in requested_sources:
372 available.append("jobs")
373 # Perplexity Agent API: opt-in additive source via INCLUDE_SOURCES=perplexity
374 if _has_perplexity_provider(config) and (
375 "perplexity" in include_sources or (requested_sources and "perplexity" in requested_sources)
376 ):
377 available.append("perplexity")
378 # LinkedIn: opt-in additive source via INCLUDE_SOURCES=linkedin (same
379 # consent pattern as Perplexity). Unlike tiktok/instagram, which are
380 # offered during SKILL.md Step 0 onboarding, LinkedIn is power-user-only
381 # and must not silently activate for existing SCRAPECREATORS_API_KEY
382 # holders.
383 if config.get("SCRAPECREATORS_API_KEY") and (
384 "linkedin" in include_sources or (requested_sources and "linkedin" in requested_sources)
385 ):
386 available.append("linkedin")
387 # Trustpilot: opt-in additive source via INCLUDE_SOURCES=trustpilot (same
388 # consent pattern as Perplexity/LinkedIn). Off by default -- unlike arXiv and
389 # Techmeme, which are zero-auth, it can spawn a one-time headless-Chrome WAF
390 # cookie harvest on a brand topic, so activating it is the user's choice.
391 if which("trustpilot-pp-cli") and (
392 "trustpilot" in include_sources or (requested_sources and "trustpilot" in requested_sources)
393 ):
394 available.append("trustpilot")
395 # Amazon: opt-in additive source, dual-gated. The Bright Data CLI must be
396 # on the agent subprocess PATH and carry a credential signal, AND the run
397 # must ask for it -- the model per-run via --search, or the user durably
398 # via INCLUDE_SOURCES=amazon. Never inferred from topic shape: the engine
399 # misroutes most shopping phrasings, and auto-firing would spend a CLI
400 # owner's credits on runs that have nothing to do with products.
401 if brightdata.is_available(config) and (
402 "amazon" in include_sources or (requested_sources and "amazon" in requested_sources)
403 ):
404 available.append("amazon")
405 # Meta Ads: opt-in additive source on the Amazon precedent. The
406 # ScrapeCreators key must be present AND the run must ask for it -- the
407 # model per-run via --search, or the user durably via
408 # INCLUDE_SOURCES=meta_ads. Never inferred from topic shape: keyword ad
409 # search on a non-brand topic returns a wrong-entity advertiser, and
410 # auto-firing would spend credits resolving it.
411 if config.get("SCRAPECREATORS_API_KEY") and (
412 "meta_ads" in include_sources
413 or (requested_sources and "meta_ads" in requested_sources)
414 ):
415 available.append("meta_ads")
416 if (
417 "xiaohongshu" in include_sources
418 or (requested_sources and "xiaohongshu" in requested_sources)
419 ) and env.is_xiaohongshu_available(config):
420 available.append("xiaohongshu")
421 # Threads: opt-in via INCLUDE_SOURCES (same pattern as perplexity/linkedin).
422 # Was auto-on with the key; gated so the onboarding "Everything" tier is a
423 # real choice vs the "Recommended" (TikTok/Instagram) tier.
424 if env.is_threads_available(config) and (
425 "threads" in include_sources or (requested_sources and "threads" in requested_sources)
426 ):
427 available.append("threads")
428 # Pinterest: opt-in via INCLUDE_SOURCES. Previously read requested_sources
429 # only, so a persisted INCLUDE_SOURCES=pinterest never activated it; now it
430 # honors both the per-run --sources list and the saved config.
431 if env.is_pinterest_available(config) and (
432 "pinterest" in include_sources or (requested_sources and "pinterest" in requested_sources)
433 ):
434 available.append("pinterest")
435 # Telegram: opt-in via INCLUDE_SOURCES AND requires a channel list. The
436 # channel list (TELEGRAM_SOURCES env or --telegram-sources CLI) is the gate:
437 # without named channels there is no discovery endpoint to call.
438 if config.get("SCRAPECREATORS_API_KEY") and (
439 "telegram" in include_sources or (requested_sources and "telegram" in requested_sources)
440 ):
441 if telegram.is_telegram_configured(config):
442 available.append("telegram")
443 # xquik is a backend of the single "x" source (see env.x_backend_chain),
444 # not a separate parallel source — registered via the "x" entry above.
445 exclude = {s.strip().lower() for s in (config.get("EXCLUDE_SOURCES") or "").split(",") if s.strip()}
446 if exclude:
447 available = [s for s in available if s not in exclude]
448 return available
449
450
451 def _mock_discovery_items(
452 source: str,
453 domain: str,
454 to_date: str,
455 ) -> list[dict[str, Any]]:
456 """Deterministic listing fixtures for the public --mock CLI contract."""
457 labels = [
458 "Agent memory protocols",
459 "Browser-using agents",
460 "Local agent runtimes",
461 "Multi-agent orchestration",
462 "Agent security sandboxes",
463 "Voice agent latency",
464 ]
465 end = datetime.fromisoformat(to_date).date()
466 items: list[dict[str, Any]] = []
467 for index, label in enumerate(labels, start=1):
468 published = (end - timedelta(days=index)).isoformat()
469 slug = re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-")
470 if source == "reddit":
471 items.append({
472 "id": f"discovery-r-{index}",
473 "title": label,
474 "url": f"https://reddit.com/r/example/comments/{slug}",
475 "subreddit": "example",
476 "date": published,
477 "engagement": {"score": 180 - index * 10, "num_comments": 30 + index},
478 "selftext": label,
479 "relevance": 0.9,
480 "why_relevant": "Mock discovery listing",
481 })
482 elif source == "hackernews":
483 items.append({
484 "id": f"discovery-hn-{index}",
485 "title": label,
486 "url": f"https://example.com/{slug}",
487 "hn_url": f"https://news.ycombinator.com/item?id={index}",
488 "author": f"example{index}",
489 "date": published,
490 "engagement": {"points": 120 - index * 8, "comments": 20 + index},
491 "relevance": 0.88,
492 "why_relevant": "Mock HN discovery listing",
493 })
494 elif source == "digg":
495 items.append({
496 "id": f"discovery-d-{index}",
497 "title": label,
498 "url": f"https://di.gg/ai/{slug}",
499 "tldr": label,
500 "date": published,
501 "engagement": {"postCount": 30 - index, "uniqueAuthors": 12 - index},
502 "relevance": 0.9,
503 "why_relevant": "Mock Digg discovery cluster",
504 })
505 elif source == "x":
506 items.append({
507 "id": f"discovery-x-{index}",
508 "text": label,
509 "url": f"https://x.com/example{index}/status/{index}",
510 "author_handle": f"example{index}",
511 "date": published,
512 "engagement": {"likes": 140 - index * 9, "reposts": 18 + index},
513 "relevance": 0.9,
514 "why_relevant": "Mock X discovery activity",
515 })
516 return items
517
518
519 def _matches_discovery_domain(domain: str, text: str) -> bool:
520 """Require a distinctive domain term, not a generic token such as ``AI``."""
521 def terms(value: str) -> set[str]:
522 # Keep BOTH the surface form and the naive stem: replacing the token
523 # broke non-plurals ("bias" -> "bia", "crisis" -> "crisi") so in-domain
524 # listings stopped intersecting. The union preserves plural matching
525 # without corrupting the anchor.
526 words: set[str] = set()
527 for word in relevance.tokenize(value):
528 words.add(word)
529 if len(word) > 4 and word.endswith("s") and not word.endswith("ss"):
530 words.add(word[:-1])
531 return words
532
533 domain_terms = terms(domain)
534 anchors = domain_terms - _DISCOVERY_GENERIC_DOMAIN_TERMS
535 return bool((anchors or domain_terms) & terms(text))
536
537
538 def _fetch_discovery_source(
539 source: str,
540 plan: schema.DiscoveryPlan,
541 *,
542 from_date: str,
543 to_date: str,
544 depth: str,
545 mock: bool,
546 config: dict[str, Any],
547 keyword_gate: bool = True,
548 ) -> tuple[list[dict[str, Any]], str | None]:
549 """Fetch one listing/river source for the nominate stage.
550
551 ``keyword_gate`` controls whether items are filtered to the domain by
552 ``_matches_discovery_domain``. Domain-scoped discovery (``--discover X``)
553 keeps the gate on; global trending (``--discover`` with no domain) turns it
554 off, because there is no keyword to gate against - the river feeds ARE the
555 "what is hot right now" signal, and the confidence floor downstream is what
556 keeps junk out, not a keyword match.
557 """
558 if mock:
559 return _mock_discovery_items(source, plan.domain, to_date), None
560 if source == "reddit":
561 result = reddit_listing.fetch_discovery_listings(
562 plan.subreddits, depth=depth, query=plan.domain,
563 )
564 items = result.get("items") or []
565 if keyword_gate:
566 items = [
567 item for item in items
568 if _matches_discovery_domain(
569 plan.domain,
570 f"{item.get('title') or ''} {item.get('selftext') or ''}",
571 )
572 ]
573 return items, "; ".join(result.get("errors") or []) or None
574 if source == "hackernews":
575 result = hackernews.fetch_discovery_listings(from_date, to_date, depth=depth)
576 items = result.get("items") or []
577 for item in items:
578 item["relevance"] = relevance.token_overlap_relevance(
579 plan.domain,
580 str(item.get("title") or ""),
581 )
582 # HN is a broad technology listing, so keep only domain-bearing stories
583 # when a domain is in play; global trending keeps the whole front page.
584 if keyword_gate:
585 items = [
586 item for item in items
587 if _matches_discovery_domain(plan.domain, str(item.get("title") or ""))
588 ]
589 errors = result.get("errors") or []
590 return items, "; ".join(errors) or None
591 if source == "digg":
592 result = digg.search_digg(plan.domain, from_date, to_date, depth=depth)
593 items = digg.parse_digg_response(result, query=plan.domain)
594 # Digg is an AI-focused broad listing, so keep only domain-bearing
595 # clusters when scoped; global trending keeps the whole feed.
596 if keyword_gate:
597 items = [
598 item for item in items
599 if _matches_discovery_domain(plan.domain, str(item.get("title") or ""))
600 ]
601 return items, result.get("error")
602 if source == "x":
603 # Discovery uses domain directly as query (no planner search_query)
604 query = plan.domain
605 last_error = ""
606 for backend in env.x_backend_chain(config):
607 items, error = _fetch_x_backend(
608 backend, query, from_date, to_date, depth, config,
609 )
610 if items:
611 # Earlier failed-over backends' errors are observability, not
612 # degradation - but the producing backend's own error means
613 # these items are partial and must surface as such.
614 if last_error:
615 print(f"[x] earlier backend failed: {last_error}", file=sys.stderr)
616 return items, error or None
617 if error:
618 last_error = f"{backend}: {error}"
619 return [], last_error or None
620 raise ValueError(f"Unsupported discovery source: {source}")
621
622
623 def _discovery_engagement(
624 items: list[schema.SourceItem],
625 ) -> dict[str, dict[str, float | int]]:
626 totals: dict[str, dict[str, float | int]] = {}
627 for item in items:
628 bucket = totals.setdefault(item.source, {})
629 for field, value in item.engagement.items():
630 if not isinstance(value, (int, float)) or isinstance(value, bool):
631 continue
632 # Rank/score/reach metadata is not additive engagement: summing
633 # Digg ranks across items fabricates a metric (agent-export uses
634 # the same counter-field rule).
635 if not schema._is_counter_field(field):
636 continue
637 bucket[field] = bucket.get(field, 0) + value
638 return {
639 source: dict(sorted(metrics.items()))
640 for source, metrics in sorted(totals.items())
641 }
642
643
644 def _discovery_momentum(items: list[schema.SourceItem], to_date: str) -> str:
645 as_of = datetime.fromisoformat(to_date).date()
646 ages: list[int] = []
647 for item in items:
648 try:
649 published = datetime.fromisoformat((item.published_at or "").replace("Z", "+00:00")).date()
650 except (TypeError, ValueError):
651 continue
652 ages.append(max(0, (as_of - published).days))
653 return "new-this-week" if ages and max(ages) < 7 else "building"
654
655
656 def nominate_candidates(
657 plan: schema.DiscoveryPlan,
658 *,
659 from_date: str,
660 to_date: str,
661 depth: str,
662 mock: bool,
663 config: dict[str, Any],
664 lookback_days: int,
665 keyword_gate: bool = True,
666 ) -> schema.RetrievalBundle:
667 """Stage 1 of discovery: fetch, normalize, and bundle candidate hot items
668 from the river/listing feeds.
669
670 This is the topic-nomination pass. For domain discovery ``keyword_gate`` is
671 on and the feeds are filtered to the domain; for global trending it is off
672 and the feeds' own hot ranking IS the signal. The returned bundle feeds the
673 clustering + enrichment stages downstream. Every source's failure is
674 recorded on the bundle (never raised) so a single dead feed cannot sink the
675 run - the confidence floor decides whether the surviving evidence is enough.
676 """
677 bundle = schema.RetrievalBundle()
678 with ThreadPoolExecutor(max_workers=max(1, len(plan.sources))) as executor:
679 futures = {
680 executor.submit(
681 _fetch_discovery_source,
682 source,
683 plan,
684 from_date=from_date,
685 to_date=to_date,
686 depth=depth,
687 mock=mock,
688 config=config,
689 keyword_gate=keyword_gate,
690 ): source
691 for source in plan.sources
692 }
693 for future in as_completed(futures):
694 source = futures[future]
695 bundle.mark_attempted(source)
696 try:
697 raw_items, partial_error = future.result()
698 normalized = normalize.normalize_source_items(
699 source,
700 raw_items,
701 from_date,
702 to_date,
703 freshness_mode="breaking",
704 )
705 # Global trending has no domain; annotate against a neutral
706 # phrase so snippet extraction still works without biasing
707 # relevance toward any keyword.
708 prepared = relevance.PreparedQuery(plan.domain or "trending now")
709 normalized = signals.annotate_stream(
710 normalized,
711 prepared,
712 "breaking",
713 reference_date=to_date,
714 max_days=lookback_days,
715 )
716 normalized = dedupe.dedupe_items(normalized)
717 for item in normalized:
718 item.snippet = snippet.extract_best_snippet(item, prepared)
719 bundle.add_items("discovery-listings", source, normalized)
720 if partial_error:
721 failure_state = (
722 bird_x.classify_run_failure(partial_error)
723 if source == "x" and partial_error.startswith("bird:")
724 else http.classify_failure(message=partial_error)
725 )
726 bundle.record_failure(
727 source,
728 failure_state,
729 partial_error,
730 )
731 except Exception as exc:
732 state, attempted = _classify_source_failure(exc)
733 bundle.record_failure(source, state, str(exc), attempted=attempted)
734 return bundle
735
736
737 @dataclass(frozen=True)
738 class Nomination:
739 """A named candidate topic produced by the nominate stage.
740
741 ``seed_score`` is the cheap pre-enrichment rank - seed velocity on the
742 nominate stage, blended with the HOST judge's content-worthiness on the
743 protocol resume leg (see ``rerank.judge_blended_score``). Enough to
744 decide WHICH candidates deserve a full pipeline pass, but not the final
745 ranking signal (that comes from enriched evidence downstream).
746 ``junk_shape`` flags help-me/beginner/musing shapes that should not
747 become content topics; ``worthiness`` is the host judge's 0-100 content
748 score, None on the heuristic path.
749 """
750
751 name: str
752 seed_score: float
753 items: list[schema.SourceItem] = field(default_factory=list)
754 summary: str = ""
755 junk_shape: bool = False
756 worthiness: float | None = None
757
758
759 def _cluster_entity_counts(
760 cluster: schema.Cluster,
761 candidate_map: dict[str, schema.Candidate],
762 ) -> Counter:
763 """Entity-token frequencies across a cluster's members (title + snippet)."""
764 counts: Counter = Counter()
765 for candidate_id in cluster.candidate_ids:
766 candidate = candidate_map.get(candidate_id)
767 if candidate:
768 counts.update(entity_extract.extract_text_entities(
769 f"{candidate.title} {candidate.snippet}"
770 ))
771 return counts
772
773
774 # Bound on how many distinguishing entity tokens a colliding cluster may try
775 # before it is treated as indistinguishable from the earlier story. Keeps a
776 # pathological cluster (dozens of unique tokens, every resulting name already
777 # taken) from scanning its whole vocabulary.
778 _DISAMBIGUATION_TOKEN_LIMIT = 5
779
780
781 def _disambiguated_topic_name(
782 name: str,
783 cluster: schema.Cluster,
784 earlier_cluster: schema.Cluster,
785 candidate_map: dict[str, schema.Candidate],
786 entity_counts_cache: dict[str, Counter],
787 taken_names: dict[str, schema.Cluster],
788 ) -> str | None:
789 """Disambiguate a colliding topic name by appending the later cluster's
790 strongest entity token that the earlier cluster does not share.
791
792 Distinguishing tokens are tried in descending strength order (bounded at
793 ``_DISAMBIGUATION_TOKEN_LIMIT``) and the first resulting name not already
794 present in ``taken_names`` (casefolded keys) wins: a first-choice suffix
795 colliding with an already-taken name must not drop a distinct story while
796 another distinguishing token remains.
797
798 ``entity_counts_cache`` (keyed by cluster id, owned by the caller) memoizes
799 per-cluster entity counts so repeated collisions against the same cluster
800 never recompute them.
801
802 Returns None when no distinguishing entity yields an unused name - the
803 clusters cannot be told apart by content, so the caller treats them as the
804 same story.
805 """
806 def cached_counts(target: schema.Cluster) -> Counter:
807 counts = entity_counts_cache.get(target.cluster_id)
808 if counts is None:
809 counts = _cluster_entity_counts(target, candidate_map)
810 entity_counts_cache[target.cluster_id] = counts
811 return counts
812
813 later_counts = cached_counts(cluster)
814 earlier_entities = set(cached_counts(earlier_cluster))
815 name_tokens = {token.casefold() for token in name.split()}
816 choices = [
817 (count, token) for token, count in later_counts.items()
818 if token not in earlier_entities and token.casefold() not in name_tokens
819 ]
820 # Strongest first = most frequent across the cluster; alphabetical
821 # tie-break keeps the result deterministic.
822 ranked = sorted(choices, key=lambda entry: (-entry[0], entry[1]))
823 for _, token in ranked[:_DISAMBIGUATION_TOKEN_LIMIT]:
824 display = token
825 for candidate_id in cluster.candidate_ids:
826 candidate = candidate_map.get(candidate_id)
827 if candidate is None:
828 continue
829 match = next(
830 (
831 word.strip("\"'`()[]{}.,:;!?")
832 for word in f"{candidate.title} {candidate.snippet}".split()
833 if word.strip("\"'`()[]{}.,:;!?").lower() == token
834 ),
835 None,
836 )
837 if match:
838 display = match
839 break
840 resolved = f"{name} {display}"
841 if resolved.casefold() not in taken_names:
842 return resolved
843 return None
844
845
846 def nominate_topic_pool(
847 bundle: schema.RetrievalBundle,
848 query_plan: schema.QueryPlan,
849 plan: schema.DiscoveryPlan,
850 *,
851 from_date: str,
852 to_date: str,
853 limit: int,
854 ) -> list[tuple[Nomination, str]]:
855 """Stage 1b of discovery: cluster nominated items into named candidate
856 topics, rank them, and pair each with its source cluster id.
857
858 This is the shared core behind ``nominate_topics`` (the one-shot path,
859 which drops the cluster ids) and the leg-1 nominate-only sweep (which
860 keys nominations-bundle rows on them, see ``run_discover_nominate``).
861
862 Naming and junk classification are the deterministic ``topic_shape``
863 heuristics and ranking is velocity-only - the engine runs no LLM here.
864 Reasoning-model judgment lives in the host-judged protocol: the host
865 renames, junk-filters, and worthiness-scores this pool from the leg-1
866 bundle, and ``run_discover_resume`` applies those verdicts. The one-shot
867 path ships the heuristic names as-is.
868
869 Casefold name collisions are disambiguated (the later cluster's strongest
870 non-shared entity token is appended, trying successive tokens when the
871 first-choice suffix is itself already taken) rather than blindly dropped:
872 short distilled names collide far more often than raw 96-char titles, and
873 a silent drop hides a distinct story. A colliding cluster is dropped only
874 when it shares a representative candidate with the earlier one (the same
875 story surfacing twice) or when no distinguishing entity token yields an
876 unused name.
877
878 Returns at most ``limit`` ``(nomination, cluster_id)`` pairs, never
879 padded - fewer clusters than ``limit`` means a shorter list, and the
880 confidence floor downstream decides whether what survived is worth
881 showing.
882 """
883 candidates = weighted_rrf(
884 bundle.items_by_source_and_query,
885 query_plan,
886 pool_limit=80,
887 range_from=from_date,
888 range_to=to_date,
889 )
890 for candidate in candidates:
891 velocity = rerank.discovery_velocity_score(candidate.source_items, as_of_date=to_date)
892 candidate.final_score = min(100.0, 12.0 * math.log1p(velocity)) if velocity else 0.0
893 candidates.sort(key=lambda candidate: (-candidate.final_score, candidate.title.lower()))
894 clusters = cluster_candidates(candidates, query_plan)
895 candidate_map = {candidate.candidate_id: candidate for candidate in candidates}
896
897 ranked_clusters: list[tuple[float, schema.Cluster, list[schema.SourceItem]]] = []
898 for cluster in clusters:
899 cluster_items: list[schema.SourceItem] = []
900 for candidate_id in cluster.candidate_ids:
901 candidate = candidate_map.get(candidate_id)
902 if candidate:
903 cluster_items.extend(candidate.source_items)
904 score = rerank.discovery_velocity_score(cluster_items, as_of_date=to_date)
905 if score <= 0:
906 continue
907 ranked_clusters.append((score, cluster, cluster_items))
908 ranked_clusters.sort(key=lambda entry: (-entry[0], entry[1].title.lower()))
909
910 # Heuristic naming from each cluster's leader text (title + snippet).
911 named: list[tuple[float, schema.Cluster, list[schema.SourceItem], str, bool]] = []
912 for score, cluster, cluster_items in ranked_clusters:
913 leader = candidate_map.get(cluster.representative_ids[0]) if cluster.representative_ids else None
914 title = (leader.title if leader else cluster.title) or ""
915 snip = (leader.snippet if leader else "") or ""
916 name = topic_shape.distill_topic_name(title, snip) or plan.domain or title
917 junk_shape = topic_shape.is_junk_shape(title, snip)
918 named.append((score, cluster, cluster_items, name, junk_shape))
919 named.sort(key=lambda entry: (-entry[0], entry[3].lower()))
920
921 pool: list[tuple[Nomination, str]] = []
922 taken_names: dict[str, schema.Cluster] = {}
923 entity_counts_cache: dict[str, Counter] = {}
924 for score, cluster, cluster_items, name, junk_shape in named:
925 name_key = name.casefold()
926 if name_key in taken_names:
927 earlier_cluster = taken_names[name_key]
928 if set(cluster.representative_ids) & set(earlier_cluster.representative_ids):
929 continue # same story surfacing twice
930 resolved = _disambiguated_topic_name(
931 name, cluster, earlier_cluster, candidate_map, entity_counts_cache,
932 taken_names,
933 )
934 if resolved is None:
935 continue # indistinguishable by content: treat as the same story
936 name = resolved
937 name_key = name.casefold()
938 taken_names[name_key] = cluster
939 leader = candidate_map.get(cluster.representative_ids[0]) if cluster.representative_ids else None
940 summary = (leader.snippet if leader else "") or (leader.title if leader else name)
941 pool.append((Nomination(
942 name=name,
943 seed_score=score,
944 items=cluster_items,
945 summary=summary,
946 junk_shape=junk_shape,
947 ), cluster.cluster_id))
948 if len(pool) >= limit:
949 break
950 return pool
951
952
953 def nominate_topics(
954 bundle: schema.RetrievalBundle,
955 query_plan: schema.QueryPlan,
956 plan: schema.DiscoveryPlan,
957 *,
958 from_date: str,
959 to_date: str,
960 limit: int,
961 ) -> list[Nomination]:
962 """``nominate_topic_pool`` without the cluster ids: the one-shot
963 discovery path's contract (see that function for the full semantics)."""
964 return [
965 nomination
966 for nomination, _cluster_id in nominate_topic_pool(
967 bundle, query_plan, plan, from_date=from_date, to_date=to_date, limit=limit,
968 )
969 ]
970
971
972 # Enrichment fan-out bounds. Sub-runs hit the same upstream APIs as a normal
973 # research pass, so parallelism stays low and the whole batch runs against a
974 # wall-clock budget - a slow topic is dropped, never fatal.
975 ENRICH_LIMIT = 6
976 ENRICH_DEPTH = "quick"
977 ENRICH_MAX_WORKERS = 3
978 ENRICH_BUDGET_SECONDS = 240.0
979
980
981 @dataclass
982 class EnrichedTopic:
983 """A nomination plus the full-pipeline evidence gathered for it.
984
985 ``report`` is None when enrichment for this topic failed or ran past the
986 batch budget - the topic survives as nomination-only and the confidence
987 floor downstream decides whether its seed evidence is enough to show.
988 """
989
990 nomination: Nomination
991 report: schema.Report | None = None
992 error: str | None = None
993
994
995 def enrich_nominations(
996 nominations: list[Nomination],
997 *,
998 config: dict[str, Any],
999 requested_sources: list[str] | None = None,
1000 mock: bool = False,
1001 depth: str = ENRICH_DEPTH,
1002 lookback_days: int = 30,
1003 as_of_date: str | None = None,
1004 max_workers: int = ENRICH_MAX_WORKERS,
1005 budget_seconds: float = ENRICH_BUDGET_SECONDS,
1006 ) -> list[EnrichedTopic]:
1007 """Stage 2 of discovery: run the real research pipeline on each nomination.
1008
1009 Each nominated topic gets a full ``run()`` pass (``internal_subrun=True``,
1010 same lane as comparison-mode sub-runs), which buys the whole multi-source
1011 corpus - Reddit with comments, X, YouTube, Techmeme, arXiv, HN, Polymarket,
1012 web - plus clustering and ranking, with zero bespoke fetch code.
1013
1014 Failure containment: a topic whose sub-run raises is returned with
1015 ``report=None`` and the error recorded; topics still unfinished when the
1016 batch budget expires are likewise dropped to nomination-only. The batch
1017 never raises and preserves nomination order.
1018 """
1019 if not nominations:
1020 return []
1021
1022 def _run_one(nomination: Nomination) -> schema.Report:
1023 return run(
1024 topic=nomination.name,
1025 config=config,
1026 depth=depth,
1027 requested_sources=requested_sources,
1028 mock=mock,
1029 lookback_days=lookback_days,
1030 as_of_date=as_of_date,
1031 internal_subrun=True,
1032 # Enrichment passes never carry a connector envelope, so the
1033 # per-session lane signal must not plan X in and record a
1034 # spurious X error on every nominated topic.
1035 suppress_x_host_lane=True,
1036 )
1037
1038 # Daemon threads + a semaphore instead of ThreadPoolExecutor: executor
1039 # threads are non-daemon and joined at interpreter shutdown, so one hung
1040 # sub-run could keep the whole process alive long after its topic was
1041 # downgraded to nomination-only. Daemon workers make the wall-clock budget
1042 # real - stragglers cannot delay process exit. Abandonment is safe because
1043 # internal_subrun passes write nothing to disk (no save, no library sync,
1044 # no store), and every fetch layer inside run() carries its own timeout.
1045 youtube_yt.reset_search_cache()
1046 enriched: dict[str, EnrichedTopic] = {}
1047 results_queue: queue.Queue[tuple[Nomination, schema.Report | None, Exception | None]] = queue.Queue()
1048 slots = threading.Semaphore(max(1, max_workers))
1049
1050 def _worker(nomination: Nomination) -> None:
1051 with slots:
1052 try:
1053 results_queue.put((nomination, _run_one(nomination), None))
1054 except Exception as exc: # noqa: BLE001 - containment is the contract
1055 results_queue.put((nomination, None, exc))
1056
1057 for nomination in nominations:
1058 threading.Thread(
1059 target=_worker,
1060 args=(nomination,),
1061 name=f"discover-enrich-{nomination.name[:32]}",
1062 daemon=True,
1063 ).start()
1064
1065 deadline = time.monotonic() + max(1.0, budget_seconds)
1066 pending = len(nominations)
1067 while pending and (remaining := deadline - time.monotonic()) > 0:
1068 try:
1069 nomination, report, exc = results_queue.get(timeout=min(remaining, 0.5))
1070 except queue.Empty:
1071 continue
1072 pending -= 1
1073 if exc is None:
1074 enriched[nomination.name] = EnrichedTopic(
1075 nomination=nomination, report=report,
1076 )
1077 else:
1078 enriched[nomination.name] = EnrichedTopic(
1079 nomination=nomination,
1080 error=f"{type(exc).__name__}: {exc}",
1081 )
1082 print(
1083 f"[Discover] enrichment failed for {nomination.name!r}: "
1084 f"{type(exc).__name__}: {exc}",
1085 file=sys.stderr,
1086 )
1087 # Budget expired (or all done): unfinished topics fall through below as
1088 # nomination-only; their daemon workers are abandoned and cannot block exit.
1089
1090 results: list[EnrichedTopic] = []
1091 for nomination in nominations:
1092 entry = enriched.get(nomination.name)
1093 if entry is None:
1094 entry = EnrichedTopic(
1095 nomination=nomination,
1096 error="enrichment budget exhausted",
1097 )
1098 print(
1099 f"[Discover] enrichment budget exhausted before {nomination.name!r} "
1100 "finished; keeping nomination-only evidence",
1101 file=sys.stderr,
1102 )
1103 results.append(entry)
1104 return results
1105
1106
1107 def _enriched_evidence_items(entry: EnrichedTopic) -> list[schema.SourceItem]:
1108 """The items a topic is judged on: the enriched corpus when the pipeline
1109 pass succeeded, the nomination's seed items otherwise."""
1110 if entry.report is not None:
1111 flattened: list[schema.SourceItem] = []
1112 for source_items in entry.report.items_by_source.values():
1113 flattened.extend(source_items)
1114 if flattened:
1115 return flattened
1116 return entry.nomination.items
1117
1118
1119 def _best_community_comment(items: list[schema.SourceItem]) -> str | None:
1120 """The strongest verbatim community comment across a topic's evidence,
1121 formatted with attribution - the voice-of-the-people line on a trend card.
1122
1123 Vote strength is per-platform-normalized (signals.normalized_comment_vote)
1124 so one viral platform's counts don't drown out the rest.
1125 """
1126 best: tuple[float, str, str | None, float | int | None] | None = None
1127 for item in items:
1128 comments = item.metadata.get("top_comments") or []
1129 for comment in comments:
1130 if not isinstance(comment, dict):
1131 continue
1132 body = (comment.get("excerpt") or comment.get("text") or comment.get("body") or "").strip()
1133 if len(body) < 12:
1134 continue
1135 strength = signals.normalized_comment_vote(item.source, comment.get("score"))
1136 if best is None or strength > best[0]:
1137 best = (strength, body, comment.get("author"), comment.get("score"))
1138 if best is None:
1139 return None
1140 _, body, author, score = best
1141 # Comment bodies that themselves start/end with quote characters would
1142 # render as doubled quotes inside our wrapping quotes.
1143 body = body.strip('"“”‘’\'').strip()
1144 if len(body) > 200:
1145 body = body[:197].rsplit(" ", 1)[0] + "..."
1146 attribution = f" - {author}" if author else ""
1147 votes = (
1148 f" ({int(score):,} votes)"
1149 if isinstance(score, (int, float)) and not isinstance(score, bool) and score > 0
1150 else ""
1151 )
1152 return f'"{body}"{attribution}{votes}'
1153
1154
1155 @dataclass(frozen=True)
1156 class _DiscoverySweep:
1157 """The shared front half of both discovery entry points: the resolved
1158 plan and window, the swept listing bundle, and finalized per-source
1159 status. Everything downstream (judging, enrichment, floor, queue)
1160 belongs to the caller's leg."""
1161
1162 plan: schema.DiscoveryPlan
1163 query_plan: schema.QueryPlan
1164 from_date: str
1165 to_date: str
1166 bundle: schema.RetrievalBundle
1167 source_status: dict[str, schema.SourceOutcome]
1168
1169
1170 def _discovery_sweep(
1171 *,
1172 domain: str,
1173 config: dict[str, Any],
1174 depth: str,
1175 requested_sources: list[str] | None,
1176 mock: bool,
1177 subreddits: list[str] | None,
1178 lookback_days: int,
1179 as_of_date: str | None,
1180 ) -> _DiscoverySweep:
1181 """Resolve the momentum window, validate/bound the listing sources, build
1182 the discovery plan, sweep the river feeds, and finalize source status.
1183
1184 Shared verbatim by ``run_discover`` (one-shot) and
1185 ``run_discover_nominate`` (protocol leg 1) so the two paths can never
1186 drift on what a sweep means."""
1187 from_date, to_date = dates.get_date_range(lookback_days, as_of_date=as_of_date)
1188 requested = normalize_requested_sources(requested_sources)
1189 unsupported = sorted(set(requested or []) - set(DISCOVERY_SOURCES))
1190 if unsupported:
1191 raise ValueError(
1192 "Discovery supports listing sources only: reddit, hackernews, digg "
1193 f"(unsupported: {', '.join(unsupported)})"
1194 )
1195 available = list(DISCOVERY_SOURCES) if mock else [
1196 source for source in available_sources(config, requested, x_pending=False)
1197 if source in DISCOVERY_SOURCES
1198 ]
1199 if requested:
1200 available = [source for source in available if source in requested]
1201 plan = planner.build_discovery_plan(
1202 domain,
1203 available_sources=available,
1204 subreddits=subreddits,
1205 )
1206
1207 global_mode = not plan.domain
1208 domain_label = plan.domain or "everything"
1209 query_plan = schema.QueryPlan(
1210 intent="breaking_news",
1211 freshness_mode="breaking",
1212 cluster_mode="story",
1213 raw_topic=plan.domain,
1214 subqueries=[schema.SubQuery(
1215 label="discovery-listings",
1216 search_query=plan.domain,
1217 ranking_query=f"What is accelerating in {domain_label}?",
1218 sources=list(plan.sources),
1219 )],
1220 source_weights={source: 1.0 for source in plan.sources},
1221 notes=["discover-mode", "listing-sweep"],
1222 )
1223
1224 bundle = nominate_candidates(
1225 plan,
1226 from_date=from_date,
1227 to_date=to_date,
1228 depth=depth,
1229 mock=mock,
1230 config=config,
1231 lookback_days=lookback_days,
1232 # Global trending has no keyword to gate against - the river feeds' own
1233 # hot ranking is the signal and the confidence floor culls the junk.
1234 keyword_gate=not global_mode,
1235 )
1236
1237 source_status: dict[str, schema.SourceOutcome] = {}
1238 for source in DISCOVERY_SOURCES:
1239 if source in bundle.source_status:
1240 continue
1241 detail = (
1242 "Source is not configured for discovery."
1243 )
1244 source_status[source] = schema.SourceOutcome(
1245 source=source,
1246 state=schema.SKIPPED_UNCONFIGURED,
1247 attempted=False,
1248 detail=detail,
1249 fix_hint="doctor",
1250 )
1251 source_status.update(_finalize_source_status(bundle.source_status, bundle.items_by_source))
1252 return _DiscoverySweep(
1253 plan=plan,
1254 query_plan=query_plan,
1255 from_date=from_date,
1256 to_date=to_date,
1257 bundle=bundle,
1258 source_status=source_status,
1259 )
1260
1261
1262 def _degraded_discovery_sources(
1263 source_status: dict[str, schema.SourceOutcome],
1264 ) -> list[str]:
1265 """Sources whose outcome is neither clean nor an expected skip."""
1266 return [
1267 source for source, outcome_state in source_status.items()
1268 if outcome_state.state not in {health.OK, schema.NO_RESULTS, schema.SKIPPED_UNCONFIGURED}
1269 ]
1270
1271
1272 @dataclass(frozen=True)
1273 class DiscoverNominateResult:
1274 """Leg 1 output of the host-judged discovery protocol: the ranked judge
1275 pool as ``(nomination, cluster_id)`` pairs plus the sweep context the CLI
1276 needs to write the nominations bundle - or to render the nothing-solid
1277 brief when the pool is empty."""
1278
1279 plan: schema.DiscoveryPlan
1280 from_date: str
1281 to_date: str
1282 source_status: dict[str, schema.SourceOutcome]
1283 pool: list[tuple[Nomination, str]]
1284
1285
1286 def run_discover_nominate(
1287 *,
1288 domain: str,
1289 config: dict[str, Any],
1290 depth: str = "default",
1291 requested_sources: list[str] | None = None,
1292 mock: bool = False,
1293 subreddits: list[str] | None = None,
1294 lookback_days: int = 30,
1295 as_of_date: str | None = None,
1296 ) -> DiscoverNominateResult:
1297 """Protocol leg 1: sweep the listings and build the FULL judge pool.
1298
1299 Same sweep and clustering as ``run_discover``, but the pool is cut at
1300 ``rerank.JUDGE_POOL_LIMIT`` (not the enrichment limit). Like every
1301 discovery path it is deterministic-heuristic: no provider is ever
1302 resolved, so names and junk flags are the ``topic_shape`` baselines the
1303 host judges against. No enrichment, no confidence floor, no queue
1304 writes - those belong to legs 2 and 3.
1305 """
1306 sweep = _discovery_sweep(
1307 domain=domain,
1308 config=config,
1309 depth=depth,
1310 requested_sources=requested_sources,
1311 mock=mock,
1312 subreddits=subreddits,
1313 lookback_days=lookback_days,
1314 as_of_date=as_of_date,
1315 )
1316 pool = nominate_topic_pool(
1317 sweep.bundle, sweep.query_plan, sweep.plan,
1318 from_date=sweep.from_date,
1319 to_date=sweep.to_date,
1320 limit=rerank.JUDGE_POOL_LIMIT,
1321 )
1322 return DiscoverNominateResult(
1323 plan=sweep.plan,
1324 from_date=sweep.from_date,
1325 to_date=sweep.to_date,
1326 source_status=sweep.source_status,
1327 pool=pool,
1328 )
1329
1330
1331 def nominate_nothing_solid_report(result: DiscoverNominateResult) -> schema.DiscoveryReport:
1332 """The honest-empty leg-1 report: a zero-nomination sweep renders the
1333 same nothing-solid brief a one-shot run would (and writes no bundle)."""
1334 warnings = [
1335 "The listing sweep nominated no topics this window; reporting "
1336 "nothing solid instead of ranked noise."
1337 ]
1338 failed = _degraded_discovery_sources(result.source_status)
1339 if failed:
1340 warnings.append(f"Some discovery sources degraded: {', '.join(sorted(failed))}.")
1341 return schema.DiscoveryReport(
1342 domain=result.plan.domain,
1343 range_from=result.from_date,
1344 range_to=result.to_date,
1345 generated_at=datetime.now(timezone.utc).isoformat(),
1346 plan=result.plan,
1347 topics=[],
1348 source_status=result.source_status,
1349 warnings=warnings,
1350 outcome="nothing-solid",
1351 weak_signal=None,
1352 )
1353
1354
1355 def _floor_survivor_records(
1356 enriched_entries: list[EnrichedTopic],
1357 *,
1358 to_date: str,
1359 topic_limit: int,
1360 ) -> tuple[
1361 list[dict[str, Any]],
1362 tuple[float, str] | None,
1363 tuple[float, str] | None,
1364 ]:
1365 """Apply the discovery confidence floor to enriched entries in order,
1366 returning the survivor records plus the strongest non-junk and junk weak
1367 signals among the failures.
1368
1369 Shared verbatim by ``run_discover`` (one-shot) and ``run_discover_resume``
1370 (protocol leg 2) so floor semantics can never drift between the paths.
1371 """
1372 survivors: list[dict[str, Any]] = []
1373 weak_signal: tuple[float, str] | None = None
1374 junk_weak_signal: tuple[float, str] | None = None
1375 for entry in enriched_entries:
1376 nomination = entry.nomination
1377 evidence_items = _enriched_evidence_items(entry)
1378 sources = sorted({item.source for item in evidence_items})
1379 native_total = sum(
1380 rerank.discovery_engagement_total(item) for item in evidence_items
1381 )
1382 score = rerank.discovery_velocity_score(evidence_items, as_of_date=to_date)
1383 if not rerank.passes_discovery_floor(
1384 source_count=len(sources),
1385 engagement_total=native_total,
1386 item_count=len(evidence_items),
1387 junk_shape=nomination.junk_shape,
1388 # Junk corroboration counts distinct SEED listing sources, never
1389 # the enriched corpus - a successful enrichment pass is
1390 # multi-source for almost any topic, so it would never bind.
1391 seed_source_count=len({item.source for item in nomination.items}),
1392 ):
1393 # Sub-floor evidence never ranks; remember what came closest so a
1394 # nothing-solid brief can still name the strongest weak signal.
1395 # Junk-shaped failures are tracked separately: the brief prefers
1396 # the strongest NON-junk failure and names a junk one only when
1397 # every failure is junk-shaped (never empty when failures exist).
1398 if nomination.junk_shape:
1399 if junk_weak_signal is None or score > junk_weak_signal[0]:
1400 junk_weak_signal = (score, nomination.name)
1401 elif weak_signal is None or score > weak_signal[0]:
1402 weak_signal = (score, nomination.name)
1403 continue
1404 if len(survivors) >= topic_limit:
1405 break
1406 source_phrase = ", ".join(sources[:-1]) + (
1407 f" and {sources[-1]}" if len(sources) > 1 else (sources[0] if sources else "the listings")
1408 )
1409 noun = "evidence item" if entry.report is not None else "listing item"
1410 why = (
1411 f"{len(evidence_items)} {noun}{'s' if len(evidence_items) != 1 else ''} on "
1412 f"{source_phrase} generated {native_total:,.0f} native interactions. "
1413 f"{nomination.summary[:220]}"
1414 )
1415 top_comment = _best_community_comment(evidence_items) if entry.report is not None else None
1416 # Stage-2 angle input: the survivor's strongest evidence, enriched
1417 # corpus when the pipeline pass succeeded, seed items otherwise
1418 # (evidence_items already resolves that).
1419 top_titles = [
1420 item.title.strip()
1421 for item in sorted(
1422 evidence_items,
1423 key=rerank.discovery_engagement_total,
1424 reverse=True,
1425 )
1426 if item.title and item.title.strip()
1427 ][:3]
1428 survivors.append({
1429 "name": nomination.name,
1430 "why": why,
1431 "momentum": _discovery_momentum(evidence_items, to_date),
1432 "velocity_score": round(score, 2),
1433 "sources": sources,
1434 "engagement_by_source": _discovery_engagement(evidence_items),
1435 "evidence_urls": list(dict.fromkeys(item.url for item in evidence_items if item.url))[:5],
1436 "top_comment": top_comment,
1437 "titles": "; ".join(top_titles),
1438 "engagement_phrase": f"{native_total:,.0f} native interactions across {source_phrase}",
1439 })
1440 return survivors, weak_signal, junk_weak_signal
1441
1442
1443 def _fold_same_story_records(survivors: list[dict[str, Any]]) -> list[dict[str, Any]]:
1444 """Same-story fold + velocity ordering over floor-survivor records.
1445
1446 Floor survivors that share enriched evidence are the SAME story wearing
1447 two judged names (the real-run failure: two topics quoting the identical
1448 1,635-vote comment). Duplicates = identical non-None top comment OR >= 2
1449 shared evidence URLs; the lower-velocity twin is dropped, and a winning
1450 replacement re-scans the kept list to a fixpoint so chained overlap
1451 (A~C~B) still collapses to one survivor. Selection stays seed-ordered
1452 upstream; this only prunes, then sorts by displayed velocity (stable) so
1453 rank 1 is the highest velocity_score.
1454 """
1455 def _same_story(a: dict[str, Any], b: dict[str, Any]) -> bool:
1456 if a["top_comment"] is not None and a["top_comment"] == b["top_comment"]:
1457 return True
1458 return len(set(a["evidence_urls"]) & set(b["evidence_urls"])) >= 2
1459
1460 folded: list[dict[str, Any]] = []
1461 for record in survivors:
1462 # Fold to a fixpoint: when the incoming record REPLACES a kept one,
1463 # the replacement may share evidence with entries the dropped record
1464 # never matched (three-way chains: A kept, C shares the comment with
1465 # A and URLs with B). The winner re-scans the remaining kept entries
1466 # until nothing matches, so one story always yields one survivor.
1467 incoming: dict[str, Any] | None = record
1468 while incoming is not None:
1469 dup_index = next(
1470 (index for index, kept in enumerate(folded) if _same_story(incoming, kept)),
1471 None,
1472 )
1473 if dup_index is None:
1474 folded.append(incoming)
1475 break
1476 kept = folded[dup_index]
1477 if incoming["velocity_score"] > kept["velocity_score"]:
1478 folded.pop(dup_index)
1479 dropped_name, kept_name = kept["name"], incoming["name"]
1480 else:
1481 dropped_name, kept_name = incoming["name"], kept["name"]
1482 incoming = None # dropped; the kept entry stays in place
1483 log.source_log(
1484 "Discover",
1485 f"folded duplicate story {dropped_name!r} into {kept_name!r} (shared evidence)",
1486 tty_only=False,
1487 )
1488
1489 folded.sort(key=lambda record: record["velocity_score"], reverse=True)
1490 return folded
1491
1492
1493 def _records_to_discovery_topics(
1494 folded: list[dict[str, Any]],
1495 ) -> list[schema.DiscoveryTopic]:
1496 """Folded survivor records to ranked topics (ranks = 1-based positions)."""
1497 return [
1498 schema.DiscoveryTopic(
1499 rank=position,
1500 name=record["name"],
1501 why_spiking=record["why"],
1502 momentum=record["momentum"],
1503 velocity_score=record["velocity_score"],
1504 sources=record["sources"],
1505 engagement_by_source=record["engagement_by_source"],
1506 command=f'/last30days "{record["name"].replace(chr(34), chr(39))}"',
1507 evidence_urls=record["evidence_urls"],
1508 top_comment=record["top_comment"],
1509 corroboration_count=len(record["sources"]),
1510 )
1511 for position, record in enumerate(folded, start=1)
1512 ]
1513
1514
1515 def _discovery_report_warnings(
1516 topics: list[schema.DiscoveryTopic],
1517 outcome: str,
1518 source_status: dict[str, schema.SourceOutcome],
1519 ) -> list[str]:
1520 """Coverage warnings shared by the one-shot and resume discovery paths.
1521 The resume leg never re-sweeps: it passes the bundle's RESTORED leg-1
1522 sweep status, so a degraded feed from the sweep still reaches the leg-2
1523 report exactly as the one-shot reports it."""
1524 warnings: list[str] = []
1525 if outcome == "nothing-solid":
1526 warnings.append(
1527 "No topic cleared the discovery confidence floor this window; "
1528 "reporting nothing solid instead of ranked noise."
1529 )
1530 elif len(topics) < 5:
1531 warnings.append("Fewer than five topic clusters cleared the confidence floor this window.")
1532 if topics and all(len(topic.sources) == 1 for topic in topics):
1533 warnings.append("Discovery evidence is single-source; configure Digg for broader confirmation.")
1534 failed = _degraded_discovery_sources(source_status)
1535 if failed:
1536 warnings.append(f"Some discovery sources degraded: {', '.join(sorted(failed))}.")
1537 return warnings
1538
1539
1540 def run_discover(
1541 *,
1542 domain: str,
1543 config: dict[str, Any],
1544 depth: str = "default",
1545 requested_sources: list[str] | None = None,
1546 mock: bool = False,
1547 subreddits: list[str] | None = None,
1548 lookback_days: int = 30,
1549 as_of_date: str | None = None,
1550 limit: int = 10,
1551 enrich: bool = False,
1552 enrich_requested_sources: list[str] | None = None,
1553 ) -> schema.DiscoveryReport:
1554 """Sweep category listings and rank the topics gaining velocity.
1555
1556 ``requested_sources`` bounds the listing sweep (discovery-capable feeds
1557 only). ``enrich_requested_sources`` bounds the per-topic research passes:
1558 None means every available source - which is what lets Techmeme, arXiv,
1559 YouTube, Polymarket, and community comments reach discovery despite having
1560 no river feed of their own. Pass the user's original --search list here so
1561 an explicit source boundary holds through enrichment too.
1562 """
1563 sweep = _discovery_sweep(
1564 domain=domain,
1565 config=config,
1566 depth=depth,
1567 requested_sources=requested_sources,
1568 mock=mock,
1569 subreddits=subreddits,
1570 lookback_days=lookback_days,
1571 as_of_date=as_of_date,
1572 )
1573 plan = sweep.plan
1574 from_date, to_date = sweep.from_date, sweep.to_date
1575 source_status = sweep.source_status
1576
1577 # The engine never names or angles topics with an LLM: the one-shot path
1578 # is deterministic-heuristic by design, and reasoning-model judgment
1579 # lives in the host-judged SKILL.md protocol. Say so loudly once per live
1580 # run; --mock stays silent (a deliberate mock run is not a degraded run).
1581 if not mock:
1582 log.source_log(
1583 "Discover",
1584 "one-shot run: topic names use deterministic heuristics and no "
1585 "content angles are generated - a reasoning-model host running "
1586 "the SKILL.md discovery protocol gets host-judged names, junk "
1587 "filtering, and podcast/X angles",
1588 tty_only=False,
1589 )
1590
1591 topic_limit = max(5, min(10, limit))
1592 nominations = nominate_topics(
1593 sweep.bundle, sweep.query_plan, plan,
1594 from_date=from_date,
1595 to_date=to_date,
1596 limit=ENRICH_LIMIT if enrich else topic_limit,
1597 )
1598
1599 if enrich and nominations:
1600 enriched_entries = enrich_nominations(
1601 nominations,
1602 config=config,
1603 requested_sources=enrich_requested_sources,
1604 mock=mock,
1605 lookback_days=lookback_days,
1606 as_of_date=as_of_date,
1607 )
1608 else:
1609 enriched_entries = [
1610 EnrichedTopic(nomination=nomination) for nomination in nominations
1611 ]
1612
1613 survivors, weak_signal, junk_weak_signal = _floor_survivor_records(
1614 enriched_entries, to_date=to_date, topic_limit=topic_limit,
1615 )
1616 folded = _fold_same_story_records(survivors)
1617 # One-shot topics ship without angles (podcast_angle / x_article_angle
1618 # stay None and the renderer omits those lines): content angles are a
1619 # host-judged protocol deliverable, written on the finalize leg.
1620 topics = _records_to_discovery_topics(folded)
1621
1622 if weak_signal is None:
1623 weak_signal = junk_weak_signal
1624
1625 outcome = "ok" if topics else "nothing-solid"
1626
1627 return schema.DiscoveryReport(
1628 domain=plan.domain,
1629 range_from=from_date,
1630 range_to=to_date,
1631 generated_at=datetime.now(timezone.utc).isoformat(),
1632 plan=plan,
1633 topics=topics,
1634 source_status=source_status,
1635 warnings=_discovery_report_warnings(topics, outcome, source_status),
1636 outcome=outcome,
1637 weak_signal=weak_signal[1] if weak_signal and not topics else None,
1638 )
1639
1640
1641 # Protocol leg 2 (resume) deep-tier enrichment bounds. The module-level
1642 # ENRICH_* constants above stay the one-shot --discover contract (quick depth,
1643 # 240s budget, 3 workers); a deep-tier bundle upgrades its per-topic sub-runs
1644 # to the default research depth with a wider wall-clock budget and one more
1645 # worker, because leg 2 is the protocol's only research pass. Shallow-tier
1646 # bundles keep the one-shot quick constants. Both tiers flow through
1647 # enrich_nominations' PARAMETERS - the constants themselves are never edited,
1648 # so neither tier can leak into the other path.
1649 RESUME_DEEP_ENRICH_DEPTH = "default"
1650 RESUME_DEEP_ENRICH_MAX_WORKERS = 4
1651 RESUME_DEEP_ENRICH_BUDGET_SECONDS = 450.0
1652
1653
1654 def _resume_enrich_budget_seconds(config: dict[str, Any]) -> float:
1655 """Deep-tier batch budget: LAST30DAYS_ENRICH_BUDGET_SECONDS from the
1656 RESOLVED config dict only (env.get_config already layers the process env
1657 over the .env files) - never read from bare os.environ. Blank,
1658 non-numeric, or non-positive values fall back to the 450s default."""
1659 raw = config.get("LAST30DAYS_ENRICH_BUDGET_SECONDS")
1660 if raw is None or str(raw).strip() == "":
1661 return RESUME_DEEP_ENRICH_BUDGET_SECONDS
1662 try:
1663 value = float(raw)
1664 except (TypeError, ValueError):
1665 return RESUME_DEEP_ENRICH_BUDGET_SECONDS
1666 return value if value > 0 else RESUME_DEEP_ENRICH_BUDGET_SECONDS
1667
1668
1669 @dataclass(frozen=True)
1670 class DiscoverResumeResult:
1671 """Leg 2 output of the host-judged discovery protocol: the floored,
1672 folded, velocity-ranked report plus the per-topic angle inputs (keyed by
1673 surviving nomination id) that the host writes leg-3 angles from.
1674 ``report.source_status`` is the bundle's restored leg-1 sweep status -
1675 leg 2 never re-sweeps the listing feeds, so the sweep's degraded-coverage
1676 signal must survive the handoff instead of reading as clean."""
1677
1678 report: schema.DiscoveryReport
1679 angle_inputs: dict[str, dict[str, str]]
1680
1681
1682 def run_discover_resume(
1683 bundle: Any,
1684 judgments: dict[str, Any],
1685 *,
1686 config: dict[str, Any],
1687 mock: bool = False,
1688 ) -> DiscoverResumeResult:
1689 """Protocol leg 2: apply host judgments to the leg-1 bundle, enrich the
1690 slot winners, and floor/fold/rank on the same code path as the one-shot
1691 run.
1692
1693 ``bundle`` is a ``discovery_handoff.NominationsBundle`` and ``judgments``
1694 the mapping ``discovery_handoff.read_judgments`` returns (annotated
1695 loosely because discovery_handoff imports this module at load time).
1696
1697 Judgment application is per field: an absent host name falls back to the
1698 bundle's heuristic name, an absent junk flag to the heuristic junk flag,
1699 and absent worthiness to the neutral blend default (None -> 50 inside
1700 ``rerank.judge_blended_score`` - the same treatment the judge-absent path
1701 always used). Applied names are collision-resolved over the whole pool
1702 before anything keys on them, and the applied name IS the enrichment
1703 sub-run topic.
1704
1705 Slot selection: host-junk rows never contend for enrichment slots, and a
1706 heuristic-junk fallback row with fewer than ``rerank.FLOOR_MIN_SOURCES``
1707 distinct seed sources is skipped pre-enrichment (it structurally cannot
1708 pass the floor's seed-corroboration rule). Both stay eligible to be the
1709 junk-tracked weak signal of a nothing-solid brief, and the brief prefers
1710 a non-junk weak signal exactly like the one-shot path. At the floor,
1711 host-judged rows pass ``junk_shape=False`` (host-junk never earned a
1712 slot) while heuristic-fallback rows keep their heuristic flag with the
1713 existing seed-source corroboration.
1714
1715 Velocity, momentum, and the enrichment window all score against the
1716 bundle's momentum window (from_date/to_date), never the resume-time
1717 clock: the host may judge up to the handoff TTL after the sweep, and the
1718 numbers must describe the window the sweep captured.
1719 """
1720 # Runtime-only import: discovery_handoff imports pipeline at module load,
1721 # so the reverse import must happen at call time (no import-time cycle).
1722 from . import discovery_handoff
1723
1724 to_date = bundle.to_date
1725 verdicts = [
1726 discovery_handoff.judgment_for(judgments, entry.nomination_id)
1727 for entry in bundle.nominations
1728 ]
1729 applied_names = discovery_handoff.resolve_name_collisions([
1730 (
1731 entry.nomination,
1732 verdict.name or entry.heuristic_name or entry.nomination.name,
1733 )
1734 for entry, verdict in zip(bundle.nominations, verdicts)
1735 ])
1736
1737 ranked: list[tuple[float, str, Nomination]] = []
1738 junk_weak_signal: tuple[float, str] | None = None
1739 for entry, verdict, name in zip(bundle.nominations, verdicts, applied_names):
1740 items = entry.nomination.items
1741 velocity = rerank.discovery_velocity_score(items, as_of_date=to_date)
1742 seed_source_count = len({item.source for item in items})
1743 host_junk = verdict.junk is True
1744 fallback_junk = verdict.junk is None and entry.heuristic_junk
1745 if host_junk or (
1746 fallback_junk and seed_source_count < rerank.FLOOR_MIN_SOURCES
1747 ):
1748 if junk_weak_signal is None or velocity > junk_weak_signal[0]:
1749 junk_weak_signal = (velocity, name)
1750 continue
1751 worthiness = (
1752 float(verdict.worthiness) if verdict.worthiness is not None else None
1753 )
1754 blended = rerank.judge_blended_score(velocity, worthiness)
1755 ranked.append((
1756 blended,
1757 entry.nomination_id,
1758 replace(
1759 entry.nomination,
1760 name=name,
1761 seed_score=blended,
1762 junk_shape=(
1763 False if verdict.junk is not None else entry.heuristic_junk
1764 ),
1765 worthiness=worthiness,
1766 ),
1767 ))
1768
1769 ranked.sort(key=lambda row: (-row[0], row[2].name.lower()))
1770 selected = ranked[:ENRICH_LIMIT]
1771 nominations = [nomination for _blended, _nomination_id, nomination in selected]
1772
1773 if bundle.tier == "shallow":
1774 depth, max_workers, budget_seconds = (
1775 ENRICH_DEPTH, ENRICH_MAX_WORKERS, ENRICH_BUDGET_SECONDS,
1776 )
1777 else:
1778 depth = RESUME_DEEP_ENRICH_DEPTH
1779 max_workers = RESUME_DEEP_ENRICH_MAX_WORKERS
1780 budget_seconds = _resume_enrich_budget_seconds(config)
1781
1782 enriched_entries = enrich_nominations(
1783 nominations,
1784 config=config,
1785 requested_sources=bundle.enrichment_source_boundary,
1786 mock=mock,
1787 depth=depth,
1788 lookback_days=bundle.lookback_days,
1789 as_of_date=to_date,
1790 max_workers=max_workers,
1791 budget_seconds=budget_seconds,
1792 ) if nominations else []
1793
1794 # topic_limit mirrors the one-shot default cap (limit=10); the slot cut
1795 # above already bounds the pool at ENRICH_LIMIT.
1796 survivors, weak_signal, floor_junk_weak_signal = _floor_survivor_records(
1797 enriched_entries, to_date=to_date, topic_limit=10,
1798 )
1799 if floor_junk_weak_signal is not None and (
1800 junk_weak_signal is None
1801 or floor_junk_weak_signal[0] > junk_weak_signal[0]
1802 ):
1803 junk_weak_signal = floor_junk_weak_signal
1804 folded = _fold_same_story_records(survivors)
1805 topics = _records_to_discovery_topics(folded)
1806
1807 nomination_id_by_name = {
1808 nomination.name: nomination_id
1809 for _blended, nomination_id, nomination in selected
1810 }
1811 angle_inputs = {
1812 nomination_id_by_name[record["name"]]: {
1813 "name": record["name"],
1814 "titles": record["titles"],
1815 "top_comment": record["top_comment"] or "",
1816 "engagement": record["engagement_phrase"],
1817 }
1818 for record in folded
1819 }
1820
1821 if weak_signal is None:
1822 weak_signal = junk_weak_signal
1823 outcome = "ok" if topics else "nothing-solid"
1824 plan = schema.DiscoveryPlan(
1825 domain=bundle.domain,
1826 category=None,
1827 subreddits=[],
1828 sources=(
1829 list(bundle.requested_sources)
1830 if bundle.requested_sources
1831 else sorted({
1832 item.source
1833 for entry in bundle.nominations
1834 for item in entry.nomination.items
1835 })
1836 ),
1837 )
1838 # The bundle's restored leg-1 sweep status (empty for pre-field bundles):
1839 # degraded sweep coverage must reach this report's status map and its
1840 # degraded-sources warning exactly as the one-shot reports it.
1841 source_status = dict(getattr(bundle, "source_status", None) or {})
1842 report = schema.DiscoveryReport(
1843 domain=bundle.domain,
1844 range_from=bundle.from_date,
1845 range_to=to_date,
1846 generated_at=datetime.now(timezone.utc).isoformat(),
1847 plan=plan,
1848 topics=topics,
1849 source_status=source_status,
1850 warnings=_discovery_report_warnings(topics, outcome, source_status),
1851 outcome=outcome,
1852 weak_signal=weak_signal[1] if weak_signal and not topics else None,
1853 )
1854 return DiscoverResumeResult(report=report, angle_inputs=angle_inputs)
1855
1856
1857 def diagnose(
1858 config: dict[str, Any],
1859 requested_sources: list[str] | None = None,
1860 *,
1861 safe: bool = False,
1862 x_envelope: bool = False,
1863 ) -> dict[str, Any]:
1864 # ``x_envelope`` is True when a validated --x-posts envelope is present for
1865 # this invocation, so available_sources lists x even without a backend
1866 # and the optional-source omission note does not fire.
1867 requested_sources = normalize_requested_sources(requested_sources)
1868 google_key = _google_key(config)
1869 x_status = env.get_x_source_status(config, probe=not safe)
1870 # Compute once and reuse for both the diag flag and available_sources below.
1871 # safe=True (doctor/--diagnose/--preflight) must stay network-free.
1872 x_pending = env.x_pending_browser_auth(config, local_only=safe)
1873 native_web_backend = None
1874 if config.get("BRAVE_API_KEY"):
1875 native_web_backend = "brave"
1876 elif config.get("EXA_API_KEY"):
1877 native_web_backend = "exa"
1878 elif config.get("SERPER_API_KEY"):
1879 native_web_backend = "serper"
1880 elif config.get("PARALLEL_API_KEY"):
1881 native_web_backend = "parallel"
1882 providers_status = {
1883 "google": bool(google_key),
1884 "openai": bool(config.get("OPENAI_API_KEY")) and config.get("OPENAI_AUTH_STATUS") == env.AUTH_STATUS_OK,
1885 "xai": bool(config.get("XAI_API_KEY")),
1886 "openrouter": bool(config.get("OPENROUTER_API_KEY")),
1887 "perplexity": bool(config.get("PERPLEXITY_API_KEY")),
1888 }
1889 reasoning_provider_available = any(
1890 providers_status[name] for name in ("google", "openai", "xai", "openrouter")
1891 )
1892 external_commands = {
1893 "yt-dlp": bool(which("yt-dlp")),
1894 "digg-pp-cli": bool(which("digg-pp-cli")),
1895 "arxiv-pp-cli": bool(which("arxiv-pp-cli")),
1896 "techmeme-pp-cli": bool(which("techmeme-pp-cli")),
1897 "trustpilot-pp-cli": bool(which("trustpilot-pp-cli")),
1898 "brightdata": bool(which("brightdata")),
1899 "gh": bool(which("gh")),
1900 }
1901 # Network-free two-field probe (bird_installed/bird_authenticated
1902 # precedent): "installed" is PATH resolution, "authenticated" is a
1903 # presence-only credential signal that never reads the secret.
1904 brightdata_status = brightdata.gate_status(config)
1905 credential_destinations = {
1906 "global_env": str(env.CONFIG_FILE) if env.CONFIG_FILE else None,
1907 }
1908 browser_cookies = {
1909 "mode": config.get("_BROWSER_COOKIE_MODE", "off"),
1910 "browsers": list(config.get("_BROWSER_COOKIE_BROWSERS") or []),
1911 "reads_values": False if safe else config.get("_BROWSER_COOKIE_MODE") == "read",
1912 }
1913 ignored_project_keys = list(config.get("_IGNORED_PROJECT_CONFIG_KEYS") or [])
1914 ignored_endpoint_overrides = [
1915 key for key in ignored_project_keys if key in permission_preflight.ENDPOINT_OVERRIDE_KEYS
1916 ]
1917 local_writes: list[dict[str, str]] = []
1918 if config.get("LAST30DAYS_MEMORY_DIR"):
1919 local_writes.append({"kind": "report", "path": str(config.get("LAST30DAYS_MEMORY_DIR"))})
1920 diag = {
1921 "providers": providers_status,
1922 "local_mode": not reasoning_provider_available,
1923 "reasoning_provider": (config.get("LAST30DAYS_REASONING_PROVIDER") or "auto").lower(),
1924 # The host-fetched connector lane serves X when no engine backend
1925 # exists and the model declared the lane.
1926 "x_backend": x_status["source"] or (
1927 "connector" if env.x_host_lane_declared(config) else None
1928 ),
1929 "bird_installed": x_status["bird_installed"],
1930 "bird_authenticated": x_status["bird_authenticated"],
1931 "bird_username": x_status["bird_username"],
1932 "x_pending_browser_auth": x_pending,
1933 "xquik_available": x_status.get("xquik_available", False),
1934 "xquik_working": x_status.get("xquik_working"),
1935 "xquik_status": x_status.get("xquik_status", ""),
1936 "native_web_backend": native_web_backend,
1937 "native_search": env.is_native_search(config),
1938 "has_scrapecreators": bool(config.get("SCRAPECREATORS_API_KEY")),
1939 "has_github": bool(config.get("GITHUB_TOKEN") or which("gh")),
1940 "brightdata_installed": brightdata_status["brightdata_installed"],
1941 "brightdata_authenticated": brightdata_status["brightdata_authenticated"],
1942 # safe=True (doctor/--diagnose/--preflight) must stay network-free:
1943 # answer X availability from local evidence only. x_pending is
1944 # precomputed by diagnose() to avoid double evaluation.
1945 "available_sources": available_sources(
1946 config, requested_sources, x_pending=x_pending, local_only=safe,
1947 x_envelope=x_envelope,
1948 ),
1949 "safe": safe,
1950 "config_source": config.get("_CONFIG_SOURCE"),
1951 "ignored_project_config": config.get("_IGNORED_PROJECT_CONFIG"),
1952 "ignored_project_config_keys": ignored_project_keys,
1953 "ignored_endpoint_overrides": ignored_endpoint_overrides,
1954 "browser_cookies": browser_cookies,
1955 "external_commands": external_commands,
1956 "credential_destinations": credential_destinations,
1957 "local_writes": local_writes,
1958 }
1959 diag["permission_preflight"] = permission_preflight.build(config, diag)
1960 return diag
1961
1962
1963 def _inner_max_workers(stream_count: int, *, internal_subrun: bool) -> int:
1964 """Worker-pool size for the per-stream fanout inside a single pipeline run.
1965
1966 Top-level runs use up to 16 workers. Subruns of ``run_competitor_fanout``
1967 cap the inner pool to 4 so a six-way competitor fan-out stays below
1968 roughly 30 worker threads in aggregate instead of ~96.
1969 """
1970 if internal_subrun:
1971 return max(2, min(4, stream_count or 1))
1972 return max(4, min(16, stream_count or 1))
1973
1974
1975 def _load_library_context(
1976 *,
1977 topic: str,
1978 config: dict[str, Any],
1979 mock: bool,
1980 internal_subrun: bool,
1981 x_handle: str | None,
1982 github_user: str | None,
1983 github_repos: list[str] | None,
1984 save_dir: Path | str | None = None,
1985 ) -> tuple[list[schema.LibraryContext], str | None]:
1986 """Resolve compact prior-run context without making a research run depend on it."""
1987 setting = str(config.get("LAST30DAYS_LIBRARY_CONTEXT") or "off").strip().lower()
1988 if mock or internal_subrun or setting in {"0", "false", "no", "off"}:
1989 return [], None
1990 if save_dir == "":
1991 return [], None
1992
1993 memory_dir = (
1994 save_dir
1995 if save_dir is not None
1996 else config.get("LAST30DAYS_MEMORY_DIR") or library.DEFAULT_MEMORY_DIR
1997 )
1998 briefs_dir = config.get("_LAST30DAYS_LIBRARY_BRIEFS_DIR") or (
1999 Path(memory_dir).expanduser() / "briefings"
2000 if save_dir is not None
2001 else library.DEFAULT_BRIEFS_DIR
2002 )
2003 db_path = config.get("_LAST30DAYS_LIBRARY_DB")
2004 if not db_path:
2005 db_path = (
2006 Path(memory_dir).expanduser().resolve() / ".last30days-library.db"
2007 if save_dir is not None
2008 else library_index.DEFAULT_LIBRARY_DB
2009 )
2010 store_db = config.get("_LAST30DAYS_STORE_DB")
2011 if not store_db:
2012 # Scoped runs read only a store inside the save dir (usually absent);
2013 # the shared store would leak other scopes' sightings into this one.
2014 store_db = (
2015 Path(memory_dir).expanduser().resolve() / "research.db"
2016 if save_dir is not None
2017 else library_index.DEFAULT_STORE_DB
2018 )
2019 queries = [topic, x_handle or "", github_user or "", *(github_repos or [])]
2020 queries = list(dict.fromkeys(value.strip() for value in queries if value and value.strip()))
2021 try:
2022 library_index.sync_library(memory_dir, briefs_dir, db_path=db_path)
2023 matches: list[library_index.LibrarySearchMatch] = []
2024 for query_text in queries:
2025 matches.extend(
2026 library_index.search(
2027 query_text,
2028 limit=6,
2029 db_path=db_path,
2030 store_db_path=store_db,
2031 )
2032 )
2033 except (library_index.LibrarySearchUnavailable, OSError, sqlite3.DatabaseError) as exc:
2034 return [], f"Library context unavailable: {exc}"
2035
2036 contexts: list[schema.LibraryContext] = []
2037 seen_runs: set[tuple[str, date]] = set()
2038 for match in sorted(
2039 matches,
2040 key=lambda item: (-item.published_date.toordinal(), item.rank, item.topic.casefold()),
2041 ):
2042 if match.run_key in seen_runs:
2043 continue
2044 seen_runs.add(match.run_key)
2045 contexts.append(
2046 schema.LibraryContext(
2047 topic=match.topic,
2048 published_date=match.published_date.isoformat(),
2049 headline=match.headline,
2050 summary=match.snippet or match.headline,
2051 source_kind=match.source_kind,
2052 )
2053 )
2054 if len(contexts) == 3:
2055 break
2056 return contexts, None
2057
2058
2059 def run(
2060 *,
2061 topic: str,
2062 config: dict[str, Any],
2063 depth: str,
2064 requested_sources: list[str] | None = None,
2065 mock: bool = False,
2066 x_handle: str | None = None,
2067 x_related: list[str] | None = None,
2068 web_backend: str = "auto",
2069 external_plan: dict | None = None,
2070 subreddits: list[str] | None = None,
2071 tiktok_hashtags: list[str] | None = None,
2072 tiktok_creators: list[str] | None = None,
2073 ig_creators: list[str] | None = None,
2074 lookback_days: int = 30,
2075 as_of_date: str | None = None,
2076 github_user: str | None = None,
2077 github_repos: list[str] | None = None,
2078 trustpilot_domain: str | None = None,
2079 trustpilot_domain_is_hint: bool = False,
2080 hiring_signals_mode: bool = False,
2081 internal_subrun: bool = False,
2082 suppress_x_host_lane: bool = False,
2083 save_dir: Path | str | None = None,
2084 corpus_dirs: list[str] | None = None,
2085 corpus_all_time: bool = False,
2086 x_posts: x_envelope.Envelope | None = None,
2087 ) -> schema.Report:
2088 # ``suppress_x_host_lane`` is distinct from ``internal_subrun``: comparison
2089 # entities share the latter and must still honor the connector lane;
2090 # only discovery enrichment passes set the former.
2091 # ``x_posts`` is a validated ``--x-posts`` envelope: when present
2092 # it replaces the engine's X fetch for this run and is served once.
2093 # Standalone runs (not competitor/discover sub-runs) own the YouTube
2094 # search-cache lifecycle. Comparison fan-out clears once before submit so
2095 # parallel entity sub-runs can still share in-run hits.
2096 if not internal_subrun:
2097 youtube_yt.reset_search_cache()
2098 settings = _resolve_depth_settings(depth, config)
2099 requested_sources = normalize_requested_sources(requested_sources)
2100 # Wall-clock origin for budget-aware enrichment lanes. Amazon review
2101 # enrichment starts at search time (inside _retrieve_stream_impl) so it
2102 # overlaps other sources instead of waiting for them all to finish.
2103 run_started = time.monotonic()
2104 from_date, to_date = dates.get_date_range(lookback_days, as_of_date=as_of_date)
2105 resolved_corpus_dirs = corpus.resolve_directories(
2106 corpus_dirs or config.get("_CORPUS_DIRS"),
2107 config.get("LAST30DAYS_CORPUS_DIRS"),
2108 )
2109 excluded_sources = {
2110 source.strip().lower()
2111 for source in str(config.get("EXCLUDE_SOURCES") or "").split(",")
2112 if source.strip()
2113 }
2114 corpus_enabled = bool(resolved_corpus_dirs) and "corpus" not in excluded_sources
2115 corpus_requested = bool(requested_sources and "corpus" in requested_sources)
2116 if corpus_enabled and requested_sources and "corpus" not in requested_sources:
2117 requested_sources = [*requested_sources, "corpus"]
2118
2119 # Host-fetched X lane. EXCLUDE_SOURCES=x or a --search list without
2120 # x wins: the envelope is ignored with a receipt line and stays unconsumed.
2121 envelope = x_posts
2122 if envelope is not None and (
2123 "x" in excluded_sources
2124 or (requested_sources and "x" not in requested_sources)
2125 ):
2126 log.source_log(
2127 "x", "host-fetched X: envelope ignored (x is excluded from this run)",
2128 tty_only=False,
2129 )
2130 envelope = None
2131 # The lane signal without an envelope is a broken handoff, not a reason to
2132 # spend a backup backend: X records the fixed not-passed outcome.
2133 x_lane_missing = (
2134 envelope is None
2135 and not mock
2136 and not suppress_x_host_lane
2137 and env.x_host_lane_declared(config)
2138 )
2139 if envelope is not None or x_lane_missing:
2140 # Ride the config dict (the _polymarket_keywords idiom) so the stream
2141 # workers and the handle-lane section see it without widening their
2142 # signatures. Copy first: comparison entities shallow-copy the shared
2143 # config and must never inherit another entity's envelope.
2144 config = dict(config)
2145 config["_x_envelope"] = envelope
2146 config["_x_lane_missing"] = x_lane_missing
2147
2148 # Gate StockTwits to ticker/crypto topics. Single chokepoint: when False,
2149 # available_sources() never registers stocktwits, so the planner can't
2150 # assign it (eligible_sources = available ∩ capabilities).
2151 config["_financial_topic"] = stocktwits.is_financial_topic(topic)
2152
2153 if mock:
2154 runtime = providers.mock_runtime(config, depth)
2155 reasoning_provider = None
2156 available = list(requested_sources or MOCK_AVAILABLE_SOURCES)
2157 if corpus_enabled and "corpus" not in available:
2158 available.append("corpus")
2159 if not corpus_enabled and not corpus_requested:
2160 available = [source for source in available if source != "corpus"]
2161 if not requested_sources and not hiring_signals_mode and not _company_topic_likely(topic):
2162 available = [source for source in available if source != "jobs"]
2163 else:
2164 runtime, reasoning_provider = providers.resolve_runtime(config, depth)
2165 available = available_sources(
2166 config, requested_sources,
2167 suppress_x_host_lane=suppress_x_host_lane,
2168 x_envelope=envelope is not None,
2169 )
2170 if requested_sources:
2171 available = [source for source in available if source in requested_sources]
2172 # Keep an explicitly requested but unconfigured corpus in the plan long
2173 # enough to record its skipped-unconfigured source outcome. It is never
2174 # submitted to the network executor below.
2175 if corpus_requested and "corpus" not in excluded_sources and "corpus" not in available:
2176 available.append("corpus")
2177 if web_backend == "none":
2178 available = [s for s in available if s != "grounding"]
2179 elif web_backend in ("brave", "exa", "serper", "parallel", "parallel-mcp", "keyless") and "grounding" not in available:
2180 available.append("grounding")
2181 if (
2182 hiring_signals_mode
2183 or (not requested_sources and _company_topic_likely(topic))
2184 ) and "jobs" not in available:
2185 available.append("jobs")
2186 if hiring_signals_mode:
2187 config = dict(config)
2188 config["_hiring_signals_mode"] = True
2189 if not requested_sources:
2190 available = ["jobs"]
2191 if not available:
2192 raise RuntimeError("No sources are available for this run.")
2193
2194 planner_requested_sources = requested_sources
2195 if hiring_signals_mode and not planner_requested_sources:
2196 planner_requested_sources = ["jobs"]
2197
2198 if external_plan is not None:
2199 # External plan provided (e.g., from Claude Code via --plan flag).
2200 # Explicit input is a contract: validate it before permissive sanitization.
2201 planner.validate_external_plan(external_plan)
2202 plan = planner._sanitize_plan(
2203 external_plan, topic, available, planner_requested_sources, depth,
2204 honor_plan_sources=True,
2205 )
2206 plan_source = "external"
2207 else:
2208 plan = planner.plan_query(
2209 topic=topic,
2210 available_sources=available,
2211 requested_sources=planner_requested_sources,
2212 depth=depth,
2213 provider=None if mock else reasoning_provider,
2214 model=None if mock else runtime.planner_model,
2215 context=config.get("_auto_resolve_context", ""),
2216 internal_subrun=internal_subrun,
2217 )
2218 # Source labelling: the fallback path annotates notes with "fallback-plan"
2219 # or "deterministic-comparison-plan"; anything else came from the LLM.
2220 if any("fallback" in note or "deterministic" in note for note in (plan.notes or [])):
2221 plan_source = "deterministic"
2222 elif not mock and reasoning_provider and runtime.planner_model:
2223 plan_source = "llm"
2224 else:
2225 plan_source = "deterministic"
2226
2227 # Safety net: ensure grounding appears in all subqueries even if the planner
2228 # omits it. This is redundant when the planner includes grounding via
2229 # SOURCE_CAPABILITIES, but kept as a fallback.
2230 if (
2231 web_backend != "none"
2232 and "grounding" in available
2233 and "drill-mode" not in plan.notes
2234 ):
2235 for sq in plan.subqueries:
2236 if "grounding" not in sq.sources:
2237 sq.sources.append("grounding")
2238 if "drill-mode" not in plan.notes:
2239 # Drill plans re-fetch only the sources that contributed to the matched
2240 # cluster; the company-topic jobs injection must not widen that set.
2241 _ensure_jobs_in_plan(plan, available, explicit=hiring_signals_mode, topic=topic)
2242 if "corpus" in available and plan.subqueries:
2243 # Corpus is deterministic and user-registered, so it always gets one
2244 # bounded stream even when a quick/LLM plan omits it. Reuse the primary
2245 # subquery instead of multiplying local scans across every subquery.
2246 if "corpus" not in plan.subqueries[0].sources:
2247 plan.subqueries[0].sources.append("corpus")
2248 if "corpus" not in plan.source_weights:
2249 plan.source_weights["corpus"] = 1.0
2250 plan.source_weights = planner._normalize_weights(plan.source_weights)
2251
2252 # Add the paid-only Perplexity lane after all normal-source safety nets.
2253 # This preserves the planner's primary subquery, gives the bounded paid
2254 # call the whole user topic, and prevents grounding, jobs, or corpus from
2255 # being attached to the dedicated lane.
2256 _ensure_perplexity_in_plan(
2257 plan,
2258 topic,
2259 available,
2260 force=bool(config.get("_deep_research")),
2261 )
2262
2263 # Always-on planner trace. Emits one summary line plus one per subquery
2264 # so retrieval-breadth failures like the 2026-04-19 Hermes Agent Use Cases
2265 # disaster are visible without --debug. Stderr only; does not leak into
2266 # the user-facing stdout synthesis.
2267 print(
2268 f"[Planner] Plan: intent={plan.intent}, freshness={plan.freshness_mode}, "
2269 f"cluster_mode={plan.cluster_mode}, subqueries={len(plan.subqueries)}, "
2270 f"source={plan_source}",
2271 file=sys.stderr,
2272 )
2273 if plan.subqueries:
2274 for index, sq in enumerate(plan.subqueries, start=1):
2275 sources_str = ",".join(sq.sources) if sq.sources else "(none)"
2276 print(
2277 f"[Planner] sq{index} label={sq.label} "
2278 f'search="{sq.search_query}" sources=[{sources_str}]',
2279 file=sys.stderr,
2280 )
2281 else:
2282 print("[Planner] (no subqueries in plan)", file=sys.stderr)
2283
2284 bundle = schema.RetrievalBundle(artifacts={"grounding": []})
2285 if envelope is not None:
2286 # The footer's X provenance reads "via X connector" (render._render_stats).
2287 bundle.artifacts["x_provenance"] = "connector"
2288 # Handles the user named explicitly. Available before any retrieval, unlike
2289 # the entity-extracted set, so Phase 1 and quick-depth runs get first-party
2290 # protection too. Without this the exemption reached only the Phase 2
2291 # supplement path -- which quick runs skip entirely -- so a subject-authored
2292 # post retrieved in Phase 1 was still pruned before fusion, which is exactly
2293 # the evidence loss this change exists to prevent.
2294 explicit_first_party = {
2295 h.lstrip("@").strip().lower()
2296 for h in ([x_handle, github_user, *(x_related or [])])
2297 if h and h.strip()
2298 }
2299 # Creator accounts named via --ig-creators / --creators carry the same
2300 # explicit intent: the run is searching those accounts, and a creator's
2301 # caption rarely repeats the topic's literal tokens, so without an
2302 # exemption the relevance floor prunes them as third-party noise (issue
2303 # #1101: 36 creator reels fetched, 0 reported). The exemption is scoped
2304 # per platform, NOT merged into the global set: each flag names accounts
2305 # on one platform, and an unrelated same-name account elsewhere must not
2306 # bypass the floors.
2307 creator_first_party = _creator_first_party_by_source(tiktok_creators, ig_creators)
2308 # Real X handles: --x-handle, --x-related, or @mentions in the topic. These
2309 # determine whether the deferred X floor applies. Topic words like "peter"
2310 # are NOT real handles and should not trigger the floor — when no real
2311 # handle is identified, the floor is skipped entirely (policy: a noisier
2312 # report beats losing the subject's evidence).
2313 explicit_x_handles = {
2314 h.lstrip("@").strip().lower()
2315 for h in ([x_handle, *(x_related or [])])
2316 if h and h.strip()
2317 } | _topic_handle_mentions(topic)
2318 # Plus handle-shaped tokens from the topic. Phase 1 and quick-depth runs
2319 # never reach automatic handle resolution, so without this a quick search
2320 # naming a subject still discards everything that subject wrote.
2321 explicit_first_party |= _topic_first_party_candidates(topic)
2322
2323 for source in (requested_sources or []):
2324 if source not in available:
2325 bundle.record_failure(
2326 source,
2327 schema.SKIPPED_UNCONFIGURED,
2328 "Source was requested but is not configured for this run.",
2329 attempted=False,
2330 )
2331 if corpus_requested and not corpus_enabled:
2332 bundle.record_failure(
2333 "corpus",
2334 schema.SKIPPED_UNCONFIGURED,
2335 "Corpus was requested but no readable directory was configured.",
2336 attempted=False,
2337 )
2338 # Expose plan_source to the renderer so render_compact can emit the
2339 # DEGRADED RUN banner when a named-entity topic was invoked bare
2340 # (source=deterministic AND no pre-research flags). LAW 7 backstop.
2341 bundle.artifacts["plan_source"] = plan_source
2342 bundle.artifacts["corpus_in_export"] = bool(config.get("_CORPUS_IN_EXPORT"))
2343 # Hiring-signals is deliberately jobs-only with no multi-source --plan, so
2344 # the LAW 7 degraded-run and Step 0.55 pre-research banners do not apply -
2345 # they would contradict the documented jobs-scoped flow. Suppress them.
2346 bundle.artifacts["hiring_signals_mode"] = hiring_signals_mode
2347 # Record the resolved Amazon keyword whenever the lane is active, so the
2348 # footer can name it on an empty result. A search that matched nothing
2349 # still spent a credit, and the fix is almost always the keyword -- a
2350 # suppressed line means nobody ever learns it was wrong.
2351 if "amazon" in (available or []):
2352 bundle.artifacts["amazon_query"] = (
2353 str(config.get("_amazon_query") or "").strip() or topic
2354 )
2355
2356 # Project-mode or person-mode GitHub: run once before the main subquery loop
2357 _github_custom_done = False
2358 _github_enriched_repos: set[str] = set()
2359
2360 # Project mode takes priority over person mode
2361 if github_repos and "github" in available:
2362 bundle.mark_attempted("github")
2363 try:
2364 project_items = github.search_github_project(
2365 github_repos, from_date, to_date,
2366 depth=depth, token=config.get("GITHUB_TOKEN"),
2367 )
2368 if project_items:
2369 normalized = _normalize_score_dedupe(
2370 "github", project_items, from_date, to_date,
2371 freshness_mode=plan.freshness_mode,
2372 ranking_query=f"What are {', '.join(github_repos)} doing on GitHub?",
2373 )
2374 primary_label = plan.subqueries[0].label if plan.subqueries else "primary"
2375 bundle.add_items(primary_label, "github", normalized)
2376 _github_custom_done = True
2377 _github_enriched_repos = {r.lower() for r in github_repos}
2378 except Exception as exc:
2379 bundle.errors_by_source["github"] = f"Project-mode failed: {exc}"
2380 state, attempted = _classify_source_failure(exc)
2381 bundle.record_failure("github", state, str(exc), attempted=attempted)
2382
2383 _github_person_done = False
2384 if github_user and "github" in available and not _github_custom_done:
2385 bundle.mark_attempted("github")
2386 _github_person_done = True
2387 try:
2388 person_items = github.search_github_person(
2389 github_user, from_date, to_date,
2390 depth=depth, token=config.get("GITHUB_TOKEN"),
2391 )
2392 if person_items:
2393 normalized = _normalize_score_dedupe(
2394 "github", person_items, from_date, to_date,
2395 freshness_mode=plan.freshness_mode,
2396 ranking_query=f"What is @{github_user} doing on GitHub?",
2397 )
2398 # Use the first subquery's label so RRF can look up the weight
2399 primary_label = plan.subqueries[0].label if plan.subqueries else "primary"
2400 bundle.add_items(primary_label, "github", normalized)
2401 else:
2402 # A pinned --github-user that yields nothing must not be
2403 # silently backfilled by generic keyword search: the report
2404 # would then present unrelated repos as this person's work.
2405 bundle.record_failure(
2406 "github",
2407 "no-results",
2408 f"Person mode found no activity for @{github_user} in the window",
2409 )
2410 except Exception as exc:
2411 bundle.errors_by_source["github"] = f"Person-mode failed: {exc}"
2412 state, attempted = _classify_source_failure(exc)
2413 bundle.record_failure("github", state, str(exc), attempted=attempted)
2414
2415 # Trustpilot session warm-up happens inside search_trustpilot at the
2416 # first (capped, single) fetch -- lazily, so it never delays the other
2417 # sources' streams and never fires for runs whose plan fetches no
2418 # Trustpilot. The module-level lock in lib/trustpilot.py serializes
2419 # concurrent vs-mode sub-runs so they never race Chrome harvests.
2420
2421 # Thread-safe set prevents redundant fetches after a source returns 429
2422 rate_limited_sources: set[str] = set()
2423 rate_limit_lock = threading.Lock()
2424
2425 # Local corpus retrieval is intentionally outside the network executor and
2426 # retry budget. One bounded stream participates in the same signal scoring,
2427 # fusion, reranking, and per-source result cap as remote sources.
2428 if corpus_enabled and plan.subqueries:
2429 primary = plan.subqueries[0]
2430 bundle.mark_attempted("corpus")
2431 result = corpus.search(
2432 topic,
2433 resolved_corpus_dirs,
2434 from_date=from_date,
2435 to_date=to_date,
2436 all_time=corpus_all_time,
2437 limit=settings["per_stream_limit"],
2438 cache_dir=env.CONFIG_DIR,
2439 )
2440 prepared_query = relevance.PreparedQuery(primary.ranking_query)
2441 lookback_window_days = (
2442 datetime.strptime(to_date, "%Y-%m-%d").date()
2443 - datetime.strptime(from_date, "%Y-%m-%d").date()
2444 ).days
2445 corpus_items = signals.annotate_stream(
2446 result.items,
2447 prepared_query,
2448 plan.freshness_mode,
2449 reference_date=to_date,
2450 max_days=lookback_window_days,
2451 )
2452 corpus_items = signals.prune_low_relevance(corpus_items)
2453 corpus_items = dedupe.dedupe_items(corpus_items)
2454 for item in corpus_items:
2455 item.snippet = snippet.extract_best_snippet(item, prepared_query)
2456 bundle.add_items(primary.label, "corpus", corpus_items)
2457 if result.notes:
2458 outcome = bundle.source_status["corpus"]
2459 bundle.source_status["corpus"] = schema.SourceOutcome(
2460 source="corpus",
2461 state=outcome.state,
2462 items_returned=outcome.items_returned,
2463 attempted=True,
2464 detail="; ".join(result.notes),
2465 )
2466 bundle.artifacts["corpus"] = {
2467 "files_scanned": result.files_scanned,
2468 "cache_hits": result.cache_hits,
2469 "all_time": corpus_all_time,
2470 }
2471
2472 futures = {}
2473 # Per-source fetch budget prevents redundant API calls
2474 source_fetch_count: dict[str, int] = {}
2475 stream_count = sum(
2476 1
2477 for subquery in plan.subqueries
2478 for source in subquery.sources
2479 if source in available and source != "corpus"
2480 )
2481 max_workers = _inner_max_workers(stream_count, internal_subrun=internal_subrun)
2482 with ThreadPoolExecutor(max_workers=max_workers) as executor:
2483 for subquery in plan.subqueries:
2484 for source in subquery.sources:
2485 if source not in available:
2486 continue
2487 if source == "corpus":
2488 continue
2489 # Skip GitHub keyword search if person-mode already ran
2490 if source == "github" and (_github_person_done or _github_custom_done):
2491 continue
2492 # Enforce per-source fetch cap. A CLI override (issue #716) raises
2493 # the cap for capped sources so every X subquery in a multi-angle
2494 # --plan fetches, instead of only the first two.
2495 cap = _source_fetch_cap(source, config)
2496 if cap is not None:
2497 if cap <= 0:
2498 continue
2499 current = source_fetch_count.get(source, 0)
2500 if current >= cap:
2501 continue
2502 shared_paid_budget = config.get("_perplexity_paid_budget")
2503 if (
2504 source == "perplexity"
2505 and isinstance(shared_paid_budget, PaidSourceBudget)
2506 and not shared_paid_budget.try_consume(
2507 cap,
2508 claimant=topic,
2509 )
2510 ):
2511 bundle.artifacts.setdefault("paid_source_budget", {})[
2512 "perplexity"
2513 ] = {
2514 "state": "skipped-budget",
2515 "attempted": False,
2516 "owner": shared_paid_budget.owner,
2517 "claimant": topic,
2518 }
2519 continue
2520 source_fetch_count[source] = current + 1
2521 bundle.mark_attempted(source)
2522 futures[
2523 executor.submit(
2524 _retrieve_stream,
2525 topic=topic,
2526 subquery=subquery,
2527 source=source,
2528 config=config,
2529 depth=depth,
2530 date_range=(from_date, to_date),
2531 runtime=runtime,
2532 mock=mock,
2533 rate_limited_sources=rate_limited_sources,
2534 rate_limit_lock=rate_limit_lock,
2535 web_backend=web_backend,
2536 raw_topic=topic,
2537 subreddits=subreddits,
2538 tiktok_hashtags=tiktok_hashtags,
2539 tiktok_creators=tiktok_creators,
2540 ig_creators=ig_creators,
2541 trustpilot_domain=trustpilot_domain,
2542 trustpilot_domain_is_hint=trustpilot_domain_is_hint,
2543 run_started=run_started,
2544 )
2545 ] = (subquery, source)
2546
2547 for future in as_completed(futures):
2548 subquery, source = futures[future]
2549 try:
2550 raw_items, artifact = future.result()
2551 except Exception as exc:
2552 # Share 429 signal so pending futures skip this source
2553 if _is_rate_limit_error(exc):
2554 with rate_limit_lock:
2555 rate_limited_sources.add(source)
2556 bundle.errors_by_source[source] = str(exc)
2557 state, attempted = _classify_source_failure(exc)
2558 bundle.record_failure(source, state, str(exc), attempted=attempted)
2559 continue
2560 # Retry once for transient 5xx errors
2561 if _is_transient_error(exc):
2562 time.sleep(3)
2563 try:
2564 raw_items, artifact = _retrieve_stream(
2565 topic=topic, subquery=subquery, source=source,
2566 config=config, depth=depth, date_range=(from_date, to_date),
2567 runtime=runtime, mock=mock,
2568 rate_limited_sources=rate_limited_sources,
2569 rate_limit_lock=rate_limit_lock,
2570 web_backend=web_backend,
2571 raw_topic=topic,
2572 subreddits=subreddits,
2573 tiktok_hashtags=tiktok_hashtags,
2574 tiktok_creators=tiktok_creators,
2575 ig_creators=ig_creators,
2576 trustpilot_domain=trustpilot_domain,
2577 trustpilot_domain_is_hint=trustpilot_domain_is_hint,
2578 run_started=run_started,
2579 )
2580 except Exception as retry_exc:
2581 detail = f"{exc} (retried once, still failed: {retry_exc})"
2582 bundle.errors_by_source[source] = detail
2583 state, attempted = _classify_source_failure(retry_exc)
2584 bundle.record_failure(source, state, detail, attempted=attempted)
2585 continue
2586 else:
2587 bundle.errors_by_source[source] = str(exc)
2588 state, attempted = _classify_source_failure(exc)
2589 bundle.record_failure(source, state, str(exc), attempted=attempted)
2590 continue
2591 outcome_note = None
2592 if isinstance(artifact, dict) and artifact.get("_source_outcome"):
2593 artifact = dict(artifact)
2594 outcome_note = artifact.pop("_source_outcome")
2595 bundle.record_failure(
2596 source,
2597 outcome_note["state"],
2598 outcome_note["detail"],
2599 attempted=outcome_note.get("attempted", True),
2600 )
2601 if isinstance(artifact, dict) and artifact.get("_source_outcome_detail"):
2602 artifact = dict(artifact)
2603 lane_state = artifact.pop("_source_outcome_detail_state", None)
2604 bundle.record_detail(
2605 source, artifact.pop("_source_outcome_detail"), state=lane_state
2606 )
2607 if lane_state == health.RATE_LIMITED:
2608 # Do not re-fan-out against a host still inside its window.
2609 with rate_limit_lock:
2610 rate_limited_sources.add(source)
2611 normalized = _normalize_score_dedupe(
2612 source, raw_items, from_date, to_date,
2613 freshness_mode=plan.freshness_mode,
2614 ranking_query=subquery.ranking_query,
2615 first_party_handles=explicit_first_party,
2616 first_party_by_source=creator_first_party,
2617 # X defers its relevance floor until resolved_handles exists.
2618 # Everything else prunes here as before.
2619 defer_relevance_prune=(source == "x"),
2620 )
2621 # Jobs is exempt from per_stream_limit: a careers board is a complete
2622 # snapshot of open roles, and truncating it to the default 12 drops
2623 # strategic postings (the whole point of hiring-signals coverage).
2624 if source != "jobs":
2625 normalized = _apply_reddit_stream_keepers(
2626 source, normalized, settings["per_stream_limit"], topic
2627 )
2628 bundle.add_items(subquery.label, source, normalized)
2629 if artifact:
2630 bundle.artifacts.setdefault("grounding", []).append(artifact)
2631
2632 # Phase 2: supplemental entity-based searches
2633 supplemental_handles: list[str] = []
2634 _run_supplemental_searches(
2635 topic=topic,
2636 bundle=bundle,
2637 plan=plan,
2638 config=config,
2639 depth=depth,
2640 date_range=(from_date, to_date),
2641 runtime=runtime,
2642 mock=mock,
2643 rate_limited_sources=rate_limited_sources,
2644 rate_limit_lock=rate_limit_lock,
2645 x_handle=x_handle,
2646 x_related=x_related,
2647 resolved_handles_out=supplemental_handles,
2648 )
2649
2650 # Phase 2b: retry thin sources with simplified query
2651 # Note: _github_skip_sources tells the retry to not re-run GitHub keyword search
2652 # when project-mode or person-mode already provided authoritative data.
2653 _github_skip_retry = {"corpus"}
2654 if _github_person_done or _github_custom_done:
2655 _github_skip_retry.add("github")
2656 _retry_thin_sources(
2657 topic=topic,
2658 bundle=bundle,
2659 plan=plan,
2660 config=config,
2661 depth=depth,
2662 date_range=(from_date, to_date),
2663 runtime=runtime,
2664 mock=mock,
2665 rate_limited_sources=rate_limited_sources,
2666 rate_limit_lock=rate_limit_lock,
2667 settings=settings,
2668 web_backend=web_backend,
2669 skip_sources=_github_skip_retry,
2670 subreddits=subreddits,
2671 tiktok_hashtags=tiktok_hashtags,
2672 tiktok_creators=tiktok_creators,
2673 ig_creators=ig_creators,
2674 first_party_handles=explicit_first_party,
2675 first_party_by_source=creator_first_party,
2676 run_started=run_started,
2677 )
2678
2679 # Reclassify partial failures as DEGRADED instead of silently dropping them.
2680 # A source that 429'd on one subquery but succeeded on another is not a hard
2681 # failure, but it is not healthy either: it likely returned fewer results
2682 # than it should have. Move it out of errors_by_source (so it isn't reported
2683 # as "failed") and into degraded_by_source (so it survives into warnings),
2684 # rather than deleting the signal outright as the engine used to.
2685 degraded_by_source: dict[str, str] = {}
2686 for source in list(bundle.errors_by_source):
2687 if bundle.items_by_source.get(source):
2688 degraded_by_source[source] = bundle.errors_by_source[source]
2689 del bundle.errors_by_source[source]
2690
2691 hiring_summary = _apply_hiring_signal_gate(
2692 bundle,
2693 explicit=hiring_signals_mode,
2694 topic=topic,
2695 )
2696 if hiring_summary:
2697 bundle.artifacts["hiring_signals"] = hiring_summary
2698
2699 items_by_source = _finalize_items_by_source(
2700 bundle.items_by_source, topic=topic, config=config, depth=depth, mock=mock,
2701 elapsed=time.monotonic() - run_started,
2702 )
2703 source_status = _finalize_source_status(bundle.source_status, items_by_source)
2704 # Normalized set of handles this run resolved for the topic. A candidate
2705 # authored by one of these is first-party and is exempted from the
2706 # entity-miss demotion in rerank (a post never repeats its own author's
2707 # name, so the body-text grounding check would otherwise zero out the
2708 # subject's own highest-signal posts). Built before fusion so the
2709 # per-author cap can give the topic's subject a higher allowance than an
2710 # incidental third-party account.
2711 resolved_handles = explicit_first_party | {
2712 h.lstrip("@").strip().lower()
2713 for h in supplemental_handles
2714 if h and h.strip()
2715 } | {h for handles in creator_first_party.values() for h in handles}
2716 # resolved_handles feeds rerank/fusion, where the first-party marks only
2717 # resist demotion of items that already passed the inclusion floors and a
2718 # named account is treated as the subject across surfaces. The inclusion
2719 # gate (prune_low_relevance) instead gets the platform-scoped
2720 # creator_first_party map so a cross-platform name collision cannot
2721 # bypass the floors.
2722 # Real X handles from explicit flags, @mentions in topic, or Phase 2 discovery.
2723 # When no real handle is identified, skip the X floor entirely — a noisier
2724 # report beats losing the subject's evidence. Topic tokens like "peter" are
2725 # NOT real handles: they populate resolved_handles for downstream first-party
2726 # protection but should NOT trigger the floor.
2727 real_x_handles = explicit_x_handles | {
2728 h.lstrip("@").strip().lower()
2729 for h in supplemental_handles
2730 if h and h.strip()
2731 }
2732 # Deferred X relevance floor. Phase 1 skipped it so this could run with the
2733 # run's actual resolved handles rather than a guess made before anyone knew
2734 # who the subject was. Applied per subquery stream so fusion sees the same
2735 # shape it always has. Only applied when we have real X handles — topic
2736 # tokens alone cannot identify the subject.
2737 if real_x_handles:
2738 # IG/TikTok creator exemptions stay on their own platforms: a creator
2739 # handle must not exempt a same-name X account from this floor. Only
2740 # creator-ONLY handles are subtracted, where X provenance means
2741 # real_x_handles (explicit X flags, @mentions in the topic, Phase 2
2742 # discovery) - a plain topic token or --github-user match is NOT X
2743 # provenance and does not preserve the exemption.
2744 x_floor_handles = resolved_handles - _creator_only_handles(
2745 creator_first_party, real_x_handles
2746 )
2747 for key, stream in list(bundle.items_by_source_and_query.items()):
2748 if key[1] != "x" or not stream:
2749 continue
2750 pruned = signals.prune_low_relevance(
2751 stream,
2752 first_party_handles=x_floor_handles,
2753 first_party_by_source=creator_first_party,
2754 )
2755 _log_prune_drop("x", len(stream), len(pruned), scope="per-query stream")
2756 bundle.items_by_source_and_query[key] = pruned
2757 if bundle.items_by_source.get("x"):
2758 x_stream = bundle.items_by_source["x"]
2759 pruned = signals.prune_low_relevance(
2760 x_stream,
2761 first_party_handles=x_floor_handles,
2762 first_party_by_source=creator_first_party,
2763 )
2764 _log_prune_drop("x", len(x_stream), len(pruned), scope="merged stream")
2765 bundle.items_by_source["x"] = pruned
2766
2767 candidates = weighted_rrf(
2768 bundle.items_by_source_and_query,
2769 plan,
2770 pool_limit=settings["pool_limit"],
2771 range_from=from_date,
2772 range_to=to_date,
2773 first_party_handles=resolved_handles,
2774 )
2775 private_candidates = [
2776 candidate
2777 for candidate in candidates
2778 if candidate.source == "corpus"
2779 or any(item.source == "corpus" for item in candidate.source_items)
2780 ]
2781 private_candidate_ids = {id(candidate) for candidate in private_candidates}
2782 public_candidates = [
2783 candidate for candidate in candidates if id(candidate) not in private_candidate_ids
2784 ]
2785 ranked_public = rerank.rerank_candidates(
2786 topic=topic,
2787 plan=plan,
2788 candidates=public_candidates,
2789 provider=None if mock else reasoning_provider,
2790 model=None if mock else runtime.rerank_model,
2791 shortlist_size=settings["rerank_limit"],
2792 resolved_handles=resolved_handles,
2793 )
2794 # Corpus titles/snippets must never enter a hosted reasoning prompt. Score
2795 # every candidate carrying corpus evidence with the deterministic fallback,
2796 # even when the rest of the run uses a remote reranker.
2797 ranked_private = rerank.rerank_candidates(
2798 topic=topic,
2799 plan=plan,
2800 candidates=private_candidates,
2801 provider=None,
2802 model=None,
2803 shortlist_size=settings["rerank_limit"],
2804 resolved_handles=resolved_handles,
2805 )
2806 ranked_public = rerank.prune_fallback_entity_misses(ranked_public, topic=topic)
2807 # Private corpus already cleared a body-aware retrieval floor; do not apply
2808 # the public title/snippet visibility gate (filenames often omit the head
2809 # token even when the document body matched).
2810 ranked_candidates = sorted(
2811 [*ranked_public, *ranked_private],
2812 key=lambda candidate: (
2813 1 if schema.candidate_out_of_window(candidate) else 0,
2814 -candidate.final_score,
2815 -(candidate.engagement or -1),
2816 min(candidate.native_ranks.values(), default=999),
2817 candidate.title,
2818 ),
2819 )
2820 rerank.score_fun(
2821 topic=topic,
2822 candidates=ranked_public,
2823 provider=None if mock else reasoning_provider,
2824 model=None if mock else runtime.rerank_model,
2825 )
2826 rerank.score_fun(
2827 topic=topic,
2828 candidates=ranked_private,
2829 provider=None,
2830 model=None,
2831 )
2832
2833 # Phase 3: post-rerank GitHub star enrichment. Record/replay-aware so the
2834 # eval harness stays fully offline: this path calls the GitHub API (and the
2835 # gh-credential fallback) outside the _retrieve_stream seam, so it gets its
2836 # own fixture exchange keyed by phase.
2837 if "github" in available and not mock:
2838 star_request = {
2839 "source": "github",
2840 "phase": "post_rerank_star_enrichment",
2841 "topic": topic,
2842 "depth": depth,
2843 }
2844 star_matched, star_replayed = http.fixture_source_replay(star_request)
2845 if star_matched:
2846 star_map = star_replayed if isinstance(star_replayed, dict) else {}
2847 github.apply_star_map(ranked_candidates, star_map)
2848 else:
2849 collected_star_map: dict[str, int] = {}
2850 github.enrich_candidates_with_stars(
2851 ranked_candidates,
2852 token=config.get("GITHUB_TOKEN"),
2853 already_enriched=_github_enriched_repos,
2854 collect_map=collected_star_map,
2855 )
2856 http.fixture_source_record(star_request, collected_star_map)
2857
2858 clusters = cluster_candidates(ranked_candidates, plan)
2859 warnings = _warnings(items_by_source, ranked_candidates, bundle.errors_by_source, degraded_by_source)
2860 # One-sided entity coverage is a reporting warning, not a source failure:
2861 # marking the source PARTIAL would trip LAST30DAYS_STRICT_EXIT on runs that
2862 # returned good X results.
2863 warnings.extend(bundle.artifacts.get("x_partial_coverage", []))
2864 # Backend receipts that are not failures (xapi's truncated window), and
2865 # the Meta Ads footer inputs. A stream artifact only ever reaches the
2866 # report as an anonymous entry in this list, so the advertiser and the
2867 # pre-truncation counts have to be lifted to named top-level artifacts or
2868 # the footer cannot render them -- least of all on a zero-item run, which
2869 # is exactly when naming the advertiser matters most.
2870 for stream_artifact in bundle.artifacts.get("grounding", []):
2871 if isinstance(stream_artifact, dict):
2872 warnings.extend(stream_artifact.get("x_receipts", []))
2873 _lift_stream_artifacts(bundle)
2874 library_context, library_warning = _load_library_context(
2875 topic=topic,
2876 config=config,
2877 mock=mock,
2878 internal_subrun=internal_subrun,
2879 x_handle=x_handle,
2880 github_user=github_user,
2881 github_repos=github_repos,
2882 save_dir=save_dir,
2883 )
2884 if library_warning:
2885 warnings.append(library_warning)
2886
2887 return schema.Report(
2888 topic=topic,
2889 range_from=from_date,
2890 range_to=to_date,
2891 generated_at=datetime.now(timezone.utc).isoformat(),
2892 provider_runtime=runtime,
2893 query_plan=plan,
2894 clusters=clusters,
2895 ranked_candidates=ranked_candidates,
2896 items_by_source=items_by_source,
2897 errors_by_source=bundle.errors_by_source,
2898 source_status=source_status,
2899 warnings=warnings,
2900 artifacts=bundle.artifacts,
2901 library_context=library_context,
2902 )
2903
2904
2905 def _candidate_is_duplicate(
2906 candidate: schema.Candidate,
2907 kept: list[schema.Candidate],
2908 ) -> bool:
2909 if any(existing.candidate_id == candidate.candidate_id for existing in kept):
2910 return True
2911 if candidate.url and any(existing.url == candidate.url for existing in kept):
2912 return True
2913 candidate_text = " ".join((candidate.title, candidate.snippet)).strip()
2914 return bool(candidate_text) and any(
2915 dedupe.hybrid_similarity(
2916 candidate_text,
2917 " ".join((existing.title, existing.snippet)).strip(),
2918 ) >= 0.7
2919 for existing in kept
2920 )
2921
2922
2923 def merge_drill_report(
2924 report: schema.Report,
2925 drill_report: schema.Report,
2926 matched_clusters: list[schema.Cluster],
2927 *,
2928 target: str,
2929 ) -> schema.Report:
2930 """Merge a narrow follow-up into its cached report while preserving other clusters."""
2931 merged = copy.deepcopy(report)
2932 selected_cluster_ids = {cluster.cluster_id for cluster in matched_clusters}
2933 selected_candidate_ids = {
2934 candidate_id
2935 for cluster in matched_clusters
2936 for candidate_id in cluster.candidate_ids
2937 }
2938 original_candidates = {
2939 candidate.candidate_id: candidate for candidate in merged.ranked_candidates
2940 }
2941 unrelated_candidates = [
2942 candidate for candidate in merged.ranked_candidates
2943 if candidate.candidate_id not in selected_candidate_ids
2944 ]
2945 original_summary = ""
2946 for cluster in matched_clusters:
2947 for candidate_id in cluster.representative_ids:
2948 candidate = original_candidates.get(candidate_id)
2949 if candidate:
2950 original_summary = candidate.snippet or candidate.explanation or candidate.title
2951 if original_summary:
2952 break
2953 if original_summary:
2954 break
2955
2956 unrelated_candidate_indexes = {
2957 candidate.candidate_id: index
2958 for index, candidate in enumerate(unrelated_candidates)
2959 }
2960 focused_candidates: list[schema.Candidate] = []
2961 for candidate in [
2962 *copy.deepcopy(drill_report.ranked_candidates),
2963 *[
2964 copy.deepcopy(candidate)
2965 for candidate in merged.ranked_candidates
2966 if candidate.candidate_id in selected_candidate_ids
2967 ],
2968 ]:
2969 unrelated_index = unrelated_candidate_indexes.get(candidate.candidate_id)
2970 if unrelated_index is not None:
2971 candidate.cluster_id = unrelated_candidates[unrelated_index].cluster_id
2972 unrelated_candidates[unrelated_index] = candidate
2973 continue
2974 if not _candidate_is_duplicate(candidate, focused_candidates):
2975 focused_candidates.append(candidate)
2976
2977 primary_cluster = matched_clusters[0]
2978 for candidate in focused_candidates:
2979 candidate.cluster_id = primary_cluster.cluster_id
2980 focused_ids = [candidate.candidate_id for candidate in focused_candidates]
2981 focused_sources = sorted({
2982 source
2983 for candidate in focused_candidates
2984 for source in schema.candidate_sources(candidate)
2985 })
2986 replacement_cluster = schema.Cluster(
2987 cluster_id=primary_cluster.cluster_id,
2988 title=primary_cluster.title,
2989 candidate_ids=focused_ids,
2990 representative_ids=focused_ids[:3],
2991 sources=focused_sources,
2992 score=max((candidate.final_score for candidate in focused_candidates), default=0.0),
2993 uncertainty="single-source" if len(focused_sources) == 1 else None,
2994 )
2995
2996 first_selected_index = min(
2997 index
2998 for index, cluster in enumerate(merged.clusters)
2999 if cluster.cluster_id in selected_cluster_ids
3000 )
3001 remaining_clusters = [
3002 cluster for cluster in merged.clusters
3003 if cluster.cluster_id not in selected_cluster_ids
3004 ]
3005 remaining_clusters.insert(first_selected_index, replacement_cluster)
3006 merged.clusters = remaining_clusters
3007
3008 merged.ranked_candidates = focused_candidates + unrelated_candidates
3009
3010 all_sources = set(merged.items_by_source) | set(drill_report.items_by_source)
3011 new_item_count = 0
3012 merged_items: dict[str, list[schema.SourceItem]] = {}
3013 for source in sorted(all_sources):
3014 old_items = merged.items_by_source.get(source, [])
3015 new_items = drill_report.items_by_source.get(source, [])
3016 # Collapse exact URL matches first, preferring the drill's copy (it
3017 # carries fresh transcripts/comments); fuzzy dedupe alone keeps both
3018 # when enrichment changed the text substantially.
3019 new_urls = {item.url for item in new_items if item.url}
3020 kept_old = [item for item in old_items if not (item.url and item.url in new_urls)]
3021 combined = dedupe.dedupe_items([*copy.deepcopy(new_items), *kept_old])
3022 old_unique = dedupe.dedupe_items(old_items)
3023 new_item_count += max(0, len(combined) - len(old_unique))
3024 merged_items[source] = combined
3025 merged.items_by_source = merged_items
3026
3027 merged.generated_at = drill_report.generated_at
3028 merged.query_plan = drill_report.query_plan
3029 # The drill's retrieval window is the report's window now (a --days/--as-of
3030 # override on the drill must not be mislabeled with the cached range).
3031 merged.range_from = drill_report.range_from
3032 merged.range_to = drill_report.range_to
3033 attempted_sources = {
3034 source
3035 for source, outcome in drill_report.source_status.items()
3036 if outcome.attempted or outcome.state == schema.SKIPPED_UNCONFIGURED
3037 }
3038 for source in attempted_sources:
3039 if source in drill_report.errors_by_source:
3040 merged.errors_by_source[source] = drill_report.errors_by_source[source]
3041 else:
3042 merged.errors_by_source.pop(source, None)
3043 merged.source_status[source] = drill_report.source_status[source]
3044 merged.source_status = _finalize_source_status(
3045 merged.source_status,
3046 merged.items_by_source,
3047 )
3048 degraded_by_source = {
3049 source: outcome.detail or "partial results"
3050 for source, outcome in merged.source_status.items()
3051 if outcome.state == schema.PARTIAL
3052 }
3053 merged.warnings = _warnings(
3054 merged.items_by_source,
3055 merged.ranked_candidates,
3056 merged.errors_by_source,
3057 degraded_by_source,
3058 )
3059 merged.artifacts.update(copy.deepcopy(drill_report.artifacts))
3060 history = list(merged.artifacts.get("drill_history") or [])
3061 history.append({
3062 "target": target,
3063 "clusters": [cluster.title for cluster in matched_clusters],
3064 "new_items": new_item_count,
3065 "generated_at": drill_report.generated_at,
3066 })
3067 merged.artifacts["drill_history"] = history
3068 merged.artifacts["drill_context"] = {
3069 "target": target,
3070 "cluster_titles": [cluster.title for cluster in matched_clusters],
3071 "original_summary": original_summary,
3072 "new_items": new_item_count,
3073 "sources": focused_sources,
3074 }
3075 merged.drill_of = primary_cluster.title
3076 return merged
3077
3078
3079 def _batch_subject_handles(raw_items: list[dict], *, top_n: int = 2) -> set[str]:
3080 """Most-mentioned handles in a batch of X items, as first-party candidates.
3081
3082 Mirrors entity_extract's ranking but runs before pruning rather than after,
3083 and keys on *mentions only* rather than mentions plus authors. That
3084 distinction is the safety property: a prolific commentator inflates the
3085 author count, but being mentioned by other accounts is what identifies the
3086 subject of a topic. Capped at the top few so a busy thread cannot exempt
3087 the whole batch.
3088 """
3089 counts: Counter = Counter()
3090 for item in raw_items or []:
3091 text = str((item or {}).get("text") or "")
3092 for mention in re.findall(r"@([A-Za-z0-9_]{1,15})", text):
3093 counts[mention.lower()] += 1
3094 if not counts:
3095 return set()
3096 return {handle for handle, _ in counts.most_common(top_n)}
3097
3098
3099 # Reddit engagement keepers: per stream, the top-N threads by upvotes plus
3100 # comments that clear the relevance floor and name the primary entity survive
3101 # per_stream_limit truncation even when their local rank score is low. The
3102 # stream order is 65% title relevance, so the month's most-discussed on-topic
3103 # thread (16K upvotes, 0.19 relevance) was otherwise cut behind one-upvote
3104 # posts with better title overlap.
3105 REDDIT_STREAM_KEEPERS = 3
3106
3107
3108 def _apply_reddit_stream_keepers(
3109 source: str,
3110 items: list[schema.SourceItem],
3111 limit: int,
3112 topic: str,
3113 ) -> list[schema.SourceItem]:
3114 """Truncate a stream to *limit*, holding slots for Reddit engagement keepers."""
3115 kept = list(items[:limit])
3116 if source != "reddit" or len(items) <= limit:
3117 return kept
3118 entity = rerank._primary_entity(topic or "") if topic else ""
3119 floor = fusion.relevance_floor_for_entity(entity)
3120 keepers = [
3121 item
3122 for item in sorted(items, key=fusion.raw_engagement, reverse=True)
3123 if fusion.reddit_thread_qualifies(item, entity, floor)
3124 ][:REDDIT_STREAM_KEEPERS]
3125 keeper_ids = {id(item) for item in keepers}
3126 for keeper in keepers:
3127 if any(item is keeper for item in kept):
3128 continue
3129 # Displace the lowest-ranked non-keeper so the slice stays at limit;
3130 # when the slice is already all keepers there is nothing to trade.
3131 displaced = False
3132 for index in range(len(kept) - 1, -1, -1):
3133 if id(kept[index]) not in keeper_ids:
3134 del kept[index]
3135 displaced = True
3136 break
3137 if displaced or len(kept) < limit:
3138 kept.append(keeper)
3139 return kept[:limit]
3140
3141
3142
3143 def _creator_first_party_by_source(
3144 tiktok_creators: Iterable[str] | None,
3145 ig_creators: Iterable[str] | None,
3146 ) -> dict[str, set[str]]:
3147 """Platform-scoped creator handles for the relevance prune.
3148
3149 --ig-creators names Instagram accounts and --creators names TikTok
3150 accounts; each exemption applies only on its own platform. Merging both
3151 into one global handle set would let an unrelated same-name account on
3152 another platform bypass the relevance and engagement floors.
3153 """
3154
3155 def _norm(handles: Iterable[str] | None) -> set[str]:
3156 return {
3157 h.lstrip("@").strip().lower()
3158 for h in (handles or [])
3159 if h and h.strip()
3160 }
3161
3162 return {"instagram": _norm(ig_creators), "tiktok": _norm(tiktok_creators)}
3163
3164
3165 def _creator_only_handles(
3166 creator_first_party: Mapping[str, set[str]],
3167 *x_provenance_sets: Iterable[str],
3168 ) -> set[str]:
3169 """Creator handles that carry no X provenance.
3170
3171 A handle named ONLY via --ig-creators / --creators must not exempt a
3172 same-name X account from the deferred X floor. But the same person is
3173 often named on both surfaces (--x-handle foo --ig-creators foo): the
3174 normalized sets collapse that to one string, so subtracting the whole
3175 creator set would strip the explicitly requested X exemption too. The
3176 subtraction therefore covers only handles absent from every
3177 X-provenance set (explicit flags, topic mentions, Phase 2 discovery).
3178 """
3179 creator_flat = {h for handles in creator_first_party.values() for h in handles}
3180 x_provenance = {h for handles in x_provenance_sets for h in handles}
3181 return creator_flat - x_provenance
3182
3183
3184 def _log_prune_drop(
3185 source: str, before: int, after: int, scope: str | None = None
3186 ) -> None:
3187 """Log when the relevance prune removes items from a stream.
3188
3189 The prune is silent by design inside ``signals`` (a pure function), but a
3190 silent drop is invisible to the user: issue #1101 fetched 36 creator reels
3191 and reported zero with no line explaining why. Log the count and the reason
3192 class here, next to the other per-stream retrieval logs. The all-weak
3193 ``filtered or items`` rescue keeps the originals, so before == after and
3194 nothing is logged - a rescue is not a drop.
3195 """
3196 dropped = before - after
3197 if dropped <= 0:
3198 return
3199 scope_note = f" ({scope})" if scope else ""
3200 log.source_log(
3201 render.SOURCE_LABELS.get(source, source.capitalize()),
3202 f"relevance prune dropped {dropped} of {before} items below the "
3203 f"relevance/engagement floor{scope_note}",
3204 tty_only=False,
3205 )
3206
3207
3208 def _normalize_score_dedupe(
3209 source: str,
3210 raw_items: list[dict],
3211 from_date: str,
3212 to_date: str,
3213 freshness_mode: str,
3214 ranking_query: str,
3215 first_party_handles: Iterable[str] | None = None,
3216 first_party_by_source: Mapping[str, Iterable[str]] | None = None,
3217 defer_relevance_prune: bool = False,
3218 ) -> list[schema.SourceItem]:
3219 """Normalize, annotate, prune, dedupe, and extract snippets for a batch of raw items.
3220
3221 ``defer_relevance_prune`` skips the relevance floor here so the caller can
3222 apply it once the run has resolved who the topic's subject is. Pruning X
3223 before handle resolution is the ordering bug behind the whole first-party
3224 evidence loss: the floor cannot exempt an author nobody has identified yet,
3225 and no amount of guessing at prune time substitutes for knowing.
3226
3227 ``first_party_handles`` names accounts this run is explicitly searching, so
3228 their own posts survive the relevance floor (see signals.prune_low_relevance).
3229 """
3230 normalized = normalize.normalize_source_items(
3231 source, raw_items, from_date, to_date,
3232 freshness_mode=freshness_mode,
3233 )
3234 prepared_query = relevance.PreparedQuery(ranking_query)
3235 lookback_window_days = (
3236 datetime.strptime(to_date, "%Y-%m-%d").date()
3237 - datetime.strptime(from_date, "%Y-%m-%d").date()
3238 ).days
3239 normalized = signals.annotate_stream(
3240 normalized,
3241 prepared_query,
3242 freshness_mode,
3243 reference_date=to_date,
3244 max_days=lookback_window_days,
3245 )
3246 if source != "jobs" and not defer_relevance_prune:
3247 floor_handles = set(first_party_handles or ())
3248 if source == "x":
3249 # Union, never a fallback. The caller's set is derived partly from
3250 # topic tokens, so it is non-empty for essentially every real topic
3251 # -- gating this on "no handles supplied" would make it dead code
3252 # and leave the name-only case exactly as broken as before.
3253 #
3254 # Reuses the engine's own resolution signal on the batch already in
3255 # hand: posts *about* a subject mention their handle, so the
3256 # most-mentioned account in a topic's own results is the subject.
3257 # Costs nothing extra -- no search, no network -- and closes the
3258 # case where the handle never appears in the topic at all
3259 # ("Peter Steinberger" -> @steipete).
3260 floor_handles |= _batch_subject_handles(raw_items)
3261 pre_prune_count = len(normalized)
3262 normalized = signals.prune_low_relevance(
3263 normalized,
3264 first_party_handles=floor_handles,
3265 first_party_by_source=first_party_by_source,
3266 )
3267 _log_prune_drop(source, pre_prune_count, len(normalized))
3268 normalized = dedupe.dedupe_items(normalized)
3269 for item in normalized:
3270 item.snippet = snippet.extract_best_snippet(item, prepared_query)
3271 return normalized
3272
3273
3274 def _finalize_items_by_source(
3275 items_by_source_raw: dict[str, list[schema.SourceItem]],
3276 topic: str = "",
3277 config: dict | None = None,
3278 depth: str = "default",
3279 mock: bool = False,
3280 elapsed: float = 0.0,
3281 ) -> dict[str, list[schema.SourceItem]]:
3282 finalized = {}
3283 for source, items in items_by_source_raw.items():
3284 items = sorted(items, key=lambda item: item.local_rank_score or 0.0, reverse=True)
3285 # Same thread from two subquery streams: fold the enriched copy into
3286 # the first before the text-similarity dedupe, which would otherwise
3287 # keep whichever copy ranked higher and drop its comments.
3288 items = collapse_duplicate_urls(items)
3289 items = dedupe.dedupe_items(items)
3290 enrichment_request = {
3291 "source": source,
3292 "phase": "post_ranking_enrichment",
3293 "topic": topic,
3294 "depth": depth,
3295 }
3296 if source == "youtube" and items and not mock:
3297 # Same budget-at-the-survivors principle as the digg branch
3298 # below: retrieval-time transcripts go to each search's
3299 # top-by-views candidates, while final selection ranks by
3300 # relevance. Backfill survivors that arrived without one so the
3301 # transcript budget lands on videos the brief actually shows
3302 # (#542).
3303 matched, replayed = http.fixture_source_replay(enrichment_request)
3304 if matched:
3305 items = _merge_replayed_enrichment(items, replayed)
3306 else:
3307 sc_token = (
3308 config.get("SCRAPECREATORS_API_KEY")
3309 if config and env.is_youtube_sc_available(config) else None
3310 )
3311 youtube_yt.backfill_transcripts(
3312 items, topic=topic, depth=depth, token=sc_token,
3313 )
3314 http.fixture_source_record(enrichment_request, schema.to_dict(items))
3315 # Post-merge topic-relevance filter for Polymarket: comparison queries
3316 # fan out into per-entity subqueries ("Hermes", "OpenClaw") whose topic
3317 # is too narrow for Gamma API to filter meaningfully. Re-validating the
3318 # merged list against the full original topic drops off-topic markets
3319 # (e.g., WTI crude oil, Elon tweet counts) before footer emission.
3320 if source == "polymarket" and topic:
3321 items = polymarket.filter_items_against_topic(topic, items)
3322 # --polymarket-keywords (via config): additional keyword filter
3323 # for ambiguous single-token topics (e.g., "Warriors" → nba,gsw).
3324 keywords = config.get("_polymarket_keywords") if isinstance(config, dict) else None
3325 if keywords:
3326 items = polymarket.filter_items_against_keywords(items, keywords)
3327 if source == "digg" and items:
3328 # Pull top-ranked X posts only for the survivors that will appear
3329 # in the brief. Spending the enrichment budget here (rather than
3330 # at retrieval time) keeps the inline 'via Digg' quotes
3331 # paired with the clusters dedupe actually kept.
3332 matched, replayed = http.fixture_source_replay(enrichment_request)
3333 if matched:
3334 items = _merge_replayed_enrichment(items, replayed)
3335 else:
3336 digg.enrich_source_items(items, top_k=3)
3337 http.fixture_source_record(enrichment_request, schema.to_dict(items))
3338 if source == "amazon" and items and not mock:
3339 # Attach-if-missing: review enrichment now runs at search time in
3340 # _retrieve_stream_impl, so items arriving here should already have
3341 # top_comments. enrich_source_items no-ops when top_comments is set.
3342 # This path handles fixture replay and any edge cases where retrieve
3343 # didn't enrich (e.g., run_started was not passed).
3344 matched, replayed = http.fixture_source_replay(enrichment_request)
3345 if matched:
3346 items = _merge_replayed_enrichment(items, replayed)
3347 else:
3348 amazon.enrich_source_items(
3349 items,
3350 depth=depth,
3351 config=config,
3352 keyword=str((config or {}).get("_amazon_query") or "").strip() or topic,
3353 elapsed=elapsed,
3354 )
3355 http.fixture_source_record(enrichment_request, schema.to_dict(items))
3356 finalized[source] = items
3357 return finalized
3358
3359
3360 def _merge_replayed_enrichment(
3361 items: list[schema.SourceItem],
3362 replayed: list[dict],
3363 ) -> list[schema.SourceItem]:
3364 """Apply recorded post-ranking enrichment onto freshly computed items.
3365
3366 Enrichment (transcripts, Digg posts) only mutates ``metadata``. Merging by
3367 item_id instead of replacing the list keeps normalization, scoring, and
3368 dedupe regressions visible to the eval - fixture state must not overwrite
3369 what the current pipeline computed.
3370 """
3371 replayed_by_id = {
3372 entry.get("item_id"): entry for entry in replayed if isinstance(entry, dict)
3373 }
3374 for item in items:
3375 record = replayed_by_id.get(item.item_id)
3376 if record and record.get("metadata"):
3377 item.metadata.update(record["metadata"])
3378 return items
3379
3380
3381 def _apply_hiring_signal_gate(
3382 bundle: schema.RetrievalBundle,
3383 *,
3384 explicit: bool,
3385 topic: str,
3386 ) -> dict[str, Any] | None:
3387 jobs_items = bundle.items_by_source.get("jobs") or []
3388 if not jobs_items:
3389 if explicit:
3390 return hiring_signals.analyze([], explicit=True, topic=topic)
3391 return None
3392
3393 summary = hiring_signals.analyze(jobs_items, explicit=explicit, topic=topic)
3394 if not explicit and not summary.get("include"):
3395 bundle.items_by_source.pop("jobs", None)
3396 for key in list(bundle.items_by_source_and_query):
3397 if key[1] == "jobs":
3398 del bundle.items_by_source_and_query[key]
3399 return summary
3400
3401
3402 def _ensure_jobs_in_plan(
3403 plan: schema.QueryPlan,
3404 available: list[str],
3405 *,
3406 explicit: bool,
3407 topic: str,
3408 ) -> None:
3409 if "jobs" not in available:
3410 return
3411 if not (explicit or _company_topic_likely(topic)):
3412 return
3413 if "jobs" not in plan.source_weights:
3414 plan.source_weights["jobs"] = 1.0
3415 for subquery in plan.subqueries:
3416 if "jobs" not in subquery.sources:
3417 subquery.sources.append("jobs")
3418
3419
3420 def _ensure_perplexity_in_plan(
3421 plan: schema.QueryPlan,
3422 topic: str,
3423 available: list[str],
3424 *,
3425 force: bool,
3426 ) -> None:
3427 """Route a bounded paid Perplexity action through the whole topic.
3428
3429 Deep Research forces its explicit lane. Normal modes are rerouted only when
3430 the sanitized plan already selected Perplexity.
3431 """
3432 if "perplexity" not in available:
3433 return
3434 planned = any(
3435 "perplexity" in subquery.sources for subquery in plan.subqueries
3436 )
3437 if not force and not planned:
3438 return
3439 retained: list[schema.SubQuery] = []
3440 for subquery in plan.subqueries:
3441 sources = [
3442 source for source in subquery.sources if source != "perplexity"
3443 ]
3444 if sources:
3445 retained.append(replace(subquery, sources=sources))
3446 retained.append(
3447 schema.SubQuery(
3448 label="deep-research" if force else "perplexity-whole-topic",
3449 search_query=topic,
3450 ranking_query=f"What current source-grounded evidence matters for {topic}?",
3451 sources=["perplexity"],
3452 weight=1.0,
3453 ),
3454 )
3455 plan.subqueries = planner._normalize_subquery_weights(retained)
3456 plan.source_weights.setdefault("perplexity", 1.0)
3457 plan.source_weights = planner._normalize_weights(plan.source_weights)
3458
3459
3460 def _company_topic_likely(topic: str) -> bool:
3461 text = topic.strip()
3462 if not text:
3463 return False
3464 lower = text.lower()
3465 if "?" in text or len(text.split()) > 4:
3466 return False
3467 generic = {
3468 "how", "what", "why", "best", "top", "tutorial", "guide", "prompts",
3469 "news", "latest", "ideas", "examples",
3470 }
3471 if any(word in generic for word in lower.split()):
3472 return False
3473 known_single_word_companies = {
3474 "apple", "uber", "google", "microsoft", "amazon", "meta", "netflix",
3475 "openai", "anthropic", "qualtrics", "stripe", "brex",
3476 }
3477 if " vs " in lower or " versus " in lower:
3478 parts = re.split(r"\s+(?:vs|versus)\s+", text, maxsplit=1, flags=re.IGNORECASE)
3479 if len(parts) != 2:
3480 return False
3481 return _comparison_side_company_like(parts[0], known_single_word_companies) or _comparison_side_company_like(
3482 parts[1], known_single_word_companies
3483 )
3484 return bool(text[:1].isupper() or lower in known_single_word_companies)
3485
3486
3487 def _comparison_side_company_like(side: str, known_companies: set[str]) -> bool:
3488 token = re.sub(r"[^\w.+#-]", "", side.strip().split()[0] if side.strip() else "")
3489 if not token:
3490 return False
3491 lower = token.lower()
3492 common_tech_terms = {
3493 "python", "ruby", "javascript", "typescript", "java", "go", "golang",
3494 "rust", "php", "swift", "kotlin", "scala", "clojure", "elixir",
3495 "react", "vue", "angular", "svelte", "node", "django", "rails",
3496 "postgres", "mysql", "redis", "kubernetes", "docker",
3497 }
3498 if lower in common_tech_terms:
3499 return False
3500 return bool(token[:1].isupper() or lower in known_companies)
3501
3502
3503 def _warnings(
3504 items_by_source: dict[str, list[schema.SourceItem]],
3505 candidates: list[schema.Candidate],
3506 errors_by_source: dict[str, str],
3507 degraded_by_source: dict[str, str] | None = None,
3508 ) -> list[str]:
3509 warnings: list[str] = []
3510 if not candidates:
3511 warnings.append("No candidates survived retrieval and ranking.")
3512 if len(candidates) < 5:
3513 warnings.append("Evidence is thin for this topic.")
3514 top_sources = {
3515 source
3516 for candidate in candidates[:5]
3517 for source in schema.candidate_sources(candidate)
3518 }
3519 if len(top_sources) <= 1 and len(candidates) >= 3:
3520 warnings.append("Top evidence is highly concentrated in one source.")
3521 if errors_by_source:
3522 warnings.append(f"Some sources failed: {', '.join(sorted(errors_by_source))}")
3523 if degraded_by_source:
3524 # Partial failures: the source returned some items but errored/timed out
3525 # on at least one subquery, so its coverage is likely incomplete. Kept
3526 # distinct from hard failures so the signal is not silently dropped.
3527 warnings.append(
3528 f"Some sources returned partial results (degraded): {', '.join(sorted(degraded_by_source))}"
3529 )
3530 if not items_by_source:
3531 warnings.append("No source returned usable items.")
3532 return warnings
3533
3534
3535 def _is_rate_limit_error(exc: Exception) -> bool:
3536 """Detect 429 rate-limit errors by status code or message text."""
3537 if hasattr(exc, "status_code") and getattr(exc, "status_code", None) == 429:
3538 return True
3539 return "429" in str(exc)
3540
3541
3542 class SourceRunError(RuntimeError):
3543 """Source-specific failure that survived a module's fallback logic."""
3544
3545 def __init__(self, message: str, state: schema.RunOutcomeState | None = None):
3546 super().__init__(message)
3547 self.outcome_state = state or http.classify_failure(message=message)
3548
3549
3550 def _classify_source_failure(exc: Exception) -> tuple[schema.RunOutcomeState, bool]:
3551 """Classify HTTP, subprocess, and module-specific failures consistently."""
3552 detail = str(exc)
3553 lowered = detail.lower()
3554 if any(marker in lowered for marker in ("not configured", "no api key", "not installed")):
3555 return schema.SKIPPED_UNCONFIGURED, False
3556 if any(
3557 marker in lowered
3558 for marker in (
3559 "cookie expired",
3560 "expired cookie",
3561 "login required",
3562 "not logged in",
3563 "grok session expired",
3564 "session expired or was revoked",
3565 "invalid_grant",
3566 "not signed in",
3567 )
3568 ):
3569 return schema.AUTH_FAILED, True
3570 state = getattr(exc, "outcome_state", None) or http.classify_failure(
3571 status_code=getattr(exc, "status_code", None),
3572 message=detail,
3573 )
3574 return state, True
3575
3576
3577 def _outcome_artifact(
3578 state: schema.RunOutcomeState,
3579 detail: str,
3580 *,
3581 attempted: bool = True,
3582 ) -> dict[str, Any]:
3583 return {
3584 "_source_outcome": {
3585 "state": state,
3586 "detail": detail,
3587 "attempted": attempted,
3588 }
3589 }
3590
3591
3592 def _result_outcome_artifact(source: str, result: Any) -> dict[str, Any]:
3593 """Convert a legacy ``{"error": ...}`` source result into typed status."""
3594 if not isinstance(result, dict) or not result.get("error"):
3595 return {}
3596 detail = str(result["error"])
3597 if source == "reddit":
3598 state = reddit.classify_run_failure(detail)
3599 attempted = True
3600 elif source == "youtube":
3601 state = youtube_yt.classify_run_failure(detail)
3602 attempted = state != schema.SKIPPED_UNCONFIGURED
3603 elif source == "x":
3604 state = bird_x.classify_run_failure(detail)
3605 attempted = True
3606 elif source == "truthsocial" and detail == "Truth Social token expired":
3607 state = schema.AUTH_FAILED
3608 attempted = True
3609 elif source == "bluesky" and "network-level block" in detail.lower():
3610 state = schema.UNREACHABLE
3611 attempted = True
3612 else:
3613 state, attempted = _classify_source_failure(SourceRunError(detail))
3614 return _outcome_artifact(state, detail, attempted=attempted)
3615
3616
3617 def _legacy_artifact_outcome(
3618 source: str,
3619 artifact: Any,
3620 ) -> dict[str, Any] | None:
3621 """Map known pre-outcome artifact contracts to a typed outcome note."""
3622 if not isinstance(artifact, dict):
3623 return None
3624 explicit = artifact.get("_source_outcome")
3625 if isinstance(explicit, dict):
3626 return explicit
3627 if source == "perplexity":
3628 candidates: list[tuple[str | None, dict[str, Any]]] = [(None, artifact)]
3629 if artifact.get("mode") == "both":
3630 for leg in ("search", "agent"):
3631 value = artifact.get(leg)
3632 if isinstance(value, dict):
3633 candidates.append((leg, value))
3634 outcomes: list[dict[str, Any]] = []
3635 for leg, candidate in candidates:
3636 if not candidate.get("error"):
3637 continue
3638 error = str(candidate["error"])
3639 detail = str(
3640 candidate.get("backgroundErrorMessage")
3641 or candidate.get("backgroundPollError")
3642 or candidate.get("agentErrorMessage")
3643 or candidate.get("asyncErrorMessage")
3644 or candidate.get("message")
3645 or error
3646 )
3647 if leg:
3648 detail = f"{leg} leg: {detail}"
3649 status_code = candidate.get("statusCode")
3650 if status_code is None:
3651 status_code = candidate.get("backgroundPollStatusCode")
3652 state = (
3653 health.TIMEOUT
3654 if error.lower() == "timeout"
3655 else http.classify_failure(
3656 status_code=status_code,
3657 message=f"{error}: {detail}",
3658 )
3659 )
3660 outcomes.append(_outcome_artifact(state, detail)["_source_outcome"])
3661 if outcomes:
3662 return min(
3663 outcomes,
3664 key=lambda outcome: _FAILURE_SPECIFICITY.get(outcome["state"], 9),
3665 )
3666 if (
3667 source == "grounding"
3668 and artifact.get("reason") == "keyless-search-unavailable"
3669 ):
3670 return _outcome_artifact(
3671 schema.UNREACHABLE,
3672 "Keyless web search unavailable",
3673 )["_source_outcome"]
3674 return None
3675
3676
3677 def _summarize_lane_failures(failures: list[http.HTTPError], source: str = "") -> str:
3678 """One line naming what a source lost to swallowed sub-request failures.
3679
3680 ``"3 sub-requests rate-limited (HTTP 429); 1 sub-request blocked (HTTP 403)"``.
3681 Used as ``SourceOutcome.detail`` on a source that still delivered items,
3682 so the loss is visible to ``doctor --postmortem`` without branding the
3683 source partial (issue #985 wording; PR #959 semantics).
3684 """
3685 counts: dict[tuple[str, int | None], int] = {}
3686 for failure in failures:
3687 state = getattr(failure, "outcome_state", None) or health.ERROR
3688 code = getattr(failure, "status_code", None)
3689 counts[(state, code)] = counts.get((state, code), 0) + 1
3690 labels = {
3691 health.RATE_LIMITED: "rate-limited",
3692 health.AUTH_FAILED: "blocked",
3693 health.PAYMENT_REQUIRED: health.credits_exhausted_label(source),
3694 health.TIMEOUT: "timed out",
3695 health.UNREACHABLE: "unreachable",
3696 health.SCHEMA_DRIFT: "returned an unexpected shape",
3697 }
3698 parts = []
3699 for (state, code), n in sorted(counts.items(), key=lambda kv: -kv[1]):
3700 noun = "sub-request" if n == 1 else "sub-requests"
3701 label = labels.get(state, "failed")
3702 suffix = f" (HTTP {code})" if code else ""
3703 parts.append(f"{n} {noun} {label}{suffix}")
3704 return "; ".join(parts)
3705
3706
3707 def _resolve_stream_outcome(
3708 source: str,
3709 artifact: Any,
3710 failures: list[http.HTTPError],
3711 ) -> dict[str, Any] | None:
3712 """Choose the most specific artifact or captured HTTP outcome."""
3713 artifact_outcome = _legacy_artifact_outcome(source, artifact)
3714 if not failures:
3715 return artifact_outcome
3716 # Pick the most specific failure rather than the last-appended one:
3717 # parallel workers append in nondeterministic order, and an auth failure
3718 # must not be masked by a later 429 (wrong doctor prescription).
3719 failure = min(
3720 failures,
3721 key=lambda f: _FAILURE_SPECIFICITY.get(f.outcome_state, 9),
3722 )
3723 captured_outcome = _outcome_artifact(
3724 failure.outcome_state,
3725 str(failure),
3726 )["_source_outcome"]
3727 if artifact_outcome is None:
3728 return captured_outcome
3729 if (
3730 artifact_outcome.get("state") == health.ERROR
3731 and failure.outcome_state != health.ERROR
3732 ):
3733 return captured_outcome
3734 return artifact_outcome
3735
3736
3737 def _finalize_source_status(
3738 outcomes: dict[str, schema.SourceOutcome],
3739 items_by_source: dict[str, list[schema.SourceItem]],
3740 ) -> dict[str, schema.SourceOutcome]:
3741 """Sync outcome counts to the final post-filter evidence set."""
3742 finalized: dict[str, schema.SourceOutcome] = {}
3743 for source, outcome in outcomes.items():
3744 count = len(items_by_source.get(source, []))
3745 state = outcome.state
3746 detail = outcome.detail
3747 fix_hint = outcome.fix_hint
3748 if state == schema.NO_RESULTS and count:
3749 state = health.OK
3750 detail = None
3751 fix_hint = None
3752 elif state == health.OK and not count:
3753 state = outcome.lane_failure_state or schema.NO_RESULTS
3754 elif state == schema.PARTIAL and not count:
3755 state = http.classify_failure(message=detail or "")
3756 finalized[source] = schema.SourceOutcome(
3757 source=source,
3758 state=state,
3759 items_returned=count,
3760 attempted=outcome.attempted,
3761 detail=detail,
3762 at=outcome.at,
3763 fix_hint=fix_hint,
3764 lane_failure_state=outcome.lane_failure_state,
3765 )
3766 return finalized
3767
3768
3769 def _is_transient_error(exc: Exception) -> bool:
3770 """Detect 5xx server errors that are worth retrying."""
3771 status = getattr(exc, "status_code", None)
3772 if isinstance(status, int) and 500 <= status < 600:
3773 return True
3774 msg = str(exc)
3775 return any(code in msg for code in ("500", "502", "503", "504"))
3776
3777
3778 def _topic_handle_mentions(topic: str) -> set[str]:
3779 """@mentions in the topic, which are real X handles.
3780
3781 These are used to determine whether the subject was identified: an
3782 @mention like "@steipete" is a real handle that can exempt its owner from
3783 the relevance floor. Regular words like "Peter" are not real handles.
3784 """
3785 return {
3786 mention.lower()
3787 for mention in re.findall(r"@([A-Za-z0-9_]{1,15})", topic or "")
3788 }
3789
3790
3791 def _topic_first_party_candidates(topic: str) -> set[str]:
3792 """Handle-shaped tokens in the topic itself, usable before any retrieval.
3793
3794 Phase 1 runs before automatic handle resolution, and a quick-depth run
3795 skips that resolution entirely, so neither has access to the extracted
3796 handle set. Without this a quick search for "Peter Steinberger steipete"
3797 still drops every post steipete wrote, which is the exact failure this
3798 branch exists to fix.
3799
3800 Deliberately permissive about what looks like a handle and strict about
3801 what it does: a candidate only ever matters if a retrieved post's *author*
3802 matches it, so an ordinary word like "lunch" costs nothing -- no author is
3803 named "lunch". The realistic false positive is an account named after a
3804 topic word, which the frequency-ranked path could surface anyway.
3805 """
3806 tokens = set()
3807 for mention in re.findall(r"@([A-Za-z0-9_]{1,15})", topic or ""):
3808 tokens.add(mention.lower())
3809 for word in re.findall(r"[A-Za-z0-9_]{3,15}", topic or ""):
3810 lowered = word.lower()
3811 if lowered not in relevance.STOPWORDS:
3812 tokens.add(lowered)
3813 return tokens
3814
3815
3816 def _name_lane_subject(topic: str) -> str:
3817 """Resolve the entity name to search for by name, not the whole topic.
3818
3819 Phrase-quoting a raw topic ("Peter Steinberger steipete") matches nothing
3820 on X: nobody writes the handle and the display name together. Prefer a
3821 title-cased proper noun the way the planner's keyword query does, and fall
3822 back to the first compound term, then to the topic.
3823 """
3824 import re as _re
3825 compounds = query.extract_compound_terms(topic) or []
3826 title_cased = [
3827 term for term in compounds
3828 if _re.match(r"^(?:[A-Z][a-z]+\s+){1,}[A-Z][a-z]+$", term)
3829 ]
3830 if title_cased:
3831 return title_cased[0]
3832 if compounds:
3833 return compounds[0]
3834 return topic.strip()
3835
3836
3837 def _run_supplemental_searches(
3838 *,
3839 topic: str,
3840 bundle: schema.RetrievalBundle,
3841 plan: schema.QueryPlan,
3842 config: dict[str, Any],
3843 depth: str,
3844 date_range: tuple[str, str],
3845 runtime: schema.ProviderRuntime,
3846 mock: bool,
3847 rate_limited_sources: set[str],
3848 rate_limit_lock: threading.Lock,
3849 x_handle: str | None = None,
3850 x_related: list[str] | None = None,
3851 resolved_handles_out: list[str] | None = None,
3852 ) -> None:
3853 """Phase 2: extract entities from Phase 1 results, run targeted supplemental searches."""
3854 from_date, to_date = date_range
3855
3856 # Host-fetched X lane: the envelope's lane calls replace the backend
3857 # lanes and are served at every depth (the host already paid for them),
3858 # before the quick/mock return and before the chain is recomputed.
3859 # Extracted-handle promotion is skipped on envelope runs; a declared lane
3860 # without an envelope runs no lane at all (the topic stream already
3861 # recorded the not-passed outcome).
3862 if config.get("_x_lane_missing"):
3863 return
3864 envelope = config.get("_x_envelope")
3865 if envelope is not None:
3866 _serve_envelope_lanes(
3867 envelope, bundle=bundle, plan=plan, x_handle=x_handle, x_related=x_related,
3868 from_date=from_date, to_date=to_date,
3869 )
3870 return
3871
3872 if depth == "quick" or mock:
3873 return
3874
3875 # Convert SourceItems to dicts for entity_extract. All X items (whatever
3876 # backend fetched them — bird, xai, xurl, xquik) land under the single "x"
3877 # slug, so this reads the whole X corpus.
3878 x_dicts = [
3879 {"author_handle": item.author or "", "text": item.body or ""}
3880 for item in bundle.items_by_source.get("x", [])
3881 ]
3882 reddit_dicts = [
3883 {
3884 "subreddit": item.container or "",
3885 "comment_insights": item.metadata.get("comment_insights", []),
3886 "top_comments": [
3887 {"excerpt": c.get("excerpt", c.get("text", ""))}
3888 for c in (item.metadata.get("top_comments") or [])
3889 if isinstance(c, dict)
3890 ],
3891 }
3892 for item in bundle.items_by_source.get("reddit", [])
3893 ]
3894
3895 if not x_dicts and not reddit_dicts and not x_handle and not x_related:
3896 return
3897
3898 entities = entity_extract.extract_entities(
3899 reddit_dicts, x_dicts,
3900 max_handles=3, max_subreddits=3,
3901 )
3902
3903 handles = entities.get("x_handles", [])
3904
3905 # Add explicit --x-handle if provided
3906 if x_handle:
3907 handle_clean = x_handle.lstrip("@").lower()
3908 if handle_clean not in [h.lower() for h in handles]:
3909 handles.insert(0, handle_clean)
3910
3911 # Collect related handles (searched separately with lower weight)
3912 related_handles = []
3913 if x_related:
3914 primary_lower = x_handle.lstrip("@").lower() if x_handle else ""
3915 for rh in x_related:
3916 rh_clean = rh.lstrip("@").lower().strip()
3917 if rh_clean and rh_clean != primary_lower and rh_clean not in [h.lower() for h in handles]:
3918 related_handles.append(rh_clean)
3919
3920 # Surface every handle this run resolved back to the caller. resolved_handles
3921 # is built later from --x-handle / --github-user / --x-related only, so
3922 # without this an auto-discovered subject handle never reaches it and every
3923 # downstream first-party protection (entity-miss exemption, FIRST_PARTY_FLOOR,
3924 # interaction floor) stays inert on any run that did not pass --x-handle.
3925 # Populated before the early return below so a run whose lanes cannot execute
3926 # still contributes its resolved handles.
3927 if resolved_handles_out is not None:
3928 # Only corroborated handles get first-party status. The extracted set is
3929 # frequency-ranked over retrieved post text, so a prolific commentator --
3930 # or an engagement-farming account that posts on every topic -- lands in
3931 # it without being the subject. First-party status is strong: it exempts
3932 # an author from the relevance floor entirely and raises their per-author
3933 # cap, so granting it on frequency alone would let a spam account buy
3934 # immunity from filtering. Require the handle to look like the topic's
3935 # subject, or to have been named explicitly by the user.
3936 explicit = {
3937 h.lstrip("@").strip().lower()
3938 for h in ([x_handle] + list(x_related or []))
3939 if h and h.strip()
3940 }
3941 topic_tokens = {t for t in re.findall(r"[a-z0-9]+", topic.lower()) if len(t) > 2}
3942 seen = {h.lower() for h in resolved_handles_out}
3943 for h in [*handles, *related_handles]:
3944 clean = h.lstrip("@").strip().lower()
3945 if not clean or clean in seen:
3946 continue
3947 corroborated = clean in explicit or any(
3948 token in clean or clean in token for token in topic_tokens
3949 )
3950 if corroborated:
3951 resolved_handles_out.append(clean)
3952 seen.add(clean)
3953
3954 if not handles and not related_handles:
3955 return
3956
3957 # Pick the X handle-search backend: the first handle-capable backend in the
3958 # chain (grok, bird, xapi, or xquik). These supplemental from:/mentions lanes are
3959 # complementary to the topic search, so when the topic primary can't run
3960 # them (xai/xurl have no handle-lane implementation) but a capable backend
3961 # is available, use it rather than skipping Phase 2. bird scrapes X GraphQL
3962 # with the user's browser cookies; xquik runs the same lanes over its REST
3963 # API. All items land under the single "x" slug.
3964 x_slug = "x"
3965 chain = env.x_backend_chain(config)
3966 # Trust an explicit runtime backend as the head of the chain.
3967 pinned = runtime.x_search_backend
3968 if pinned:
3969 chain = [pinned] + [b for b in chain if b != pinned]
3970 primary = next((b for b in chain if b in ("grok", "bird", "xapi", "xquik")), None)
3971
3972 # Name lane (posts naming the subject in plain text, no @-mention) is
3973 # grok-only for now: it needs phrase-quoting and negation operators the
3974 # other handle-capable backends do not expose uniformly. It is NOT a
3975 # fallback for the mention lane -- most discussion of a person or company
3976 # never @-mentions them, so the two lanes reach disjoint sets.
3977 _name_lane = None
3978
3979 if primary == "grok":
3980 # One budget shared by all three lanes, started here rather than per
3981 # lane: the point is to bound the total, not each part.
3982 lane_deadline = time.monotonic() + grok_x.LANE_BUDGET_SECONDS
3983
3984 def _from_lane(hs: list, count: int, and_topic: bool = False) -> tuple[list, bool]:
3985 items, revoked = grok_x.search_handles(
3986 hs, topic, from_date, to_date, count_per=count,
3987 deadline=lane_deadline, and_topic=and_topic,
3988 )
3989 return items, revoked
3990
3991 def _about_lane(hs: list, count: int) -> tuple[list, bool]:
3992 items, revoked = grok_x.search_mentions(
3993 hs, from_date, to_date, topic=topic, count_per=count,
3994 deadline=lane_deadline,
3995 )
3996 return items, revoked
3997
3998 def _name_lane(hs: list, count: int) -> tuple[list, bool]:
3999 # Use the resolved entity name, not the raw topic. Phrase-quoting
4000 # the whole topic ("Peter Steinberger steipete") matches nothing on
4001 # X; the subject's name is what other people actually write.
4002 subject = _name_lane_subject(topic)
4003 if not subject.strip():
4004 return [], False
4005 items, revoked = grok_x.search_name(
4006 subject, from_date, to_date, exclude_handles=hs, count_per=count,
4007 deadline=lane_deadline,
4008 )
4009 return items, revoked
4010 elif primary == "bird":
4011 def _from_lane(hs: list, count: int, and_topic: bool = False) -> tuple[list, bool]:
4012 # bird_x.search_handles doesn't support and_topic yet
4013 return bird_x.search_handles(hs, topic, from_date, count_per=count), False
4014
4015 def _about_lane(hs: list, count: int) -> tuple[list, bool]:
4016 return bird_x.search_mentions(hs, from_date, count_per=count), False
4017 elif primary == "xapi":
4018 # Direct X API v2 with the app-only bearer: from:/@ lanes run over
4019 # search/all with the recent-search fallback. One budget shared by
4020 # every lane below (same shape as the grok lanes): a slow key bounds
4021 # the whole supplemental phase, not each call.
4022 xapi_token = config.get("X_BEARER_TOKEN") or ""
4023 xapi_deadline = time.monotonic() + x_api.LANE_BUDGET_SECONDS
4024
4025 def _xapi_lane_receipt(lane_warnings: list[str]) -> None:
4026 # A deadline stop is incomplete coverage, reported in
4027 # report.warnings (the x_partial_coverage artifact), never a
4028 # healthy-looking silence and never a source failure.
4029 sink = bundle.artifacts.setdefault("x_partial_coverage", [])
4030 for note in lane_warnings:
4031 line = f"X handle lanes: {note}"
4032 if line not in sink:
4033 sink.append(line)
4034
4035 def _from_lane(hs: list, count: int, and_topic: bool = False) -> tuple[list, bool]:
4036 # x_api.search_handles doesn't support and_topic; topic ranks only
4037 lane_warnings: list[str] = []
4038 items = x_api.search_handles(
4039 hs, topic, from_date, to_date, count_per=count, token=xapi_token,
4040 deadline=xapi_deadline, warnings=lane_warnings,
4041 )
4042 _xapi_lane_receipt(lane_warnings)
4043 return items, False
4044
4045 def _about_lane(hs: list, count: int) -> tuple[list, bool]:
4046 lane_warnings: list[str] = []
4047 items = x_api.search_mentions(
4048 hs, from_date, to_date, topic=topic, count_per=count, token=xapi_token,
4049 deadline=xapi_deadline, warnings=lane_warnings,
4050 )
4051 _xapi_lane_receipt(lane_warnings)
4052 return items, False
4053 elif primary == "xquik":
4054 xquik_token = env.get_xquik_token(config)
4055
4056 def _from_lane(hs: list, count: int, and_topic: bool = False) -> tuple[list, bool]:
4057 # xquik.search_handles doesn't support and_topic yet
4058 return xquik.search_handles(hs, topic, from_date, to_date, count_per=count, token=xquik_token), False
4059
4060 def _about_lane(hs: list, count: int) -> tuple[list, bool]:
4061 return xquik.search_mentions(hs, from_date, to_date, topic=topic, count_per=count, token=xquik_token), False
4062 else:
4063 return # primary X backend has no handle-lane support (xai/xurl) or none configured
4064
4065 # Skip if the X source is rate-limited.
4066 if x_slug in rate_limited_sources:
4067 return
4068
4069 # Collect existing URLs for deduplication
4070 existing_urls = {
4071 item.url
4072 for items in bundle.items_by_source.values()
4073 for item in items
4074 if item.url
4075 }
4076
4077 ranking_query = plan.subqueries[0].ranking_query if plan.subqueries else topic
4078 primary_label = plan.subqueries[0].label if plan.subqueries else "primary"
4079
4080 # Split FROM promotion: determine which handles get FROM lane and how.
4081 # - Primary explicit handle (--x-handle): always FROM, no AND topic, full weight
4082 # - x_related handles: searched separately with lower weight (0.3), kept in
4083 # related_handles variable for the supplemental-related section below
4084 # - Extracted handles: FROM only if ≥2 on-topic hits AND ratio ≥0.5,
4085 # and those pulls DO AND the topic (from:handle Rome)
4086 primary_explicit = [x_handle] if x_handle else []
4087
4088 explicit_promotable, extracted_promotable = x_judge.promotable_handles(
4089 x_dicts, # Phase 1 X items for judging
4090 topic,
4091 handles, # entity_extract handles
4092 explicit_handles=primary_explicit,
4093 ranking_query=ranking_query,
4094 )
4095
4096 # All promotable handles for ABOUT and NAME lanes (primary only, not related)
4097 all_promotable = list(set(explicit_promotable + extracted_promotable))
4098
4099 # Search primary handles (full weight): FROM lane (their own tweets) +
4100 # ABOUT lane (tweets mentioning them). Both engagement-weighted and deduped
4101 # by URL at normalize time.
4102 any_revoked = False # Track auth revocation across lanes
4103 if all_promotable:
4104 # Independent try/except per lane so a failure in one does not discard
4105 # the other's already-computed results.
4106 from_items: list = []
4107 about_items: list = []
4108 about_revoked = False
4109 name_revoked = False
4110
4111 # FROM lane: explicit handles without AND topic (person posts omit their own name)
4112 if explicit_promotable:
4113 try:
4114 explicit_items, explicit_revoked = _from_lane(explicit_promotable, FROM_LANE_COUNT_PER, and_topic=False)
4115 from_items.extend(explicit_items)
4116 if explicit_revoked:
4117 any_revoked = True
4118 bundle.record_failure(
4119 x_slug, schema.AUTH_FAILED,
4120 "Phase 2 FROM-lane (explicit): grok session expired or was revoked",
4121 attempted=True,
4122 )
4123 except Exception as exc:
4124 print(f"[Pipeline] Phase 2 FROM-lane (explicit) failed: {exc}", file=sys.stderr)
4125 state, attempted = _classify_source_failure(exc)
4126 bundle.record_failure(
4127 x_slug, state, f"Phase 2 FROM-lane (explicit): {exc}", attempted=attempted,
4128 )
4129
4130 # FROM lane: extracted handles WITH AND topic (from:handle Rome)
4131 if extracted_promotable:
4132 try:
4133 extracted_items, extracted_revoked = _from_lane(extracted_promotable, FROM_LANE_COUNT_PER, and_topic=True)
4134 from_items.extend(extracted_items)
4135 if extracted_revoked:
4136 any_revoked = True
4137 bundle.record_failure(
4138 x_slug, schema.AUTH_FAILED,
4139 "Phase 2 FROM-lane (extracted): grok session expired or was revoked",
4140 attempted=True,
4141 )
4142 except Exception as exc:
4143 print(f"[Pipeline] Phase 2 FROM-lane (extracted) failed: {exc}", file=sys.stderr)
4144 state, attempted = _classify_source_failure(exc)
4145 bundle.record_failure(
4146 x_slug, state, f"Phase 2 FROM-lane (extracted): {exc}", attempted=attempted,
4147 )
4148 if not bundle.items_by_source.get(x_slug):
4149 bundle.errors_by_source[x_slug] = f"Phase 2 FROM-lane: {exc}"
4150
4151 try:
4152 about_items, about_revoked = _about_lane(all_promotable, MENTION_LANE_COUNT_PER)
4153 if about_revoked:
4154 any_revoked = True
4155 bundle.record_failure(
4156 x_slug, schema.AUTH_FAILED,
4157 "Phase 2 ABOUT-lane: grok session expired or was revoked",
4158 attempted=True,
4159 )
4160 except Exception as exc:
4161 print(f"[Pipeline] Phase 2 ABOUT-lane search failed: {exc}", file=sys.stderr)
4162 state, attempted = _classify_source_failure(exc)
4163 bundle.record_failure(
4164 x_slug,
4165 state,
4166 f"Phase 2 ABOUT-lane: {exc}",
4167 attempted=attempted,
4168 )
4169 name_items: list = []
4170 if _name_lane is not None:
4171 try:
4172 name_items, name_revoked = _name_lane(all_promotable, MENTION_LANE_COUNT_PER)
4173 if name_revoked:
4174 any_revoked = True
4175 bundle.record_failure(
4176 x_slug, schema.AUTH_FAILED,
4177 "Phase 2 NAME-lane: grok session expired or was revoked",
4178 attempted=True,
4179 )
4180 except Exception as exc:
4181 print(f"[Pipeline] Phase 2 NAME-lane search failed: {exc}", file=sys.stderr)
4182 state, attempted = _classify_source_failure(exc)
4183 bundle.record_failure(
4184 x_slug, state, f"Phase 2 NAME-lane: {exc}", attempted=attempted,
4185 )
4186
4187 raw_items = from_items + about_items + name_items
4188
4189 # Partial coverage is a reportable outcome, not a normal result: a
4190 # report carrying only one side of an entity topic is incomplete, and
4191 # without this it looks indistinguishable from genuinely thin
4192 # discussion.
4193 if _name_lane is not None:
4194 empty = [
4195 label for label, items in
4196 (("by", from_items), ("mention", about_items), ("name", name_items))
4197 if not items
4198 ]
4199 if empty and len(empty) < 3:
4200 # A warning, not a source outcome. record_failure would set the
4201 # X source to PARTIAL, which is outside _STRICT_EXIT_OK_STATES
4202 # and would make wrappers using LAST30DAYS_STRICT_EXIT exit 3 on
4203 # runs that returned perfectly good X coverage. An empty lane is
4204 # common and legitimate: the name lane carries an engagement
4205 # floor and the mention lane is empty for most non-famous
4206 # handles.
4207 bundle.artifacts.setdefault("x_partial_coverage", []).append(
4208 f"X partial coverage: {', '.join(empty)} lane(s) returned "
4209 "nothing; the report may show only one side of this entity."
4210 )
4211
4212 if raw_items:
4213 # First-party handles: only primary explicit handle, not promoted commentators
4214 # (first-party exempts from relevance floor; granting to commentators
4215 # would let junk become un-prunable)
4216 first_party_for_normalize = list(set(
4217 h.lower().lstrip("@") for h in primary_explicit if h
4218 ))
4219 normalized = _normalize_score_dedupe(
4220 x_slug, raw_items, from_date, to_date,
4221 freshness_mode=plan.freshness_mode,
4222 ranking_query=ranking_query,
4223 first_party_handles=first_party_for_normalize,
4224 )
4225 # Deduplicate against Phase 1 URLs
4226 normalized = [item for item in normalized if item.url not in existing_urls]
4227 if normalized:
4228 bundle.add_items(primary_label, x_slug, normalized)
4229 # Update existing URLs for related-handle dedup
4230 for item in normalized:
4231 if item.url:
4232 existing_urls.add(item.url)
4233
4234 # Search related handles with lower weight (0.3)
4235 # Related handles are explicit (--x-related), so FROM without AND topic.
4236 if related_handles:
4237 try:
4238 raw_items, rel_revoked = _from_lane(related_handles, RELATED_HANDLE_COUNT_PER, and_topic=False)
4239 if rel_revoked:
4240 any_revoked = True
4241 bundle.record_failure(
4242 x_slug, schema.AUTH_FAILED,
4243 "Phase 2 related handle search: grok session expired or was revoked",
4244 attempted=True,
4245 )
4246 except Exception as exc:
4247 print(f"[Pipeline] Phase 2 related handle search failed: {exc}", file=sys.stderr)
4248 state, attempted = _classify_source_failure(exc)
4249 bundle.record_failure(
4250 x_slug,
4251 state,
4252 f"Phase 2 related handle search: {exc}",
4253 attempted=attempted,
4254 )
4255 raw_items = []
4256
4257 if raw_items:
4258 normalized = _normalize_score_dedupe(
4259 x_slug, raw_items, from_date, to_date,
4260 freshness_mode=plan.freshness_mode,
4261 ranking_query=ranking_query,
4262 first_party_handles=related_handles,
4263 )
4264 # Deduplicate against all existing URLs (Phase 1 + primary handles)
4265 normalized = [item for item in normalized if item.url not in existing_urls]
4266 if normalized:
4267 # Use a separate subquery label with lower weight so RRF
4268 # scores related-handle results below primary results.
4269 bundle.add_items("supplemental-related", x_slug, normalized)
4270 # Register the supplemental-related label in the plan for fusion
4271 if not any(sq.label == "supplemental-related" for sq in plan.subqueries):
4272 plan.subqueries.append(
4273 schema.SubQuery(
4274 label="supplemental-related",
4275 search_query=", ".join(related_handles),
4276 ranking_query=ranking_query,
4277 sources=[x_slug],
4278 weight=0.3,
4279 )
4280 )
4281
4282
4283 def _retry_thin_sources(
4284 *,
4285 topic: str,
4286 bundle: schema.RetrievalBundle,
4287 plan: schema.QueryPlan,
4288 config: dict[str, Any],
4289 depth: str,
4290 date_range: tuple[str, str],
4291 runtime: schema.ProviderRuntime,
4292 mock: bool,
4293 rate_limited_sources: set[str],
4294 rate_limit_lock: threading.Lock,
4295 settings: dict[str, Any],
4296 web_backend: str = "auto",
4297 skip_sources: set[str] | None = None,
4298 subreddits: list[str] | None = None,
4299 tiktok_hashtags: list[str] | None = None,
4300 tiktok_creators: list[str] | None = None,
4301 ig_creators: list[str] | None = None,
4302 first_party_handles: Iterable[str] | None = None,
4303 first_party_by_source: Mapping[str, Iterable[str]] | None = None,
4304 run_started: float | None = None,
4305 ) -> None:
4306 """Retry sources with thin results using simplified core subject query."""
4307 if depth == "quick":
4308 return
4309
4310 planned_sources: list[str] = []
4311 for subquery in plan.subqueries:
4312 for source in subquery.sources:
4313 if source not in planned_sources:
4314 planned_sources.append(source)
4315 _skip = (skip_sources or set()) | THIN_RETRY_EXEMPT
4316 thin_sources = [
4317 source
4318 for source in planned_sources
4319 if len(bundle.items_by_source.get(source, [])) < 3
4320 and source not in bundle.errors_by_source
4321 and source not in _skip
4322 ]
4323
4324 if not thin_sources:
4325 return
4326
4327 core = query.extract_core_subject(topic, max_words=3)
4328 if not core:
4329 return
4330 # Note: we intentionally do NOT skip when core == topic. For short topics
4331 # like "Kanye West", the 3-word core IS the topic — but the planner may
4332 # have sent a different (worse) query to the source. Retrying with the
4333 # raw core subject is still valuable.
4334
4335 from_date, to_date = date_range
4336
4337 # Create a retry subquery with the simplified core subject
4338 retry_subquery = schema.SubQuery(
4339 label="retry",
4340 search_query=core,
4341 ranking_query=f"What recent evidence from the last 30 days matters for {core}?",
4342 sources=thin_sources,
4343 weight=0.3,
4344 )
4345
4346 def _retry_one_source(
4347 source: str,
4348 ) -> tuple[str, list[schema.SourceItem], dict[str, Any] | None]:
4349 raw_items, artifact = _retrieve_stream(
4350 topic=topic,
4351 subquery=retry_subquery,
4352 source=source,
4353 config=config,
4354 depth=depth,
4355 date_range=date_range,
4356 runtime=runtime,
4357 mock=mock,
4358 rate_limited_sources=rate_limited_sources,
4359 rate_limit_lock=rate_limit_lock,
4360 web_backend=web_backend,
4361 raw_topic=topic,
4362 subreddits=subreddits,
4363 tiktok_hashtags=tiktok_hashtags,
4364 tiktok_creators=tiktok_creators,
4365 ig_creators=ig_creators,
4366 run_started=run_started,
4367 # Skip Amazon review enrichment here to avoid duplicate Bright Data
4368 # pulls for ASINs already enriched in Phase 1. Finalize will enrich
4369 # any genuinely new products that weren't in Phase 1.
4370 skip_amazon_enrichment=True,
4371 )
4372 outcome_note = artifact.get("_source_outcome") if isinstance(artifact, dict) else None
4373 detail_note = artifact.get("_source_outcome_detail") if isinstance(artifact, dict) else None
4374 detail_state = artifact.get("_source_outcome_detail_state") if isinstance(artifact, dict) else None
4375 normalized = _normalize_score_dedupe(
4376 source,
4377 raw_items,
4378 from_date,
4379 to_date,
4380 freshness_mode=plan.freshness_mode,
4381 ranking_query=retry_subquery.ranking_query,
4382 first_party_handles=first_party_handles,
4383 first_party_by_source=first_party_by_source,
4384 # Match Phase 1: X defers its relevance floor until the run has
4385 # resolved handles. Applying it here would discard a subject-
4386 # authored post that does not repeat the subject's name, and the
4387 # later resolved-handle floor cannot recover a post that never
4388 # entered the bundle.
4389 defer_relevance_prune=(source == "x"),
4390 )
4391 if source == "jobs":
4392 return source, normalized, outcome_note, (detail_note, detail_state)
4393 normalized = _apply_reddit_stream_keepers(
4394 source, normalized, settings["per_stream_limit"], topic
4395 )
4396 return source, normalized, outcome_note, (detail_note, detail_state)
4397
4398 retryable = [s for s in thin_sources if s not in rate_limited_sources]
4399
4400 from concurrent.futures import ThreadPoolExecutor, as_completed
4401 with ThreadPoolExecutor(max_workers=min(4, len(retryable) or 1)) as executor:
4402 futures = {executor.submit(_retry_one_source, s): s for s in retryable}
4403 for future in as_completed(futures):
4404 source = futures[future]
4405 try:
4406 source, normalized, outcome_note, (detail_note, detail_state) = future.result()
4407 if outcome_note:
4408 bundle.record_failure(
4409 source,
4410 outcome_note["state"],
4411 outcome_note["detail"],
4412 attempted=outcome_note.get("attempted", True),
4413 )
4414 if detail_note:
4415 bundle.record_detail(source, detail_note, state=detail_state)
4416 existing_urls = {item.url for item in bundle.items_by_source.get(source, []) if item.url}
4417 new_items = [item for item in normalized if item.url not in existing_urls]
4418
4419 if new_items:
4420 primary_label = plan.subqueries[0].label if plan.subqueries else "primary"
4421 bundle.add_items(primary_label, source, new_items)
4422 except Exception as exc:
4423 print(f"[Pipeline] Retry failed for {source}: {type(exc).__name__}: {exc}", file=sys.stderr)
4424 state, attempted = _classify_source_failure(exc)
4425 bundle.record_failure(
4426 source,
4427 state,
4428 f"Simplified-query retry failed: {exc}",
4429 attempted=attempted,
4430 )
4431
4432
4433 def _fetch_x_backend(backend, query, from_date, to_date, depth, config, warnings=None):
4434 """Fetch X items from a single backend. Returns (items, error_str).
4435
4436 ``warnings``, when given, collects backend receipts that are not
4437 failures (xapi's "window truncated to 7 days" after the recent-search
4438 fallback) so the X branch can surface them as run artifacts.
4439
4440 Backends are tried in priority order by the caller (env.x_backend_chain);
4441 a non-empty error_str signals a hard failure (auth/payment/etc.) so the
4442 caller can fail over to the next backend or surface the error honestly.
4443
4444 For grok, auth_revoked signals mid-run session revocation: the error
4445 string includes "grok session expired" so _classify_source_failure maps
4446 it to AUTH_FAILED with a proper fix hint, distinct from "never signed in".
4447
4448 The ``query`` parameter is the compiled search query - typically
4449 ``raw_topic or topic`` (like Reddit/YouTube), NOT the planner's
4450 ``search_query`` which may contain operator strings like "Rome Italy".
4451 """
4452 if backend == "bird":
4453 result = bird_x.search_x(query, from_date, to_date, depth=depth)
4454 items = bird_x.parse_bird_response(result, query=query)
4455 elif backend == "grok":
4456 result = grok_x.search_x(query, from_date, to_date, depth=depth)
4457 items = result.get("items", []) if isinstance(result, dict) else []
4458 if isinstance(result, dict) and result.get("auth_revoked"):
4459 err = result.get("error") or "grok session expired or was revoked"
4460 return items, f"grok: {err}"
4461 elif backend == "xai":
4462 model = config.get("LAST30DAYS_X_MODEL") or config.get("XAI_MODEL_PIN") or providers.XAI_DEFAULT
4463 result = xai_x.search_x(config["XAI_API_KEY"], model, query, from_date, to_date, depth=depth)
4464 items = xai_x.parse_x_response(result)
4465 elif backend == "xurl":
4466 result = xurl_x.search_x(query, depth=depth)
4467 items = xurl_x.parse_x_response(result, topic=query)
4468 elif backend == "xquik":
4469 result = xquik.search_xquik(query, from_date, to_date, depth=depth, token=env.get_xquik_token(config))
4470 items = xquik.parse_xquik_response(result)
4471 elif backend == "xapi":
4472 result = x_api.search_x(config.get("X_BEARER_TOKEN") or "", query, from_date, to_date, depth=depth)
4473 items = result.get("items", []) if isinstance(result, dict) else []
4474 warning = result.get("warning") if isinstance(result, dict) else None
4475 if warning:
4476 print(f"[X] xapi: {warning}", file=sys.stderr)
4477 if warnings is not None:
4478 warnings.append(f"X: xapi {warning}")
4479 else:
4480 return [], f"unknown X backend: {backend}"
4481 err = result.get("error") if isinstance(result, dict) else ""
4482 return items, (err or "")
4483
4484
4485 def _reddit_post_key(item: dict) -> str:
4486 """Stable per-thread dedupe key (base36 post id from the url/permalink)."""
4487 url = item.get("url") or item.get("permalink") or ""
4488 m = re.search(r"/comments/([A-Za-z0-9]+)", url)
4489 return m.group(1) if m else url
4490
4491
4492 def _merge_reddit_items(free: list[dict], sc: list[dict]) -> list[dict]:
4493 """Merge free + ScrapeCreators Reddit items, free first, deduped by post id.
4494
4495 Used when the thinness-floor trigger backfills a thin free run with SC, so a
4496 thread present in both is never double-listed.
4497 """
4498 merged = list(free)
4499 seen = {_reddit_post_key(it) for it in free}
4500 for it in sc:
4501 key = _reddit_post_key(it)
4502 if key and key not in seen:
4503 seen.add(key)
4504 merged.append(it)
4505 return merged
4506
4507
4508 def _retrieve_stream(*args, **kwargs) -> tuple[list[dict], dict]:
4509 """Run one stream and retain HTTP failures swallowed by source adapters."""
4510 # run_started is passed through but not used here; it goes to _retrieve_stream_impl
4511 source = str(kwargs.get("source") or "")
4512 fixture_request = {
4513 "source": source,
4514 "topic": kwargs.get("topic") or "",
4515 "search_query": getattr(kwargs.get("subquery"), "search_query", ""),
4516 "date_range": list(kwargs.get("date_range") or ()),
4517 "depth": kwargs.get("depth") or "",
4518 }
4519 module_backed = source in {
4520 "reddit",
4521 "x",
4522 "youtube",
4523 "stocktwits",
4524 "digg",
4525 "arxiv",
4526 "techmeme",
4527 "trustpilot",
4528 "github",
4529 }
4530 if module_backed:
4531 matched, replayed = http.fixture_source_replay(fixture_request)
4532 if matched:
4533 return replayed[0], replayed[1]
4534 try:
4535 with http.capture_failures() as failures, \
4536 http.fixture_module_capture(module_backed):
4537 items, artifact = _retrieve_stream_impl(*args, **kwargs)
4538 except Exception as exc:
4539 recorded_exc = exc
4540 if failures and not getattr(exc, "outcome_state", None):
4541 failure = failures[-1]
4542 recorded_exc = SourceRunError(str(exc), failure.outcome_state)
4543 if module_backed:
4544 http.fixture_source_record_error(fixture_request, recorded_exc)
4545 if recorded_exc is not exc:
4546 raise recorded_exc from exc
4547 raise
4548 outcome_note = _resolve_stream_outcome(
4549 str(kwargs.get("source") or ""),
4550 artifact,
4551 failures,
4552 )
4553 if outcome_note:
4554 # Lane-level HTTP failures (e.g. a blocked shreddit partial on a
4555 # datacenter IP) are captured by the sink even when the source
4556 # delivered items. Only attach them when the run produced nothing,
4557 # or when the impl attached its own explicit outcome artifact (e.g.
4558 # "primary failed; fallback returned N items"). A swallowed lane
4559 # failure must not brand a successful source auth-failed/partial.
4560 # An adapter-declared outcome (typed ``_source_outcome`` or a legacy
4561 # ``{"error": ...}`` / per-leg artifact) is explicit and always
4562 # brands the source, even with items; only failures the adapter
4563 # swallowed into the capture sink are demoted to detail.
4564 explicit = isinstance(artifact, dict) and (
4565 bool(artifact.get("_source_outcome"))
4566 or _legacy_artifact_outcome(str(kwargs.get("source") or ""), artifact) is not None
4567 )
4568 if explicit or not items:
4569 artifact = dict(artifact or {})
4570 artifact["_source_outcome"] = outcome_note
4571 elif failures:
4572 # The source delivered items. Keep it ``ok`` but carry what the
4573 # swallowed sub-requests lost, so doctor can still show it, and
4574 # the most specific failure state so a later empty filter result
4575 # or the thin-source retry can act on it.
4576 artifact = dict(artifact or {})
4577 artifact["_source_outcome_detail"] = _summarize_lane_failures(
4578 failures, str(kwargs.get("source") or "")
4579 )
4580 artifact["_source_outcome_detail_state"] = min(
4581 failures, key=lambda f: _FAILURE_SPECIFICITY.get(f.outcome_state, 9)
4582 ).outcome_state
4583 if module_backed:
4584 http.fixture_source_record(fixture_request, [items, artifact])
4585 return items, artifact
4586
4587
4588 def _serve_envelope_topic(envelope: x_envelope.Envelope) -> tuple[list[dict], dict]:
4589 """Serve the envelope's topic-lane rows once.
4590
4591 The first X subquery takes the rows and the envelope-status outcome;
4592 every later call (a second planner subquery, judge-retry, thin-retry)
4593 gets no items and no error, and no backend is ever consulted.
4594 """
4595 items = envelope.take_topic()
4596 if items is None:
4597 return [], {}
4598 artifact: dict[str, Any] = {}
4599 if envelope.warnings:
4600 # A narrower host window is a receipt (report.warnings), not a failure.
4601 artifact["x_receipts"] = [f"X: {warning}" for warning in envelope.warnings]
4602 outcome = envelope.outcome()
4603 if outcome is not None:
4604 state, detail = outcome
4605 artifact.update(_outcome_artifact(state, detail))
4606 return items, artifact
4607
4608
4609 def _serve_envelope_lanes(
4610 envelope: x_envelope.Envelope,
4611 *,
4612 bundle: schema.RetrievalBundle,
4613 plan: schema.QueryPlan,
4614 x_handle: str | None,
4615 x_related: list[str] | None,
4616 from_date: str,
4617 to_date: str,
4618 ) -> None:
4619 """Serve the envelope's from/mention/related calls into the lane merge.
4620
4621 Mirrors the backend lanes: primary-handle rows (from + mention) join the
4622 primary subquery with first-party handling for the explicit handle and
4623 the per-handle lane counts; related rows join ``supplemental-related``
4624 at the 0.3 weight. Lane claims were already validated at read time.
4625 """
4626 calls = envelope.take_lanes()
4627 if not calls:
4628 return
4629 x_slug = "x"
4630 existing_urls = {
4631 item.url
4632 for items in bundle.items_by_source.values()
4633 for item in items
4634 if item.url
4635 }
4636 ranking_query = plan.subqueries[0].ranking_query if plan.subqueries else ""
4637 primary_label = plan.subqueries[0].label if plan.subqueries else "primary"
4638 primary_handles = sorted(
4639 {x_handle.lstrip("@").strip().lower()} if x_handle and x_handle.strip() else set()
4640 )
4641 related_handles = [
4642 h.lstrip("@").strip().lower()
4643 for h in (x_related or [])
4644 if h.strip() and h.lstrip("@").strip().lower() not in primary_handles
4645 ]
4646
4647 def _cap_per_author(posts: list[dict], cap: int) -> list[dict]:
4648 seen: Counter[str] = Counter()
4649 kept: list[dict] = []
4650 for post in posts:
4651 author = str(post.get("author_handle") or "").lower()
4652 if seen[author] >= cap:
4653 continue
4654 seen[author] += 1
4655 kept.append(post)
4656 return kept
4657
4658 primary_items: list[dict] = []
4659 related_items: list[dict] = []
4660 for call in calls:
4661 if call.lane == "from":
4662 primary_items.extend(_cap_per_author(call.posts, FROM_LANE_COUNT_PER))
4663 elif call.lane == "mention":
4664 primary_items.extend(
4665 call.posts[: MENTION_LANE_COUNT_PER * max(1, len(call.handles))]
4666 )
4667 elif call.lane == "related":
4668 related_items.extend(_cap_per_author(call.posts, RELATED_HANDLE_COUNT_PER))
4669
4670 if primary_items:
4671 normalized = _normalize_score_dedupe(
4672 x_slug, primary_items, from_date, to_date,
4673 freshness_mode=plan.freshness_mode,
4674 ranking_query=ranking_query,
4675 first_party_handles=primary_handles,
4676 )
4677 normalized = [item for item in normalized if item.url not in existing_urls]
4678 if normalized:
4679 bundle.add_items(primary_label, x_slug, normalized)
4680 existing_urls.update(item.url for item in normalized if item.url)
4681
4682 if related_items:
4683 normalized = _normalize_score_dedupe(
4684 x_slug, related_items, from_date, to_date,
4685 freshness_mode=plan.freshness_mode,
4686 ranking_query=ranking_query,
4687 first_party_handles=related_handles,
4688 )
4689 normalized = [item for item in normalized if item.url not in existing_urls]
4690 if normalized:
4691 bundle.add_items("supplemental-related", x_slug, normalized)
4692 if not any(sq.label == "supplemental-related" for sq in plan.subqueries):
4693 plan.subqueries.append(
4694 schema.SubQuery(
4695 label="supplemental-related",
4696 search_query=", ".join(related_handles),
4697 ranking_query=ranking_query,
4698 sources=[x_slug],
4699 weight=0.3,
4700 )
4701 )
4702
4703
4704 def _retrieve_stream_impl(
4705 *,
4706 topic: str,
4707 subquery: schema.SubQuery,
4708 source: str,
4709 config: dict[str, Any],
4710 depth: str,
4711 date_range: tuple[str, str],
4712 runtime: schema.ProviderRuntime,
4713 mock: bool,
4714 rate_limited_sources: set[str] | None = None,
4715 rate_limit_lock: threading.Lock | None = None,
4716 web_backend: str = "auto",
4717 raw_topic: str = "",
4718 subreddits: list[str] | None = None,
4719 tiktok_hashtags: list[str] | None = None,
4720 tiktok_creators: list[str] | None = None,
4721 ig_creators: list[str] | None = None,
4722 trustpilot_domain: str | None = None,
4723 trustpilot_domain_is_hint: bool = False,
4724 run_started: float | None = None,
4725 skip_amazon_enrichment: bool = False,
4726 ) -> tuple[list[dict], dict]:
4727 # Early exit if source was rate-limited by a sibling future
4728 if rate_limited_sources is not None and source in rate_limited_sources:
4729 return [], {}
4730 from_date, to_date = date_range
4731 if mock:
4732 return _mock_stream_results(source, subquery)
4733 if source == "grounding":
4734 return grounding.web_search(
4735 subquery.search_query, date_range, config, backend=web_backend)
4736 if source == "jobs":
4737 return jobs.search_jobs(
4738 raw_topic or topic or subquery.search_query,
4739 date_range,
4740 config,
4741 depth=depth,
4742 web_backend=web_backend,
4743 explicit=bool(config.get("_hiring_signals_mode")),
4744 )
4745 if source == "reddit":
4746 # Use raw_topic so expand_reddit_queries() generates diverse variants
4747 # from the original user topic, not the planner's narrowed search_query.
4748 reddit_query = raw_topic or subquery.search_query
4749 dedicated_subreddits = config.get("_dedicated_subreddits") or None
4750 has_sc_key = bool(config.get("SCRAPECREATORS_API_KEY"))
4751 sc_first = (
4752 has_sc_key
4753 and (config.get(env.REDDIT_BACKEND_PIN_VAR) or "").lower()
4754 == "scrapecreators"
4755 )
4756 if sc_first:
4757 # env.REDDIT_BACKEND_PIN_VAR=scrapecreators: SC primary, public fallback
4758 primary_failure: Exception | None = None
4759 try:
4760 result = reddit.search_and_enrich(
4761 reddit_query, from_date, to_date, depth=depth,
4762 token=config.get("SCRAPECREATORS_API_KEY"),
4763 subreddits=subreddits,
4764 )
4765 items = reddit.parse_reddit_response(result)
4766 if items:
4767 return items, {}
4768 sys.stderr.write(
4769 "[Reddit] ScrapeCreators primary returned no items, "
4770 "using public fallback\n"
4771 )
4772 except Exception as exc:
4773 primary_failure = exc
4774 sys.stderr.write(
4775 f"[Reddit] ScrapeCreators primary failed "
4776 f"({type(exc).__name__}: {exc}), using public fallback\n"
4777 )
4778 public_failure: Exception | None = None
4779 try:
4780 public_results = reddit_public.search_reddit_public(
4781 reddit_query, from_date, to_date, depth=depth,
4782 subreddits=subreddits,
4783 )
4784 if public_results:
4785 if primary_failure is not None:
4786 state = reddit.classify_run_failure(str(primary_failure))
4787 return public_results, _outcome_artifact(
4788 state,
4789 f"Reddit primary failed; public fallback returned "
4790 f"{len(public_results)} items: {primary_failure}",
4791 )
4792 return public_results, {}
4793 sys.stderr.write(
4794 "[Reddit] Public fallback returned no items after "
4795 "ScrapeCreators primary miss\n"
4796 )
4797 except Exception as exc:
4798 public_failure = exc
4799 sys.stderr.write(
4800 f"[Reddit] Public fallback also failed "
4801 f"({type(exc).__name__}: {exc})\n"
4802 )
4803 failure = public_failure or primary_failure
4804 if failure is not None:
4805 state = reddit.classify_run_failure(str(failure))
4806 raise SourceRunError(
4807 f"Reddit primary and fallback produced no results after failure: {failure}",
4808 state,
4809 )
4810 return [], {}
4811
4812 # Default: public Reddit first (free). ScrapeCreators backfills when the
4813 # free path is empty OR returns fewer than the configured thinness floor
4814 # (env.REDDIT_SC_MIN_ITEMS_VAR, default 0 = empty-only — today's
4815 # behavior, no extra credit spend unless the user opts in).
4816 try:
4817 min_items = int(config.get(env.REDDIT_SC_MIN_ITEMS_VAR) or 0)
4818 except (TypeError, ValueError):
4819 min_items = 0
4820 public_results: list[dict] = []
4821 public_failure: Exception | None = None
4822 try:
4823 public_results = reddit_public.search_reddit_public(
4824 reddit_query, from_date, to_date, depth=depth,
4825 subreddits=subreddits, dedicated_subreddits=dedicated_subreddits,
4826 ) or []
4827 except Exception as exc:
4828 public_failure = exc
4829 sys.stderr.write(
4830 f"[Reddit] Public search failed ({type(exc).__name__}: {exc})"
4831 )
4832 if not has_sc_key:
4833 sys.stderr.write("\n")
4834 state = reddit.classify_run_failure(str(exc))
4835 raise SourceRunError(f"Reddit public search failed: {exc}", state) from exc
4836 sys.stderr.write(", using ScrapeCreators backup\n")
4837 # Enough free results, or no key to backfill with -> done. max(min_items,
4838 # 1) keeps the default (min_items=0) as empty-only AND treats exactly
4839 # `min_items` results as acceptable (no backfill) for min_items > 0.
4840 if len(public_results) >= max(min_items, 1) or not has_sc_key:
4841 return public_results, {}
4842 if public_results:
4843 sys.stderr.write(
4844 f"[Reddit] Free path returned {len(public_results)} "
4845 f"(below the {min_items}-item floor); backfilling with ScrapeCreators\n"
4846 )
4847 try:
4848 result = reddit.search_and_enrich(
4849 reddit_query, from_date, to_date, depth=depth,
4850 token=config.get("SCRAPECREATORS_API_KEY"),
4851 subreddits=subreddits,
4852 )
4853 sc_items = reddit.parse_reddit_response(result)
4854 except Exception as exc:
4855 sys.stderr.write(
4856 f"[Reddit] ScrapeCreators backup also failed "
4857 f"({type(exc).__name__}: {exc})\n"
4858 )
4859 state = reddit.classify_run_failure(str(exc))
4860 return public_results, _outcome_artifact(
4861 state,
4862 f"Reddit backup failed after {len(public_results)} public items: {exc}",
4863 )
4864 merged = _merge_reddit_items(public_results, sc_items)
4865 if public_failure is not None:
4866 state = reddit.classify_run_failure(str(public_failure))
4867 return merged, _outcome_artifact(
4868 state,
4869 f"Reddit public search failed; backup returned {len(sc_items)} items: "
4870 f"{public_failure}",
4871 )
4872 return merged, {}
4873 if source == "x":
4874 if config.get("_x_lane_missing"):
4875 # The model declared the connector lane but passed no envelope.
4876 return [], _outcome_artifact(health.ERROR, x_envelope.DETAIL_NOT_PASSED)
4877 envelope = config.get("_x_envelope")
4878 if envelope is not None:
4879 # Host-fetched lane: the envelope replaces the backend chain and
4880 # is single-serve, so no backend runs and no judge-retry follows.
4881 return _serve_envelope_topic(envelope)
4882
4883 # Compile X query from raw_topic (like Reddit/YouTube), not planner's
4884 # search_query which may contain operator strings like "Rome Italy".
4885 x_query = raw_topic or topic or subquery.search_query
4886 ranking_query = subquery.ranking_query
4887
4888 # One X source, an ordered chain of interchangeable backends. Try the
4889 # primary; fall through to the next only if it returns nothing or errors.
4890 chain = env.x_backend_chain(config)
4891 # Trust an explicit runtime backend as the primary (already resolved as
4892 # available), keeping the rest of the chain as failover backups.
4893 pinned = runtime.x_search_backend
4894 if pinned:
4895 chain = [pinned] + [b for b in chain if b != pinned]
4896 if not chain:
4897 raise RuntimeError("No X backend is available.")
4898 last_error = ""
4899 chain_errors: list[str] = []
4900 items = []
4901 used_backend = None
4902 x_warnings: list[str] = []
4903 for i, backend in enumerate(chain):
4904 items, err = _fetch_x_backend(
4905 backend, x_query, from_date, to_date, depth, config, warnings=x_warnings,
4906 )
4907 if items:
4908 if i > 0:
4909 # xapi is metered: name the spend when it served as a backup.
4910 spend = " (spends X API credits)" if backend == "xapi" else ""
4911 print(
4912 f"[X] primary backend(s) returned nothing; used fallback '{backend}'{spend}",
4913 file=sys.stderr,
4914 )
4915 # Check for auth errors before proceeding to judge-retry
4916 if last_error:
4917 # Fallback succeeded after earlier backend failed. Classify
4918 # the original error: if it was AUTH_FAILED (grok revoked),
4919 # preserve that state so user gets re-login guidance.
4920 prior_state = http.classify_failure(message=last_error)
4921 if prior_state == schema.AUTH_FAILED:
4922 # Keep AUTH_FAILED visible so host shows re-login hint
4923 return items, _outcome_artifact(
4924 schema.AUTH_FAILED,
4925 f"X served via {backend} after {last_error}; re-login needed for primary backend",
4926 )
4927 # Prior error was non-auth. Check if *current* backend also
4928 # reported an error (e.g., grok returned items + revocation).
4929 if err:
4930 current_state = http.classify_failure(message=err)
4931 if current_state == schema.AUTH_FAILED:
4932 return items, _outcome_artifact(
4933 schema.AUTH_FAILED,
4934 f"X served {len(items)} items via {backend} but also errored: {err}; re-login needed",
4935 )
4936 # Non-auth prior error, no current auth error → fallback OK
4937 return items, _outcome_artifact(
4938 health.OK,
4939 f"X served via {backend} after {last_error}",
4940 )
4941 if err:
4942 # Mixed result: backend returned items BUT also hit an error
4943 # (e.g., grok got some posts then auth was revoked mid-fanout).
4944 # Surface the error so the user gets re-login guidance.
4945 state = http.classify_failure(message=err)
4946 return items, _outcome_artifact(
4947 state,
4948 f"X returned {len(items)} items but also errored: {err}",
4949 )
4950 # No auth issues and no prior errors - proceed to judge-retry
4951 used_backend = backend
4952 break
4953 if err:
4954 last_error = f"{backend}: {err}"
4955 chain_errors.append(last_error)
4956 print(f"[X] backend '{backend}' failed ({err}); trying next", file=sys.stderr)
4957
4958 if not items and last_error:
4959 # A credit-exhaustion failure earlier in the chain is the most
4960 # specific outcome (top up, not re-authenticate); a later
4961 # backend's generic failure must not mask it.
4962 for candidate in chain_errors:
4963 if http.classify_failure(message=candidate) == health.PAYMENT_REQUIRED:
4964 last_error = candidate
4965 break
4966 state = (
4967 bird_x.classify_run_failure(last_error)
4968 if last_error.startswith("bird:")
4969 else http.classify_failure(message=last_error)
4970 )
4971 raise SourceRunError(f"All X backends failed — {last_error}", state)
4972
4973 # Retrieve-judge-retry: judge corpus and retry if off-topic flood.
4974 # Skip retry on quick/mock (same as Phase 2).
4975 artifact = {}
4976 if x_warnings:
4977 # e.g. xapi's "window truncated to 7 days": a receipt that reaches
4978 # report.warnings (see the grounding artifacts walk in
4979 # _build_report), never a source failure.
4980 artifact["x_receipts"] = list(x_warnings)
4981 if items and depth != "quick" and not mock:
4982 items_for_judge = [
4983 {"author_handle": it.get("author_handle", ""), "text": it.get("text", "")}
4984 for it in items
4985 ]
4986 if x_judge.should_retry_x_search(items_for_judge, x_query, ranking_query=ranking_query, depth=depth):
4987 # Retry with cleaned query (1 retry, ≤2 extra grok calls)
4988 # Strip noise words but preserve all significant terms to avoid
4989 # losing disambiguating terms (e.g., "react server components")
4990 core_tokens = query.extract_core_subject(x_query)
4991 retry_query = core_tokens or x_query
4992 print(f"[X] corpus off-topic; retrying with '{retry_query}'", file=sys.stderr)
4993
4994 if used_backend:
4995 retry_items, retry_err = _fetch_x_backend(
4996 used_backend, retry_query, from_date, to_date, depth, config
4997 )
4998 if retry_items:
4999 # Judge retry corpus
5000 retry_for_judge = [
5001 {"author_handle": it.get("author_handle", ""), "text": it.get("text", "")}
5002 for it in retry_items
5003 ]
5004 retry_judgment = x_judge.judge_x_corpus(
5005 retry_for_judge, x_query, ranking_query=ranking_query
5006 )
5007 orig_judgment = x_judge.judge_x_corpus(
5008 items_for_judge, x_query, ranking_query=ranking_query
5009 )
5010 # Use retry if better on-topic ratio
5011 if retry_judgment["on_topic_ratio"] > orig_judgment["on_topic_ratio"]:
5012 print(
5013 f"[X] retry improved on-topic ratio: "
5014 f"{orig_judgment['on_topic_ratio']:.0%} -> "
5015 f"{retry_judgment['on_topic_ratio']:.0%}",
5016 file=sys.stderr,
5017 )
5018 items = retry_items
5019
5020 # Prune off-topic items before the pool. Eight on-topic → ok with 8.
5021 # Zero on-topic after retry → no-results, not ok with 40 junk.
5022 # Only prune items that have text to judge; items without text pass through.
5023 original_count = len(items)
5024 items_with_text = [(i, it) for i, it in enumerate(items) if it.get("text", "").strip()]
5025
5026 if items_with_text:
5027 items_for_prune = [
5028 {"author_handle": it.get("author_handle", ""), "text": it.get("text", "")}
5029 for _, it in items_with_text
5030 ]
5031 judgment = x_judge.judge_x_corpus(
5032 items_for_prune, x_query, ranking_query=ranking_query
5033 )
5034 # Build set of indices for on-topic items
5035 on_topic_indices = set()
5036 for (orig_idx, _), pruned_item in zip(items_with_text, items_for_prune):
5037 if pruned_item in judgment["on_topic_items"]:
5038 on_topic_indices.add(orig_idx)
5039
5040 # Keep items that are on-topic OR have no text (can't judge)
5041 items = [
5042 it for i, it in enumerate(items)
5043 if i in on_topic_indices or not it.get("text", "").strip()
5044 ]
5045
5046 # Record warning if significant pruning occurred (artifact, not failure)
5047 if len(items) < original_count:
5048 pruned = original_count - len(items)
5049 artifact.setdefault("_warnings", []).append(
5050 f"X: pruned {pruned} off-topic items; {len(items)} on-topic remain"
5051 )
5052
5053 if last_error and items:
5054 state = (
5055 bird_x.classify_run_failure(last_error)
5056 if last_error.startswith("bird:")
5057 else http.classify_failure(message=last_error)
5058 )
5059 return items, _outcome_artifact(
5060 state,
5061 f"X fallback '{used_backend}' returned {len(items)} items after {last_error}",
5062 )
5063 return items, artifact
5064 if source == "youtube":
5065 # Use raw_topic so expand_youtube_queries() generates diverse variants
5066 # from the original user topic, not the planner's narrowed search_query.
5067 yt_query = raw_topic or subquery.search_query
5068 result = None
5069 youtube_failure: str | None = None
5070 # ScrapeCreators key (when present) is the default-on backup tier: it
5071 # powers the per-video transcript fallback, the SC search fallback, and
5072 # comment enrichment. None when no key, which keeps everything keyless.
5073 sc_token = (
5074 config.get("SCRAPECREATORS_API_KEY", "")
5075 if env.is_youtube_sc_available(config) else None
5076 )
5077 # Try yt-dlp first; the SC transcript fallback covers per-video failures.
5078 if which("yt-dlp"):
5079 try:
5080 result = youtube_yt.search_and_transcribe(
5081 yt_query, from_date, to_date, depth=depth, token=sc_token,
5082 )
5083 if result.get("error"):
5084 youtube_failure = str(result["error"])
5085 except Exception as exc:
5086 youtube_failure = str(exc)
5087 result = None
5088 # Fall back to SC YouTube search if yt-dlp failed or isn't installed.
5089 if (result is None or not result.get("items")) and sc_token:
5090 try:
5091 result = youtube_yt.search_youtube_sc(
5092 yt_query, from_date, to_date, depth=depth, token=sc_token,
5093 )
5094 if result.get("error"):
5095 youtube_failure = str(result["error"])
5096 except Exception as exc:
5097 youtube_failure = str(exc)
5098 result = None
5099 if result is None:
5100 result = {"items": []}
5101 # Enrich top videos with comments (default-on when a key is present).
5102 items = youtube_yt.parse_youtube_response(result)
5103 if items and env.is_youtube_comments_available(config):
5104 youtube_yt.enrich_with_comments(
5105 items, token=config.get("SCRAPECREATORS_API_KEY", ""),
5106 )
5107 if youtube_failure:
5108 state = youtube_yt.classify_run_failure(youtube_failure)
5109 attempted = state != schema.SKIPPED_UNCONFIGURED
5110 return items, _outcome_artifact(state, youtube_failure, attempted=attempted)
5111 return items, {}
5112 if source == "tiktok":
5113 # Use raw_topic so expand_tiktok_queries() generates diverse variants
5114 # from the original user topic, not the planner's narrowed search_query.
5115 tiktok_query = raw_topic or subquery.search_query
5116 result = tiktok.search_and_enrich(
5117 tiktok_query,
5118 from_date,
5119 to_date,
5120 depth=depth,
5121 token=env.get_tiktok_token(config),
5122 hashtags=tiktok_hashtags,
5123 creators=tiktok_creators,
5124 )
5125 items = tiktok.parse_tiktok_response(result)
5126 if items and env.is_tiktok_comments_available(config):
5127 sc_token = config.get("SCRAPECREATORS_API_KEY", "")
5128 tiktok.enrich_with_comments(items, token=sc_token)
5129 return items, _result_outcome_artifact(source, result)
5130 if source == "instagram":
5131 # Use raw_topic so expand_instagram_queries() generates diverse variants
5132 # from the original user topic, not the planner's narrowed search_query.
5133 ig_query = raw_topic or subquery.search_query
5134 result = instagram.search_and_enrich(
5135 ig_query,
5136 from_date,
5137 to_date,
5138 depth=depth,
5139 token=env.get_instagram_token(config),
5140 ig_creators=ig_creators,
5141 )
5142 items = instagram.parse_instagram_response(result)
5143 if items and env.is_instagram_comments_available(config):
5144 instagram.enrich_with_comments(
5145 items, token=config.get("SCRAPECREATORS_API_KEY", ""),
5146 )
5147 return items, _result_outcome_artifact(source, result)
5148 if source == "linkedin":
5149 token = config.get("SCRAPECREATORS_API_KEY", "")
5150 result = linkedin.search_linkedin(
5151 subquery.search_query,
5152 from_date,
5153 to_date,
5154 depth=depth,
5155 token=token,
5156 )
5157 items = linkedin.parse_linkedin_response(
5158 result, from_date=from_date, to_date=to_date
5159 )
5160 # Articles never appear in post search — surface them (high signal)
5161 # via a bounded profile-enrichment lane on person topics.
5162 items += linkedin.enrich_articles(
5163 items, raw_topic or topic, token, from_date=from_date, to_date=to_date
5164 )
5165 return items, _result_outcome_artifact(source, result)
5166 if source == "hackernews":
5167 result = hackernews.search_hackernews(subquery.search_query, from_date, to_date, depth=depth)
5168 return (
5169 hackernews.parse_hackernews_response(result, query=subquery.search_query),
5170 _result_outcome_artifact(source, result),
5171 )
5172 if source == "stocktwits":
5173 # Pass raw_topic so symbol detection sees the full topic, not the
5174 # narrowed per-subquery search_query (same rationale as reddit).
5175 result = stocktwits.search_stocktwits(
5176 raw_topic or topic or subquery.search_query, from_date, to_date, depth=depth)
5177 return (
5178 stocktwits.parse_stocktwits_response(result, query=subquery.search_query),
5179 _result_outcome_artifact(source, result),
5180 )
5181 if source == "dripstack":
5182 result = dripstack.search_dripstack(
5183 subquery.search_query, from_date, to_date, depth=depth)
5184 relevance_topic = raw_topic or topic or subquery.search_query
5185 return (
5186 dripstack.parse_dripstack_response(result, query=relevance_topic),
5187 _result_outcome_artifact(source, result),
5188 )
5189 if source == "digg":
5190 result = digg.search_digg(subquery.search_query, from_date, to_date, depth=depth)
5191 items = digg.parse_digg_response(result, query=subquery.search_query)
5192 # Enrichment with attached X posts is deferred to
5193 # _finalize_items_by_source so it runs on the items that actually
5194 # survive dedupe rather than on top-K of the raw fanout.
5195 return items, _result_outcome_artifact(source, result)
5196 if source == "arxiv":
5197 result = arxiv.search_arxiv(subquery.search_query, from_date, to_date, depth=depth)
5198 # Relevance keys off the stable research topic, not the per-subquery
5199 # search_query, so off-topic narrowing does not let weak matches through.
5200 relevance_topic = raw_topic or topic or subquery.search_query
5201 return (
5202 arxiv.parse_arxiv_response(result, query=relevance_topic),
5203 _result_outcome_artifact(source, result),
5204 )
5205 if source == "techmeme":
5206 result = techmeme.search_techmeme(subquery.search_query, from_date, to_date, depth=depth)
5207 relevance_topic = raw_topic or topic or subquery.search_query
5208 return (
5209 techmeme.parse_techmeme_response(result, query=relevance_topic),
5210 _result_outcome_artifact(source, result),
5211 )
5212 if source == "trustpilot":
5213 # Brand-shape gate keys off the stable research topic, not the narrowed
5214 # per-subquery search_query, so the company is detected consistently.
5215 relevance_topic = raw_topic or topic or subquery.search_query
5216 result = trustpilot.search_trustpilot(
5217 relevance_topic, from_date, to_date, depth=depth, config=config,
5218 explicit_domain=trustpilot_domain,
5219 domain_is_hint=trustpilot_domain_is_hint,
5220 )
5221 return (
5222 trustpilot.parse_trustpilot_response(result, query=relevance_topic),
5223 _result_outcome_artifact(source, result),
5224 )
5225 if source == "amazon":
5226 # The search keyword is model-supplied and may differ from the topic
5227 # ("Matt Van Horn" searches "June Oven"), so it keys off the stable
5228 # research topic rather than the narrowed per-subquery search_query.
5229 keyword = (
5230 str((config or {}).get("_amazon_query") or "").strip()
5231 or raw_topic or topic or subquery.search_query
5232 )
5233 domain = str((config or {}).get("LAST30DAYS_AMAZON_DOMAIN") or amazon.DEFAULT_DOMAIN)
5234 result = amazon.search_products(keyword, domain=domain, config=config)
5235 products = amazon.parse_search_response(result, keyword, domain=domain)
5236 artifact = _result_outcome_artifact(source, result)
5237
5238 # Skip enrichment when called from thin retry (_retry_thin_sources) to
5239 # avoid duplicate Bright Data pulls for ASINs already enriched in Phase 1.
5240 # Finalize will enrich any NEW products (enrich_source_items no-ops when
5241 # top_comments is already set, so duplicates get skipped there too).
5242 if skip_amazon_enrichment:
5243 return products, artifact
5244
5245 # Start review enrichment now, while other sources are still running.
5246 # Elapsed is measured from run_started so multi-source runs that finish
5247 # search quickly (30-90s) still have 190-250s of budget (clamped to 180).
5248 # This replaces the old deferred-to-finalize path which left only crumbs
5249 # (e.g. 11s) after long retrieval phases.
5250 elapsed = time.monotonic() - run_started if run_started else 0.0
5251 enriched, review_status = amazon.enrich_with_reviews(
5252 products,
5253 depth=depth,
5254 config=config,
5255 elapsed=elapsed,
5256 keyword=keyword,
5257 )
5258
5259 # Record PARTIAL status if review lane was skipped or all pulls dropped
5260 if review_status:
5261 artifact = artifact or {}
5262 artifact = dict(artifact) if artifact else {}
5263 artifact["_source_outcome"] = {
5264 "state": schema.PARTIAL,
5265 "detail": review_status,
5266 "attempted": True,
5267 }
5268
5269 return enriched, artifact
5270 if source == "meta_ads":
5271 # The advertiser is resolved from the stable research topic, not the
5272 # narrowed per-subquery search_query: a subquery like "kettle reviews"
5273 # would resolve a different page than the brand the run is about.
5274 brand = raw_topic or topic or subquery.search_query
5275 result = meta_ads.search_meta_ads(
5276 brand,
5277 from_date,
5278 to_date,
5279 depth=depth,
5280 token=(config or {}).get("SCRAPECREATORS_API_KEY") or "",
5281 country=str(
5282 (config or {}).get("LAST30DAYS_META_ADS_COUNTRY")
5283 or meta_ads.DEFAULT_COUNTRY
5284 ),
5285 page_override=str((config or {}).get("_meta_ads_page") or "").strip(),
5286 )
5287 if result.get("partial"):
5288 # A partial lane carries `error` too, so the generic classifier
5289 # would run and have its verdict overwritten here regardless.
5290 artifact = {
5291 "_source_outcome": {
5292 "state": schema.PARTIAL,
5293 "detail": str(result.get("error") or "partial"),
5294 "attempted": True,
5295 }
5296 }
5297 else:
5298 artifact = dict(_result_outcome_artifact(source, result) or {})
5299 # The footer needs the resolved advertiser and the pre-truncation
5300 # counts even on a run that produced zero items, and stream artifacts
5301 # only reach the report through the grounding list, so they ride here
5302 # and are lifted to top-level artifacts after retrieval.
5303 artifact["meta_ads_page"] = result.get("page") or {}
5304 artifact["meta_ads_tally"] = result.get("tally") or {}
5305 return result.get("ads") or [], artifact
5306 if source == "bluesky":
5307 result = bluesky.search_bluesky(subquery.search_query, from_date, to_date, depth=depth, config=config)
5308 return bluesky.parse_bluesky_response(result), _result_outcome_artifact(source, result)
5309 if source == "threads":
5310 result = threads.search_threads(
5311 subquery.search_query, from_date, to_date,
5312 depth=depth,
5313 token=config.get("SCRAPECREATORS_API_KEY"),
5314 )
5315 return threads.parse_threads_response(result), _result_outcome_artifact(source, result)
5316 if source == "telegram":
5317 result = telegram.search_telegram(
5318 subquery.search_query, from_date, to_date,
5319 depth=depth,
5320 token=config.get("SCRAPECREATORS_API_KEY"),
5321 config=config,
5322 )
5323 return telegram.parse_telegram_response(result), _result_outcome_artifact(source, result)
5324 if source == "truthsocial":
5325 result = truthsocial.search_truthsocial(subquery.search_query, from_date, to_date, depth=depth, config=config)
5326 return truthsocial.parse_truthsocial_response(result), _result_outcome_artifact(source, result)
5327 if source == "polymarket":
5328 result = polymarket.search_polymarket(subquery.search_query, from_date, to_date, depth=depth)
5329 # Relevance filtering keys off the stable original research topic, not the
5330 # per-subquery search_query (which narrows differently on each fanout pass
5331 # and would let off-topic markets through on broad subqueries while dropping
5332 # everything on narrow ones).
5333 relevance_topic = raw_topic or topic or subquery.search_query
5334 return (
5335 polymarket.parse_polymarket_response(result, topic=relevance_topic),
5336 _result_outcome_artifact(source, result),
5337 )
5338 if source == "github":
5339 # Resolve once at the pipeline boundary so search and enrich
5340 # share the result; otherwise each call would re-run the env
5341 # lookup and gh-CLI subprocess fallback (up to 5s timeout each).
5342 token = github.resolve_token(config.get("GITHUB_TOKEN"))
5343 response = github.search_github(subquery.search_query, from_date, to_date, depth=depth, token=token)
5344 items = github.parse_github_response(response)
5345 # Note: an unauth rate-limit (response["error"]) is expected on the
5346 # tokenless anon tier and returns empty here rather than raising — github
5347 # is now always eligible, so raising would spam "github failed" on every
5348 # tokenless run. The condition is logged in github.search_github.
5349 items = github.enrich_with_comments(items, depth=depth, token=token)
5350 return items, _result_outcome_artifact(source, response)
5351 if source == "pinterest":
5352 result = pinterest.search_pinterest(
5353 subquery.search_query, from_date, to_date,
5354 depth=depth,
5355 token=env.get_pinterest_token(config),
5356 )
5357 return pinterest.parse_pinterest_response(result), _result_outcome_artifact(source, result)
5358 if source == "xiaohongshu":
5359 return xiaohongshu_api.search_feeds(
5360 subquery.search_query,
5361 from_date,
5362 to_date,
5363 env.get_xiaohongshu_api_base(config),
5364 depth=depth,
5365 ), {}
5366 if source == "perplexity":
5367 return perplexity.search(subquery.search_query, date_range, config, deep=config.get("_deep_research", False))
5368 raise RuntimeError(f"Unsupported source: {source}")
5369
5370
5371 def _google_key(config: dict[str, Any]) -> str | None:
5372 return config.get("GOOGLE_API_KEY") or config.get("GEMINI_API_KEY") or config.get("GOOGLE_GENAI_API_KEY")
5373
5374
5375
5376
5377 def _mock_stream_results(source: str, subquery: schema.SubQuery) -> tuple[list[dict], dict]:
5378 # Namespace URLs and the canned comment by topic: real runs never hand two
5379 # distinct stories byte-identical evidence, and discovery's same-story fold
5380 # (correctly) collapses topics that share it. Mock enrichment sub-runs feed
5381 # this fixture one topic per subquery, so the slug keeps them distinct.
5382 slug = re.sub(r"[^a-z0-9]+", "-", subquery.search_query.lower()).strip("-") or "topic"
5383 payloads = {
5384 "reddit": [
5385 {
5386 "id": "R1",
5387 "title": f"{subquery.search_query} discussion thread",
5388 "url": f"https://reddit.com/r/example/comments/{slug}-1",
5389 "subreddit": "example",
5390 "date": dates.get_date_range(5)[0],
5391 "engagement": {"score": 120, "num_comments": 48, "upvote_ratio": 0.91},
5392 "selftext": f"Community discussion about {subquery.search_query}.",
5393 "top_comments": [{"excerpt": f"Strong firsthand feedback from {subquery.search_query} users."}],
5394 "relevance": 0.82,
5395 "why_relevant": "Mock Reddit result",
5396 }
5397 ],
5398 "x": [
5399 {
5400 "id": "X1",
5401 "text": f"People on X are discussing {subquery.search_query} right now.",
5402 "url": f"https://x.com/example/status/{slug}-1",
5403 "author_handle": "example",
5404 "date": dates.get_date_range(2)[0],
5405 "engagement": {"likes": 200, "reposts": 35, "replies": 18, "quotes": 4},
5406 "relevance": 0.79,
5407 "why_relevant": "Mock X result",
5408 }
5409 ],
5410 "grounding": [
5411 {
5412 "id": "WB1",
5413 "title": f"{subquery.search_query} article",
5414 "url": f"https://example.com/article/{slug}",
5415 "source_domain": "example.com",
5416 "snippet": f"Recent web reporting about {subquery.search_query}.",
5417 "date": dates.get_date_range(7)[0],
5418 "relevance": 0.88,
5419 "why_relevant": "Brave web search",
5420 }
5421 ],
5422 "digg": [
5423 {
5424 "id": "mock1abc",
5425 "title": f"Digg cluster about {subquery.search_query}",
5426 "url": f"https://di.gg/ai/mock1abc-{slug}",
5427 "tldr": f"Curated cluster summarizing recent {subquery.search_query} discussion across the AI 1000.",
5428 "author": "",
5429 "date": dates.get_date_range(3)[0],
5430 "engagement": {"postCount": 8, "uniqueAuthors": 5, "rank": 2, "rank_score": 49.0},
5431 "first_post_age": "3d",
5432 "posts": [
5433 {
5434 "username": "exampledev",
5435 "display_name": "Example Dev",
5436 "category": "Engineer",
5437 "rank": 142,
5438 "body": f"Quote from the AI 1000 about {subquery.search_query}.",
5439 "post_type": "tweet",
5440 "x_url": "https://x.com/exampledev/status/1",
5441 "posted_at": dates.get_date_range(3)[0],
5442 },
5443 ],
5444 "relevance": 0.84,
5445 "why_relevant": "Mock Digg cluster",
5446 },
5447 {
5448 "id": "mock2def",
5449 "title": f"Second Digg cluster on {subquery.search_query}",
5450 "url": f"https://di.gg/ai/mock2def-{slug}",
5451 "tldr": f"Another angle on {subquery.search_query}.",
5452 "author": "",
5453 "date": dates.get_date_range(8)[0],
5454 "engagement": {"postCount": 3, "uniqueAuthors": 2, "rank": 18, "rank_score": 33.0},
5455 "first_post_age": "8d",
5456 "posts": [],
5457 "relevance": 0.71,
5458 "why_relevant": "Mock Digg cluster",
5459 },
5460 ],
5461 "arxiv": [
5462 {
5463 "id": f"http://arxiv.org/abs/2606.00001v1-{slug}",
5464 "title": f"A Survey of {subquery.search_query}",
5465 "url": f"https://arxiv.org/abs/2606.00001v1-{slug}",
5466 "summary": f"We present a comprehensive study of {subquery.search_query} and its recent advances.",
5467 "author": "Ada Lovelace et al.",
5468 "authors": ["Ada Lovelace", "Alan Turing"],
5469 "date": dates.get_date_range(20)[0],
5470 "engagement": {},
5471 "relevance": 0.86,
5472 "why_relevant": "Mock arXiv paper",
5473 },
5474 ],
5475 "techmeme": [
5476 {
5477 "id": f"https://www.techmeme.com/260627/p1-{slug}",
5478 "title": f"Major development in {subquery.search_query} reshapes the industry",
5479 "url": f"https://www.techmeme.com/260627/p1-{slug}",
5480 "source_name": "techcrunch.com",
5481 "date": dates.get_date_range(1)[0],
5482 "engagement": {},
5483 "relevance": 0.83,
5484 "why_relevant": "Mock Techmeme headline",
5485 },
5486 ],
5487 "dripstack": [
5488 {
5489 "id": "DS1",
5490 "title": f"Deep dive: {subquery.search_query} from a paid newsletter",
5491 "url": f"https://newsletter.example.com/deep-dive-{slug}",
5492 "author": "newsletter.example.com",
5493 "date": dates.get_date_range(3)[0],
5494 "engagement": {},
5495 "relevance": 0.85,
5496 "why_relevant": "Mock DripStack newsletter result",
5497 "snippet": f"Professional analyst coverage of {subquery.search_query}.",
5498 "metadata": {
5499 "publication_slug": "newsletter.example.com",
5500 "post_slug": "deep-dive",
5501 "relevance_score": 85,
5502 "match_confidence": "strong",
5503 },
5504 },
5505 ],
5506 "trustpilot": [
5507 {
5508 "id": "example.com",
5509 "title": f"{subquery.search_query}: TrustScore 3.4",
5510 "url": f"https://www.trustpilot.com/review/{slug}.example.com",
5511 "summary": f"Across recent reviews, customers were split on {subquery.search_query}: some praised support, others cited delays.",
5512 "name": subquery.search_query,
5513 "trustScore": 3.4,
5514 "reviewCount": 128,
5515 "date": dates.get_date_range(1)[0],
5516 "engagement": {"reviews": 128, "trustScore": 3.4},
5517 "relevance": 0.8,
5518 "why_relevant": "Mock Trustpilot sentiment",
5519 },
5520 ],
5521 # Three products spanning the drift states the footer renders: one
5522 # sagging below its all-time average (with enough in-window reviews
5523 # to clear the arrow threshold), one steady, and one too new to have
5524 # a baseline. Mock runs exercise the full R1c line without a CLI.
5525 "amazon": [
5526 {
5527 "asin": "B0MOCK00X1",
5528 "date": dates.get_date_range(1)[1],
5529 "name": f"{subquery.search_query} Pro Model | Flagship Edition",
5530 "short_name": "Pro Model",
5531 "brand": subquery.search_query.split()[0].title() if subquery.search_query else "Example",
5532 "url": "https://www.amazon.com/dp/B0MOCK00X1",
5533 "rating": 4.4,
5534 "num_ratings": 459,
5535 "price": 39.99,
5536 "currency": "USD",
5537 "badge": "Best Seller",
5538 "sponsored": False,
5539 "relevance": 0.85,
5540 "why_relevant": "Mock Amazon product",
5541 "product_rating": 4.4,
5542 "product_rating_count": 459,
5543 "star_distribution": {
5544 "one_star": 28, "two_star": 9, "three_star": 28,
5545 "four_star": 60, "five_star": 335,
5546 },
5547 "top_comments": [
5548 {
5549 "score": 3, "rating": 2, "verified": True,
5550 "date": dates.get_date_range(3)[1],
5551 "excerpt": "The tray shifts in transit and the lid jams shut.",
5552 "title": "Lid jams",
5553 },
5554 {
5555 "score": 1, "rating": 4, "verified": True,
5556 "date": dates.get_date_range(9)[1],
5557 "excerpt": "Solid build, but arrived with a dented panel.",
5558 "title": "Shipping dent",
5559 },
5560 {
5561 "score": 0, "rating": 5, "verified": True,
5562 "date": dates.get_date_range(14)[1],
5563 "excerpt": "Keeps everything cold through a full school day.",
5564 "title": "Works great",
5565 },
5566 {
5567 "score": 0, "rating": 4, "verified": True,
5568 "date": dates.get_date_range(19)[1],
5569 "excerpt": "Good size for the price.",
5570 "title": "Good value",
5571 },
5572 {
5573 "score": 0, "rating": 4, "verified": False,
5574 "date": dates.get_date_range(24)[1],
5575 "excerpt": "Does the job, nothing fancy.",
5576 "title": "Fine",
5577 },
5578 ],
5579 },
5580 {
5581 "asin": "B0MOCK00X2",
5582 "date": dates.get_date_range(1)[1],
5583 "name": f"{subquery.search_query} Classic | Everyday Model",
5584 "short_name": "Classic",
5585 "brand": subquery.search_query.split()[0].title() if subquery.search_query else "Example",
5586 "url": "https://www.amazon.com/dp/B0MOCK00X2",
5587 "rating": 4.7,
5588 "num_ratings": 8446,
5589 "price": 24.99,
5590 "currency": "USD",
5591 "sponsored": False,
5592 "relevance": 0.8,
5593 "why_relevant": "Mock Amazon product",
5594 "product_rating": 4.7,
5595 "product_rating_count": 8446,
5596 "star_distribution": {
5597 "one_star": 120, "two_star": 90, "three_star": 300,
5598 "four_star": 1010, "five_star": 6926,
5599 },
5600 "top_comments": [
5601 {
5602 "score": 12, "rating": 5, "verified": True,
5603 "date": dates.get_date_range(4)[1],
5604 "excerpt": "Third one we've bought. They last.",
5605 "title": "Repeat buyer",
5606 },
5607 ],
5608 },
5609 {
5610 "asin": "B0MOCK00X3",
5611 "date": dates.get_date_range(1)[1],
5612 "name": f"{subquery.search_query} Mini | New Release",
5613 "short_name": "Mini",
5614 "brand": subquery.search_query.split()[0].title() if subquery.search_query else "Example",
5615 "url": "https://www.amazon.com/dp/B0MOCK00X3",
5616 "rating": None,
5617 "num_ratings": 57,
5618 "price": 19.99,
5619 "currency": "USD",
5620 "sponsored": False,
5621 "relevance": 0.72,
5622 "why_relevant": "Mock Amazon product",
5623 },
5624 ],
5625 "jobs": [
5626 {
5627 "id": "J1",
5628 "title": "Founding Enterprise Solutions Engineer",
5629 "url": f"https://boards.greenhouse.io/example/jobs/{slug}-1",
5630 "description": (
5631 f"Work with enterprise customers on SSO, SOC 2, security, "
5632 f"and procurement workflows for {subquery.search_query}."
5633 ),
5634 "department": "Sales",
5635 "location": "San Francisco, CA",
5636 "date": dates.get_date_range(4)[0],
5637 "provider": "mock",
5638 "relevance": 0.8,
5639 "why_relevant": "Mock public job posting",
5640 },
5641 {
5642 "id": "J2",
5643 "title": "Security Platform Engineer",
5644 "url": f"https://boards.greenhouse.io/example/jobs/{slug}-2",
5645 "description": "Build enterprise security, audit, and admin workflows.",
5646 "department": "Engineering",
5647 "location": "Remote",
5648 "date": dates.get_date_range(6)[0],
5649 "provider": "mock",
5650 "relevance": 0.78,
5651 "why_relevant": "Mock public job posting",
5652 },
5653 ],
5654 }
5655 if source == "grounding":
5656 return payloads.get(source, []), {
5657 "label": subquery.label,
5658 "mock": True,
5659 "webSearchQueries": [subquery.search_query],
5660 "resultCount": 1,
5661 }
5662 return payloads.get(source, []), {}
5663
5663 lines PYTHON