返回 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 import math
7 import queue
8 import re
9 import sqlite3
10 import sys
11 import threading
12 import time
13 from collections import Counter
14 from concurrent.futures import ThreadPoolExecutor, as_completed
15 from dataclasses import dataclass, field, replace
16 from datetime import date, datetime, timedelta, timezone
17 from pathlib import Path
18 from shutil import which
19 from typing import Any
20
21 from . import (
22 arxiv,
23 bird_x,
24 bluesky,
25 corpus,
26 dates,
27 dedupe,
28 digg,
29 dripstack,
30 entity_extract,
31 env,
32 github,
33 grounding,
34 hackernews,
35 health,
36 hiring_signals,
37 http,
38 instagram,
39 jobs,
40 linkedin,
41 library,
42 library_index,
43 log,
44 normalize,
45 permission_preflight,
46 perplexity,
47 pinterest,
48 planner,
49 polymarket,
50 providers,
51 query,
52 reddit,
53 reddit_listing,
54 reddit_public,
55 relevance,
56 rerank,
57 schema,
58 signals,
59 snippet,
60 stocktwits,
61 techmeme,
62 threads,
63 tiktok,
64 topic_shape,
65 truthsocial,
66 trustpilot,
67 xai_x,
68 xiaohongshu_api,
69 xquik,
70 xurl_x,
71 youtube_yt,
72 )
73 from .cluster import cluster_candidates
74 from .fusion import weighted_rrf
75
76 DISCOVERY_SOURCES = ("reddit", "hackernews", "digg", "x")
77 _DISCOVERY_GENERIC_DOMAIN_TERMS = {
78 "ai", "artificial", "intelligence", "tech", "technology", "trending", "trend",
79 }
80
81 DEPTH_SETTINGS = {
82 "quick": {"per_stream_limit": 6, "pool_limit": 15, "rerank_limit": 12},
83 "default": {"per_stream_limit": 12, "pool_limit": 40, "rerank_limit": 40},
84 "deep": {"per_stream_limit": 20, "pool_limit": 60, "rerank_limit": 60},
85 }
86
87 SEARCH_ALIAS = {
88 "hn": "hackernews",
89 "bsky": "bluesky",
90 "truth": "truthsocial",
91 "web": "grounding",
92 "xhs": "xiaohongshu",
93 "xquik": "x", # xquik is a backend of the single "x" source, not its own source
94 }
95
96 # trustpilot is capped at 1: every subquery would use the identical company
97 # identifier, so N streams are pure redundancy -- and each extra stream risks
98 # its own WAF-cookie Chrome harvest.
99 MAX_SOURCE_FETCHES: dict[str, int] = {"x": 2, "jobs": 1, "linkedin": 1, "stocktwits": 1, "trustpilot": 1}
100
101
102 def _resolve_depth_settings(depth: str, config: dict[str, Any]) -> dict[str, int]:
103 """Depth profile with optional CLI cap overrides applied (issue #716).
104
105 Returns a copy so the module-level DEPTH_SETTINGS is never mutated. Overrides
106 are set directly (not max()) so callers can also lower a cap. `--max-results`
107 raises the final ranked pool (pool_limit/rerank_limit); `--max-per-source`
108 raises the per-stream truncation applied before pooling. The per-source fetch
109 cap (`--max-source-fetches`) is applied separately at the fetch site.
110 """
111 settings = dict(DEPTH_SETTINGS[depth])
112 # `is not None` (not truthiness) so an explicit 0 is honored as a real lower
113 # bound rather than ignored as "unset" — matches how main() stashes these.
114 max_per_source = config.get("_max_per_source")
115 if max_per_source is not None:
116 settings["per_stream_limit"] = int(max_per_source)
117 max_results = config.get("_max_results")
118 if max_results is not None:
119 settings["pool_limit"] = int(max_results)
120 settings["rerank_limit"] = int(max_results)
121 return settings
122
123 # Per-handle result caps for the X handle-search lanes. The FROM lane (the
124 # subject's own timeline) is the single best source for a person topic, so it
125 # gets the highest cap; the ABOUT (mention) and related-handle lanes stay
126 # modest so total volume and request budget don't balloon.
127 FROM_LANE_COUNT_PER = 8
128 MENTION_LANE_COUNT_PER = 5
129 RELATED_HANDLE_COUNT_PER = 3
130
131
132 def _has_perplexity_provider(config: dict[str, Any]) -> bool:
133 return bool(config.get("PERPLEXITY_API_KEY") or config.get("OPENROUTER_API_KEY"))
134
135 MOCK_AVAILABLE_SOURCES = [
136 "reddit",
137 "x",
138 "youtube",
139 "tiktok",
140 "instagram",
141 "hackernews",
142 "bluesky",
143 "truthsocial",
144 "polymarket",
145 "grounding",
146 "xiaohongshu",
147 "github",
148 "perplexity",
149 "threads",
150 "pinterest",
151 "digg",
152 "arxiv",
153 "techmeme",
154 "trustpilot",
155 "jobs",
156 "linkedin",
157 "corpus",
158 "dripstack",
159 ]
160
161
162 def normalize_requested_sources(sources: list[str] | None) -> list[str] | None:
163 if not sources:
164 return None
165 normalized = []
166 for source in sources:
167 key = SEARCH_ALIAS.get(source.lower(), source.lower())
168 if key not in normalized:
169 normalized.append(key)
170 return normalized
171
172
173 def available_sources(
174 config: dict[str, Any],
175 requested_sources: list[str] | None = None,
176 *,
177 x_pending: bool | None = None,
178 local_only: bool = False,
179 ) -> list[str]:
180 """List the sources the next run can serve.
181
182 ``local_only=True`` is the safe/diagnose flavor (doctor's permission
183 block): availability is answered from local evidence only, so the X
184 check never spawns xurl's live ``whoami`` network call. Research-time
185 callers keep the default live semantics.
186 """
187 available: list[str] = []
188 # reddit_public needs no API key - always available
189 available.append("reddit")
190 if corpus.resolve_directories(
191 config.get("_CORPUS_DIRS"), config.get("LAST30DAYS_CORPUS_DIRS")
192 ):
193 available.append("corpus")
194 if config.get("SCRAPECREATORS_API_KEY"):
195 available.extend(["tiktok", "instagram"])
196 if env.get_x_source(config, local_only=local_only):
197 available.append("x")
198 else:
199 # Safe inspection (--diagnose/--preflight) skips browser-cookie
200 # extraction, so get_x_source is None even though a real run would
201 # authenticate X via FROM_BROWSER. Report it as available so consumers
202 # of available_sources (SKILL.md ACTIVE_SOURCES_LIST) don't under-report.
203 # diagnose() precomputes the predicate and passes it via x_pending to
204 # avoid evaluating it twice in one diagnose() call.
205 if x_pending is None:
206 x_pending = env.x_pending_browser_auth(config)
207 if x_pending:
208 available.append("x")
209 if which("yt-dlp") or env.is_youtube_sc_available(config):
210 available.append("youtube")
211 available.extend(["hackernews", "polymarket"])
212 # StockTwits is gated to ticker/crypto topics only (flag set in run()).
213 if config.get("_financial_topic"):
214 available.append("stocktwits")
215 # GitHub is reachable via the unauthenticated REST tier too, so it is
216 # available even without a token/gh CLI (a token only raises rate limits).
217 available.append("github")
218 # DripStack is opt-in only (owner decision, #791): a commercial
219 # third-party API must never receive default-run traffic. Opt in per run
220 # (--search dripstack) or persistently (INCLUDE_SOURCES=dripstack in
221 # .env, the LinkedIn/Perplexity pattern); the search API is free and
222 # public (no key), so the opt-in itself is the gate.
223 include_sources = {
224 token.strip()
225 for token in (config.get("INCLUDE_SOURCES") or "").lower().split(",")
226 if token.strip()
227 }
228 if "dripstack" in include_sources or (
229 requested_sources and "dripstack" in requested_sources
230 ):
231 available.append("dripstack")
232 if which("digg-pp-cli"):
233 available.append("digg")
234 # arXiv is default-on when its Printing Press CLI is installed (zero auth).
235 # The adapter relevance-and-recency gates so it stays quiet off-topic.
236 if which("arxiv-pp-cli"):
237 available.append("arxiv")
238 # Techmeme is default-on when its CLI is installed (zero auth; sub-second
239 # local sync before each run's first search).
240 if which("techmeme-pp-cli"):
241 available.append("techmeme")
242 if env.is_bluesky_available(config):
243 available.append("bluesky")
244 if env.is_truthsocial_available(config):
245 available.append("truthsocial")
246 # Grounding (general web) is available when a paid backend is configured OR
247 # the keyless floor is permitted (i.e. the host has no native search). On a
248 # native-search host with no paid key, keyless_web_allowed is False and the
249 # engine leaves general web to the model's own search.
250 if (config.get("BRAVE_API_KEY") or config.get("EXA_API_KEY")
251 or config.get("SERPER_API_KEY") or config.get("PARALLEL_API_KEY")
252 or env.keyless_web_allowed(config)):
253 available.append("grounding")
254 if requested_sources and "jobs" in requested_sources:
255 available.append("jobs")
256 # Perplexity Sonar: opt-in additive source via INCLUDE_SOURCES=perplexity
257 if _has_perplexity_provider(config) and (
258 "perplexity" in include_sources or (requested_sources and "perplexity" in requested_sources)
259 ):
260 available.append("perplexity")
261 # LinkedIn: opt-in additive source via INCLUDE_SOURCES=linkedin (same
262 # consent pattern as Perplexity). Unlike tiktok/instagram, which are
263 # offered during SKILL.md Step 0 onboarding, LinkedIn is power-user-only
264 # and must not silently activate for existing SCRAPECREATORS_API_KEY
265 # holders.
266 if config.get("SCRAPECREATORS_API_KEY") and (
267 "linkedin" in include_sources or (requested_sources and "linkedin" in requested_sources)
268 ):
269 available.append("linkedin")
270 # Trustpilot: opt-in additive source via INCLUDE_SOURCES=trustpilot (same
271 # consent pattern as Perplexity/LinkedIn). Off by default -- unlike arXiv and
272 # Techmeme, which are zero-auth, it can spawn a one-time headless-Chrome WAF
273 # cookie harvest on a brand topic, so activating it is the user's choice.
274 if which("trustpilot-pp-cli") and (
275 "trustpilot" in include_sources or (requested_sources and "trustpilot" in requested_sources)
276 ):
277 available.append("trustpilot")
278 if (
279 "xiaohongshu" in include_sources
280 or (requested_sources and "xiaohongshu" in requested_sources)
281 ) and env.is_xiaohongshu_available(config):
282 available.append("xiaohongshu")
283 # Threads: opt-in via INCLUDE_SOURCES (same pattern as perplexity/linkedin).
284 # Was auto-on with the key; gated so the onboarding "Everything" tier is a
285 # real choice vs the "Recommended" (TikTok/Instagram) tier.
286 if env.is_threads_available(config) and (
287 "threads" in include_sources or (requested_sources and "threads" in requested_sources)
288 ):
289 available.append("threads")
290 # Pinterest: opt-in via INCLUDE_SOURCES. Previously read requested_sources
291 # only, so a persisted INCLUDE_SOURCES=pinterest never activated it; now it
292 # honors both the per-run --sources list and the saved config.
293 if env.is_pinterest_available(config) and (
294 "pinterest" in include_sources or (requested_sources and "pinterest" in requested_sources)
295 ):
296 available.append("pinterest")
297 # xquik is a backend of the single "x" source (see env.x_backend_chain),
298 # not a separate parallel source — registered via the "x" entry above.
299 exclude = {s.strip().lower() for s in (config.get("EXCLUDE_SOURCES") or "").split(",") if s.strip()}
300 if exclude:
301 available = [s for s in available if s not in exclude]
302 return available
303
304
305 def _mock_discovery_items(
306 source: str,
307 domain: str,
308 to_date: str,
309 ) -> list[dict[str, Any]]:
310 """Deterministic listing fixtures for the public --mock CLI contract."""
311 labels = [
312 "Agent memory protocols",
313 "Browser-using agents",
314 "Local agent runtimes",
315 "Multi-agent orchestration",
316 "Agent security sandboxes",
317 "Voice agent latency",
318 ]
319 end = datetime.fromisoformat(to_date).date()
320 items: list[dict[str, Any]] = []
321 for index, label in enumerate(labels, start=1):
322 published = (end - timedelta(days=index)).isoformat()
323 slug = re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-")
324 if source == "reddit":
325 items.append({
326 "id": f"discovery-r-{index}",
327 "title": label,
328 "url": f"https://reddit.com/r/example/comments/{slug}",
329 "subreddit": "example",
330 "date": published,
331 "engagement": {"score": 180 - index * 10, "num_comments": 30 + index},
332 "selftext": label,
333 "relevance": 0.9,
334 "why_relevant": "Mock discovery listing",
335 })
336 elif source == "hackernews":
337 items.append({
338 "id": f"discovery-hn-{index}",
339 "title": label,
340 "url": f"https://example.com/{slug}",
341 "hn_url": f"https://news.ycombinator.com/item?id={index}",
342 "author": f"example{index}",
343 "date": published,
344 "engagement": {"points": 120 - index * 8, "comments": 20 + index},
345 "relevance": 0.88,
346 "why_relevant": "Mock HN discovery listing",
347 })
348 elif source == "digg":
349 items.append({
350 "id": f"discovery-d-{index}",
351 "title": label,
352 "url": f"https://di.gg/ai/{slug}",
353 "tldr": label,
354 "date": published,
355 "engagement": {"postCount": 30 - index, "uniqueAuthors": 12 - index},
356 "relevance": 0.9,
357 "why_relevant": "Mock Digg discovery cluster",
358 })
359 elif source == "x":
360 items.append({
361 "id": f"discovery-x-{index}",
362 "text": label,
363 "url": f"https://x.com/example{index}/status/{index}",
364 "author_handle": f"example{index}",
365 "date": published,
366 "engagement": {"likes": 140 - index * 9, "reposts": 18 + index},
367 "relevance": 0.9,
368 "why_relevant": "Mock X discovery activity",
369 })
370 return items
371
372
373 def _matches_discovery_domain(domain: str, text: str) -> bool:
374 """Require a distinctive domain term, not a generic token such as ``AI``."""
375 def terms(value: str) -> set[str]:
376 # Keep BOTH the surface form and the naive stem: replacing the token
377 # broke non-plurals ("bias" -> "bia", "crisis" -> "crisi") so in-domain
378 # listings stopped intersecting. The union preserves plural matching
379 # without corrupting the anchor.
380 words: set[str] = set()
381 for word in relevance.tokenize(value):
382 words.add(word)
383 if len(word) > 4 and word.endswith("s") and not word.endswith("ss"):
384 words.add(word[:-1])
385 return words
386
387 domain_terms = terms(domain)
388 anchors = domain_terms - _DISCOVERY_GENERIC_DOMAIN_TERMS
389 return bool((anchors or domain_terms) & terms(text))
390
391
392 def _fetch_discovery_source(
393 source: str,
394 plan: schema.DiscoveryPlan,
395 *,
396 from_date: str,
397 to_date: str,
398 depth: str,
399 mock: bool,
400 config: dict[str, Any],
401 keyword_gate: bool = True,
402 ) -> tuple[list[dict[str, Any]], str | None]:
403 """Fetch one listing/river source for the nominate stage.
404
405 ``keyword_gate`` controls whether items are filtered to the domain by
406 ``_matches_discovery_domain``. Domain-scoped discovery (``--discover X``)
407 keeps the gate on; global trending (``--discover`` with no domain) turns it
408 off, because there is no keyword to gate against - the river feeds ARE the
409 "what is hot right now" signal, and the confidence floor downstream is what
410 keeps junk out, not a keyword match.
411 """
412 if mock:
413 return _mock_discovery_items(source, plan.domain, to_date), None
414 if source == "reddit":
415 result = reddit_listing.fetch_discovery_listings(
416 plan.subreddits, depth=depth, query=plan.domain,
417 )
418 items = result.get("items") or []
419 if keyword_gate:
420 items = [
421 item for item in items
422 if _matches_discovery_domain(
423 plan.domain,
424 f"{item.get('title') or ''} {item.get('selftext') or ''}",
425 )
426 ]
427 return items, "; ".join(result.get("errors") or []) or None
428 if source == "hackernews":
429 result = hackernews.fetch_discovery_listings(from_date, to_date, depth=depth)
430 items = result.get("items") or []
431 for item in items:
432 item["relevance"] = relevance.token_overlap_relevance(
433 plan.domain,
434 str(item.get("title") or ""),
435 )
436 # HN is a broad technology listing, so keep only domain-bearing stories
437 # when a domain is in play; global trending keeps the whole front page.
438 if keyword_gate:
439 items = [
440 item for item in items
441 if _matches_discovery_domain(plan.domain, str(item.get("title") or ""))
442 ]
443 errors = result.get("errors") or []
444 return items, "; ".join(errors) or None
445 if source == "digg":
446 result = digg.search_digg(plan.domain, from_date, to_date, depth=depth)
447 items = digg.parse_digg_response(result, query=plan.domain)
448 # Digg is an AI-focused broad listing, so keep only domain-bearing
449 # clusters when scoped; global trending keeps the whole feed.
450 if keyword_gate:
451 items = [
452 item for item in items
453 if _matches_discovery_domain(plan.domain, str(item.get("title") or ""))
454 ]
455 return items, result.get("error")
456 if source == "x":
457 subquery = schema.SubQuery(
458 label="discovery-listings",
459 search_query=plan.domain,
460 ranking_query=f"What is accelerating in {plan.domain}?",
461 sources=["x"],
462 )
463 last_error = ""
464 for backend in env.x_backend_chain(config):
465 items, error = _fetch_x_backend(
466 backend, subquery, from_date, to_date, depth, config,
467 )
468 if items:
469 # Earlier failed-over backends' errors are observability, not
470 # degradation - but the producing backend's own error means
471 # these items are partial and must surface as such.
472 if last_error:
473 print(f"[x] earlier backend failed: {last_error}", file=sys.stderr)
474 return items, error or None
475 if error:
476 last_error = f"{backend}: {error}"
477 return [], last_error or None
478 raise ValueError(f"Unsupported discovery source: {source}")
479
480
481 def _discovery_engagement(
482 items: list[schema.SourceItem],
483 ) -> dict[str, dict[str, float | int]]:
484 totals: dict[str, dict[str, float | int]] = {}
485 for item in items:
486 bucket = totals.setdefault(item.source, {})
487 for field, value in item.engagement.items():
488 if not isinstance(value, (int, float)) or isinstance(value, bool):
489 continue
490 # Rank/score/reach metadata is not additive engagement: summing
491 # Digg ranks across items fabricates a metric (agent-export uses
492 # the same counter-field rule).
493 if not schema._is_counter_field(field):
494 continue
495 bucket[field] = bucket.get(field, 0) + value
496 return {
497 source: dict(sorted(metrics.items()))
498 for source, metrics in sorted(totals.items())
499 }
500
501
502 def _discovery_momentum(items: list[schema.SourceItem], to_date: str) -> str:
503 as_of = datetime.fromisoformat(to_date).date()
504 ages: list[int] = []
505 for item in items:
506 try:
507 published = datetime.fromisoformat((item.published_at or "").replace("Z", "+00:00")).date()
508 except (TypeError, ValueError):
509 continue
510 ages.append(max(0, (as_of - published).days))
511 return "new-this-week" if ages and max(ages) < 7 else "building"
512
513
514 def nominate_candidates(
515 plan: schema.DiscoveryPlan,
516 *,
517 from_date: str,
518 to_date: str,
519 depth: str,
520 mock: bool,
521 config: dict[str, Any],
522 lookback_days: int,
523 keyword_gate: bool = True,
524 ) -> schema.RetrievalBundle:
525 """Stage 1 of discovery: fetch, normalize, and bundle candidate hot items
526 from the river/listing feeds.
527
528 This is the topic-nomination pass. For domain discovery ``keyword_gate`` is
529 on and the feeds are filtered to the domain; for global trending it is off
530 and the feeds' own hot ranking IS the signal. The returned bundle feeds the
531 clustering + enrichment stages downstream. Every source's failure is
532 recorded on the bundle (never raised) so a single dead feed cannot sink the
533 run - the confidence floor decides whether the surviving evidence is enough.
534 """
535 bundle = schema.RetrievalBundle()
536 with ThreadPoolExecutor(max_workers=max(1, len(plan.sources))) as executor:
537 futures = {
538 executor.submit(
539 _fetch_discovery_source,
540 source,
541 plan,
542 from_date=from_date,
543 to_date=to_date,
544 depth=depth,
545 mock=mock,
546 config=config,
547 keyword_gate=keyword_gate,
548 ): source
549 for source in plan.sources
550 }
551 for future in as_completed(futures):
552 source = futures[future]
553 bundle.mark_attempted(source)
554 try:
555 raw_items, partial_error = future.result()
556 normalized = normalize.normalize_source_items(
557 source,
558 raw_items,
559 from_date,
560 to_date,
561 freshness_mode="breaking",
562 )
563 # Global trending has no domain; annotate against a neutral
564 # phrase so snippet extraction still works without biasing
565 # relevance toward any keyword.
566 prepared = relevance.PreparedQuery(plan.domain or "trending now")
567 normalized = signals.annotate_stream(
568 normalized,
569 prepared,
570 "breaking",
571 reference_date=to_date,
572 max_days=lookback_days,
573 )
574 normalized = dedupe.dedupe_items(normalized)
575 for item in normalized:
576 item.snippet = snippet.extract_best_snippet(item, prepared)
577 bundle.add_items("discovery-listings", source, normalized)
578 if partial_error:
579 failure_state = (
580 bird_x.classify_run_failure(partial_error)
581 if source == "x" and partial_error.startswith("bird:")
582 else http.classify_failure(message=partial_error)
583 )
584 bundle.record_failure(
585 source,
586 failure_state,
587 partial_error,
588 )
589 except Exception as exc:
590 state, attempted = _classify_source_failure(exc)
591 bundle.record_failure(source, state, str(exc), attempted=attempted)
592 return bundle
593
594
595 @dataclass(frozen=True)
596 class Nomination:
597 """A named candidate topic produced by the nominate stage.
598
599 ``seed_score`` is the cheap pre-enrichment rank - seed velocity on the
600 nominate stage, blended with the HOST judge's content-worthiness on the
601 protocol resume leg (see ``rerank.judge_blended_score``). Enough to
602 decide WHICH candidates deserve a full pipeline pass, but not the final
603 ranking signal (that comes from enriched evidence downstream).
604 ``junk_shape`` flags help-me/beginner/musing shapes that should not
605 become content topics; ``worthiness`` is the host judge's 0-100 content
606 score, None on the heuristic path.
607 """
608
609 name: str
610 seed_score: float
611 items: list[schema.SourceItem] = field(default_factory=list)
612 summary: str = ""
613 junk_shape: bool = False
614 worthiness: float | None = None
615
616
617 def _cluster_entity_counts(
618 cluster: schema.Cluster,
619 candidate_map: dict[str, schema.Candidate],
620 ) -> Counter:
621 """Entity-token frequencies across a cluster's members (title + snippet)."""
622 counts: Counter = Counter()
623 for candidate_id in cluster.candidate_ids:
624 candidate = candidate_map.get(candidate_id)
625 if candidate:
626 counts.update(entity_extract.extract_text_entities(
627 f"{candidate.title} {candidate.snippet}"
628 ))
629 return counts
630
631
632 # Bound on how many distinguishing entity tokens a colliding cluster may try
633 # before it is treated as indistinguishable from the earlier story. Keeps a
634 # pathological cluster (dozens of unique tokens, every resulting name already
635 # taken) from scanning its whole vocabulary.
636 _DISAMBIGUATION_TOKEN_LIMIT = 5
637
638
639 def _disambiguated_topic_name(
640 name: str,
641 cluster: schema.Cluster,
642 earlier_cluster: schema.Cluster,
643 candidate_map: dict[str, schema.Candidate],
644 entity_counts_cache: dict[str, Counter],
645 taken_names: dict[str, schema.Cluster],
646 ) -> str | None:
647 """Disambiguate a colliding topic name by appending the later cluster's
648 strongest entity token that the earlier cluster does not share.
649
650 Distinguishing tokens are tried in descending strength order (bounded at
651 ``_DISAMBIGUATION_TOKEN_LIMIT``) and the first resulting name not already
652 present in ``taken_names`` (casefolded keys) wins: a first-choice suffix
653 colliding with an already-taken name must not drop a distinct story while
654 another distinguishing token remains.
655
656 ``entity_counts_cache`` (keyed by cluster id, owned by the caller) memoizes
657 per-cluster entity counts so repeated collisions against the same cluster
658 never recompute them.
659
660 Returns None when no distinguishing entity yields an unused name - the
661 clusters cannot be told apart by content, so the caller treats them as the
662 same story.
663 """
664 def cached_counts(target: schema.Cluster) -> Counter:
665 counts = entity_counts_cache.get(target.cluster_id)
666 if counts is None:
667 counts = _cluster_entity_counts(target, candidate_map)
668 entity_counts_cache[target.cluster_id] = counts
669 return counts
670
671 later_counts = cached_counts(cluster)
672 earlier_entities = set(cached_counts(earlier_cluster))
673 name_tokens = {token.casefold() for token in name.split()}
674 choices = [
675 (count, token) for token, count in later_counts.items()
676 if token not in earlier_entities and token.casefold() not in name_tokens
677 ]
678 # Strongest first = most frequent across the cluster; alphabetical
679 # tie-break keeps the result deterministic.
680 ranked = sorted(choices, key=lambda entry: (-entry[0], entry[1]))
681 for _, token in ranked[:_DISAMBIGUATION_TOKEN_LIMIT]:
682 display = token
683 for candidate_id in cluster.candidate_ids:
684 candidate = candidate_map.get(candidate_id)
685 if candidate is None:
686 continue
687 match = next(
688 (
689 word.strip("\"'`()[]{}.,:;!?")
690 for word in f"{candidate.title} {candidate.snippet}".split()
691 if word.strip("\"'`()[]{}.,:;!?").lower() == token
692 ),
693 None,
694 )
695 if match:
696 display = match
697 break
698 resolved = f"{name} {display}"
699 if resolved.casefold() not in taken_names:
700 return resolved
701 return None
702
703
704 def nominate_topic_pool(
705 bundle: schema.RetrievalBundle,
706 query_plan: schema.QueryPlan,
707 plan: schema.DiscoveryPlan,
708 *,
709 to_date: str,
710 limit: int,
711 ) -> list[tuple[Nomination, str]]:
712 """Stage 1b of discovery: cluster nominated items into named candidate
713 topics, rank them, and pair each with its source cluster id.
714
715 This is the shared core behind ``nominate_topics`` (the one-shot path,
716 which drops the cluster ids) and the leg-1 nominate-only sweep (which
717 keys nominations-bundle rows on them, see ``run_discover_nominate``).
718
719 Naming and junk classification are the deterministic ``topic_shape``
720 heuristics and ranking is velocity-only - the engine runs no LLM here.
721 Reasoning-model judgment lives in the host-judged protocol: the host
722 renames, junk-filters, and worthiness-scores this pool from the leg-1
723 bundle, and ``run_discover_resume`` applies those verdicts. The one-shot
724 path ships the heuristic names as-is.
725
726 Casefold name collisions are disambiguated (the later cluster's strongest
727 non-shared entity token is appended, trying successive tokens when the
728 first-choice suffix is itself already taken) rather than blindly dropped:
729 short distilled names collide far more often than raw 96-char titles, and
730 a silent drop hides a distinct story. A colliding cluster is dropped only
731 when it shares a representative candidate with the earlier one (the same
732 story surfacing twice) or when no distinguishing entity token yields an
733 unused name.
734
735 Returns at most ``limit`` ``(nomination, cluster_id)`` pairs, never
736 padded - fewer clusters than ``limit`` means a shorter list, and the
737 confidence floor downstream decides whether what survived is worth
738 showing.
739 """
740 candidates = weighted_rrf(bundle.items_by_source_and_query, query_plan, pool_limit=80)
741 for candidate in candidates:
742 velocity = rerank.discovery_velocity_score(candidate.source_items, as_of_date=to_date)
743 candidate.final_score = min(100.0, 12.0 * math.log1p(velocity)) if velocity else 0.0
744 candidates.sort(key=lambda candidate: (-candidate.final_score, candidate.title.lower()))
745 clusters = cluster_candidates(candidates, query_plan)
746 candidate_map = {candidate.candidate_id: candidate for candidate in candidates}
747
748 ranked_clusters: list[tuple[float, schema.Cluster, list[schema.SourceItem]]] = []
749 for cluster in clusters:
750 cluster_items: list[schema.SourceItem] = []
751 for candidate_id in cluster.candidate_ids:
752 candidate = candidate_map.get(candidate_id)
753 if candidate:
754 cluster_items.extend(candidate.source_items)
755 score = rerank.discovery_velocity_score(cluster_items, as_of_date=to_date)
756 if score <= 0:
757 continue
758 ranked_clusters.append((score, cluster, cluster_items))
759 ranked_clusters.sort(key=lambda entry: (-entry[0], entry[1].title.lower()))
760
761 # Heuristic naming from each cluster's leader text (title + snippet).
762 named: list[tuple[float, schema.Cluster, list[schema.SourceItem], str, bool]] = []
763 for score, cluster, cluster_items in ranked_clusters:
764 leader = candidate_map.get(cluster.representative_ids[0]) if cluster.representative_ids else None
765 title = (leader.title if leader else cluster.title) or ""
766 snip = (leader.snippet if leader else "") or ""
767 name = topic_shape.distill_topic_name(title, snip) or plan.domain or title
768 junk_shape = topic_shape.is_junk_shape(title, snip)
769 named.append((score, cluster, cluster_items, name, junk_shape))
770 named.sort(key=lambda entry: (-entry[0], entry[3].lower()))
771
772 pool: list[tuple[Nomination, str]] = []
773 taken_names: dict[str, schema.Cluster] = {}
774 entity_counts_cache: dict[str, Counter] = {}
775 for score, cluster, cluster_items, name, junk_shape in named:
776 name_key = name.casefold()
777 if name_key in taken_names:
778 earlier_cluster = taken_names[name_key]
779 if set(cluster.representative_ids) & set(earlier_cluster.representative_ids):
780 continue # same story surfacing twice
781 resolved = _disambiguated_topic_name(
782 name, cluster, earlier_cluster, candidate_map, entity_counts_cache,
783 taken_names,
784 )
785 if resolved is None:
786 continue # indistinguishable by content: treat as the same story
787 name = resolved
788 name_key = name.casefold()
789 taken_names[name_key] = cluster
790 leader = candidate_map.get(cluster.representative_ids[0]) if cluster.representative_ids else None
791 summary = (leader.snippet if leader else "") or (leader.title if leader else name)
792 pool.append((Nomination(
793 name=name,
794 seed_score=score,
795 items=cluster_items,
796 summary=summary,
797 junk_shape=junk_shape,
798 ), cluster.cluster_id))
799 if len(pool) >= limit:
800 break
801 return pool
802
803
804 def nominate_topics(
805 bundle: schema.RetrievalBundle,
806 query_plan: schema.QueryPlan,
807 plan: schema.DiscoveryPlan,
808 *,
809 to_date: str,
810 limit: int,
811 ) -> list[Nomination]:
812 """``nominate_topic_pool`` without the cluster ids: the one-shot
813 discovery path's contract (see that function for the full semantics)."""
814 return [
815 nomination
816 for nomination, _cluster_id in nominate_topic_pool(
817 bundle, query_plan, plan, to_date=to_date, limit=limit,
818 )
819 ]
820
821
822 # Enrichment fan-out bounds. Sub-runs hit the same upstream APIs as a normal
823 # research pass, so parallelism stays low and the whole batch runs against a
824 # wall-clock budget - a slow topic is dropped, never fatal.
825 ENRICH_LIMIT = 6
826 ENRICH_DEPTH = "quick"
827 ENRICH_MAX_WORKERS = 3
828 ENRICH_BUDGET_SECONDS = 240.0
829
830
831 @dataclass
832 class EnrichedTopic:
833 """A nomination plus the full-pipeline evidence gathered for it.
834
835 ``report`` is None when enrichment for this topic failed or ran past the
836 batch budget - the topic survives as nomination-only and the confidence
837 floor downstream decides whether its seed evidence is enough to show.
838 """
839
840 nomination: Nomination
841 report: schema.Report | None = None
842 error: str | None = None
843
844
845 def enrich_nominations(
846 nominations: list[Nomination],
847 *,
848 config: dict[str, Any],
849 requested_sources: list[str] | None = None,
850 mock: bool = False,
851 depth: str = ENRICH_DEPTH,
852 lookback_days: int = 30,
853 as_of_date: str | None = None,
854 max_workers: int = ENRICH_MAX_WORKERS,
855 budget_seconds: float = ENRICH_BUDGET_SECONDS,
856 ) -> list[EnrichedTopic]:
857 """Stage 2 of discovery: run the real research pipeline on each nomination.
858
859 Each nominated topic gets a full ``run()`` pass (``internal_subrun=True``,
860 same lane as comparison-mode sub-runs), which buys the whole multi-source
861 corpus - Reddit with comments, X, YouTube, Techmeme, arXiv, HN, Polymarket,
862 web - plus clustering and ranking, with zero bespoke fetch code.
863
864 Failure containment: a topic whose sub-run raises is returned with
865 ``report=None`` and the error recorded; topics still unfinished when the
866 batch budget expires are likewise dropped to nomination-only. The batch
867 never raises and preserves nomination order.
868 """
869 if not nominations:
870 return []
871
872 def _run_one(nomination: Nomination) -> schema.Report:
873 return run(
874 topic=nomination.name,
875 config=config,
876 depth=depth,
877 requested_sources=requested_sources,
878 mock=mock,
879 lookback_days=lookback_days,
880 as_of_date=as_of_date,
881 internal_subrun=True,
882 )
883
884 # Daemon threads + a semaphore instead of ThreadPoolExecutor: executor
885 # threads are non-daemon and joined at interpreter shutdown, so one hung
886 # sub-run could keep the whole process alive long after its topic was
887 # downgraded to nomination-only. Daemon workers make the wall-clock budget
888 # real - stragglers cannot delay process exit. Abandonment is safe because
889 # internal_subrun passes write nothing to disk (no save, no library sync,
890 # no store), and every fetch layer inside run() carries its own timeout.
891 youtube_yt.reset_search_cache()
892 enriched: dict[str, EnrichedTopic] = {}
893 results_queue: queue.Queue[tuple[Nomination, schema.Report | None, Exception | None]] = queue.Queue()
894 slots = threading.Semaphore(max(1, max_workers))
895
896 def _worker(nomination: Nomination) -> None:
897 with slots:
898 try:
899 results_queue.put((nomination, _run_one(nomination), None))
900 except Exception as exc: # noqa: BLE001 - containment is the contract
901 results_queue.put((nomination, None, exc))
902
903 for nomination in nominations:
904 threading.Thread(
905 target=_worker,
906 args=(nomination,),
907 name=f"discover-enrich-{nomination.name[:32]}",
908 daemon=True,
909 ).start()
910
911 deadline = time.monotonic() + max(1.0, budget_seconds)
912 pending = len(nominations)
913 while pending and (remaining := deadline - time.monotonic()) > 0:
914 try:
915 nomination, report, exc = results_queue.get(timeout=min(remaining, 0.5))
916 except queue.Empty:
917 continue
918 pending -= 1
919 if exc is None:
920 enriched[nomination.name] = EnrichedTopic(
921 nomination=nomination, report=report,
922 )
923 else:
924 enriched[nomination.name] = EnrichedTopic(
925 nomination=nomination,
926 error=f"{type(exc).__name__}: {exc}",
927 )
928 print(
929 f"[Discover] enrichment failed for {nomination.name!r}: "
930 f"{type(exc).__name__}: {exc}",
931 file=sys.stderr,
932 )
933 # Budget expired (or all done): unfinished topics fall through below as
934 # nomination-only; their daemon workers are abandoned and cannot block exit.
935
936 results: list[EnrichedTopic] = []
937 for nomination in nominations:
938 entry = enriched.get(nomination.name)
939 if entry is None:
940 entry = EnrichedTopic(
941 nomination=nomination,
942 error="enrichment budget exhausted",
943 )
944 print(
945 f"[Discover] enrichment budget exhausted before {nomination.name!r} "
946 "finished; keeping nomination-only evidence",
947 file=sys.stderr,
948 )
949 results.append(entry)
950 return results
951
952
953 def _enriched_evidence_items(entry: EnrichedTopic) -> list[schema.SourceItem]:
954 """The items a topic is judged on: the enriched corpus when the pipeline
955 pass succeeded, the nomination's seed items otherwise."""
956 if entry.report is not None:
957 flattened: list[schema.SourceItem] = []
958 for source_items in entry.report.items_by_source.values():
959 flattened.extend(source_items)
960 if flattened:
961 return flattened
962 return entry.nomination.items
963
964
965 def _best_community_comment(items: list[schema.SourceItem]) -> str | None:
966 """The strongest verbatim community comment across a topic's evidence,
967 formatted with attribution - the voice-of-the-people line on a trend card.
968
969 Vote strength is per-platform-normalized (signals.normalized_comment_vote)
970 so one viral platform's counts don't drown out the rest.
971 """
972 best: tuple[float, str, str | None, float | int | None] | None = None
973 for item in items:
974 comments = item.metadata.get("top_comments") or []
975 for comment in comments:
976 if not isinstance(comment, dict):
977 continue
978 body = (comment.get("excerpt") or comment.get("text") or comment.get("body") or "").strip()
979 if len(body) < 12:
980 continue
981 strength = signals.normalized_comment_vote(item.source, comment.get("score"))
982 if best is None or strength > best[0]:
983 best = (strength, body, comment.get("author"), comment.get("score"))
984 if best is None:
985 return None
986 _, body, author, score = best
987 # Comment bodies that themselves start/end with quote characters would
988 # render as doubled quotes inside our wrapping quotes.
989 body = body.strip('"“”‘’\'').strip()
990 if len(body) > 200:
991 body = body[:197].rsplit(" ", 1)[0] + "..."
992 attribution = f" - {author}" if author else ""
993 votes = (
994 f" ({int(score):,} votes)"
995 if isinstance(score, (int, float)) and not isinstance(score, bool) and score > 0
996 else ""
997 )
998 return f'"{body}"{attribution}{votes}'
999
1000
1001 @dataclass(frozen=True)
1002 class _DiscoverySweep:
1003 """The shared front half of both discovery entry points: the resolved
1004 plan and window, the swept listing bundle, and finalized per-source
1005 status. Everything downstream (judging, enrichment, floor, queue)
1006 belongs to the caller's leg."""
1007
1008 plan: schema.DiscoveryPlan
1009 query_plan: schema.QueryPlan
1010 from_date: str
1011 to_date: str
1012 bundle: schema.RetrievalBundle
1013 source_status: dict[str, schema.SourceOutcome]
1014
1015
1016 def _discovery_sweep(
1017 *,
1018 domain: str,
1019 config: dict[str, Any],
1020 depth: str,
1021 requested_sources: list[str] | None,
1022 mock: bool,
1023 subreddits: list[str] | None,
1024 lookback_days: int,
1025 as_of_date: str | None,
1026 ) -> _DiscoverySweep:
1027 """Resolve the momentum window, validate/bound the listing sources, build
1028 the discovery plan, sweep the river feeds, and finalize source status.
1029
1030 Shared verbatim by ``run_discover`` (one-shot) and
1031 ``run_discover_nominate`` (protocol leg 1) so the two paths can never
1032 drift on what a sweep means."""
1033 from_date, to_date = dates.get_date_range(lookback_days, as_of_date=as_of_date)
1034 requested = normalize_requested_sources(requested_sources)
1035 unsupported = sorted(set(requested or []) - set(DISCOVERY_SOURCES))
1036 if unsupported:
1037 raise ValueError(
1038 "Discovery supports listing sources only: reddit, hackernews, digg "
1039 f"(unsupported: {', '.join(unsupported)})"
1040 )
1041 available = list(DISCOVERY_SOURCES) if mock else [
1042 source for source in available_sources(config, requested, x_pending=False)
1043 if source in DISCOVERY_SOURCES
1044 ]
1045 if requested:
1046 available = [source for source in available if source in requested]
1047 plan = planner.build_discovery_plan(
1048 domain,
1049 available_sources=available,
1050 subreddits=subreddits,
1051 )
1052
1053 global_mode = not plan.domain
1054 domain_label = plan.domain or "everything"
1055 query_plan = schema.QueryPlan(
1056 intent="breaking_news",
1057 freshness_mode="breaking",
1058 cluster_mode="story",
1059 raw_topic=plan.domain,
1060 subqueries=[schema.SubQuery(
1061 label="discovery-listings",
1062 search_query=plan.domain,
1063 ranking_query=f"What is accelerating in {domain_label}?",
1064 sources=list(plan.sources),
1065 )],
1066 source_weights={source: 1.0 for source in plan.sources},
1067 notes=["discover-mode", "listing-sweep"],
1068 )
1069
1070 bundle = nominate_candidates(
1071 plan,
1072 from_date=from_date,
1073 to_date=to_date,
1074 depth=depth,
1075 mock=mock,
1076 config=config,
1077 lookback_days=lookback_days,
1078 # Global trending has no keyword to gate against - the river feeds' own
1079 # hot ranking is the signal and the confidence floor culls the junk.
1080 keyword_gate=not global_mode,
1081 )
1082
1083 source_status: dict[str, schema.SourceOutcome] = {}
1084 for source in DISCOVERY_SOURCES:
1085 if source in bundle.source_status:
1086 continue
1087 detail = (
1088 "Source is not configured for discovery."
1089 )
1090 source_status[source] = schema.SourceOutcome(
1091 source=source,
1092 state=schema.SKIPPED_UNCONFIGURED,
1093 attempted=False,
1094 detail=detail,
1095 fix_hint="doctor",
1096 )
1097 source_status.update(_finalize_source_status(bundle.source_status, bundle.items_by_source))
1098 return _DiscoverySweep(
1099 plan=plan,
1100 query_plan=query_plan,
1101 from_date=from_date,
1102 to_date=to_date,
1103 bundle=bundle,
1104 source_status=source_status,
1105 )
1106
1107
1108 def _degraded_discovery_sources(
1109 source_status: dict[str, schema.SourceOutcome],
1110 ) -> list[str]:
1111 """Sources whose outcome is neither clean nor an expected skip."""
1112 return [
1113 source for source, outcome_state in source_status.items()
1114 if outcome_state.state not in {health.OK, schema.NO_RESULTS, schema.SKIPPED_UNCONFIGURED}
1115 ]
1116
1117
1118 @dataclass(frozen=True)
1119 class DiscoverNominateResult:
1120 """Leg 1 output of the host-judged discovery protocol: the ranked judge
1121 pool as ``(nomination, cluster_id)`` pairs plus the sweep context the CLI
1122 needs to write the nominations bundle - or to render the nothing-solid
1123 brief when the pool is empty."""
1124
1125 plan: schema.DiscoveryPlan
1126 from_date: str
1127 to_date: str
1128 source_status: dict[str, schema.SourceOutcome]
1129 pool: list[tuple[Nomination, str]]
1130
1131
1132 def run_discover_nominate(
1133 *,
1134 domain: str,
1135 config: dict[str, Any],
1136 depth: str = "default",
1137 requested_sources: list[str] | None = None,
1138 mock: bool = False,
1139 subreddits: list[str] | None = None,
1140 lookback_days: int = 30,
1141 as_of_date: str | None = None,
1142 ) -> DiscoverNominateResult:
1143 """Protocol leg 1: sweep the listings and build the FULL judge pool.
1144
1145 Same sweep and clustering as ``run_discover``, but the pool is cut at
1146 ``rerank.JUDGE_POOL_LIMIT`` (not the enrichment limit). Like every
1147 discovery path it is deterministic-heuristic: no provider is ever
1148 resolved, so names and junk flags are the ``topic_shape`` baselines the
1149 host judges against. No enrichment, no confidence floor, no queue
1150 writes - those belong to legs 2 and 3.
1151 """
1152 sweep = _discovery_sweep(
1153 domain=domain,
1154 config=config,
1155 depth=depth,
1156 requested_sources=requested_sources,
1157 mock=mock,
1158 subreddits=subreddits,
1159 lookback_days=lookback_days,
1160 as_of_date=as_of_date,
1161 )
1162 pool = nominate_topic_pool(
1163 sweep.bundle, sweep.query_plan, sweep.plan,
1164 to_date=sweep.to_date,
1165 limit=rerank.JUDGE_POOL_LIMIT,
1166 )
1167 return DiscoverNominateResult(
1168 plan=sweep.plan,
1169 from_date=sweep.from_date,
1170 to_date=sweep.to_date,
1171 source_status=sweep.source_status,
1172 pool=pool,
1173 )
1174
1175
1176 def nominate_nothing_solid_report(result: DiscoverNominateResult) -> schema.DiscoveryReport:
1177 """The honest-empty leg-1 report: a zero-nomination sweep renders the
1178 same nothing-solid brief a one-shot run would (and writes no bundle)."""
1179 warnings = [
1180 "The listing sweep nominated no topics this window; reporting "
1181 "nothing solid instead of ranked noise."
1182 ]
1183 failed = _degraded_discovery_sources(result.source_status)
1184 if failed:
1185 warnings.append(f"Some discovery sources degraded: {', '.join(sorted(failed))}.")
1186 return schema.DiscoveryReport(
1187 domain=result.plan.domain,
1188 range_from=result.from_date,
1189 range_to=result.to_date,
1190 generated_at=datetime.now(timezone.utc).isoformat(),
1191 plan=result.plan,
1192 topics=[],
1193 source_status=result.source_status,
1194 warnings=warnings,
1195 outcome="nothing-solid",
1196 weak_signal=None,
1197 )
1198
1199
1200 def _floor_survivor_records(
1201 enriched_entries: list[EnrichedTopic],
1202 *,
1203 to_date: str,
1204 topic_limit: int,
1205 ) -> tuple[
1206 list[dict[str, Any]],
1207 tuple[float, str] | None,
1208 tuple[float, str] | None,
1209 ]:
1210 """Apply the discovery confidence floor to enriched entries in order,
1211 returning the survivor records plus the strongest non-junk and junk weak
1212 signals among the failures.
1213
1214 Shared verbatim by ``run_discover`` (one-shot) and ``run_discover_resume``
1215 (protocol leg 2) so floor semantics can never drift between the paths.
1216 """
1217 survivors: list[dict[str, Any]] = []
1218 weak_signal: tuple[float, str] | None = None
1219 junk_weak_signal: tuple[float, str] | None = None
1220 for entry in enriched_entries:
1221 nomination = entry.nomination
1222 evidence_items = _enriched_evidence_items(entry)
1223 sources = sorted({item.source for item in evidence_items})
1224 native_total = sum(
1225 rerank.discovery_engagement_total(item) for item in evidence_items
1226 )
1227 score = rerank.discovery_velocity_score(evidence_items, as_of_date=to_date)
1228 if not rerank.passes_discovery_floor(
1229 source_count=len(sources),
1230 engagement_total=native_total,
1231 item_count=len(evidence_items),
1232 junk_shape=nomination.junk_shape,
1233 # Junk corroboration counts distinct SEED listing sources, never
1234 # the enriched corpus - a successful enrichment pass is
1235 # multi-source for almost any topic, so it would never bind.
1236 seed_source_count=len({item.source for item in nomination.items}),
1237 ):
1238 # Sub-floor evidence never ranks; remember what came closest so a
1239 # nothing-solid brief can still name the strongest weak signal.
1240 # Junk-shaped failures are tracked separately: the brief prefers
1241 # the strongest NON-junk failure and names a junk one only when
1242 # every failure is junk-shaped (never empty when failures exist).
1243 if nomination.junk_shape:
1244 if junk_weak_signal is None or score > junk_weak_signal[0]:
1245 junk_weak_signal = (score, nomination.name)
1246 elif weak_signal is None or score > weak_signal[0]:
1247 weak_signal = (score, nomination.name)
1248 continue
1249 if len(survivors) >= topic_limit:
1250 break
1251 source_phrase = ", ".join(sources[:-1]) + (
1252 f" and {sources[-1]}" if len(sources) > 1 else (sources[0] if sources else "the listings")
1253 )
1254 noun = "evidence item" if entry.report is not None else "listing item"
1255 why = (
1256 f"{len(evidence_items)} {noun}{'s' if len(evidence_items) != 1 else ''} on "
1257 f"{source_phrase} generated {native_total:,.0f} native interactions. "
1258 f"{nomination.summary[:220]}"
1259 )
1260 top_comment = _best_community_comment(evidence_items) if entry.report is not None else None
1261 # Stage-2 angle input: the survivor's strongest evidence, enriched
1262 # corpus when the pipeline pass succeeded, seed items otherwise
1263 # (evidence_items already resolves that).
1264 top_titles = [
1265 item.title.strip()
1266 for item in sorted(
1267 evidence_items,
1268 key=rerank.discovery_engagement_total,
1269 reverse=True,
1270 )
1271 if item.title and item.title.strip()
1272 ][:3]
1273 survivors.append({
1274 "name": nomination.name,
1275 "why": why,
1276 "momentum": _discovery_momentum(evidence_items, to_date),
1277 "velocity_score": round(score, 2),
1278 "sources": sources,
1279 "engagement_by_source": _discovery_engagement(evidence_items),
1280 "evidence_urls": list(dict.fromkeys(item.url for item in evidence_items if item.url))[:5],
1281 "top_comment": top_comment,
1282 "titles": "; ".join(top_titles),
1283 "engagement_phrase": f"{native_total:,.0f} native interactions across {source_phrase}",
1284 })
1285 return survivors, weak_signal, junk_weak_signal
1286
1287
1288 def _fold_same_story_records(survivors: list[dict[str, Any]]) -> list[dict[str, Any]]:
1289 """Same-story fold + velocity ordering over floor-survivor records.
1290
1291 Floor survivors that share enriched evidence are the SAME story wearing
1292 two judged names (the real-run failure: two topics quoting the identical
1293 1,635-vote comment). Duplicates = identical non-None top comment OR >= 2
1294 shared evidence URLs; the lower-velocity twin is dropped, and a winning
1295 replacement re-scans the kept list to a fixpoint so chained overlap
1296 (A~C~B) still collapses to one survivor. Selection stays seed-ordered
1297 upstream; this only prunes, then sorts by displayed velocity (stable) so
1298 rank 1 is the highest velocity_score.
1299 """
1300 def _same_story(a: dict[str, Any], b: dict[str, Any]) -> bool:
1301 if a["top_comment"] is not None and a["top_comment"] == b["top_comment"]:
1302 return True
1303 return len(set(a["evidence_urls"]) & set(b["evidence_urls"])) >= 2
1304
1305 folded: list[dict[str, Any]] = []
1306 for record in survivors:
1307 # Fold to a fixpoint: when the incoming record REPLACES a kept one,
1308 # the replacement may share evidence with entries the dropped record
1309 # never matched (three-way chains: A kept, C shares the comment with
1310 # A and URLs with B). The winner re-scans the remaining kept entries
1311 # until nothing matches, so one story always yields one survivor.
1312 incoming: dict[str, Any] | None = record
1313 while incoming is not None:
1314 dup_index = next(
1315 (index for index, kept in enumerate(folded) if _same_story(incoming, kept)),
1316 None,
1317 )
1318 if dup_index is None:
1319 folded.append(incoming)
1320 break
1321 kept = folded[dup_index]
1322 if incoming["velocity_score"] > kept["velocity_score"]:
1323 folded.pop(dup_index)
1324 dropped_name, kept_name = kept["name"], incoming["name"]
1325 else:
1326 dropped_name, kept_name = incoming["name"], kept["name"]
1327 incoming = None # dropped; the kept entry stays in place
1328 log.source_log(
1329 "Discover",
1330 f"folded duplicate story {dropped_name!r} into {kept_name!r} (shared evidence)",
1331 tty_only=False,
1332 )
1333
1334 folded.sort(key=lambda record: record["velocity_score"], reverse=True)
1335 return folded
1336
1337
1338 def _records_to_discovery_topics(
1339 folded: list[dict[str, Any]],
1340 ) -> list[schema.DiscoveryTopic]:
1341 """Folded survivor records to ranked topics (ranks = 1-based positions)."""
1342 return [
1343 schema.DiscoveryTopic(
1344 rank=position,
1345 name=record["name"],
1346 why_spiking=record["why"],
1347 momentum=record["momentum"],
1348 velocity_score=record["velocity_score"],
1349 sources=record["sources"],
1350 engagement_by_source=record["engagement_by_source"],
1351 command=f'/last30days "{record["name"].replace(chr(34), chr(39))}"',
1352 evidence_urls=record["evidence_urls"],
1353 top_comment=record["top_comment"],
1354 corroboration_count=len(record["sources"]),
1355 )
1356 for position, record in enumerate(folded, start=1)
1357 ]
1358
1359
1360 def _discovery_report_warnings(
1361 topics: list[schema.DiscoveryTopic],
1362 outcome: str,
1363 source_status: dict[str, schema.SourceOutcome],
1364 ) -> list[str]:
1365 """Coverage warnings shared by the one-shot and resume discovery paths.
1366 The resume leg never re-sweeps: it passes the bundle's RESTORED leg-1
1367 sweep status, so a degraded feed from the sweep still reaches the leg-2
1368 report exactly as the one-shot reports it."""
1369 warnings: list[str] = []
1370 if outcome == "nothing-solid":
1371 warnings.append(
1372 "No topic cleared the discovery confidence floor this window; "
1373 "reporting nothing solid instead of ranked noise."
1374 )
1375 elif len(topics) < 5:
1376 warnings.append("Fewer than five topic clusters cleared the confidence floor this window.")
1377 if topics and all(len(topic.sources) == 1 for topic in topics):
1378 warnings.append("Discovery evidence is single-source; configure Digg for broader confirmation.")
1379 failed = _degraded_discovery_sources(source_status)
1380 if failed:
1381 warnings.append(f"Some discovery sources degraded: {', '.join(sorted(failed))}.")
1382 return warnings
1383
1384
1385 def run_discover(
1386 *,
1387 domain: str,
1388 config: dict[str, Any],
1389 depth: str = "default",
1390 requested_sources: list[str] | None = None,
1391 mock: bool = False,
1392 subreddits: list[str] | None = None,
1393 lookback_days: int = 30,
1394 as_of_date: str | None = None,
1395 limit: int = 10,
1396 enrich: bool = False,
1397 enrich_requested_sources: list[str] | None = None,
1398 ) -> schema.DiscoveryReport:
1399 """Sweep category listings and rank the topics gaining velocity.
1400
1401 ``requested_sources`` bounds the listing sweep (discovery-capable feeds
1402 only). ``enrich_requested_sources`` bounds the per-topic research passes:
1403 None means every available source - which is what lets Techmeme, arXiv,
1404 YouTube, Polymarket, and community comments reach discovery despite having
1405 no river feed of their own. Pass the user's original --search list here so
1406 an explicit source boundary holds through enrichment too.
1407 """
1408 sweep = _discovery_sweep(
1409 domain=domain,
1410 config=config,
1411 depth=depth,
1412 requested_sources=requested_sources,
1413 mock=mock,
1414 subreddits=subreddits,
1415 lookback_days=lookback_days,
1416 as_of_date=as_of_date,
1417 )
1418 plan = sweep.plan
1419 from_date, to_date = sweep.from_date, sweep.to_date
1420 source_status = sweep.source_status
1421
1422 # The engine never names or angles topics with an LLM: the one-shot path
1423 # is deterministic-heuristic by design, and reasoning-model judgment
1424 # lives in the host-judged SKILL.md protocol. Say so loudly once per live
1425 # run; --mock stays silent (a deliberate mock run is not a degraded run).
1426 if not mock:
1427 log.source_log(
1428 "Discover",
1429 "one-shot run: topic names use deterministic heuristics and no "
1430 "content angles are generated - a reasoning-model host running "
1431 "the SKILL.md discovery protocol gets host-judged names, junk "
1432 "filtering, and podcast/X angles",
1433 tty_only=False,
1434 )
1435
1436 topic_limit = max(5, min(10, limit))
1437 nominations = nominate_topics(
1438 sweep.bundle, sweep.query_plan, plan,
1439 to_date=to_date,
1440 limit=ENRICH_LIMIT if enrich else topic_limit,
1441 )
1442
1443 if enrich and nominations:
1444 enriched_entries = enrich_nominations(
1445 nominations,
1446 config=config,
1447 requested_sources=enrich_requested_sources,
1448 mock=mock,
1449 lookback_days=lookback_days,
1450 as_of_date=as_of_date,
1451 )
1452 else:
1453 enriched_entries = [
1454 EnrichedTopic(nomination=nomination) for nomination in nominations
1455 ]
1456
1457 survivors, weak_signal, junk_weak_signal = _floor_survivor_records(
1458 enriched_entries, to_date=to_date, topic_limit=topic_limit,
1459 )
1460 folded = _fold_same_story_records(survivors)
1461 # One-shot topics ship without angles (podcast_angle / x_article_angle
1462 # stay None and the renderer omits those lines): content angles are a
1463 # host-judged protocol deliverable, written on the finalize leg.
1464 topics = _records_to_discovery_topics(folded)
1465
1466 if weak_signal is None:
1467 weak_signal = junk_weak_signal
1468
1469 outcome = "ok" if topics else "nothing-solid"
1470
1471 return schema.DiscoveryReport(
1472 domain=plan.domain,
1473 range_from=from_date,
1474 range_to=to_date,
1475 generated_at=datetime.now(timezone.utc).isoformat(),
1476 plan=plan,
1477 topics=topics,
1478 source_status=source_status,
1479 warnings=_discovery_report_warnings(topics, outcome, source_status),
1480 outcome=outcome,
1481 weak_signal=weak_signal[1] if weak_signal and not topics else None,
1482 )
1483
1484
1485 # Protocol leg 2 (resume) deep-tier enrichment bounds. The module-level
1486 # ENRICH_* constants above stay the one-shot --discover contract (quick depth,
1487 # 240s budget, 3 workers); a deep-tier bundle upgrades its per-topic sub-runs
1488 # to the default research depth with a wider wall-clock budget and one more
1489 # worker, because leg 2 is the protocol's only research pass. Shallow-tier
1490 # bundles keep the one-shot quick constants. Both tiers flow through
1491 # enrich_nominations' PARAMETERS - the constants themselves are never edited,
1492 # so neither tier can leak into the other path.
1493 RESUME_DEEP_ENRICH_DEPTH = "default"
1494 RESUME_DEEP_ENRICH_MAX_WORKERS = 4
1495 RESUME_DEEP_ENRICH_BUDGET_SECONDS = 450.0
1496
1497
1498 def _resume_enrich_budget_seconds(config: dict[str, Any]) -> float:
1499 """Deep-tier batch budget: LAST30DAYS_ENRICH_BUDGET_SECONDS from the
1500 RESOLVED config dict only (env.get_config already layers the process env
1501 over the .env files) - never read from bare os.environ. Blank,
1502 non-numeric, or non-positive values fall back to the 450s default."""
1503 raw = config.get("LAST30DAYS_ENRICH_BUDGET_SECONDS")
1504 if raw is None or str(raw).strip() == "":
1505 return RESUME_DEEP_ENRICH_BUDGET_SECONDS
1506 try:
1507 value = float(raw)
1508 except (TypeError, ValueError):
1509 return RESUME_DEEP_ENRICH_BUDGET_SECONDS
1510 return value if value > 0 else RESUME_DEEP_ENRICH_BUDGET_SECONDS
1511
1512
1513 @dataclass(frozen=True)
1514 class DiscoverResumeResult:
1515 """Leg 2 output of the host-judged discovery protocol: the floored,
1516 folded, velocity-ranked report plus the per-topic angle inputs (keyed by
1517 surviving nomination id) that the host writes leg-3 angles from.
1518 ``report.source_status`` is the bundle's restored leg-1 sweep status -
1519 leg 2 never re-sweeps the listing feeds, so the sweep's degraded-coverage
1520 signal must survive the handoff instead of reading as clean."""
1521
1522 report: schema.DiscoveryReport
1523 angle_inputs: dict[str, dict[str, str]]
1524
1525
1526 def run_discover_resume(
1527 bundle: Any,
1528 judgments: dict[str, Any],
1529 *,
1530 config: dict[str, Any],
1531 mock: bool = False,
1532 ) -> DiscoverResumeResult:
1533 """Protocol leg 2: apply host judgments to the leg-1 bundle, enrich the
1534 slot winners, and floor/fold/rank on the same code path as the one-shot
1535 run.
1536
1537 ``bundle`` is a ``discovery_handoff.NominationsBundle`` and ``judgments``
1538 the mapping ``discovery_handoff.read_judgments`` returns (annotated
1539 loosely because discovery_handoff imports this module at load time).
1540
1541 Judgment application is per field: an absent host name falls back to the
1542 bundle's heuristic name, an absent junk flag to the heuristic junk flag,
1543 and absent worthiness to the neutral blend default (None -> 50 inside
1544 ``rerank.judge_blended_score`` - the same treatment the judge-absent path
1545 always used). Applied names are collision-resolved over the whole pool
1546 before anything keys on them, and the applied name IS the enrichment
1547 sub-run topic.
1548
1549 Slot selection: host-junk rows never contend for enrichment slots, and a
1550 heuristic-junk fallback row with fewer than ``rerank.FLOOR_MIN_SOURCES``
1551 distinct seed sources is skipped pre-enrichment (it structurally cannot
1552 pass the floor's seed-corroboration rule). Both stay eligible to be the
1553 junk-tracked weak signal of a nothing-solid brief, and the brief prefers
1554 a non-junk weak signal exactly like the one-shot path. At the floor,
1555 host-judged rows pass ``junk_shape=False`` (host-junk never earned a
1556 slot) while heuristic-fallback rows keep their heuristic flag with the
1557 existing seed-source corroboration.
1558
1559 Velocity, momentum, and the enrichment window all score against the
1560 bundle's momentum window (from_date/to_date), never the resume-time
1561 clock: the host may judge up to the handoff TTL after the sweep, and the
1562 numbers must describe the window the sweep captured.
1563 """
1564 # Runtime-only import: discovery_handoff imports pipeline at module load,
1565 # so the reverse import must happen at call time (no import-time cycle).
1566 from . import discovery_handoff
1567
1568 to_date = bundle.to_date
1569 verdicts = [
1570 discovery_handoff.judgment_for(judgments, entry.nomination_id)
1571 for entry in bundle.nominations
1572 ]
1573 applied_names = discovery_handoff.resolve_name_collisions([
1574 (
1575 entry.nomination,
1576 verdict.name or entry.heuristic_name or entry.nomination.name,
1577 )
1578 for entry, verdict in zip(bundle.nominations, verdicts)
1579 ])
1580
1581 ranked: list[tuple[float, str, Nomination]] = []
1582 junk_weak_signal: tuple[float, str] | None = None
1583 for entry, verdict, name in zip(bundle.nominations, verdicts, applied_names):
1584 items = entry.nomination.items
1585 velocity = rerank.discovery_velocity_score(items, as_of_date=to_date)
1586 seed_source_count = len({item.source for item in items})
1587 host_junk = verdict.junk is True
1588 fallback_junk = verdict.junk is None and entry.heuristic_junk
1589 if host_junk or (
1590 fallback_junk and seed_source_count < rerank.FLOOR_MIN_SOURCES
1591 ):
1592 if junk_weak_signal is None or velocity > junk_weak_signal[0]:
1593 junk_weak_signal = (velocity, name)
1594 continue
1595 worthiness = (
1596 float(verdict.worthiness) if verdict.worthiness is not None else None
1597 )
1598 blended = rerank.judge_blended_score(velocity, worthiness)
1599 ranked.append((
1600 blended,
1601 entry.nomination_id,
1602 replace(
1603 entry.nomination,
1604 name=name,
1605 seed_score=blended,
1606 junk_shape=(
1607 False if verdict.junk is not None else entry.heuristic_junk
1608 ),
1609 worthiness=worthiness,
1610 ),
1611 ))
1612
1613 ranked.sort(key=lambda row: (-row[0], row[2].name.lower()))
1614 selected = ranked[:ENRICH_LIMIT]
1615 nominations = [nomination for _blended, _nomination_id, nomination in selected]
1616
1617 if bundle.tier == "shallow":
1618 depth, max_workers, budget_seconds = (
1619 ENRICH_DEPTH, ENRICH_MAX_WORKERS, ENRICH_BUDGET_SECONDS,
1620 )
1621 else:
1622 depth = RESUME_DEEP_ENRICH_DEPTH
1623 max_workers = RESUME_DEEP_ENRICH_MAX_WORKERS
1624 budget_seconds = _resume_enrich_budget_seconds(config)
1625
1626 enriched_entries = enrich_nominations(
1627 nominations,
1628 config=config,
1629 requested_sources=bundle.enrichment_source_boundary,
1630 mock=mock,
1631 depth=depth,
1632 lookback_days=bundle.lookback_days,
1633 as_of_date=to_date,
1634 max_workers=max_workers,
1635 budget_seconds=budget_seconds,
1636 ) if nominations else []
1637
1638 # topic_limit mirrors the one-shot default cap (limit=10); the slot cut
1639 # above already bounds the pool at ENRICH_LIMIT.
1640 survivors, weak_signal, floor_junk_weak_signal = _floor_survivor_records(
1641 enriched_entries, to_date=to_date, topic_limit=10,
1642 )
1643 if floor_junk_weak_signal is not None and (
1644 junk_weak_signal is None
1645 or floor_junk_weak_signal[0] > junk_weak_signal[0]
1646 ):
1647 junk_weak_signal = floor_junk_weak_signal
1648 folded = _fold_same_story_records(survivors)
1649 topics = _records_to_discovery_topics(folded)
1650
1651 nomination_id_by_name = {
1652 nomination.name: nomination_id
1653 for _blended, nomination_id, nomination in selected
1654 }
1655 angle_inputs = {
1656 nomination_id_by_name[record["name"]]: {
1657 "name": record["name"],
1658 "titles": record["titles"],
1659 "top_comment": record["top_comment"] or "",
1660 "engagement": record["engagement_phrase"],
1661 }
1662 for record in folded
1663 }
1664
1665 if weak_signal is None:
1666 weak_signal = junk_weak_signal
1667 outcome = "ok" if topics else "nothing-solid"
1668 plan = schema.DiscoveryPlan(
1669 domain=bundle.domain,
1670 category=None,
1671 subreddits=[],
1672 sources=(
1673 list(bundle.requested_sources)
1674 if bundle.requested_sources
1675 else sorted({
1676 item.source
1677 for entry in bundle.nominations
1678 for item in entry.nomination.items
1679 })
1680 ),
1681 )
1682 # The bundle's restored leg-1 sweep status (empty for pre-field bundles):
1683 # degraded sweep coverage must reach this report's status map and its
1684 # degraded-sources warning exactly as the one-shot reports it.
1685 source_status = dict(getattr(bundle, "source_status", None) or {})
1686 report = schema.DiscoveryReport(
1687 domain=bundle.domain,
1688 range_from=bundle.from_date,
1689 range_to=to_date,
1690 generated_at=datetime.now(timezone.utc).isoformat(),
1691 plan=plan,
1692 topics=topics,
1693 source_status=source_status,
1694 warnings=_discovery_report_warnings(topics, outcome, source_status),
1695 outcome=outcome,
1696 weak_signal=weak_signal[1] if weak_signal and not topics else None,
1697 )
1698 return DiscoverResumeResult(report=report, angle_inputs=angle_inputs)
1699
1700
1701 def diagnose(
1702 config: dict[str, Any],
1703 requested_sources: list[str] | None = None,
1704 *,
1705 safe: bool = False,
1706 ) -> dict[str, Any]:
1707 requested_sources = normalize_requested_sources(requested_sources)
1708 google_key = _google_key(config)
1709 x_status = env.get_x_source_status(config, probe=not safe)
1710 # Compute once and reuse for both the diag flag and available_sources below.
1711 # safe=True (doctor/--diagnose/--preflight) must stay network-free.
1712 x_pending = env.x_pending_browser_auth(config, local_only=safe)
1713 native_web_backend = None
1714 if config.get("BRAVE_API_KEY"):
1715 native_web_backend = "brave"
1716 elif config.get("EXA_API_KEY"):
1717 native_web_backend = "exa"
1718 elif config.get("SERPER_API_KEY"):
1719 native_web_backend = "serper"
1720 elif config.get("PARALLEL_API_KEY"):
1721 native_web_backend = "parallel"
1722 providers_status = {
1723 "google": bool(google_key),
1724 "openai": bool(config.get("OPENAI_API_KEY")) and config.get("OPENAI_AUTH_STATUS") == env.AUTH_STATUS_OK,
1725 "xai": bool(config.get("XAI_API_KEY")),
1726 "openrouter": bool(config.get("OPENROUTER_API_KEY")),
1727 "perplexity": bool(config.get("PERPLEXITY_API_KEY")),
1728 }
1729 reasoning_provider_available = any(
1730 providers_status[name] for name in ("google", "openai", "xai", "openrouter")
1731 )
1732 external_commands = {
1733 "yt-dlp": bool(which("yt-dlp")),
1734 "digg-pp-cli": bool(which("digg-pp-cli")),
1735 "arxiv-pp-cli": bool(which("arxiv-pp-cli")),
1736 "techmeme-pp-cli": bool(which("techmeme-pp-cli")),
1737 "trustpilot-pp-cli": bool(which("trustpilot-pp-cli")),
1738 "gh": bool(which("gh")),
1739 }
1740 credential_destinations = {
1741 "global_env": str(env.CONFIG_FILE) if env.CONFIG_FILE else None,
1742 }
1743 browser_cookies = {
1744 "mode": config.get("_BROWSER_COOKIE_MODE", "off"),
1745 "browsers": list(config.get("_BROWSER_COOKIE_BROWSERS") or []),
1746 "reads_values": False if safe else config.get("_BROWSER_COOKIE_MODE") == "read",
1747 }
1748 ignored_project_keys = list(config.get("_IGNORED_PROJECT_CONFIG_KEYS") or [])
1749 ignored_endpoint_overrides = [
1750 key for key in ignored_project_keys if key in permission_preflight.ENDPOINT_OVERRIDE_KEYS
1751 ]
1752 local_writes: list[dict[str, str]] = []
1753 if config.get("LAST30DAYS_MEMORY_DIR"):
1754 local_writes.append({"kind": "report", "path": str(config.get("LAST30DAYS_MEMORY_DIR"))})
1755 diag = {
1756 "providers": providers_status,
1757 "local_mode": not reasoning_provider_available,
1758 "reasoning_provider": (config.get("LAST30DAYS_REASONING_PROVIDER") or "auto").lower(),
1759 "x_backend": x_status["source"],
1760 "bird_installed": x_status["bird_installed"],
1761 "bird_authenticated": x_status["bird_authenticated"],
1762 "bird_username": x_status["bird_username"],
1763 "x_pending_browser_auth": x_pending,
1764 "xquik_available": x_status.get("xquik_available", False),
1765 "xquik_working": x_status.get("xquik_working"),
1766 "xquik_status": x_status.get("xquik_status", ""),
1767 "native_web_backend": native_web_backend,
1768 "native_search": env.is_native_search(config),
1769 "has_scrapecreators": bool(config.get("SCRAPECREATORS_API_KEY")),
1770 "has_github": bool(config.get("GITHUB_TOKEN") or which("gh")),
1771 # safe=True (doctor/--diagnose/--preflight) must stay network-free:
1772 # answer X availability from local evidence only. x_pending is
1773 # precomputed by diagnose() to avoid double evaluation.
1774 "available_sources": available_sources(
1775 config, requested_sources, x_pending=x_pending, local_only=safe
1776 ),
1777 "safe": safe,
1778 "config_source": config.get("_CONFIG_SOURCE"),
1779 "ignored_project_config": config.get("_IGNORED_PROJECT_CONFIG"),
1780 "ignored_project_config_keys": ignored_project_keys,
1781 "ignored_endpoint_overrides": ignored_endpoint_overrides,
1782 "browser_cookies": browser_cookies,
1783 "external_commands": external_commands,
1784 "credential_destinations": credential_destinations,
1785 "local_writes": local_writes,
1786 }
1787 diag["permission_preflight"] = permission_preflight.build(config, diag)
1788 return diag
1789
1790
1791 def _inner_max_workers(stream_count: int, *, internal_subrun: bool) -> int:
1792 """Worker-pool size for the per-stream fanout inside a single pipeline run.
1793
1794 Top-level runs use up to 16 workers. Subruns of ``run_competitor_fanout``
1795 cap the inner pool to 4 so a six-way competitor fan-out stays below
1796 roughly 30 worker threads in aggregate instead of ~96.
1797 """
1798 if internal_subrun:
1799 return max(2, min(4, stream_count or 1))
1800 return max(4, min(16, stream_count or 1))
1801
1802
1803 def _load_library_context(
1804 *,
1805 topic: str,
1806 config: dict[str, Any],
1807 mock: bool,
1808 internal_subrun: bool,
1809 x_handle: str | None,
1810 github_user: str | None,
1811 github_repos: list[str] | None,
1812 save_dir: Path | str | None = None,
1813 ) -> tuple[list[schema.LibraryContext], str | None]:
1814 """Resolve compact prior-run context without making a research run depend on it."""
1815 setting = str(config.get("LAST30DAYS_LIBRARY_CONTEXT") or "off").strip().lower()
1816 if mock or internal_subrun or setting in {"0", "false", "no", "off"}:
1817 return [], None
1818 if save_dir == "":
1819 return [], None
1820
1821 memory_dir = (
1822 save_dir
1823 if save_dir is not None
1824 else config.get("LAST30DAYS_MEMORY_DIR") or library.DEFAULT_MEMORY_DIR
1825 )
1826 briefs_dir = config.get("_LAST30DAYS_LIBRARY_BRIEFS_DIR") or (
1827 Path(memory_dir).expanduser() / "briefings"
1828 if save_dir is not None
1829 else library.DEFAULT_BRIEFS_DIR
1830 )
1831 db_path = config.get("_LAST30DAYS_LIBRARY_DB")
1832 if not db_path:
1833 db_path = (
1834 Path(memory_dir).expanduser().resolve() / ".last30days-library.db"
1835 if save_dir is not None
1836 else library_index.DEFAULT_LIBRARY_DB
1837 )
1838 store_db = config.get("_LAST30DAYS_STORE_DB")
1839 if not store_db:
1840 # Scoped runs read only a store inside the save dir (usually absent);
1841 # the shared store would leak other scopes' sightings into this one.
1842 store_db = (
1843 Path(memory_dir).expanduser().resolve() / "research.db"
1844 if save_dir is not None
1845 else library_index.DEFAULT_STORE_DB
1846 )
1847 queries = [topic, x_handle or "", github_user or "", *(github_repos or [])]
1848 queries = list(dict.fromkeys(value.strip() for value in queries if value and value.strip()))
1849 try:
1850 library_index.sync_library(memory_dir, briefs_dir, db_path=db_path)
1851 matches: list[library_index.LibrarySearchMatch] = []
1852 for query_text in queries:
1853 matches.extend(
1854 library_index.search(
1855 query_text,
1856 limit=6,
1857 db_path=db_path,
1858 store_db_path=store_db,
1859 )
1860 )
1861 except (library_index.LibrarySearchUnavailable, OSError, sqlite3.DatabaseError) as exc:
1862 return [], f"Library context unavailable: {exc}"
1863
1864 contexts: list[schema.LibraryContext] = []
1865 seen_runs: set[tuple[str, date]] = set()
1866 for match in sorted(
1867 matches,
1868 key=lambda item: (-item.published_date.toordinal(), item.rank, item.topic.casefold()),
1869 ):
1870 if match.run_key in seen_runs:
1871 continue
1872 seen_runs.add(match.run_key)
1873 contexts.append(
1874 schema.LibraryContext(
1875 topic=match.topic,
1876 published_date=match.published_date.isoformat(),
1877 headline=match.headline,
1878 summary=match.snippet or match.headline,
1879 source_kind=match.source_kind,
1880 )
1881 )
1882 if len(contexts) == 3:
1883 break
1884 return contexts, None
1885
1886
1887 def run(
1888 *,
1889 topic: str,
1890 config: dict[str, Any],
1891 depth: str,
1892 requested_sources: list[str] | None = None,
1893 mock: bool = False,
1894 x_handle: str | None = None,
1895 x_related: list[str] | None = None,
1896 web_backend: str = "auto",
1897 external_plan: dict | None = None,
1898 subreddits: list[str] | None = None,
1899 tiktok_hashtags: list[str] | None = None,
1900 tiktok_creators: list[str] | None = None,
1901 ig_creators: list[str] | None = None,
1902 lookback_days: int = 30,
1903 as_of_date: str | None = None,
1904 github_user: str | None = None,
1905 github_repos: list[str] | None = None,
1906 trustpilot_domain: str | None = None,
1907 trustpilot_domain_is_hint: bool = False,
1908 hiring_signals_mode: bool = False,
1909 internal_subrun: bool = False,
1910 save_dir: Path | str | None = None,
1911 corpus_dirs: list[str] | None = None,
1912 corpus_all_time: bool = False,
1913 ) -> schema.Report:
1914 # Standalone runs (not competitor/discover sub-runs) own the YouTube
1915 # search-cache lifecycle. Comparison fan-out clears once before submit so
1916 # parallel entity sub-runs can still share in-run hits.
1917 if not internal_subrun:
1918 youtube_yt.reset_search_cache()
1919 settings = _resolve_depth_settings(depth, config)
1920 requested_sources = normalize_requested_sources(requested_sources)
1921 from_date, to_date = dates.get_date_range(lookback_days, as_of_date=as_of_date)
1922 resolved_corpus_dirs = corpus.resolve_directories(
1923 corpus_dirs or config.get("_CORPUS_DIRS"),
1924 config.get("LAST30DAYS_CORPUS_DIRS"),
1925 )
1926 excluded_sources = {
1927 source.strip().lower()
1928 for source in str(config.get("EXCLUDE_SOURCES") or "").split(",")
1929 if source.strip()
1930 }
1931 corpus_enabled = bool(resolved_corpus_dirs) and "corpus" not in excluded_sources
1932 corpus_requested = bool(requested_sources and "corpus" in requested_sources)
1933 if corpus_enabled and requested_sources and "corpus" not in requested_sources:
1934 requested_sources = [*requested_sources, "corpus"]
1935
1936 # Gate StockTwits to ticker/crypto topics. Single chokepoint: when False,
1937 # available_sources() never registers stocktwits, so the planner can't
1938 # assign it (eligible_sources = available ∩ capabilities).
1939 config["_financial_topic"] = stocktwits.is_financial_topic(topic)
1940
1941 if mock:
1942 runtime = providers.mock_runtime(config, depth)
1943 reasoning_provider = None
1944 available = list(requested_sources or MOCK_AVAILABLE_SOURCES)
1945 if corpus_enabled and "corpus" not in available:
1946 available.append("corpus")
1947 if not corpus_enabled and not corpus_requested:
1948 available = [source for source in available if source != "corpus"]
1949 if not requested_sources and not hiring_signals_mode and not _company_topic_likely(topic):
1950 available = [source for source in available if source != "jobs"]
1951 else:
1952 runtime, reasoning_provider = providers.resolve_runtime(config, depth)
1953 available = available_sources(config, requested_sources)
1954 if requested_sources:
1955 available = [source for source in available if source in requested_sources]
1956 # Keep an explicitly requested but unconfigured corpus in the plan long
1957 # enough to record its skipped-unconfigured source outcome. It is never
1958 # submitted to the network executor below.
1959 if corpus_requested and "corpus" not in excluded_sources and "corpus" not in available:
1960 available.append("corpus")
1961 if web_backend == "none":
1962 available = [s for s in available if s != "grounding"]
1963 elif web_backend in ("brave", "exa", "serper", "parallel", "keyless") and "grounding" not in available:
1964 available.append("grounding")
1965 if (
1966 hiring_signals_mode
1967 or (not requested_sources and _company_topic_likely(topic))
1968 ) and "jobs" not in available:
1969 available.append("jobs")
1970 if hiring_signals_mode:
1971 config = dict(config)
1972 config["_hiring_signals_mode"] = True
1973 if not requested_sources:
1974 available = ["jobs"]
1975 if not available:
1976 raise RuntimeError("No sources are available for this run.")
1977
1978 planner_requested_sources = requested_sources
1979 if hiring_signals_mode and not planner_requested_sources:
1980 planner_requested_sources = ["jobs"]
1981
1982 if external_plan is not None:
1983 # External plan provided (e.g., from Claude Code via --plan flag).
1984 # Explicit input is a contract: validate it before permissive sanitization.
1985 planner.validate_external_plan(external_plan)
1986 plan = planner._sanitize_plan(
1987 external_plan, topic, available, planner_requested_sources, depth,
1988 )
1989 plan_source = "external"
1990 else:
1991 plan = planner.plan_query(
1992 topic=topic,
1993 available_sources=available,
1994 requested_sources=planner_requested_sources,
1995 depth=depth,
1996 provider=None if mock else reasoning_provider,
1997 model=None if mock else runtime.planner_model,
1998 context=config.get("_auto_resolve_context", ""),
1999 internal_subrun=internal_subrun,
2000 )
2001 # Source labelling: the fallback path annotates notes with "fallback-plan"
2002 # or "deterministic-comparison-plan"; anything else came from the LLM.
2003 if any("fallback" in note or "deterministic" in note for note in (plan.notes or [])):
2004 plan_source = "deterministic"
2005 elif not mock and reasoning_provider and runtime.planner_model:
2006 plan_source = "llm"
2007 else:
2008 plan_source = "deterministic"
2009
2010 # Safety net: ensure grounding appears in all subqueries even if the planner
2011 # omits it. This is redundant when the planner includes grounding via
2012 # SOURCE_CAPABILITIES, but kept as a fallback.
2013 if (
2014 web_backend != "none"
2015 and "grounding" in available
2016 and "drill-mode" not in plan.notes
2017 ):
2018 for sq in plan.subqueries:
2019 if "grounding" not in sq.sources:
2020 sq.sources.append("grounding")
2021 if "drill-mode" not in plan.notes:
2022 # Drill plans re-fetch only the sources that contributed to the matched
2023 # cluster; the company-topic jobs injection must not widen that set.
2024 _ensure_jobs_in_plan(plan, available, explicit=hiring_signals_mode, topic=topic)
2025 if "corpus" in available and plan.subqueries:
2026 # Corpus is deterministic and user-registered, so it always gets one
2027 # bounded stream even when a quick/LLM plan omits it. Reuse the primary
2028 # subquery instead of multiplying local scans across every subquery.
2029 if "corpus" not in plan.subqueries[0].sources:
2030 plan.subqueries[0].sources.append("corpus")
2031 if "corpus" not in plan.source_weights:
2032 plan.source_weights["corpus"] = 1.0
2033 plan.source_weights = planner._normalize_weights(plan.source_weights)
2034
2035 # Always-on planner trace. Emits one summary line plus one per subquery
2036 # so retrieval-breadth failures like the 2026-04-19 Hermes Agent Use Cases
2037 # disaster are visible without --debug. Stderr only; does not leak into
2038 # the user-facing stdout synthesis.
2039 print(
2040 f"[Planner] Plan: intent={plan.intent}, freshness={plan.freshness_mode}, "
2041 f"cluster_mode={plan.cluster_mode}, subqueries={len(plan.subqueries)}, "
2042 f"source={plan_source}",
2043 file=sys.stderr,
2044 )
2045 if plan.subqueries:
2046 for index, sq in enumerate(plan.subqueries, start=1):
2047 sources_str = ",".join(sq.sources) if sq.sources else "(none)"
2048 print(
2049 f"[Planner] sq{index} label={sq.label} "
2050 f'search="{sq.search_query}" sources=[{sources_str}]',
2051 file=sys.stderr,
2052 )
2053 else:
2054 print("[Planner] (no subqueries in plan)", file=sys.stderr)
2055
2056 bundle = schema.RetrievalBundle(artifacts={"grounding": []})
2057 for source in (requested_sources or []):
2058 if source not in available:
2059 bundle.record_failure(
2060 source,
2061 schema.SKIPPED_UNCONFIGURED,
2062 "Source was requested but is not configured for this run.",
2063 attempted=False,
2064 )
2065 if corpus_requested and not corpus_enabled:
2066 bundle.record_failure(
2067 "corpus",
2068 schema.SKIPPED_UNCONFIGURED,
2069 "Corpus was requested but no readable directory was configured.",
2070 attempted=False,
2071 )
2072 # Expose plan_source to the renderer so render_compact can emit the
2073 # DEGRADED RUN banner when a named-entity topic was invoked bare
2074 # (source=deterministic AND no pre-research flags). LAW 7 backstop.
2075 bundle.artifacts["plan_source"] = plan_source
2076 bundle.artifacts["corpus_in_export"] = bool(config.get("_CORPUS_IN_EXPORT"))
2077 # Hiring-signals is deliberately jobs-only with no multi-source --plan, so
2078 # the LAW 7 degraded-run and Step 0.55 pre-research banners do not apply -
2079 # they would contradict the documented jobs-scoped flow. Suppress them.
2080 bundle.artifacts["hiring_signals_mode"] = hiring_signals_mode
2081
2082 # Project-mode or person-mode GitHub: run once before the main subquery loop
2083 _github_custom_done = False
2084 _github_enriched_repos: set[str] = set()
2085
2086 # Project mode takes priority over person mode
2087 if github_repos and "github" in available:
2088 bundle.mark_attempted("github")
2089 try:
2090 project_items = github.search_github_project(
2091 github_repos, from_date, to_date,
2092 depth=depth, token=config.get("GITHUB_TOKEN"),
2093 )
2094 if project_items:
2095 normalized = _normalize_score_dedupe(
2096 "github", project_items, from_date, to_date,
2097 freshness_mode=plan.freshness_mode,
2098 ranking_query=f"What are {', '.join(github_repos)} doing on GitHub?",
2099 )
2100 primary_label = plan.subqueries[0].label if plan.subqueries else "primary"
2101 bundle.add_items(primary_label, "github", normalized)
2102 _github_custom_done = True
2103 _github_enriched_repos = {r.lower() for r in github_repos}
2104 except Exception as exc:
2105 bundle.errors_by_source["github"] = f"Project-mode failed: {exc}"
2106 state, attempted = _classify_source_failure(exc)
2107 bundle.record_failure("github", state, str(exc), attempted=attempted)
2108
2109 _github_person_done = False
2110 if github_user and "github" in available and not _github_custom_done:
2111 bundle.mark_attempted("github")
2112 _github_person_done = True
2113 try:
2114 person_items = github.search_github_person(
2115 github_user, from_date, to_date,
2116 depth=depth, token=config.get("GITHUB_TOKEN"),
2117 )
2118 if person_items:
2119 normalized = _normalize_score_dedupe(
2120 "github", person_items, from_date, to_date,
2121 freshness_mode=plan.freshness_mode,
2122 ranking_query=f"What is @{github_user} doing on GitHub?",
2123 )
2124 # Use the first subquery's label so RRF can look up the weight
2125 primary_label = plan.subqueries[0].label if plan.subqueries else "primary"
2126 bundle.add_items(primary_label, "github", normalized)
2127 else:
2128 # A pinned --github-user that yields nothing must not be
2129 # silently backfilled by generic keyword search: the report
2130 # would then present unrelated repos as this person's work.
2131 bundle.record_failure(
2132 "github",
2133 "no-results",
2134 f"Person mode found no activity for @{github_user} in the window",
2135 )
2136 except Exception as exc:
2137 bundle.errors_by_source["github"] = f"Person-mode failed: {exc}"
2138 state, attempted = _classify_source_failure(exc)
2139 bundle.record_failure("github", state, str(exc), attempted=attempted)
2140
2141 # Trustpilot session warm-up happens inside search_trustpilot at the
2142 # first (capped, single) fetch -- lazily, so it never delays the other
2143 # sources' streams and never fires for runs whose plan fetches no
2144 # Trustpilot. The module-level lock in lib/trustpilot.py serializes
2145 # concurrent vs-mode sub-runs so they never race Chrome harvests.
2146
2147 # Thread-safe set prevents redundant fetches after a source returns 429
2148 rate_limited_sources: set[str] = set()
2149 rate_limit_lock = threading.Lock()
2150
2151 # Local corpus retrieval is intentionally outside the network executor and
2152 # retry budget. One bounded stream participates in the same signal scoring,
2153 # fusion, reranking, and per-source result cap as remote sources.
2154 if corpus_enabled and plan.subqueries:
2155 primary = plan.subqueries[0]
2156 bundle.mark_attempted("corpus")
2157 result = corpus.search(
2158 topic,
2159 resolved_corpus_dirs,
2160 from_date=from_date,
2161 to_date=to_date,
2162 all_time=corpus_all_time,
2163 limit=settings["per_stream_limit"],
2164 cache_dir=env.CONFIG_DIR,
2165 )
2166 prepared_query = relevance.PreparedQuery(primary.ranking_query)
2167 lookback_window_days = (
2168 datetime.strptime(to_date, "%Y-%m-%d").date()
2169 - datetime.strptime(from_date, "%Y-%m-%d").date()
2170 ).days
2171 corpus_items = signals.annotate_stream(
2172 result.items,
2173 prepared_query,
2174 plan.freshness_mode,
2175 reference_date=to_date,
2176 max_days=lookback_window_days,
2177 )
2178 corpus_items = signals.prune_low_relevance(corpus_items)
2179 corpus_items = dedupe.dedupe_items(corpus_items)
2180 for item in corpus_items:
2181 item.snippet = snippet.extract_best_snippet(item, prepared_query)
2182 bundle.add_items(primary.label, "corpus", corpus_items)
2183 if result.notes:
2184 outcome = bundle.source_status["corpus"]
2185 bundle.source_status["corpus"] = schema.SourceOutcome(
2186 source="corpus",
2187 state=outcome.state,
2188 items_returned=outcome.items_returned,
2189 attempted=True,
2190 detail="; ".join(result.notes),
2191 )
2192 bundle.artifacts["corpus"] = {
2193 "files_scanned": result.files_scanned,
2194 "cache_hits": result.cache_hits,
2195 "all_time": corpus_all_time,
2196 }
2197
2198 futures = {}
2199 # Per-source fetch budget prevents redundant API calls
2200 source_fetch_count: dict[str, int] = {}
2201 stream_count = sum(
2202 1
2203 for subquery in plan.subqueries
2204 for source in subquery.sources
2205 if source in available and source != "corpus"
2206 )
2207 max_workers = _inner_max_workers(stream_count, internal_subrun=internal_subrun)
2208 with ThreadPoolExecutor(max_workers=max_workers) as executor:
2209 for subquery in plan.subqueries:
2210 for source in subquery.sources:
2211 if source not in available:
2212 continue
2213 if source == "corpus":
2214 continue
2215 # Skip GitHub keyword search if person-mode already ran
2216 if source == "github" and (_github_person_done or _github_custom_done):
2217 continue
2218 # Enforce per-source fetch cap. A CLI override (issue #716) raises
2219 # the cap for capped sources so every X subquery in a multi-angle
2220 # --plan fetches, instead of only the first two.
2221 cap = MAX_SOURCE_FETCHES.get(source)
2222 _cap_override = config.get("_max_source_fetches")
2223 if cap is not None and _cap_override is not None:
2224 # `is not None` so --max-source-fetches 0 (disable fetching a
2225 # capped source) is honored instead of falling back to default.
2226 cap = int(_cap_override)
2227 if cap is not None:
2228 current = source_fetch_count.get(source, 0)
2229 if current >= cap:
2230 continue
2231 source_fetch_count[source] = current + 1
2232 bundle.mark_attempted(source)
2233 futures[
2234 executor.submit(
2235 _retrieve_stream,
2236 topic=topic,
2237 subquery=subquery,
2238 source=source,
2239 config=config,
2240 depth=depth,
2241 date_range=(from_date, to_date),
2242 runtime=runtime,
2243 mock=mock,
2244 rate_limited_sources=rate_limited_sources,
2245 rate_limit_lock=rate_limit_lock,
2246 web_backend=web_backend,
2247 raw_topic=topic,
2248 subreddits=subreddits,
2249 tiktok_hashtags=tiktok_hashtags,
2250 tiktok_creators=tiktok_creators,
2251 ig_creators=ig_creators,
2252 trustpilot_domain=trustpilot_domain,
2253 trustpilot_domain_is_hint=trustpilot_domain_is_hint,
2254 )
2255 ] = (subquery, source)
2256
2257 for future in as_completed(futures):
2258 subquery, source = futures[future]
2259 try:
2260 raw_items, artifact = future.result()
2261 except Exception as exc:
2262 # Share 429 signal so pending futures skip this source
2263 if _is_rate_limit_error(exc):
2264 with rate_limit_lock:
2265 rate_limited_sources.add(source)
2266 bundle.errors_by_source[source] = str(exc)
2267 state, attempted = _classify_source_failure(exc)
2268 bundle.record_failure(source, state, str(exc), attempted=attempted)
2269 continue
2270 # Retry once for transient 5xx errors
2271 if _is_transient_error(exc):
2272 time.sleep(3)
2273 try:
2274 raw_items, artifact = _retrieve_stream(
2275 topic=topic, subquery=subquery, source=source,
2276 config=config, depth=depth, date_range=(from_date, to_date),
2277 runtime=runtime, mock=mock,
2278 rate_limited_sources=rate_limited_sources,
2279 rate_limit_lock=rate_limit_lock,
2280 web_backend=web_backend,
2281 raw_topic=topic,
2282 subreddits=subreddits,
2283 tiktok_hashtags=tiktok_hashtags,
2284 tiktok_creators=tiktok_creators,
2285 ig_creators=ig_creators,
2286 trustpilot_domain=trustpilot_domain,
2287 trustpilot_domain_is_hint=trustpilot_domain_is_hint,
2288 )
2289 except Exception as retry_exc:
2290 detail = f"{exc} (retried once, still failed: {retry_exc})"
2291 bundle.errors_by_source[source] = detail
2292 state, attempted = _classify_source_failure(retry_exc)
2293 bundle.record_failure(source, state, detail, attempted=attempted)
2294 continue
2295 else:
2296 bundle.errors_by_source[source] = str(exc)
2297 state, attempted = _classify_source_failure(exc)
2298 bundle.record_failure(source, state, str(exc), attempted=attempted)
2299 continue
2300 outcome_note = None
2301 if isinstance(artifact, dict) and artifact.get("_source_outcome"):
2302 artifact = dict(artifact)
2303 outcome_note = artifact.pop("_source_outcome")
2304 bundle.record_failure(
2305 source,
2306 outcome_note["state"],
2307 outcome_note["detail"],
2308 attempted=outcome_note.get("attempted", True),
2309 )
2310 normalized = _normalize_score_dedupe(
2311 source, raw_items, from_date, to_date,
2312 freshness_mode=plan.freshness_mode,
2313 ranking_query=subquery.ranking_query,
2314 )
2315 # Jobs is exempt from per_stream_limit: a careers board is a complete
2316 # snapshot of open roles, and truncating it to the default 12 drops
2317 # strategic postings (the whole point of hiring-signals coverage).
2318 if source != "jobs":
2319 normalized = normalized[: settings["per_stream_limit"]]
2320 bundle.add_items(subquery.label, source, normalized)
2321 if artifact:
2322 bundle.artifacts.setdefault("grounding", []).append(artifact)
2323
2324 # Phase 2: supplemental entity-based searches
2325 _run_supplemental_searches(
2326 topic=topic,
2327 bundle=bundle,
2328 plan=plan,
2329 config=config,
2330 depth=depth,
2331 date_range=(from_date, to_date),
2332 runtime=runtime,
2333 mock=mock,
2334 rate_limited_sources=rate_limited_sources,
2335 rate_limit_lock=rate_limit_lock,
2336 x_handle=x_handle,
2337 x_related=x_related,
2338 )
2339
2340 # Phase 2b: retry thin sources with simplified query
2341 # Note: _github_skip_sources tells the retry to not re-run GitHub keyword search
2342 # when project-mode or person-mode already provided authoritative data.
2343 _github_skip_retry = {"corpus"}
2344 if _github_person_done or _github_custom_done:
2345 _github_skip_retry.add("github")
2346 _retry_thin_sources(
2347 topic=topic,
2348 bundle=bundle,
2349 plan=plan,
2350 config=config,
2351 depth=depth,
2352 date_range=(from_date, to_date),
2353 runtime=runtime,
2354 mock=mock,
2355 rate_limited_sources=rate_limited_sources,
2356 rate_limit_lock=rate_limit_lock,
2357 settings=settings,
2358 web_backend=web_backend,
2359 skip_sources=_github_skip_retry,
2360 subreddits=subreddits,
2361 tiktok_hashtags=tiktok_hashtags,
2362 tiktok_creators=tiktok_creators,
2363 ig_creators=ig_creators,
2364 )
2365
2366 # Reclassify partial failures as DEGRADED instead of silently dropping them.
2367 # A source that 429'd on one subquery but succeeded on another is not a hard
2368 # failure, but it is not healthy either: it likely returned fewer results
2369 # than it should have. Move it out of errors_by_source (so it isn't reported
2370 # as "failed") and into degraded_by_source (so it survives into warnings),
2371 # rather than deleting the signal outright as the engine used to.
2372 degraded_by_source: dict[str, str] = {}
2373 for source in list(bundle.errors_by_source):
2374 if bundle.items_by_source.get(source):
2375 degraded_by_source[source] = bundle.errors_by_source[source]
2376 del bundle.errors_by_source[source]
2377
2378 hiring_summary = _apply_hiring_signal_gate(
2379 bundle,
2380 explicit=hiring_signals_mode,
2381 topic=topic,
2382 )
2383 if hiring_summary:
2384 bundle.artifacts["hiring_signals"] = hiring_summary
2385
2386 items_by_source = _finalize_items_by_source(
2387 bundle.items_by_source, topic=topic, config=config, depth=depth, mock=mock,
2388 )
2389 source_status = _finalize_source_status(bundle.source_status, items_by_source)
2390 candidates = weighted_rrf(bundle.items_by_source_and_query, plan, pool_limit=settings["pool_limit"])
2391 # Normalized set of handles this run resolved for the topic. A candidate
2392 # authored by one of these is first-party and is exempted from the
2393 # entity-miss demotion in rerank (a post never repeats its own author's
2394 # name, so the body-text grounding check would otherwise zero out the
2395 # subject's own highest-signal posts).
2396 resolved_handles = {
2397 h.lstrip("@").strip().lower()
2398 for h in ([x_handle, github_user, *(x_related or [])])
2399 if h and h.strip()
2400 }
2401 private_candidates = [
2402 candidate
2403 for candidate in candidates
2404 if candidate.source == "corpus"
2405 or any(item.source == "corpus" for item in candidate.source_items)
2406 ]
2407 private_candidate_ids = {id(candidate) for candidate in private_candidates}
2408 public_candidates = [
2409 candidate for candidate in candidates if id(candidate) not in private_candidate_ids
2410 ]
2411 ranked_public = rerank.rerank_candidates(
2412 topic=topic,
2413 plan=plan,
2414 candidates=public_candidates,
2415 provider=None if mock else reasoning_provider,
2416 model=None if mock else runtime.rerank_model,
2417 shortlist_size=settings["rerank_limit"],
2418 resolved_handles=resolved_handles,
2419 )
2420 # Corpus titles/snippets must never enter a hosted reasoning prompt. Score
2421 # every candidate carrying corpus evidence with the deterministic fallback,
2422 # even when the rest of the run uses a remote reranker.
2423 ranked_private = rerank.rerank_candidates(
2424 topic=topic,
2425 plan=plan,
2426 candidates=private_candidates,
2427 provider=None,
2428 model=None,
2429 shortlist_size=settings["rerank_limit"],
2430 resolved_handles=resolved_handles,
2431 )
2432 ranked_public = rerank.prune_fallback_entity_misses(ranked_public, topic=topic)
2433 # Private corpus already cleared a body-aware retrieval floor; do not apply
2434 # the public title/snippet visibility gate (filenames often omit the head
2435 # token even when the document body matched).
2436 ranked_candidates = sorted(
2437 [*ranked_public, *ranked_private],
2438 key=lambda candidate: (
2439 -candidate.final_score,
2440 -(candidate.engagement or -1),
2441 min(candidate.native_ranks.values(), default=999),
2442 candidate.title,
2443 ),
2444 )
2445 rerank.score_fun(
2446 topic=topic,
2447 candidates=ranked_public,
2448 provider=None if mock else reasoning_provider,
2449 model=None if mock else runtime.rerank_model,
2450 )
2451 rerank.score_fun(
2452 topic=topic,
2453 candidates=ranked_private,
2454 provider=None,
2455 model=None,
2456 )
2457
2458 # Phase 3: post-rerank GitHub star enrichment. Record/replay-aware so the
2459 # eval harness stays fully offline: this path calls the GitHub API (and the
2460 # gh-credential fallback) outside the _retrieve_stream seam, so it gets its
2461 # own fixture exchange keyed by phase.
2462 if "github" in available and not mock:
2463 star_request = {
2464 "source": "github",
2465 "phase": "post_rerank_star_enrichment",
2466 "topic": topic,
2467 "depth": depth,
2468 }
2469 star_matched, star_replayed = http.fixture_source_replay(star_request)
2470 if star_matched:
2471 star_map = star_replayed if isinstance(star_replayed, dict) else {}
2472 github.apply_star_map(ranked_candidates, star_map)
2473 else:
2474 collected_star_map: dict[str, int] = {}
2475 github.enrich_candidates_with_stars(
2476 ranked_candidates,
2477 token=config.get("GITHUB_TOKEN"),
2478 already_enriched=_github_enriched_repos,
2479 collect_map=collected_star_map,
2480 )
2481 http.fixture_source_record(star_request, collected_star_map)
2482
2483 clusters = cluster_candidates(ranked_candidates, plan)
2484 warnings = _warnings(items_by_source, ranked_candidates, bundle.errors_by_source, degraded_by_source)
2485 library_context, library_warning = _load_library_context(
2486 topic=topic,
2487 config=config,
2488 mock=mock,
2489 internal_subrun=internal_subrun,
2490 x_handle=x_handle,
2491 github_user=github_user,
2492 github_repos=github_repos,
2493 save_dir=save_dir,
2494 )
2495 if library_warning:
2496 warnings.append(library_warning)
2497
2498 return schema.Report(
2499 topic=topic,
2500 range_from=from_date,
2501 range_to=to_date,
2502 generated_at=datetime.now(timezone.utc).isoformat(),
2503 provider_runtime=runtime,
2504 query_plan=plan,
2505 clusters=clusters,
2506 ranked_candidates=ranked_candidates,
2507 items_by_source=items_by_source,
2508 errors_by_source=bundle.errors_by_source,
2509 source_status=source_status,
2510 warnings=warnings,
2511 artifacts=bundle.artifacts,
2512 library_context=library_context,
2513 )
2514
2515
2516 def _candidate_is_duplicate(
2517 candidate: schema.Candidate,
2518 kept: list[schema.Candidate],
2519 ) -> bool:
2520 if any(existing.candidate_id == candidate.candidate_id for existing in kept):
2521 return True
2522 if candidate.url and any(existing.url == candidate.url for existing in kept):
2523 return True
2524 candidate_text = " ".join((candidate.title, candidate.snippet)).strip()
2525 return bool(candidate_text) and any(
2526 dedupe.hybrid_similarity(
2527 candidate_text,
2528 " ".join((existing.title, existing.snippet)).strip(),
2529 ) >= 0.7
2530 for existing in kept
2531 )
2532
2533
2534 def merge_drill_report(
2535 report: schema.Report,
2536 drill_report: schema.Report,
2537 matched_clusters: list[schema.Cluster],
2538 *,
2539 target: str,
2540 ) -> schema.Report:
2541 """Merge a narrow follow-up into its cached report while preserving other clusters."""
2542 merged = copy.deepcopy(report)
2543 selected_cluster_ids = {cluster.cluster_id for cluster in matched_clusters}
2544 selected_candidate_ids = {
2545 candidate_id
2546 for cluster in matched_clusters
2547 for candidate_id in cluster.candidate_ids
2548 }
2549 original_candidates = {
2550 candidate.candidate_id: candidate for candidate in merged.ranked_candidates
2551 }
2552 unrelated_candidates = [
2553 candidate for candidate in merged.ranked_candidates
2554 if candidate.candidate_id not in selected_candidate_ids
2555 ]
2556 original_summary = ""
2557 for cluster in matched_clusters:
2558 for candidate_id in cluster.representative_ids:
2559 candidate = original_candidates.get(candidate_id)
2560 if candidate:
2561 original_summary = candidate.snippet or candidate.explanation or candidate.title
2562 if original_summary:
2563 break
2564 if original_summary:
2565 break
2566
2567 unrelated_candidate_indexes = {
2568 candidate.candidate_id: index
2569 for index, candidate in enumerate(unrelated_candidates)
2570 }
2571 focused_candidates: list[schema.Candidate] = []
2572 for candidate in [
2573 *copy.deepcopy(drill_report.ranked_candidates),
2574 *[
2575 copy.deepcopy(candidate)
2576 for candidate in merged.ranked_candidates
2577 if candidate.candidate_id in selected_candidate_ids
2578 ],
2579 ]:
2580 unrelated_index = unrelated_candidate_indexes.get(candidate.candidate_id)
2581 if unrelated_index is not None:
2582 candidate.cluster_id = unrelated_candidates[unrelated_index].cluster_id
2583 unrelated_candidates[unrelated_index] = candidate
2584 continue
2585 if not _candidate_is_duplicate(candidate, focused_candidates):
2586 focused_candidates.append(candidate)
2587
2588 primary_cluster = matched_clusters[0]
2589 for candidate in focused_candidates:
2590 candidate.cluster_id = primary_cluster.cluster_id
2591 focused_ids = [candidate.candidate_id for candidate in focused_candidates]
2592 focused_sources = sorted({
2593 source
2594 for candidate in focused_candidates
2595 for source in schema.candidate_sources(candidate)
2596 })
2597 replacement_cluster = schema.Cluster(
2598 cluster_id=primary_cluster.cluster_id,
2599 title=primary_cluster.title,
2600 candidate_ids=focused_ids,
2601 representative_ids=focused_ids[:3],
2602 sources=focused_sources,
2603 score=max((candidate.final_score for candidate in focused_candidates), default=0.0),
2604 uncertainty="single-source" if len(focused_sources) == 1 else None,
2605 )
2606
2607 first_selected_index = min(
2608 index
2609 for index, cluster in enumerate(merged.clusters)
2610 if cluster.cluster_id in selected_cluster_ids
2611 )
2612 remaining_clusters = [
2613 cluster for cluster in merged.clusters
2614 if cluster.cluster_id not in selected_cluster_ids
2615 ]
2616 remaining_clusters.insert(first_selected_index, replacement_cluster)
2617 merged.clusters = remaining_clusters
2618
2619 merged.ranked_candidates = focused_candidates + unrelated_candidates
2620
2621 all_sources = set(merged.items_by_source) | set(drill_report.items_by_source)
2622 new_item_count = 0
2623 merged_items: dict[str, list[schema.SourceItem]] = {}
2624 for source in sorted(all_sources):
2625 old_items = merged.items_by_source.get(source, [])
2626 new_items = drill_report.items_by_source.get(source, [])
2627 # Collapse exact URL matches first, preferring the drill's copy (it
2628 # carries fresh transcripts/comments); fuzzy dedupe alone keeps both
2629 # when enrichment changed the text substantially.
2630 new_urls = {item.url for item in new_items if item.url}
2631 kept_old = [item for item in old_items if not (item.url and item.url in new_urls)]
2632 combined = dedupe.dedupe_items([*copy.deepcopy(new_items), *kept_old])
2633 old_unique = dedupe.dedupe_items(old_items)
2634 new_item_count += max(0, len(combined) - len(old_unique))
2635 merged_items[source] = combined
2636 merged.items_by_source = merged_items
2637
2638 merged.generated_at = drill_report.generated_at
2639 merged.query_plan = drill_report.query_plan
2640 # The drill's retrieval window is the report's window now (a --days/--as-of
2641 # override on the drill must not be mislabeled with the cached range).
2642 merged.range_from = drill_report.range_from
2643 merged.range_to = drill_report.range_to
2644 attempted_sources = {
2645 source
2646 for source, outcome in drill_report.source_status.items()
2647 if outcome.attempted or outcome.state == schema.SKIPPED_UNCONFIGURED
2648 }
2649 for source in attempted_sources:
2650 if source in drill_report.errors_by_source:
2651 merged.errors_by_source[source] = drill_report.errors_by_source[source]
2652 else:
2653 merged.errors_by_source.pop(source, None)
2654 merged.source_status[source] = drill_report.source_status[source]
2655 merged.source_status = _finalize_source_status(
2656 merged.source_status,
2657 merged.items_by_source,
2658 )
2659 degraded_by_source = {
2660 source: outcome.detail or "partial results"
2661 for source, outcome in merged.source_status.items()
2662 if outcome.state == schema.PARTIAL
2663 }
2664 merged.warnings = _warnings(
2665 merged.items_by_source,
2666 merged.ranked_candidates,
2667 merged.errors_by_source,
2668 degraded_by_source,
2669 )
2670 merged.artifacts.update(copy.deepcopy(drill_report.artifacts))
2671 history = list(merged.artifacts.get("drill_history") or [])
2672 history.append({
2673 "target": target,
2674 "clusters": [cluster.title for cluster in matched_clusters],
2675 "new_items": new_item_count,
2676 "generated_at": drill_report.generated_at,
2677 })
2678 merged.artifacts["drill_history"] = history
2679 merged.artifacts["drill_context"] = {
2680 "target": target,
2681 "cluster_titles": [cluster.title for cluster in matched_clusters],
2682 "original_summary": original_summary,
2683 "new_items": new_item_count,
2684 "sources": focused_sources,
2685 }
2686 merged.drill_of = primary_cluster.title
2687 return merged
2688
2689
2690 def _normalize_score_dedupe(
2691 source: str,
2692 raw_items: list[dict],
2693 from_date: str,
2694 to_date: str,
2695 freshness_mode: str,
2696 ranking_query: str,
2697 ) -> list[schema.SourceItem]:
2698 """Normalize, annotate, prune, dedupe, and extract snippets for a batch of raw items."""
2699 normalized = normalize.normalize_source_items(
2700 source, raw_items, from_date, to_date,
2701 freshness_mode=freshness_mode,
2702 )
2703 prepared_query = relevance.PreparedQuery(ranking_query)
2704 lookback_window_days = (
2705 datetime.strptime(to_date, "%Y-%m-%d").date()
2706 - datetime.strptime(from_date, "%Y-%m-%d").date()
2707 ).days
2708 normalized = signals.annotate_stream(
2709 normalized,
2710 prepared_query,
2711 freshness_mode,
2712 reference_date=to_date,
2713 max_days=lookback_window_days,
2714 )
2715 if source != "jobs":
2716 normalized = signals.prune_low_relevance(normalized)
2717 normalized = dedupe.dedupe_items(normalized)
2718 for item in normalized:
2719 item.snippet = snippet.extract_best_snippet(item, prepared_query)
2720 return normalized
2721
2722
2723 def _finalize_items_by_source(
2724 items_by_source_raw: dict[str, list[schema.SourceItem]],
2725 topic: str = "",
2726 config: dict | None = None,
2727 depth: str = "default",
2728 mock: bool = False,
2729 ) -> dict[str, list[schema.SourceItem]]:
2730 finalized = {}
2731 for source, items in items_by_source_raw.items():
2732 items = sorted(items, key=lambda item: item.local_rank_score or 0.0, reverse=True)
2733 items = dedupe.dedupe_items(items)
2734 enrichment_request = {
2735 "source": source,
2736 "phase": "post_ranking_enrichment",
2737 "topic": topic,
2738 "depth": depth,
2739 }
2740 if source == "youtube" and items and not mock:
2741 # Same budget-at-the-survivors principle as the digg branch
2742 # below: retrieval-time transcripts go to each search's
2743 # top-by-views candidates, while final selection ranks by
2744 # relevance. Backfill survivors that arrived without one so the
2745 # transcript budget lands on videos the brief actually shows
2746 # (#542).
2747 matched, replayed = http.fixture_source_replay(enrichment_request)
2748 if matched:
2749 items = _merge_replayed_enrichment(items, replayed)
2750 else:
2751 sc_token = (
2752 config.get("SCRAPECREATORS_API_KEY")
2753 if config and env.is_youtube_sc_available(config) else None
2754 )
2755 youtube_yt.backfill_transcripts(
2756 items, topic=topic, depth=depth, token=sc_token,
2757 )
2758 http.fixture_source_record(enrichment_request, schema.to_dict(items))
2759 # Post-merge topic-relevance filter for Polymarket: comparison queries
2760 # fan out into per-entity subqueries ("Hermes", "OpenClaw") whose topic
2761 # is too narrow for Gamma API to filter meaningfully. Re-validating the
2762 # merged list against the full original topic drops off-topic markets
2763 # (e.g., WTI crude oil, Elon tweet counts) before footer emission.
2764 if source == "polymarket" and topic:
2765 items = polymarket.filter_items_against_topic(topic, items)
2766 # --polymarket-keywords (via config): additional keyword filter
2767 # for ambiguous single-token topics (e.g., "Warriors" → nba,gsw).
2768 keywords = config.get("_polymarket_keywords") if isinstance(config, dict) else None
2769 if keywords:
2770 items = polymarket.filter_items_against_keywords(items, keywords)
2771 if source == "digg" and items:
2772 # Pull top-ranked X posts only for the survivors that will appear
2773 # in the brief. Spending the enrichment budget here (rather than
2774 # at retrieval time) keeps the inline 'via Digg' quotes
2775 # paired with the clusters dedupe actually kept.
2776 matched, replayed = http.fixture_source_replay(enrichment_request)
2777 if matched:
2778 items = _merge_replayed_enrichment(items, replayed)
2779 else:
2780 digg.enrich_source_items(items, top_k=3)
2781 http.fixture_source_record(enrichment_request, schema.to_dict(items))
2782 finalized[source] = items
2783 return finalized
2784
2785
2786 def _merge_replayed_enrichment(
2787 items: list[schema.SourceItem],
2788 replayed: list[dict],
2789 ) -> list[schema.SourceItem]:
2790 """Apply recorded post-ranking enrichment onto freshly computed items.
2791
2792 Enrichment (transcripts, Digg posts) only mutates ``metadata``. Merging by
2793 item_id instead of replacing the list keeps normalization, scoring, and
2794 dedupe regressions visible to the eval - fixture state must not overwrite
2795 what the current pipeline computed.
2796 """
2797 replayed_by_id = {
2798 entry.get("item_id"): entry for entry in replayed if isinstance(entry, dict)
2799 }
2800 for item in items:
2801 record = replayed_by_id.get(item.item_id)
2802 if record and record.get("metadata"):
2803 item.metadata.update(record["metadata"])
2804 return items
2805
2806
2807 def _apply_hiring_signal_gate(
2808 bundle: schema.RetrievalBundle,
2809 *,
2810 explicit: bool,
2811 topic: str,
2812 ) -> dict[str, Any] | None:
2813 jobs_items = bundle.items_by_source.get("jobs") or []
2814 if not jobs_items:
2815 if explicit:
2816 return hiring_signals.analyze([], explicit=True, topic=topic)
2817 return None
2818
2819 summary = hiring_signals.analyze(jobs_items, explicit=explicit, topic=topic)
2820 if not explicit and not summary.get("include"):
2821 bundle.items_by_source.pop("jobs", None)
2822 for key in list(bundle.items_by_source_and_query):
2823 if key[1] == "jobs":
2824 del bundle.items_by_source_and_query[key]
2825 return summary
2826
2827
2828 def _ensure_jobs_in_plan(
2829 plan: schema.QueryPlan,
2830 available: list[str],
2831 *,
2832 explicit: bool,
2833 topic: str,
2834 ) -> None:
2835 if "jobs" not in available:
2836 return
2837 if not (explicit or _company_topic_likely(topic)):
2838 return
2839 if "jobs" not in plan.source_weights:
2840 plan.source_weights["jobs"] = 1.0
2841 for subquery in plan.subqueries:
2842 if "jobs" not in subquery.sources:
2843 subquery.sources.append("jobs")
2844
2845
2846 def _company_topic_likely(topic: str) -> bool:
2847 text = topic.strip()
2848 if not text:
2849 return False
2850 lower = text.lower()
2851 if "?" in text or len(text.split()) > 4:
2852 return False
2853 generic = {
2854 "how", "what", "why", "best", "top", "tutorial", "guide", "prompts",
2855 "news", "latest", "ideas", "examples",
2856 }
2857 if any(word in generic for word in lower.split()):
2858 return False
2859 known_single_word_companies = {
2860 "apple", "uber", "google", "microsoft", "amazon", "meta", "netflix",
2861 "openai", "anthropic", "qualtrics", "stripe", "brex",
2862 }
2863 if " vs " in lower or " versus " in lower:
2864 parts = re.split(r"\s+(?:vs|versus)\s+", text, maxsplit=1, flags=re.IGNORECASE)
2865 if len(parts) != 2:
2866 return False
2867 return _comparison_side_company_like(parts[0], known_single_word_companies) or _comparison_side_company_like(
2868 parts[1], known_single_word_companies
2869 )
2870 return bool(text[:1].isupper() or lower in known_single_word_companies)
2871
2872
2873 def _comparison_side_company_like(side: str, known_companies: set[str]) -> bool:
2874 token = re.sub(r"[^\w.+#-]", "", side.strip().split()[0] if side.strip() else "")
2875 if not token:
2876 return False
2877 lower = token.lower()
2878 common_tech_terms = {
2879 "python", "ruby", "javascript", "typescript", "java", "go", "golang",
2880 "rust", "php", "swift", "kotlin", "scala", "clojure", "elixir",
2881 "react", "vue", "angular", "svelte", "node", "django", "rails",
2882 "postgres", "mysql", "redis", "kubernetes", "docker",
2883 }
2884 if lower in common_tech_terms:
2885 return False
2886 return bool(token[:1].isupper() or lower in known_companies)
2887
2888
2889 def _warnings(
2890 items_by_source: dict[str, list[schema.SourceItem]],
2891 candidates: list[schema.Candidate],
2892 errors_by_source: dict[str, str],
2893 degraded_by_source: dict[str, str] | None = None,
2894 ) -> list[str]:
2895 warnings: list[str] = []
2896 if not candidates:
2897 warnings.append("No candidates survived retrieval and ranking.")
2898 if len(candidates) < 5:
2899 warnings.append("Evidence is thin for this topic.")
2900 top_sources = {
2901 source
2902 for candidate in candidates[:5]
2903 for source in schema.candidate_sources(candidate)
2904 }
2905 if len(top_sources) <= 1 and len(candidates) >= 3:
2906 warnings.append("Top evidence is highly concentrated in one source.")
2907 if errors_by_source:
2908 warnings.append(f"Some sources failed: {', '.join(sorted(errors_by_source))}")
2909 if degraded_by_source:
2910 # Partial failures: the source returned some items but errored/timed out
2911 # on at least one subquery, so its coverage is likely incomplete. Kept
2912 # distinct from hard failures so the signal is not silently dropped.
2913 warnings.append(
2914 f"Some sources returned partial results (degraded): {', '.join(sorted(degraded_by_source))}"
2915 )
2916 if not items_by_source:
2917 warnings.append("No source returned usable items.")
2918 return warnings
2919
2920
2921 def _is_rate_limit_error(exc: Exception) -> bool:
2922 """Detect 429 rate-limit errors by status code or message text."""
2923 if hasattr(exc, "status_code") and getattr(exc, "status_code", None) == 429:
2924 return True
2925 return "429" in str(exc)
2926
2927
2928 class SourceRunError(RuntimeError):
2929 """Source-specific failure that survived a module's fallback logic."""
2930
2931 def __init__(self, message: str, state: schema.RunOutcomeState | None = None):
2932 super().__init__(message)
2933 self.outcome_state = state or http.classify_failure(message=message)
2934
2935
2936 def _classify_source_failure(exc: Exception) -> tuple[schema.RunOutcomeState, bool]:
2937 """Classify HTTP, subprocess, and module-specific failures consistently."""
2938 detail = str(exc)
2939 lowered = detail.lower()
2940 if any(marker in lowered for marker in ("not configured", "no api key", "not installed")):
2941 return schema.SKIPPED_UNCONFIGURED, False
2942 if any(
2943 marker in lowered
2944 for marker in ("cookie expired", "expired cookie", "login required", "not logged in")
2945 ):
2946 return schema.AUTH_FAILED, True
2947 state = getattr(exc, "outcome_state", None) or http.classify_failure(
2948 status_code=getattr(exc, "status_code", None),
2949 message=detail,
2950 )
2951 return state, True
2952
2953
2954 def _outcome_artifact(
2955 state: schema.RunOutcomeState,
2956 detail: str,
2957 *,
2958 attempted: bool = True,
2959 ) -> dict[str, Any]:
2960 return {
2961 "_source_outcome": {
2962 "state": state,
2963 "detail": detail,
2964 "attempted": attempted,
2965 }
2966 }
2967
2968
2969 def _result_outcome_artifact(source: str, result: Any) -> dict[str, Any]:
2970 """Convert a legacy ``{"error": ...}`` source result into typed status."""
2971 if not isinstance(result, dict) or not result.get("error"):
2972 return {}
2973 detail = str(result["error"])
2974 if source == "reddit":
2975 state = reddit.classify_run_failure(detail)
2976 attempted = True
2977 elif source == "youtube":
2978 state = youtube_yt.classify_run_failure(detail)
2979 attempted = state != schema.SKIPPED_UNCONFIGURED
2980 elif source == "x":
2981 state = bird_x.classify_run_failure(detail)
2982 attempted = True
2983 elif source == "truthsocial" and detail == "Truth Social token expired":
2984 state = schema.AUTH_FAILED
2985 attempted = True
2986 elif source == "bluesky" and "network-level block" in detail.lower():
2987 state = schema.UNREACHABLE
2988 attempted = True
2989 else:
2990 state, attempted = _classify_source_failure(SourceRunError(detail))
2991 return _outcome_artifact(state, detail, attempted=attempted)
2992
2993
2994 def _legacy_artifact_outcome(
2995 source: str,
2996 artifact: Any,
2997 ) -> dict[str, Any] | None:
2998 """Map known pre-outcome artifact contracts to a typed outcome note."""
2999 if not isinstance(artifact, dict):
3000 return None
3001 explicit = artifact.get("_source_outcome")
3002 if isinstance(explicit, dict):
3003 return explicit
3004 if source == "perplexity" and artifact.get("error"):
3005 error = str(artifact["error"])
3006 detail = str(
3007 artifact.get("asyncErrorMessage")
3008 or artifact.get("message")
3009 or error
3010 )
3011 state = (
3012 health.TIMEOUT
3013 if error.lower() == "timeout"
3014 else http.classify_failure(
3015 status_code=artifact.get("statusCode"),
3016 message=f"{error}: {detail}",
3017 )
3018 )
3019 return _outcome_artifact(state, detail)["_source_outcome"]
3020 if (
3021 source == "grounding"
3022 and artifact.get("reason") == "keyless-search-unavailable"
3023 ):
3024 return _outcome_artifact(
3025 schema.UNREACHABLE,
3026 "Keyless web search unavailable",
3027 )["_source_outcome"]
3028 return None
3029
3030
3031 def _resolve_stream_outcome(
3032 source: str,
3033 artifact: Any,
3034 failures: list[http.HTTPError],
3035 ) -> dict[str, Any] | None:
3036 """Choose the most specific artifact or captured HTTP outcome."""
3037 artifact_outcome = _legacy_artifact_outcome(source, artifact)
3038 if not failures:
3039 return artifact_outcome
3040 # Pick the most specific failure rather than the last-appended one:
3041 # parallel workers append in nondeterministic order, and an auth failure
3042 # must not be masked by a later 429 (wrong doctor prescription).
3043 _FAILURE_SPECIFICITY = {
3044 health.AUTH_FAILED: 0,
3045 health.RATE_LIMITED: 1,
3046 health.SCHEMA_DRIFT: 2,
3047 health.TIMEOUT: 3,
3048 health.UNREACHABLE: 4,
3049 health.ERROR: 5,
3050 }
3051 failure = min(
3052 failures,
3053 key=lambda f: _FAILURE_SPECIFICITY.get(f.outcome_state, 9),
3054 )
3055 captured_outcome = _outcome_artifact(
3056 failure.outcome_state,
3057 str(failure),
3058 )["_source_outcome"]
3059 if artifact_outcome is None:
3060 return captured_outcome
3061 if (
3062 artifact_outcome.get("state") == health.ERROR
3063 and failure.outcome_state != health.ERROR
3064 ):
3065 return captured_outcome
3066 return artifact_outcome
3067
3068
3069 def _finalize_source_status(
3070 outcomes: dict[str, schema.SourceOutcome],
3071 items_by_source: dict[str, list[schema.SourceItem]],
3072 ) -> dict[str, schema.SourceOutcome]:
3073 """Sync outcome counts to the final post-filter evidence set."""
3074 finalized: dict[str, schema.SourceOutcome] = {}
3075 for source, outcome in outcomes.items():
3076 count = len(items_by_source.get(source, []))
3077 state = outcome.state
3078 detail = outcome.detail
3079 fix_hint = outcome.fix_hint
3080 if state == schema.NO_RESULTS and count:
3081 state = health.OK
3082 detail = None
3083 fix_hint = None
3084 elif state == health.OK and not count:
3085 state = schema.NO_RESULTS
3086 elif state == schema.PARTIAL and not count:
3087 state = http.classify_failure(message=detail or "")
3088 finalized[source] = schema.SourceOutcome(
3089 source=source,
3090 state=state,
3091 items_returned=count,
3092 attempted=outcome.attempted,
3093 detail=detail,
3094 at=outcome.at,
3095 fix_hint=fix_hint,
3096 )
3097 return finalized
3098
3099
3100 def _is_transient_error(exc: Exception) -> bool:
3101 """Detect 5xx server errors that are worth retrying."""
3102 status = getattr(exc, "status_code", None)
3103 if isinstance(status, int) and 500 <= status < 600:
3104 return True
3105 msg = str(exc)
3106 return any(code in msg for code in ("500", "502", "503", "504"))
3107
3108
3109 def _run_supplemental_searches(
3110 *,
3111 topic: str,
3112 bundle: schema.RetrievalBundle,
3113 plan: schema.QueryPlan,
3114 config: dict[str, Any],
3115 depth: str,
3116 date_range: tuple[str, str],
3117 runtime: schema.ProviderRuntime,
3118 mock: bool,
3119 rate_limited_sources: set[str],
3120 rate_limit_lock: threading.Lock,
3121 x_handle: str | None = None,
3122 x_related: list[str] | None = None,
3123 ) -> None:
3124 """Phase 2: extract entities from Phase 1 results, run targeted supplemental searches."""
3125 if depth == "quick" or mock:
3126 return
3127
3128 from_date, to_date = date_range
3129
3130 # Convert SourceItems to dicts for entity_extract. All X items (whatever
3131 # backend fetched them — bird, xai, xurl, xquik) land under the single "x"
3132 # slug, so this reads the whole X corpus.
3133 x_dicts = [
3134 {"author_handle": item.author or "", "text": item.body or ""}
3135 for item in bundle.items_by_source.get("x", [])
3136 ]
3137 reddit_dicts = [
3138 {
3139 "subreddit": item.container or "",
3140 "comment_insights": item.metadata.get("comment_insights", []),
3141 "top_comments": [
3142 {"excerpt": c.get("excerpt", c.get("text", ""))}
3143 for c in (item.metadata.get("top_comments") or [])
3144 if isinstance(c, dict)
3145 ],
3146 }
3147 for item in bundle.items_by_source.get("reddit", [])
3148 ]
3149
3150 if not x_dicts and not reddit_dicts and not x_handle and not x_related:
3151 return
3152
3153 entities = entity_extract.extract_entities(
3154 reddit_dicts, x_dicts,
3155 max_handles=3, max_subreddits=3,
3156 )
3157
3158 handles = entities.get("x_handles", [])
3159
3160 # Add explicit --x-handle if provided
3161 if x_handle:
3162 handle_clean = x_handle.lstrip("@").lower()
3163 if handle_clean not in [h.lower() for h in handles]:
3164 handles.insert(0, handle_clean)
3165
3166 # Collect related handles (searched separately with lower weight)
3167 related_handles = []
3168 if x_related:
3169 primary_lower = x_handle.lstrip("@").lower() if x_handle else ""
3170 for rh in x_related:
3171 rh_clean = rh.lstrip("@").lower().strip()
3172 if rh_clean and rh_clean != primary_lower and rh_clean not in [h.lower() for h in handles]:
3173 related_handles.append(rh_clean)
3174
3175 if not handles and not related_handles:
3176 return
3177
3178 # Pick the X handle-search backend: the first handle-capable backend in the
3179 # chain (bird or xquik). These supplemental from:/mentions lanes are
3180 # complementary to the topic search, so when the topic primary can't run
3181 # them (xai/xurl have no handle-lane implementation) but a capable backend
3182 # is available, use it rather than skipping Phase 2. bird scrapes X GraphQL
3183 # with the user's browser cookies; xquik runs the same lanes over its REST
3184 # API. All items land under the single "x" slug.
3185 x_slug = "x"
3186 chain = env.x_backend_chain(config)
3187 # Trust an explicit runtime backend as the head of the chain.
3188 pinned = runtime.x_search_backend
3189 if pinned:
3190 chain = [pinned] + [b for b in chain if b != pinned]
3191 primary = next((b for b in chain if b in ("bird", "xquik")), None)
3192
3193 if primary == "bird":
3194 def _from_lane(hs: list, count: int) -> list:
3195 return bird_x.search_handles(hs, topic, from_date, count_per=count)
3196
3197 def _about_lane(hs: list, count: int) -> list:
3198 return bird_x.search_mentions(hs, from_date, count_per=count)
3199 elif primary == "xquik":
3200 xquik_token = env.get_xquik_token(config)
3201
3202 def _from_lane(hs: list, count: int) -> list:
3203 return xquik.search_handles(hs, topic, from_date, to_date, count_per=count, token=xquik_token)
3204
3205 def _about_lane(hs: list, count: int) -> list:
3206 return xquik.search_mentions(hs, from_date, to_date, topic=topic, count_per=count, token=xquik_token)
3207 else:
3208 return # primary X backend has no handle-lane support (xai/xurl) or none configured
3209
3210 # Skip if the X source is rate-limited.
3211 if x_slug in rate_limited_sources:
3212 return
3213
3214 # Collect existing URLs for deduplication
3215 existing_urls = {
3216 item.url
3217 for items in bundle.items_by_source.values()
3218 for item in items
3219 if item.url
3220 }
3221
3222 ranking_query = plan.subqueries[0].ranking_query if plan.subqueries else topic
3223 primary_label = plan.subqueries[0].label if plan.subqueries else "primary"
3224
3225 # Search primary handles (full weight): FROM lane (their own tweets) +
3226 # ABOUT lane (tweets mentioning them). Both engagement-weighted and deduped
3227 # by URL at normalize time.
3228 if handles:
3229 # Independent try/except per lane so a failure in one does not discard
3230 # the other's already-computed results.
3231 from_items: list = []
3232 about_items: list = []
3233 try:
3234 from_items = _from_lane(handles, FROM_LANE_COUNT_PER)
3235 except Exception as exc:
3236 print(f"[Pipeline] Phase 2 FROM-lane search failed: {exc}", file=sys.stderr)
3237 state, attempted = _classify_source_failure(exc)
3238 bundle.record_failure(
3239 x_slug,
3240 state,
3241 f"Phase 2 FROM-lane: {exc}",
3242 attempted=attempted,
3243 )
3244 if not bundle.items_by_source.get(x_slug):
3245 bundle.errors_by_source[x_slug] = f"Phase 2 FROM-lane: {exc}"
3246 try:
3247 about_items = _about_lane(handles, MENTION_LANE_COUNT_PER)
3248 except Exception as exc:
3249 print(f"[Pipeline] Phase 2 ABOUT-lane search failed: {exc}", file=sys.stderr)
3250 state, attempted = _classify_source_failure(exc)
3251 bundle.record_failure(
3252 x_slug,
3253 state,
3254 f"Phase 2 ABOUT-lane: {exc}",
3255 attempted=attempted,
3256 )
3257 raw_items = from_items + about_items
3258
3259 if raw_items:
3260 normalized = _normalize_score_dedupe(
3261 x_slug, raw_items, from_date, to_date,
3262 freshness_mode=plan.freshness_mode,
3263 ranking_query=ranking_query,
3264 )
3265 # Deduplicate against Phase 1 URLs
3266 normalized = [item for item in normalized if item.url not in existing_urls]
3267 if normalized:
3268 bundle.add_items(primary_label, x_slug, normalized)
3269 # Update existing URLs for related-handle dedup
3270 for item in normalized:
3271 if item.url:
3272 existing_urls.add(item.url)
3273
3274 # Search related handles with lower weight (0.3)
3275 if related_handles:
3276 try:
3277 raw_items = _from_lane(related_handles, RELATED_HANDLE_COUNT_PER)
3278 except Exception as exc:
3279 print(f"[Pipeline] Phase 2 related handle search failed: {exc}", file=sys.stderr)
3280 state, attempted = _classify_source_failure(exc)
3281 bundle.record_failure(
3282 x_slug,
3283 state,
3284 f"Phase 2 related handle search: {exc}",
3285 attempted=attempted,
3286 )
3287 raw_items = []
3288
3289 if raw_items:
3290 normalized = _normalize_score_dedupe(
3291 x_slug, raw_items, from_date, to_date,
3292 freshness_mode=plan.freshness_mode,
3293 ranking_query=ranking_query,
3294 )
3295 # Deduplicate against all existing URLs (Phase 1 + primary handles)
3296 normalized = [item for item in normalized if item.url not in existing_urls]
3297 if normalized:
3298 # Use a separate subquery label with lower weight so RRF
3299 # scores related-handle results below primary results.
3300 bundle.add_items("supplemental-related", x_slug, normalized)
3301 # Register the supplemental-related label in the plan for fusion
3302 if not any(sq.label == "supplemental-related" for sq in plan.subqueries):
3303 plan.subqueries.append(
3304 schema.SubQuery(
3305 label="supplemental-related",
3306 search_query=", ".join(related_handles),
3307 ranking_query=ranking_query,
3308 sources=[x_slug],
3309 weight=0.3,
3310 )
3311 )
3312
3313
3314 def _retry_thin_sources(
3315 *,
3316 topic: str,
3317 bundle: schema.RetrievalBundle,
3318 plan: schema.QueryPlan,
3319 config: dict[str, Any],
3320 depth: str,
3321 date_range: tuple[str, str],
3322 runtime: schema.ProviderRuntime,
3323 mock: bool,
3324 rate_limited_sources: set[str],
3325 rate_limit_lock: threading.Lock,
3326 settings: dict[str, Any],
3327 web_backend: str = "auto",
3328 skip_sources: set[str] | None = None,
3329 subreddits: list[str] | None = None,
3330 tiktok_hashtags: list[str] | None = None,
3331 tiktok_creators: list[str] | None = None,
3332 ig_creators: list[str] | None = None,
3333 ) -> None:
3334 """Retry sources with thin results using simplified core subject query."""
3335 if depth == "quick":
3336 return
3337
3338 planned_sources: list[str] = []
3339 for subquery in plan.subqueries:
3340 for source in subquery.sources:
3341 if source not in planned_sources:
3342 planned_sources.append(source)
3343 # trustpilot returns at most ONE item by design, so the "<3 items" rule
3344 # would re-fetch it after every successful lookup -- bypassing
3345 # MAX_SOURCE_FETCHES and re-resolving WITHOUT the caller's
3346 # --trustpilot-domain (a lookalike-misattribution path). Its thin result
3347 # is its normal success state; never retry it here.
3348 _skip = (skip_sources or set()) | {"trustpilot"}
3349 thin_sources = [
3350 source
3351 for source in planned_sources
3352 if len(bundle.items_by_source.get(source, [])) < 3
3353 and source not in bundle.errors_by_source
3354 and source not in _skip
3355 ]
3356
3357 if not thin_sources:
3358 return
3359
3360 core = query.extract_core_subject(topic, max_words=3)
3361 if not core:
3362 return
3363 # Note: we intentionally do NOT skip when core == topic. For short topics
3364 # like "Kanye West", the 3-word core IS the topic — but the planner may
3365 # have sent a different (worse) query to the source. Retrying with the
3366 # raw core subject is still valuable.
3367
3368 from_date, to_date = date_range
3369
3370 # Create a retry subquery with the simplified core subject
3371 retry_subquery = schema.SubQuery(
3372 label="retry",
3373 search_query=core,
3374 ranking_query=f"What recent evidence from the last 30 days matters for {core}?",
3375 sources=thin_sources,
3376 weight=0.3,
3377 )
3378
3379 def _retry_one_source(
3380 source: str,
3381 ) -> tuple[str, list[schema.SourceItem], dict[str, Any] | None]:
3382 raw_items, artifact = _retrieve_stream(
3383 topic=topic,
3384 subquery=retry_subquery,
3385 source=source,
3386 config=config,
3387 depth=depth,
3388 date_range=date_range,
3389 runtime=runtime,
3390 mock=mock,
3391 rate_limited_sources=rate_limited_sources,
3392 rate_limit_lock=rate_limit_lock,
3393 web_backend=web_backend,
3394 raw_topic=topic,
3395 subreddits=subreddits,
3396 tiktok_hashtags=tiktok_hashtags,
3397 tiktok_creators=tiktok_creators,
3398 ig_creators=ig_creators,
3399 )
3400 outcome_note = artifact.get("_source_outcome") if isinstance(artifact, dict) else None
3401 normalized = _normalize_score_dedupe(
3402 source,
3403 raw_items,
3404 from_date,
3405 to_date,
3406 freshness_mode=plan.freshness_mode,
3407 ranking_query=retry_subquery.ranking_query,
3408 )
3409 if source == "jobs":
3410 return source, normalized, outcome_note
3411 return source, normalized[:settings["per_stream_limit"]], outcome_note
3412
3413 retryable = [s for s in thin_sources if s not in rate_limited_sources]
3414
3415 from concurrent.futures import ThreadPoolExecutor, as_completed
3416 with ThreadPoolExecutor(max_workers=min(4, len(retryable) or 1)) as executor:
3417 futures = {executor.submit(_retry_one_source, s): s for s in retryable}
3418 for future in as_completed(futures):
3419 source = futures[future]
3420 try:
3421 source, normalized, outcome_note = future.result()
3422 if outcome_note:
3423 bundle.record_failure(
3424 source,
3425 outcome_note["state"],
3426 outcome_note["detail"],
3427 attempted=outcome_note.get("attempted", True),
3428 )
3429 existing_urls = {item.url for item in bundle.items_by_source.get(source, []) if item.url}
3430 new_items = [item for item in normalized if item.url not in existing_urls]
3431
3432 if new_items:
3433 primary_label = plan.subqueries[0].label if plan.subqueries else "primary"
3434 bundle.add_items(primary_label, source, new_items)
3435 except Exception as exc:
3436 print(f"[Pipeline] Retry failed for {source}: {type(exc).__name__}: {exc}", file=sys.stderr)
3437 state, attempted = _classify_source_failure(exc)
3438 bundle.record_failure(
3439 source,
3440 state,
3441 f"Simplified-query retry failed: {exc}",
3442 attempted=attempted,
3443 )
3444
3445
3446 def _fetch_x_backend(backend, subquery, from_date, to_date, depth, config):
3447 """Fetch X items from a single backend. Returns (items, error_str).
3448
3449 Backends are tried in priority order by the caller (env.x_backend_chain);
3450 a non-empty error_str signals a hard failure (auth/payment/etc.) so the
3451 caller can fail over to the next backend or surface the error honestly.
3452 """
3453 query = subquery.search_query
3454 if backend == "bird":
3455 result = bird_x.search_x(query, from_date, to_date, depth=depth)
3456 items = bird_x.parse_bird_response(result, query=query)
3457 elif backend == "xai":
3458 model = config.get("LAST30DAYS_X_MODEL") or config.get("XAI_MODEL_PIN") or providers.XAI_DEFAULT
3459 result = xai_x.search_x(config["XAI_API_KEY"], model, query, from_date, to_date, depth=depth)
3460 items = xai_x.parse_x_response(result)
3461 elif backend == "xurl":
3462 result = xurl_x.search_x(query, depth=depth)
3463 items = xurl_x.parse_x_response(result, topic=query)
3464 elif backend == "xquik":
3465 result = xquik.search_xquik(query, from_date, to_date, depth=depth, token=env.get_xquik_token(config))
3466 items = xquik.parse_xquik_response(result)
3467 else:
3468 return [], f"unknown X backend: {backend}"
3469 err = result.get("error") if isinstance(result, dict) else ""
3470 return items, (err or "")
3471
3472
3473 def _reddit_post_key(item: dict) -> str:
3474 """Stable per-thread dedupe key (base36 post id from the url/permalink)."""
3475 url = item.get("url") or item.get("permalink") or ""
3476 m = re.search(r"/comments/([A-Za-z0-9]+)", url)
3477 return m.group(1) if m else url
3478
3479
3480 def _merge_reddit_items(free: list[dict], sc: list[dict]) -> list[dict]:
3481 """Merge free + ScrapeCreators Reddit items, free first, deduped by post id.
3482
3483 Used when the thinness-floor trigger backfills a thin free run with SC, so a
3484 thread present in both is never double-listed.
3485 """
3486 merged = list(free)
3487 seen = {_reddit_post_key(it) for it in free}
3488 for it in sc:
3489 key = _reddit_post_key(it)
3490 if key and key not in seen:
3491 seen.add(key)
3492 merged.append(it)
3493 return merged
3494
3495
3496 def _retrieve_stream(*args, **kwargs) -> tuple[list[dict], dict]:
3497 """Run one stream and retain HTTP failures swallowed by source adapters."""
3498 source = str(kwargs.get("source") or "")
3499 fixture_request = {
3500 "source": source,
3501 "topic": kwargs.get("topic") or "",
3502 "search_query": getattr(kwargs.get("subquery"), "search_query", ""),
3503 "date_range": list(kwargs.get("date_range") or ()),
3504 "depth": kwargs.get("depth") or "",
3505 }
3506 module_backed = source in {
3507 "reddit",
3508 "x",
3509 "youtube",
3510 "stocktwits",
3511 "digg",
3512 "arxiv",
3513 "techmeme",
3514 "trustpilot",
3515 "github",
3516 }
3517 if module_backed:
3518 matched, replayed = http.fixture_source_replay(fixture_request)
3519 if matched:
3520 return replayed[0], replayed[1]
3521 try:
3522 with http.capture_failures() as failures, \
3523 http.fixture_module_capture(module_backed):
3524 items, artifact = _retrieve_stream_impl(*args, **kwargs)
3525 except Exception as exc:
3526 recorded_exc = exc
3527 if failures and not getattr(exc, "outcome_state", None):
3528 failure = failures[-1]
3529 recorded_exc = SourceRunError(str(exc), failure.outcome_state)
3530 if module_backed:
3531 http.fixture_source_record_error(fixture_request, recorded_exc)
3532 if recorded_exc is not exc:
3533 raise recorded_exc from exc
3534 raise
3535 outcome_note = _resolve_stream_outcome(
3536 str(kwargs.get("source") or ""),
3537 artifact,
3538 failures,
3539 )
3540 if outcome_note:
3541 artifact = dict(artifact or {})
3542 artifact["_source_outcome"] = outcome_note
3543 if module_backed:
3544 http.fixture_source_record(fixture_request, [items, artifact])
3545 return items, artifact
3546
3547
3548 def _retrieve_stream_impl(
3549 *,
3550 topic: str,
3551 subquery: schema.SubQuery,
3552 source: str,
3553 config: dict[str, Any],
3554 depth: str,
3555 date_range: tuple[str, str],
3556 runtime: schema.ProviderRuntime,
3557 mock: bool,
3558 rate_limited_sources: set[str] | None = None,
3559 rate_limit_lock: threading.Lock | None = None,
3560 web_backend: str = "auto",
3561 raw_topic: str = "",
3562 subreddits: list[str] | None = None,
3563 tiktok_hashtags: list[str] | None = None,
3564 tiktok_creators: list[str] | None = None,
3565 ig_creators: list[str] | None = None,
3566 trustpilot_domain: str | None = None,
3567 trustpilot_domain_is_hint: bool = False,
3568 ) -> tuple[list[dict], dict]:
3569 # Early exit if source was rate-limited by a sibling future
3570 if rate_limited_sources is not None and source in rate_limited_sources:
3571 return [], {}
3572 from_date, to_date = date_range
3573 if mock:
3574 return _mock_stream_results(source, subquery)
3575 if source == "grounding":
3576 return grounding.web_search(
3577 subquery.search_query, date_range, config, backend=web_backend)
3578 if source == "jobs":
3579 return jobs.search_jobs(
3580 raw_topic or topic or subquery.search_query,
3581 date_range,
3582 config,
3583 depth=depth,
3584 web_backend=web_backend,
3585 explicit=bool(config.get("_hiring_signals_mode")),
3586 )
3587 if source == "reddit":
3588 # Use raw_topic so expand_reddit_queries() generates diverse variants
3589 # from the original user topic, not the planner's narrowed search_query.
3590 reddit_query = raw_topic or subquery.search_query
3591 dedicated_subreddits = config.get("_dedicated_subreddits") or None
3592 has_sc_key = bool(config.get("SCRAPECREATORS_API_KEY"))
3593 sc_first = (
3594 has_sc_key
3595 and (config.get(env.REDDIT_BACKEND_PIN_VAR) or "").lower()
3596 == "scrapecreators"
3597 )
3598 if sc_first:
3599 # env.REDDIT_BACKEND_PIN_VAR=scrapecreators: SC primary, public fallback
3600 primary_failure: Exception | None = None
3601 try:
3602 result = reddit.search_and_enrich(
3603 reddit_query, from_date, to_date, depth=depth,
3604 token=config.get("SCRAPECREATORS_API_KEY"),
3605 subreddits=subreddits,
3606 )
3607 items = reddit.parse_reddit_response(result)
3608 if items:
3609 return items, {}
3610 sys.stderr.write(
3611 "[Reddit] ScrapeCreators primary returned no items, "
3612 "using public fallback\n"
3613 )
3614 except Exception as exc:
3615 primary_failure = exc
3616 sys.stderr.write(
3617 f"[Reddit] ScrapeCreators primary failed "
3618 f"({type(exc).__name__}: {exc}), using public fallback\n"
3619 )
3620 public_failure: Exception | None = None
3621 try:
3622 public_results = reddit_public.search_reddit_public(
3623 reddit_query, from_date, to_date, depth=depth,
3624 subreddits=subreddits,
3625 )
3626 if public_results:
3627 if primary_failure is not None:
3628 state = reddit.classify_run_failure(str(primary_failure))
3629 return public_results, _outcome_artifact(
3630 state,
3631 f"Reddit primary failed; public fallback returned "
3632 f"{len(public_results)} items: {primary_failure}",
3633 )
3634 return public_results, {}
3635 sys.stderr.write(
3636 "[Reddit] Public fallback returned no items after "
3637 "ScrapeCreators primary miss\n"
3638 )
3639 except Exception as exc:
3640 public_failure = exc
3641 sys.stderr.write(
3642 f"[Reddit] Public fallback also failed "
3643 f"({type(exc).__name__}: {exc})\n"
3644 )
3645 failure = public_failure or primary_failure
3646 if failure is not None:
3647 state = reddit.classify_run_failure(str(failure))
3648 raise SourceRunError(
3649 f"Reddit primary and fallback produced no results after failure: {failure}",
3650 state,
3651 )
3652 return [], {}
3653
3654 # Default: public Reddit first (free). ScrapeCreators backfills when the
3655 # free path is empty OR returns fewer than the configured thinness floor
3656 # (env.REDDIT_SC_MIN_ITEMS_VAR, default 0 = empty-only — today's
3657 # behavior, no extra credit spend unless the user opts in).
3658 try:
3659 min_items = int(config.get(env.REDDIT_SC_MIN_ITEMS_VAR) or 0)
3660 except (TypeError, ValueError):
3661 min_items = 0
3662 public_results: list[dict] = []
3663 public_failure: Exception | None = None
3664 try:
3665 public_results = reddit_public.search_reddit_public(
3666 reddit_query, from_date, to_date, depth=depth,
3667 subreddits=subreddits, dedicated_subreddits=dedicated_subreddits,
3668 ) or []
3669 except Exception as exc:
3670 public_failure = exc
3671 sys.stderr.write(
3672 f"[Reddit] Public search failed ({type(exc).__name__}: {exc})"
3673 )
3674 if not has_sc_key:
3675 sys.stderr.write("\n")
3676 state = reddit.classify_run_failure(str(exc))
3677 raise SourceRunError(f"Reddit public search failed: {exc}", state) from exc
3678 sys.stderr.write(", using ScrapeCreators backup\n")
3679 # Enough free results, or no key to backfill with -> done. max(min_items,
3680 # 1) keeps the default (min_items=0) as empty-only AND treats exactly
3681 # `min_items` results as acceptable (no backfill) for min_items > 0.
3682 if len(public_results) >= max(min_items, 1) or not has_sc_key:
3683 return public_results, {}
3684 if public_results:
3685 sys.stderr.write(
3686 f"[Reddit] Free path returned {len(public_results)} "
3687 f"(below the {min_items}-item floor); backfilling with ScrapeCreators\n"
3688 )
3689 try:
3690 result = reddit.search_and_enrich(
3691 reddit_query, from_date, to_date, depth=depth,
3692 token=config.get("SCRAPECREATORS_API_KEY"),
3693 subreddits=subreddits,
3694 )
3695 sc_items = reddit.parse_reddit_response(result)
3696 except Exception as exc:
3697 sys.stderr.write(
3698 f"[Reddit] ScrapeCreators backup also failed "
3699 f"({type(exc).__name__}: {exc})\n"
3700 )
3701 state = reddit.classify_run_failure(str(exc))
3702 return public_results, _outcome_artifact(
3703 state,
3704 f"Reddit backup failed after {len(public_results)} public items: {exc}",
3705 )
3706 merged = _merge_reddit_items(public_results, sc_items)
3707 if public_failure is not None:
3708 state = reddit.classify_run_failure(str(public_failure))
3709 return merged, _outcome_artifact(
3710 state,
3711 f"Reddit public search failed; backup returned {len(sc_items)} items: "
3712 f"{public_failure}",
3713 )
3714 return merged, {}
3715 if source == "x":
3716 # One X source, an ordered chain of interchangeable backends. Try the
3717 # primary; fall through to the next only if it returns nothing or errors.
3718 chain = env.x_backend_chain(config)
3719 # Trust an explicit runtime backend as the primary (already resolved as
3720 # available), keeping the rest of the chain as failover backups.
3721 pinned = runtime.x_search_backend
3722 if pinned:
3723 chain = [pinned] + [b for b in chain if b != pinned]
3724 if not chain:
3725 raise RuntimeError("No X backend is available.")
3726 last_error = ""
3727 for i, backend in enumerate(chain):
3728 items, err = _fetch_x_backend(backend, subquery, from_date, to_date, depth, config)
3729 if items:
3730 if i > 0:
3731 print(f"[X] primary backend(s) returned nothing; used fallback '{backend}'", file=sys.stderr)
3732 if last_error:
3733 state = (
3734 bird_x.classify_run_failure(last_error)
3735 if last_error.startswith("bird:")
3736 else http.classify_failure(message=last_error)
3737 )
3738 return items, _outcome_artifact(
3739 state,
3740 f"X fallback '{backend}' returned {len(items)} items after {last_error}",
3741 )
3742 return items, {}
3743 if err:
3744 last_error = f"{backend}: {err}"
3745 print(f"[X] backend '{backend}' failed ({err}); trying next", file=sys.stderr)
3746 if last_error:
3747 state = (
3748 bird_x.classify_run_failure(last_error)
3749 if last_error.startswith("bird:")
3750 else http.classify_failure(message=last_error)
3751 )
3752 raise SourceRunError(f"All X backends failed — {last_error}", state)
3753 return [], {}
3754 if source == "youtube":
3755 # Use raw_topic so expand_youtube_queries() generates diverse variants
3756 # from the original user topic, not the planner's narrowed search_query.
3757 yt_query = raw_topic or subquery.search_query
3758 result = None
3759 youtube_failure: str | None = None
3760 # ScrapeCreators key (when present) is the default-on backup tier: it
3761 # powers the per-video transcript fallback, the SC search fallback, and
3762 # comment enrichment. None when no key, which keeps everything keyless.
3763 sc_token = (
3764 config.get("SCRAPECREATORS_API_KEY", "")
3765 if env.is_youtube_sc_available(config) else None
3766 )
3767 # Try yt-dlp first; the SC transcript fallback covers per-video failures.
3768 if which("yt-dlp"):
3769 try:
3770 result = youtube_yt.search_and_transcribe(
3771 yt_query, from_date, to_date, depth=depth, token=sc_token,
3772 )
3773 if result.get("error"):
3774 youtube_failure = str(result["error"])
3775 except Exception as exc:
3776 youtube_failure = str(exc)
3777 result = None
3778 # Fall back to SC YouTube search if yt-dlp failed or isn't installed.
3779 if (result is None or not result.get("items")) and sc_token:
3780 try:
3781 result = youtube_yt.search_youtube_sc(
3782 yt_query, from_date, to_date, depth=depth, token=sc_token,
3783 )
3784 if result.get("error"):
3785 youtube_failure = str(result["error"])
3786 except Exception as exc:
3787 youtube_failure = str(exc)
3788 result = None
3789 if result is None:
3790 result = {"items": []}
3791 # Enrich top videos with comments (default-on when a key is present).
3792 items = youtube_yt.parse_youtube_response(result)
3793 if items and env.is_youtube_comments_available(config):
3794 youtube_yt.enrich_with_comments(
3795 items, token=config.get("SCRAPECREATORS_API_KEY", ""),
3796 )
3797 if youtube_failure:
3798 state = youtube_yt.classify_run_failure(youtube_failure)
3799 attempted = state != schema.SKIPPED_UNCONFIGURED
3800 return items, _outcome_artifact(state, youtube_failure, attempted=attempted)
3801 return items, {}
3802 if source == "tiktok":
3803 # Use raw_topic so expand_tiktok_queries() generates diverse variants
3804 # from the original user topic, not the planner's narrowed search_query.
3805 tiktok_query = raw_topic or subquery.search_query
3806 result = tiktok.search_and_enrich(
3807 tiktok_query,
3808 from_date,
3809 to_date,
3810 depth=depth,
3811 token=env.get_tiktok_token(config),
3812 hashtags=tiktok_hashtags,
3813 creators=tiktok_creators,
3814 )
3815 items = tiktok.parse_tiktok_response(result)
3816 if items and env.is_tiktok_comments_available(config):
3817 sc_token = config.get("SCRAPECREATORS_API_KEY", "")
3818 tiktok.enrich_with_comments(items, token=sc_token)
3819 return items, _result_outcome_artifact(source, result)
3820 if source == "instagram":
3821 # Use raw_topic so expand_instagram_queries() generates diverse variants
3822 # from the original user topic, not the planner's narrowed search_query.
3823 ig_query = raw_topic or subquery.search_query
3824 result = instagram.search_and_enrich(
3825 ig_query,
3826 from_date,
3827 to_date,
3828 depth=depth,
3829 token=env.get_instagram_token(config),
3830 ig_creators=ig_creators,
3831 )
3832 items = instagram.parse_instagram_response(result)
3833 if items and env.is_instagram_comments_available(config):
3834 instagram.enrich_with_comments(
3835 items, token=config.get("SCRAPECREATORS_API_KEY", ""),
3836 )
3837 return items, _result_outcome_artifact(source, result)
3838 if source == "linkedin":
3839 token = config.get("SCRAPECREATORS_API_KEY", "")
3840 result = linkedin.search_linkedin(
3841 subquery.search_query,
3842 from_date,
3843 to_date,
3844 depth=depth,
3845 token=token,
3846 )
3847 items = linkedin.parse_linkedin_response(
3848 result, from_date=from_date, to_date=to_date
3849 )
3850 # Articles never appear in post search — surface them (high signal)
3851 # via a bounded profile-enrichment lane on person topics.
3852 items += linkedin.enrich_articles(
3853 items, raw_topic or topic, token, from_date=from_date, to_date=to_date
3854 )
3855 return items, _result_outcome_artifact(source, result)
3856 if source == "hackernews":
3857 result = hackernews.search_hackernews(subquery.search_query, from_date, to_date, depth=depth)
3858 return (
3859 hackernews.parse_hackernews_response(result, query=subquery.search_query),
3860 _result_outcome_artifact(source, result),
3861 )
3862 if source == "stocktwits":
3863 # Pass raw_topic so symbol detection sees the full topic, not the
3864 # narrowed per-subquery search_query (same rationale as reddit).
3865 result = stocktwits.search_stocktwits(
3866 raw_topic or topic or subquery.search_query, from_date, to_date, depth=depth)
3867 return (
3868 stocktwits.parse_stocktwits_response(result, query=subquery.search_query),
3869 _result_outcome_artifact(source, result),
3870 )
3871 if source == "dripstack":
3872 result = dripstack.search_dripstack(
3873 subquery.search_query, from_date, to_date, depth=depth)
3874 relevance_topic = raw_topic or topic or subquery.search_query
3875 return (
3876 dripstack.parse_dripstack_response(result, query=relevance_topic),
3877 _result_outcome_artifact(source, result),
3878 )
3879 if source == "digg":
3880 result = digg.search_digg(subquery.search_query, from_date, to_date, depth=depth)
3881 items = digg.parse_digg_response(result, query=subquery.search_query)
3882 # Enrichment with attached X posts is deferred to
3883 # _finalize_items_by_source so it runs on the items that actually
3884 # survive dedupe rather than on top-K of the raw fanout.
3885 return items, _result_outcome_artifact(source, result)
3886 if source == "arxiv":
3887 result = arxiv.search_arxiv(subquery.search_query, from_date, to_date, depth=depth)
3888 # Relevance keys off the stable research topic, not the per-subquery
3889 # search_query, so off-topic narrowing does not let weak matches through.
3890 relevance_topic = raw_topic or topic or subquery.search_query
3891 return (
3892 arxiv.parse_arxiv_response(result, query=relevance_topic),
3893 _result_outcome_artifact(source, result),
3894 )
3895 if source == "techmeme":
3896 result = techmeme.search_techmeme(subquery.search_query, from_date, to_date, depth=depth)
3897 relevance_topic = raw_topic or topic or subquery.search_query
3898 return (
3899 techmeme.parse_techmeme_response(result, query=relevance_topic),
3900 _result_outcome_artifact(source, result),
3901 )
3902 if source == "trustpilot":
3903 # Brand-shape gate keys off the stable research topic, not the narrowed
3904 # per-subquery search_query, so the company is detected consistently.
3905 relevance_topic = raw_topic or topic or subquery.search_query
3906 result = trustpilot.search_trustpilot(
3907 relevance_topic, from_date, to_date, depth=depth, config=config,
3908 explicit_domain=trustpilot_domain,
3909 domain_is_hint=trustpilot_domain_is_hint,
3910 )
3911 return (
3912 trustpilot.parse_trustpilot_response(result, query=relevance_topic),
3913 _result_outcome_artifact(source, result),
3914 )
3915 if source == "bluesky":
3916 result = bluesky.search_bluesky(subquery.search_query, from_date, to_date, depth=depth, config=config)
3917 return bluesky.parse_bluesky_response(result), _result_outcome_artifact(source, result)
3918 if source == "threads":
3919 result = threads.search_threads(
3920 subquery.search_query, from_date, to_date,
3921 depth=depth,
3922 token=config.get("SCRAPECREATORS_API_KEY"),
3923 )
3924 return threads.parse_threads_response(result), _result_outcome_artifact(source, result)
3925 if source == "truthsocial":
3926 result = truthsocial.search_truthsocial(subquery.search_query, from_date, to_date, depth=depth, config=config)
3927 return truthsocial.parse_truthsocial_response(result), _result_outcome_artifact(source, result)
3928 if source == "polymarket":
3929 result = polymarket.search_polymarket(subquery.search_query, from_date, to_date, depth=depth)
3930 # Relevance filtering keys off the stable original research topic, not the
3931 # per-subquery search_query (which narrows differently on each fanout pass
3932 # and would let off-topic markets through on broad subqueries while dropping
3933 # everything on narrow ones).
3934 relevance_topic = raw_topic or topic or subquery.search_query
3935 return (
3936 polymarket.parse_polymarket_response(result, topic=relevance_topic),
3937 _result_outcome_artifact(source, result),
3938 )
3939 if source == "github":
3940 # Resolve once at the pipeline boundary so search and enrich
3941 # share the result; otherwise each call would re-run the env
3942 # lookup and gh-CLI subprocess fallback (up to 5s timeout each).
3943 token = github.resolve_token(config.get("GITHUB_TOKEN"))
3944 response = github.search_github(subquery.search_query, from_date, to_date, depth=depth, token=token)
3945 items = github.parse_github_response(response)
3946 # Note: an unauth rate-limit (response["error"]) is expected on the
3947 # tokenless anon tier and returns empty here rather than raising — github
3948 # is now always eligible, so raising would spam "github failed" on every
3949 # tokenless run. The condition is logged in github.search_github.
3950 items = github.enrich_with_comments(items, depth=depth, token=token)
3951 return items, _result_outcome_artifact(source, response)
3952 if source == "pinterest":
3953 result = pinterest.search_pinterest(
3954 subquery.search_query, from_date, to_date,
3955 depth=depth,
3956 token=env.get_pinterest_token(config),
3957 )
3958 return pinterest.parse_pinterest_response(result), _result_outcome_artifact(source, result)
3959 if source == "xiaohongshu":
3960 return xiaohongshu_api.search_feeds(
3961 subquery.search_query,
3962 from_date,
3963 to_date,
3964 env.get_xiaohongshu_api_base(config),
3965 depth=depth,
3966 ), {}
3967 if source == "perplexity":
3968 return perplexity.search(subquery.search_query, date_range, config, deep=config.get("_deep_research", False))
3969 raise RuntimeError(f"Unsupported source: {source}")
3970
3971
3972 def _google_key(config: dict[str, Any]) -> str | None:
3973 return config.get("GOOGLE_API_KEY") or config.get("GEMINI_API_KEY") or config.get("GOOGLE_GENAI_API_KEY")
3974
3975
3976
3977
3978 def _mock_stream_results(source: str, subquery: schema.SubQuery) -> tuple[list[dict], dict]:
3979 # Namespace URLs and the canned comment by topic: real runs never hand two
3980 # distinct stories byte-identical evidence, and discovery's same-story fold
3981 # (correctly) collapses topics that share it. Mock enrichment sub-runs feed
3982 # this fixture one topic per subquery, so the slug keeps them distinct.
3983 slug = re.sub(r"[^a-z0-9]+", "-", subquery.search_query.lower()).strip("-") or "topic"
3984 payloads = {
3985 "reddit": [
3986 {
3987 "id": "R1",
3988 "title": f"{subquery.search_query} discussion thread",
3989 "url": f"https://reddit.com/r/example/comments/{slug}-1",
3990 "subreddit": "example",
3991 "date": dates.get_date_range(5)[0],
3992 "engagement": {"score": 120, "num_comments": 48, "upvote_ratio": 0.91},
3993 "selftext": f"Community discussion about {subquery.search_query}.",
3994 "top_comments": [{"excerpt": f"Strong firsthand feedback from {subquery.search_query} users."}],
3995 "relevance": 0.82,
3996 "why_relevant": "Mock Reddit result",
3997 }
3998 ],
3999 "x": [
4000 {
4001 "id": "X1",
4002 "text": f"People on X are discussing {subquery.search_query} right now.",
4003 "url": f"https://x.com/example/status/{slug}-1",
4004 "author_handle": "example",
4005 "date": dates.get_date_range(2)[0],
4006 "engagement": {"likes": 200, "reposts": 35, "replies": 18, "quotes": 4},
4007 "relevance": 0.79,
4008 "why_relevant": "Mock X result",
4009 }
4010 ],
4011 "grounding": [
4012 {
4013 "id": "WB1",
4014 "title": f"{subquery.search_query} article",
4015 "url": f"https://example.com/article/{slug}",
4016 "source_domain": "example.com",
4017 "snippet": f"Recent web reporting about {subquery.search_query}.",
4018 "date": dates.get_date_range(7)[0],
4019 "relevance": 0.88,
4020 "why_relevant": "Brave web search",
4021 }
4022 ],
4023 "digg": [
4024 {
4025 "id": "mock1abc",
4026 "title": f"Digg cluster about {subquery.search_query}",
4027 "url": f"https://di.gg/ai/mock1abc-{slug}",
4028 "tldr": f"Curated cluster summarizing recent {subquery.search_query} discussion across the AI 1000.",
4029 "author": "",
4030 "date": dates.get_date_range(3)[0],
4031 "engagement": {"postCount": 8, "uniqueAuthors": 5, "rank": 2, "rank_score": 49.0},
4032 "first_post_age": "3d",
4033 "posts": [
4034 {
4035 "username": "exampledev",
4036 "display_name": "Example Dev",
4037 "category": "Engineer",
4038 "rank": 142,
4039 "body": f"Quote from the AI 1000 about {subquery.search_query}.",
4040 "post_type": "tweet",
4041 "x_url": "https://x.com/exampledev/status/1",
4042 "posted_at": dates.get_date_range(3)[0],
4043 },
4044 ],
4045 "relevance": 0.84,
4046 "why_relevant": "Mock Digg cluster",
4047 },
4048 {
4049 "id": "mock2def",
4050 "title": f"Second Digg cluster on {subquery.search_query}",
4051 "url": f"https://di.gg/ai/mock2def-{slug}",
4052 "tldr": f"Another angle on {subquery.search_query}.",
4053 "author": "",
4054 "date": dates.get_date_range(8)[0],
4055 "engagement": {"postCount": 3, "uniqueAuthors": 2, "rank": 18, "rank_score": 33.0},
4056 "first_post_age": "8d",
4057 "posts": [],
4058 "relevance": 0.71,
4059 "why_relevant": "Mock Digg cluster",
4060 },
4061 ],
4062 "arxiv": [
4063 {
4064 "id": f"http://arxiv.org/abs/2606.00001v1-{slug}",
4065 "title": f"A Survey of {subquery.search_query}",
4066 "url": f"https://arxiv.org/abs/2606.00001v1-{slug}",
4067 "summary": f"We present a comprehensive study of {subquery.search_query} and its recent advances.",
4068 "author": "Ada Lovelace et al.",
4069 "authors": ["Ada Lovelace", "Alan Turing"],
4070 "date": dates.get_date_range(20)[0],
4071 "engagement": {},
4072 "relevance": 0.86,
4073 "why_relevant": "Mock arXiv paper",
4074 },
4075 ],
4076 "techmeme": [
4077 {
4078 "id": f"https://www.techmeme.com/260627/p1-{slug}",
4079 "title": f"Major development in {subquery.search_query} reshapes the industry",
4080 "url": f"https://www.techmeme.com/260627/p1-{slug}",
4081 "source_name": "techcrunch.com",
4082 "date": dates.get_date_range(1)[0],
4083 "engagement": {},
4084 "relevance": 0.83,
4085 "why_relevant": "Mock Techmeme headline",
4086 },
4087 ],
4088 "dripstack": [
4089 {
4090 "id": "DS1",
4091 "title": f"Deep dive: {subquery.search_query} from a paid newsletter",
4092 "url": f"https://newsletter.example.com/deep-dive-{slug}",
4093 "author": "newsletter.example.com",
4094 "date": dates.get_date_range(3)[0],
4095 "engagement": {},
4096 "relevance": 0.85,
4097 "why_relevant": "Mock DripStack newsletter result",
4098 "snippet": f"Professional analyst coverage of {subquery.search_query}.",
4099 "metadata": {
4100 "publication_slug": "newsletter.example.com",
4101 "post_slug": "deep-dive",
4102 "relevance_score": 85,
4103 "match_confidence": "strong",
4104 },
4105 },
4106 ],
4107 "trustpilot": [
4108 {
4109 "id": "example.com",
4110 "title": f"{subquery.search_query}: TrustScore 3.4",
4111 "url": f"https://www.trustpilot.com/review/{slug}.example.com",
4112 "summary": f"Across recent reviews, customers were split on {subquery.search_query}: some praised support, others cited delays.",
4113 "name": subquery.search_query,
4114 "trustScore": 3.4,
4115 "reviewCount": 128,
4116 "date": dates.get_date_range(1)[0],
4117 "engagement": {"reviews": 128, "trustScore": 3.4},
4118 "relevance": 0.8,
4119 "why_relevant": "Mock Trustpilot sentiment",
4120 },
4121 ],
4122 "jobs": [
4123 {
4124 "id": "J1",
4125 "title": "Founding Enterprise Solutions Engineer",
4126 "url": f"https://boards.greenhouse.io/example/jobs/{slug}-1",
4127 "description": (
4128 f"Work with enterprise customers on SSO, SOC 2, security, "
4129 f"and procurement workflows for {subquery.search_query}."
4130 ),
4131 "department": "Sales",
4132 "location": "San Francisco, CA",
4133 "date": dates.get_date_range(4)[0],
4134 "provider": "mock",
4135 "relevance": 0.8,
4136 "why_relevant": "Mock public job posting",
4137 },
4138 {
4139 "id": "J2",
4140 "title": "Security Platform Engineer",
4141 "url": f"https://boards.greenhouse.io/example/jobs/{slug}-2",
4142 "description": "Build enterprise security, audit, and admin workflows.",
4143 "department": "Engineering",
4144 "location": "Remote",
4145 "date": dates.get_date_range(6)[0],
4146 "provider": "mock",
4147 "relevance": 0.78,
4148 "why_relevant": "Mock public job posting",
4149 },
4150 ],
4151 }
4152 if source == "grounding":
4153 return payloads.get(source, []), {
4154 "label": subquery.label,
4155 "mock": True,
4156 "webSearchQueries": [subquery.search_query],
4157 "resultCount": 1,
4158 }
4159 return payloads.get(source, []), {}
4160
4161
4161 lines PYTHON