| 1 | """LLM-first query planning with deterministic guards for risky queries.""" |
| 2 | |
| 3 | from __future__ import annotations |
| 4 | |
| 5 | import json |
| 6 | import re |
| 7 | import unicodedata |
| 8 | from collections import Counter |
| 9 | |
| 10 | from . import categories, competitors, entity_extract, http, providers, query, relevance, schema |
| 11 | |
| 12 | # Hebrew Unicode block: U+0590–U+05FF |
| 13 | _HEBREW_RE = re.compile(r'[\u0590-\u05FF]') |
| 14 | |
| 15 | DISCOVERY_SOURCE_ORDER = ("reddit", "hackernews", "digg", "x") |
| 16 | |
| 17 | |
| 18 | def detect_language(text: str) -> str | None: |
| 19 | """Return 'he' if the text contains Hebrew characters, else None.""" |
| 20 | return 'he' if _HEBREW_RE.search(text) else None |
| 21 | |
| 22 | |
| 23 | def build_discovery_plan( |
| 24 | domain: str, |
| 25 | *, |
| 26 | available_sources: list[str] | None = None, |
| 27 | subreddits: list[str] | None = None, |
| 28 | ) -> schema.DiscoveryPlan: |
| 29 | """Resolve a domain to the existing category-peer community feeds. |
| 30 | |
| 31 | An empty domain is global trending: sweep every river feed's own hot list |
| 32 | (r/all, HN front page, Digg) with no category scoping. Keyword-driven |
| 33 | sources (X, Techmeme, arXiv - none of which expose a river/front-page |
| 34 | lane) sit out of the global nominate stage and join per-topic at the |
| 35 | enrichment pass, where every nomination gets a full research run. |
| 36 | """ |
| 37 | normalized_domain = " ".join(domain.split()) |
| 38 | if not normalized_domain: |
| 39 | resolved = [ |
| 40 | subreddit.removeprefix("r/").strip() |
| 41 | for subreddit in (subreddits or ["all"]) |
| 42 | if subreddit.strip() |
| 43 | ] |
| 44 | allowed = set(DISCOVERY_SOURCE_ORDER if available_sources is None else available_sources) |
| 45 | allowed.discard("x") |
| 46 | sources = [source for source in DISCOVERY_SOURCE_ORDER if source in allowed] |
| 47 | if not sources: |
| 48 | raise ValueError("No listing sources are available for global trending") |
| 49 | return schema.DiscoveryPlan( |
| 50 | domain="", |
| 51 | category=None, |
| 52 | subreddits=resolved or ["all"], |
| 53 | sources=sources, |
| 54 | ) |
| 55 | |
| 56 | category = categories.detect_category(normalized_domain) |
| 57 | candidate_subreddits = list(subreddits or categories.peer_subs_for(category)) |
| 58 | seen_subreddits: set[str] = set() |
| 59 | resolved_subreddits: list[str] = [] |
| 60 | for subreddit in candidate_subreddits: |
| 61 | normalized_subreddit = subreddit.removeprefix("r/").strip() |
| 62 | key = normalized_subreddit.lower() |
| 63 | if not normalized_subreddit or key in seen_subreddits: |
| 64 | continue |
| 65 | seen_subreddits.add(key) |
| 66 | resolved_subreddits.append(normalized_subreddit) |
| 67 | # The curated map intentionally stays small. Keep discovery's keyless floor |
| 68 | # for uncategorized domains by sweeping r/all and applying domain relevance |
| 69 | # during normalization instead of inventing a second category resolver. |
| 70 | if not resolved_subreddits: |
| 71 | resolved_subreddits = ["all"] |
| 72 | |
| 73 | allowed = set(DISCOVERY_SOURCE_ORDER if available_sources is None else available_sources) |
| 74 | sources = [source for source in DISCOVERY_SOURCE_ORDER if source in allowed] |
| 75 | if not sources: |
| 76 | raise ValueError(f"No listing sources are available for {normalized_domain!r}") |
| 77 | |
| 78 | return schema.DiscoveryPlan( |
| 79 | domain=normalized_domain, |
| 80 | category=category, |
| 81 | subreddits=resolved_subreddits, |
| 82 | sources=sources, |
| 83 | ) |
| 84 | |
| 85 | ALLOWED_INTENTS = { |
| 86 | "factual", |
| 87 | "product", |
| 88 | "concept", |
| 89 | "opinion", |
| 90 | "how_to", |
| 91 | "comparison", |
| 92 | "breaking_news", |
| 93 | "prediction", |
| 94 | } |
| 95 | ALLOWED_CLUSTER_MODES = {"none", "story", "workflow", "market", "debate"} |
| 96 | |
| 97 | QUICK_SOURCE_PRIORITY = { |
| 98 | "factual": ["hackernews", "reddit", "x", "xquik", "youtube"], |
| 99 | "product": ["jobs", "youtube", "reddit", "x", "xquik", "tiktok"], |
| 100 | "concept": ["hackernews", "reddit", "x", "xquik", "youtube"], |
| 101 | "opinion": ["reddit", "x", "xquik", "youtube", "hackernews"], |
| 102 | "how_to": ["youtube", "reddit", "x", "xquik", "hackernews"], |
| 103 | "comparison": ["reddit", "x", "xquik", "hackernews", "youtube"], |
| 104 | "breaking_news": ["x", "xquik", "reddit", "hackernews", "youtube", "polymarket"], |
| 105 | "prediction": ["polymarket", "x", "xquik", "hackernews", "reddit", "youtube"], |
| 106 | } |
| 107 | SOURCE_PRIORITY = { |
| 108 | "factual": ["hackernews", "reddit", "x", "youtube"], |
| 109 | "product": ["jobs", "youtube", "reddit", "x", "tiktok", "hackernews"], |
| 110 | "concept": ["hackernews", "reddit", "x", "youtube"], |
| 111 | "opinion": ["reddit", "x", "stocktwits", "dripstack", "youtube", "hackernews"], |
| 112 | "how_to": ["youtube", "reddit", "x", "hackernews"], |
| 113 | "comparison": ["reddit", "x", "hackernews", "youtube"], |
| 114 | "breaking_news": ["x", "stocktwits", "reddit", "hackernews", "youtube", "polymarket"], |
| 115 | "prediction": ["polymarket", "stocktwits", "dripstack", "x", "hackernews", "reddit", "youtube"], |
| 116 | } |
| 117 | SOURCE_LIMITS = { |
| 118 | "quick": { |
| 119 | "factual": 2, |
| 120 | "product": 2, |
| 121 | "concept": 2, |
| 122 | "opinion": 2, |
| 123 | "how_to": 2, |
| 124 | "comparison": 2, |
| 125 | "breaking_news": 2, |
| 126 | "prediction": 2, |
| 127 | }, |
| 128 | # "default" intentionally absent: all available sources are searched |
| 129 | # at default depth. Fusion and reranking handle quality. quick mode |
| 130 | # uses tight budgets above for latency. |
| 131 | } |
| 132 | INTENT_SOURCE_EXCLUSIONS: dict[str, set[str]] = { |
| 133 | "concept": {"polymarket"}, |
| 134 | "how_to": {"polymarket"}, |
| 135 | } |
| 136 | SOURCE_CAPABILITIES = { |
| 137 | "reddit": {"discussion", "social"}, |
| 138 | "x": {"discussion", "social"}, |
| 139 | "xquik": {"discussion", "social"}, |
| 140 | "youtube": {"video", "video_longform", "discussion"}, |
| 141 | "tiktok": {"video", "video_shortform", "social"}, |
| 142 | "instagram": {"video", "video_shortform", "social"}, |
| 143 | "hackernews": {"discussion", "link"}, |
| 144 | "bluesky": {"discussion", "social"}, |
| 145 | "truthsocial": {"discussion", "social"}, |
| 146 | "polymarket": {"market"}, |
| 147 | "stocktwits": {"social", "market", "finance_social"}, |
| 148 | "dripstack": {"reference", "analysis", "link"}, |
| 149 | "digg": {"discussion", "social", "link"}, |
| 150 | "arxiv": {"reference", "analysis", "link"}, |
| 151 | "techmeme": {"discussion", "link", "reference"}, |
| 152 | "trustpilot": {"reference", "company_signal", "social"}, |
| 153 | "xiaohongshu": {"video", "video_shortform", "social"}, |
| 154 | "github": {"discussion", "link"}, |
| 155 | "grounding": {"web", "reference", "link"}, |
| 156 | "perplexity": {"web", "reference", "analysis"}, |
| 157 | "jobs": {"jobs", "company_signal", "link"}, |
| 158 | "corpus": {"reference", "analysis"}, |
| 159 | } |
| 160 | |
| 161 | |
| 162 | def validate_external_plan(raw: dict) -> None: |
| 163 | """Validate explicit-plan structure before permissive sanitization. |
| 164 | |
| 165 | Enum-like metadata stays permissive because direct pipeline callers rely on |
| 166 | the sanitizer to canonicalize those values. |
| 167 | """ |
| 168 | if not isinstance(raw, dict): |
| 169 | raise ValueError("top-level plan must be an object") |
| 170 | for field in ("intent", "freshness_mode", "cluster_mode", "subqueries"): |
| 171 | if field not in raw: |
| 172 | raise ValueError(f"missing required field '{field}'") |
| 173 | for field in ("intent", "freshness_mode", "cluster_mode"): |
| 174 | if not isinstance(raw[field], str) or not raw[field].strip(): |
| 175 | raise ValueError(f"field '{field}' must be a non-empty string") |
| 176 | |
| 177 | source_weights = raw.get("source_weights") |
| 178 | if source_weights is not None and not isinstance(source_weights, dict): |
| 179 | raise ValueError("field 'source_weights' must be an object when provided") |
| 180 | for source, weight in (source_weights or {}).items(): |
| 181 | if ( |
| 182 | not isinstance(source, str) |
| 183 | or not source.strip() |
| 184 | or isinstance(weight, bool) |
| 185 | or not isinstance(weight, (int, float)) |
| 186 | ): |
| 187 | raise ValueError("field 'source_weights' must map source names to numbers") |
| 188 | subqueries = raw["subqueries"] |
| 189 | if not isinstance(subqueries, list) or not subqueries: |
| 190 | raise ValueError("field 'subqueries' must be a non-empty array") |
| 191 | for index, subquery in enumerate(subqueries): |
| 192 | if not isinstance(subquery, dict): |
| 193 | raise ValueError(f"subqueries[{index}] must be an object") |
| 194 | for field in ("search_query", "ranking_query"): |
| 195 | if not isinstance(subquery.get(field), str) or not subquery[field].strip(): |
| 196 | raise ValueError(f"subqueries[{index}].{field} must be a non-empty string") |
| 197 | sources = subquery.get("sources") |
| 198 | if not isinstance(sources, list) or not sources or not all( |
| 199 | isinstance(source, str) and source.strip() for source in sources |
| 200 | ): |
| 201 | raise ValueError(f"subqueries[{index}].sources must be a non-empty string array") |
| 202 | weight = subquery.get("weight") |
| 203 | if weight is not None and ( |
| 204 | isinstance(weight, bool) or not isinstance(weight, (int, float)) |
| 205 | ): |
| 206 | raise ValueError(f"subqueries[{index}].weight must be a number when provided") |
| 207 | |
| 208 | |
| 209 | DEFAULT_INTENT_CAPABILITIES = { |
| 210 | "comparison": {"discussion", "video", "web", "reference", "social", "link", "market"}, |
| 211 | "how_to": {"discussion", "video", "web", "reference", "link"}, |
| 212 | } |
| 213 | |
| 214 | |
| 215 | class DrillTargetError(ValueError): |
| 216 | """Raised when a follow-up target cannot be resolved to a report cluster.""" |
| 217 | |
| 218 | def __init__(self, target: str, clusters: list[schema.Cluster]) -> None: |
| 219 | candidates = ", ".join( |
| 220 | f"{index}. {cluster.title}" |
| 221 | for index, cluster in enumerate(clusters, start=1) |
| 222 | ) or "(no clusters in the cached report)" |
| 223 | super().__init__(f"No cluster matched {target!r}. Available clusters: {candidates}") |
| 224 | |
| 225 | |
| 226 | def _drill_cluster_text(report: schema.Report, cluster: schema.Cluster) -> str: |
| 227 | candidates = {candidate.candidate_id: candidate for candidate in report.ranked_candidates} |
| 228 | parts = [cluster.title] |
| 229 | for candidate_id in cluster.candidate_ids: |
| 230 | candidate = candidates.get(candidate_id) |
| 231 | if candidate: |
| 232 | parts.extend((candidate.title, candidate.snippet)) |
| 233 | return " ".join(part for part in parts if part) |
| 234 | |
| 235 | |
| 236 | def resolve_drill_clusters(report: schema.Report, target: str) -> list[schema.Cluster]: |
| 237 | """Resolve a 1-based cluster index or fuzzy title/entity description.""" |
| 238 | cleaned = target.strip() |
| 239 | numeric = re.fullmatch(r"(?:cluster\s*)?#?(\d+)", cleaned, flags=re.IGNORECASE) |
| 240 | if numeric: |
| 241 | index = int(numeric.group(1)) |
| 242 | if 1 <= index <= len(report.clusters): |
| 243 | return [report.clusters[index - 1]] |
| 244 | raise DrillTargetError(target, report.clusters) |
| 245 | |
| 246 | target_entities = entity_extract.extract_text_entities(cleaned) |
| 247 | scored: list[tuple[float, schema.Cluster]] = [] |
| 248 | for cluster in report.clusters: |
| 249 | cluster_text = _drill_cluster_text(report, cluster) |
| 250 | title_score = relevance.token_overlap_relevance(cleaned, cluster.title) |
| 251 | body_score = relevance.token_overlap_relevance(cleaned, cluster_text) |
| 252 | entity_score = entity_extract.entity_overlap( |
| 253 | target_entities, |
| 254 | entity_extract.extract_text_entities(cluster_text), |
| 255 | ) |
| 256 | score = max(title_score, (0.75 * body_score) + (0.25 * entity_score)) |
| 257 | scored.append((score, cluster)) |
| 258 | |
| 259 | scored.sort(key=lambda entry: entry[0], reverse=True) |
| 260 | if not scored or scored[0][0] < 0.35: |
| 261 | raise DrillTargetError(target, report.clusters) |
| 262 | return [scored[0][1]] |
| 263 | |
| 264 | |
| 265 | def build_drill_plan( |
| 266 | report: schema.Report, |
| 267 | target: str, |
| 268 | *, |
| 269 | clusters: list[schema.Cluster] | None = None, |
| 270 | ) -> schema.QueryPlan: |
| 271 | """Build a deep follow-up plan limited to the matched clusters' sources.""" |
| 272 | matched = clusters or resolve_drill_clusters(report, target) |
| 273 | candidates = {candidate.candidate_id: candidate for candidate in report.ranked_candidates} |
| 274 | |
| 275 | sources: list[str] = [] |
| 276 | for cluster in matched: |
| 277 | for source in cluster.sources: |
| 278 | if source and source not in sources: |
| 279 | sources.append(source) |
| 280 | for candidate_id in cluster.candidate_ids: |
| 281 | candidate = candidates.get(candidate_id) |
| 282 | if not candidate: |
| 283 | continue |
| 284 | for source in schema.candidate_sources(candidate): |
| 285 | if source and source not in sources: |
| 286 | sources.append(source) |
| 287 | if not sources: |
| 288 | raise DrillTargetError(target, report.clusters) |
| 289 | |
| 290 | titles: list[str] = [] |
| 291 | entity_counts: Counter[str] = Counter() |
| 292 | for cluster in matched: |
| 293 | titles.append(cluster.title) |
| 294 | entity_counts.update(entity_extract.extract_text_entities(cluster.title)) |
| 295 | for candidate_id in cluster.representative_ids: |
| 296 | candidate = candidates.get(candidate_id) |
| 297 | if candidate: |
| 298 | titles.append(candidate.title) |
| 299 | entity_counts.update(entity_extract.extract_text_entities(candidate.title)) |
| 300 | |
| 301 | queries: list[str] = [] |
| 302 | for query_text in [ |
| 303 | " ".join(titles[: len(matched)]), |
| 304 | " ".join(entity for entity, _ in entity_counts.most_common(8)), |
| 305 | *titles[len(matched):], |
| 306 | ]: |
| 307 | query_text = " ".join(query_text.split()).strip() |
| 308 | if query_text and query_text.lower() not in {item.lower() for item in queries}: |
| 309 | queries.append(query_text) |
| 310 | if len(queries) == 3: |
| 311 | break |
| 312 | |
| 313 | subqueries = [ |
| 314 | schema.SubQuery( |
| 315 | label=f"drill-{index}", |
| 316 | search_query=search_query, |
| 317 | ranking_query=( |
| 318 | "What deeper evidence, firsthand discussion, comments, and transcripts " |
| 319 | f"explain {search_query}?" |
| 320 | ), |
| 321 | sources=list(sources), |
| 322 | weight=1.0 if index == 1 else 0.85, |
| 323 | ) |
| 324 | for index, search_query in enumerate(queries, start=1) |
| 325 | ] |
| 326 | return schema.QueryPlan( |
| 327 | intent=report.query_plan.intent, |
| 328 | freshness_mode=report.query_plan.freshness_mode, |
| 329 | cluster_mode=report.query_plan.cluster_mode, |
| 330 | raw_topic=report.topic, |
| 331 | subqueries=subqueries, |
| 332 | source_weights={ |
| 333 | source: report.query_plan.source_weights.get(source, 1.0) |
| 334 | for source in sources |
| 335 | }, |
| 336 | notes=[ |
| 337 | "drill-mode", |
| 338 | "drill-targets:" + ",".join(cluster.cluster_id for cluster in matched), |
| 339 | ], |
| 340 | ) |
| 341 | |
| 342 | def plan_query( |
| 343 | *, |
| 344 | topic: str, |
| 345 | available_sources: list[str], |
| 346 | requested_sources: list[str] | None, |
| 347 | depth: str, |
| 348 | provider: providers.ReasoningClient | None, |
| 349 | model: str | None, |
| 350 | context: str = "", |
| 351 | internal_subrun: bool = False, |
| 352 | ) -> schema.QueryPlan: |
| 353 | """Create a query plan. Comparison queries with extractable entities use a |
| 354 | deterministic plan; other intents prefer the configured reasoning provider. |
| 355 | |
| 356 | internal_subrun: when True, suppress the LAW 7 "No --plan passed" stderr |
| 357 | warning. LAW 7 targets the hosting-reasoning-model path; competitor |
| 358 | fan-out sub-runs are engine-internal and the warning is a false positive |
| 359 | there. Default False preserves the warning on every user-facing invocation. |
| 360 | """ |
| 361 | if _should_force_deterministic_plan(topic): |
| 362 | return _fallback_plan( |
| 363 | topic, |
| 364 | available_sources, |
| 365 | requested_sources, |
| 366 | depth, |
| 367 | note="deterministic-comparison-plan", |
| 368 | ) |
| 369 | prompt = _build_prompt(topic, available_sources, requested_sources, depth) |
| 370 | if context: |
| 371 | prompt += f"\n\nCurrent context (from web search): {context}" |
| 372 | if provider and model: |
| 373 | try: |
| 374 | raw = provider.generate_json(model, prompt) |
| 375 | plan = _sanitize_plan(raw, topic, available_sources, requested_sources, depth) |
| 376 | if plan.subqueries: |
| 377 | return plan |
| 378 | except (ValueError, KeyError, json.JSONDecodeError, OSError, http.HTTPError) as exc: |
| 379 | import sys |
| 380 | print(f"[Planner] LLM planning failed, using deterministic fallback: {type(exc).__name__}: {exc}", file=sys.stderr) |
| 381 | return _fallback_plan( |
| 382 | topic, available_sources, requested_sources, depth, |
| 383 | note=f"fallback-plan (LLM error: {type(exc).__name__})", |
| 384 | ) |
| 385 | # No --plan was passed and no engine-internal provider is configured. |
| 386 | # The deterministic fallback path produces weaker plans than either the |
| 387 | # --plan path or the engine-internal LLM planner path. Surface this so |
| 388 | # the HOSTING reasoning model (Claude Code, Codex, Hermes, Gemini, etc.) |
| 389 | # realizes it is supposed to generate the plan itself and pass --plan. |
| 390 | # The word "provider" in engine messages refers to the engine's INTERNAL |
| 391 | # planner credentials - NOT a prerequisite the caller needs. If you are |
| 392 | # the hosting reasoning model, YOU are the provider. LAW 7 / 2026-04-19 |
| 393 | # Hermes Agent Use Cases failure mode. |
| 394 | if not internal_subrun: |
| 395 | import sys |
| 396 | print( |
| 397 | "[Planner] No --plan passed. If you are the reasoning model hosting " |
| 398 | "this skill (Claude Code, Codex, Hermes, Gemini, or any agent runtime), " |
| 399 | "YOU ARE the planner: generate a JSON query plan yourself and pass it " |
| 400 | "via --plan. You do not need an API key or credentials; you ARE the " |
| 401 | "LLM. The deterministic fallback below is the headless/cron path only. " |
| 402 | "See LAW 7 in SKILL.md and Step 0.75 for the plan schema.", |
| 403 | file=sys.stderr, |
| 404 | ) |
| 405 | return _fallback_plan(topic, available_sources, requested_sources, depth) |
| 406 | |
| 407 | |
| 408 | def _build_prompt( |
| 409 | topic: str, |
| 410 | available_sources: list[str], |
| 411 | requested_sources: list[str] | None, |
| 412 | depth: str, |
| 413 | ) -> str: |
| 414 | requested = ", ".join(requested_sources or ["auto"]) |
| 415 | available = ", ".join(available_sources) |
| 416 | return f""" |
| 417 | You are the query planner for a live last-30-days research pipeline. |
| 418 | |
| 419 | Topic: {topic} |
| 420 | Depth: {depth} |
| 421 | Available sources: {available} |
| 422 | Requested sources: {requested} |
| 423 | |
| 424 | Return JSON only with this shape: |
| 425 | {{ |
| 426 | "intent": "factual|product|concept|opinion|how_to|comparison|breaking_news|prediction", |
| 427 | "freshness_mode": "strict_recent|balanced_recent|evergreen_ok", |
| 428 | "cluster_mode": "none|story|workflow|market|debate", |
| 429 | "source_weights": {{"source_name": 0.0}}, |
| 430 | "subqueries": [ |
| 431 | {{ |
| 432 | "label": "short label", |
| 433 | "search_query": "keyword style query for search APIs", |
| 434 | "ranking_query": "natural language rewrite for reranking", |
| 435 | "sources": ["reddit", "x", "grounding"], |
| 436 | "weight": 1.0 |
| 437 | }} |
| 438 | ], |
| 439 | "notes": ["optional short notes"] |
| 440 | }} |
| 441 | |
| 442 | Rules: |
| 443 | - emit 1 to 5 subqueries (how_to/opinion/product/breaking_news intents benefit from 4-5; factual/concept from 2) |
| 444 | - every subquery must include both search_query and ranking_query |
| 445 | - sources must be drawn from Available sources only |
| 446 | - use cluster_mode=none for factual or many how-to queries |
| 447 | - use strict_recent for breaking news and most predictions |
| 448 | - use debate for comparison/opinion, market for prediction, workflow for how_to, story for breaking_news |
| 449 | - search_query should be concise and keyword-heavy |
| 450 | - ranking_query should read like a natural-language question |
| 451 | - preserve exact proper nouns and entity strings from the topic |
| 452 | - NEVER include temporal phrases in search_query: no 'last 30 days', 'recent', month names, year numbers |
| 453 | - NEVER include meta-research phrases: no 'news', 'updates', 'public appearances', 'latest developments' |
| 454 | - INTENT-MODIFIER HANDLING: when the topic contains one of {{use cases, use case, workflows, workflow, examples, tutorial, tutorials, review, reviews, comparison, applications, in practice, production, production use, how i use}}, STRIP that phrase from every search_query (keep its meaning in ranking_query). Emit 4-5 paraphrased subqueries that each express the intent differently (e.g., 'production', 'workflow OR pipeline', 'review OR experience', 'vs COMPETITOR', 'community discussion'). Broad retrieval, narrow ranking. This was the 2026-04-19 Hermes Agent Use Cases failure mode: the planner echoed "hermes agent use cases" as a literal search string and returned near-zero results because nobody posts that exact phrase. |
| 455 | - DO NOT quote the user's full topic verbatim in search_query. Quote only multi-word proper nouns like "Hermes Agent", "Claude Code", "Nous Research". Bare keywords OR'd together retrieve more than exact-phrase searches. |
| 456 | - search_query should match how content is TITLED on platforms |
| 457 | - GitHub (Issues/PRs) is best for engineering, developer tools, and open source topics: 'kanye west bully' not 'kanye west album news March 2026' |
| 458 | """.strip() |
| 459 | |
| 460 | |
| 461 | def _sanitize_plan( |
| 462 | raw: dict, |
| 463 | topic: str, |
| 464 | available_sources: list[str], |
| 465 | requested_sources: list[str] | None, |
| 466 | depth: str, |
| 467 | ) -> schema.QueryPlan: |
| 468 | intent_hint = str(raw.get("intent") or _infer_intent(topic)).strip() |
| 469 | if intent_hint not in ALLOWED_INTENTS: |
| 470 | intent_hint = _infer_intent(topic) |
| 471 | requested = set(requested_sources or []) |
| 472 | available = set(available_sources) |
| 473 | eligible_sources = [ |
| 474 | source for source in available_sources |
| 475 | if (not requested or source in requested) |
| 476 | ] |
| 477 | source_weights = { |
| 478 | source: float(weight) |
| 479 | for source, weight in (raw.get("source_weights") or {}).items() |
| 480 | if source in available |
| 481 | } |
| 482 | if requested: |
| 483 | source_weights = {source: weight for source, weight in source_weights.items() if source in requested} |
| 484 | if not source_weights: |
| 485 | source_weights = _default_source_weights(_infer_intent(topic), eligible_sources) |
| 486 | # Ensure all eligible sources are available for subqueries. The LLM may |
| 487 | # assign high weights to its preferred sources, but omitted sources still |
| 488 | # participate with base weight so retrieval can overfetch and let fusion |
| 489 | # decide quality. |
| 490 | for source in eligible_sources: |
| 491 | source_weights.setdefault(source, 1.0) |
| 492 | if intent_hint in DEFAULT_INTENT_CAPABILITIES and depth != "quick": |
| 493 | for source in _default_sources_for_intent(intent_hint, eligible_sources): |
| 494 | source_weights.setdefault(source, 1.0) |
| 495 | source_weights = _normalize_weights(source_weights) |
| 496 | |
| 497 | subqueries: list[schema.SubQuery] = [] |
| 498 | for index, subquery in enumerate((raw.get("subqueries") or [])[:_max_subqueries(intent_hint, topic)], start=1): |
| 499 | if not isinstance(subquery, dict): |
| 500 | continue |
| 501 | sources = [source for source in subquery.get("sources") or [] if source in source_weights] |
| 502 | if requested: |
| 503 | sources = [source for source in sources if source in requested] |
| 504 | if not sources: |
| 505 | sources = list(source_weights) |
| 506 | search_query = str(subquery.get("search_query") or "").strip() |
| 507 | ranking_query = str(subquery.get("ranking_query") or "").strip() |
| 508 | if not search_query or not ranking_query: |
| 509 | continue |
| 510 | subqueries.append( |
| 511 | schema.SubQuery( |
| 512 | label=str(subquery.get("label") or f"q{index}").strip() or f"q{index}", |
| 513 | search_query=search_query, |
| 514 | ranking_query=ranking_query, |
| 515 | sources=sources, |
| 516 | weight=max(0.05, float(subquery.get("weight") or 1.0)), |
| 517 | ) |
| 518 | ) |
| 519 | if depth == "quick" and subqueries: |
| 520 | subqueries = subqueries[:1] |
| 521 | if not subqueries: |
| 522 | return _fallback_plan(topic, available_sources, requested_sources, depth) |
| 523 | |
| 524 | intent = intent_hint |
| 525 | freshness_mode = str(raw.get("freshness_mode") or _default_freshness(intent)).strip() |
| 526 | if intent == "how_to": |
| 527 | freshness_mode = "evergreen_ok" |
| 528 | cluster_mode = str(raw.get("cluster_mode") or _default_cluster_mode(intent)).strip() |
| 529 | if cluster_mode not in ALLOWED_CLUSTER_MODES: |
| 530 | cluster_mode = _default_cluster_mode(intent) |
| 531 | |
| 532 | return schema.QueryPlan( |
| 533 | intent=intent, |
| 534 | freshness_mode=freshness_mode, |
| 535 | cluster_mode=cluster_mode, |
| 536 | raw_topic=topic, |
| 537 | subqueries=_normalize_subquery_weights( |
| 538 | _trim_subqueries_for_depth( |
| 539 | subqueries, |
| 540 | intent, |
| 541 | depth, |
| 542 | eligible_sources, |
| 543 | requested_sources=requested_sources, |
| 544 | ) |
| 545 | ), |
| 546 | source_weights=source_weights, |
| 547 | notes=[str(note).strip() for note in raw.get("notes") or [] if str(note).strip()], |
| 548 | ) |
| 549 | |
| 550 | |
| 551 | def _normalize_subquery_weights(subqueries: list[schema.SubQuery]) -> list[schema.SubQuery]: |
| 552 | total = sum(subquery.weight for subquery in subqueries) or 1.0 |
| 553 | return [ |
| 554 | schema.SubQuery( |
| 555 | label=subquery.label, |
| 556 | search_query=subquery.search_query, |
| 557 | ranking_query=subquery.ranking_query, |
| 558 | sources=subquery.sources, |
| 559 | weight=subquery.weight / total, |
| 560 | ) |
| 561 | for subquery in subqueries |
| 562 | ] |
| 563 | |
| 564 | |
| 565 | def _normalize_weights(weights: dict[str, float]) -> dict[str, float]: |
| 566 | total = sum(max(weight, 0.0) for weight in weights.values()) or 1.0 |
| 567 | return { |
| 568 | source: max(weight, 0.0) / total |
| 569 | for source, weight in weights.items() |
| 570 | } |
| 571 | |
| 572 | |
| 573 | def _trim_subqueries_for_depth( |
| 574 | subqueries: list[schema.SubQuery], |
| 575 | intent: str, |
| 576 | depth: str, |
| 577 | available_sources: list[str], |
| 578 | requested_sources: list[str] | None = None, |
| 579 | ) -> list[schema.SubQuery]: |
| 580 | # At non-quick depth, expand sources: use capability routing for intents |
| 581 | # that define it, or all available sources otherwise. The LLM planner may |
| 582 | # assign narrow source lists; we override to let fusion decide quality. |
| 583 | if depth != "quick": |
| 584 | expanded_sources = _default_sources_for_intent(intent, available_sources) |
| 585 | return [ |
| 586 | schema.SubQuery( |
| 587 | label=subquery.label, |
| 588 | search_query=subquery.search_query, |
| 589 | ranking_query=subquery.ranking_query, |
| 590 | sources=expanded_sources, |
| 591 | weight=subquery.weight, |
| 592 | ) |
| 593 | for subquery in subqueries |
| 594 | ] |
| 595 | limits = SOURCE_LIMITS.get(depth) |
| 596 | if not limits: |
| 597 | return subqueries |
| 598 | priority_table = QUICK_SOURCE_PRIORITY |
| 599 | priority = priority_table.get(intent, priority_table["breaking_news"]) |
| 600 | limit = limits.get(intent, 3) |
| 601 | ranked_sources = [source for source in priority if source in available_sources] |
| 602 | if not ranked_sources: |
| 603 | ranked_sources = list(available_sources) |
| 604 | trimmed = [] |
| 605 | for subquery in subqueries: |
| 606 | # Quick depth only reaches this block. Honor the plan's explicit |
| 607 | # per-subquery sources: prefer priority-ranked plan sources first, then |
| 608 | # append any plan sources absent from the priority table (e.g. |
| 609 | # instagram). Explicit --search sources are user overrides, so they get |
| 610 | # first claim on the quick slots when present. The final list remains |
| 611 | # capped to the quick-depth limit. |
| 612 | plan_sources = [s for s in ranked_sources if s in subquery.sources] |
| 613 | for source in subquery.sources: |
| 614 | if source not in plan_sources: |
| 615 | plan_sources.append(source) |
| 616 | if not plan_sources: |
| 617 | plan_sources = ranked_sources[:limit] |
| 618 | preferred_sources: list[str] = [] |
| 619 | if requested_sources: |
| 620 | for source in requested_sources: |
| 621 | if ( |
| 622 | source in available_sources |
| 623 | and source in subquery.sources |
| 624 | and source not in preferred_sources |
| 625 | ): |
| 626 | preferred_sources.append(source) |
| 627 | if len(preferred_sources) >= limit: |
| 628 | break |
| 629 | for source in plan_sources: |
| 630 | if len(preferred_sources) >= limit: |
| 631 | break |
| 632 | if source not in preferred_sources: |
| 633 | preferred_sources.append(source) |
| 634 | trimmed.append( |
| 635 | schema.SubQuery( |
| 636 | label=subquery.label, |
| 637 | search_query=subquery.search_query, |
| 638 | ranking_query=subquery.ranking_query, |
| 639 | sources=preferred_sources, |
| 640 | weight=subquery.weight, |
| 641 | ) |
| 642 | ) |
| 643 | return trimmed |
| 644 | |
| 645 | |
| 646 | def _fallback_plan( |
| 647 | topic: str, |
| 648 | available_sources: list[str], |
| 649 | requested_sources: list[str] | None, |
| 650 | depth: str, |
| 651 | note: str = "fallback-plan", |
| 652 | ) -> schema.QueryPlan: |
| 653 | intent = _infer_intent(topic) |
| 654 | # Hebrew-language topics: elevate web search (grounding) to the front of |
| 655 | # the source list since Reddit/HN/GitHub are English-dominant platforms. |
| 656 | # Grounding covers Ynet, Walla, Mako, N12 etc. if a web search key is set. |
| 657 | if detect_language(topic) == 'he' and 'grounding' in available_sources: |
| 658 | ordered = ['grounding'] + [s for s in available_sources if s != 'grounding'] |
| 659 | available_sources = ordered |
| 660 | if requested_sources: |
| 661 | requested_sources = ['grounding'] + [s for s in requested_sources if s != 'grounding'] |
| 662 | allowed_sources = requested_sources or available_sources |
| 663 | source_weights = _default_source_weights(intent, allowed_sources) |
| 664 | core = query.extract_core_subject(topic, max_words=6, strip_suffixes=True) |
| 665 | base_search = _keyword_query(topic, core) |
| 666 | base_ranking = _ranking_query(topic, core) |
| 667 | |
| 668 | subqueries = [schema.SubQuery( |
| 669 | label="primary", |
| 670 | search_query=base_search, |
| 671 | ranking_query=base_ranking, |
| 672 | sources=list(source_weights), |
| 673 | weight=1.0, |
| 674 | )] |
| 675 | |
| 676 | if depth != "quick" and intent == "comparison": |
| 677 | entities = _comparison_entities(topic) |
| 678 | if entities: |
| 679 | for index, entity in enumerate(entities, start=1): |
| 680 | subqueries.append( |
| 681 | schema.SubQuery( |
| 682 | label=f"entity-{index}", |
| 683 | search_query=entity, |
| 684 | ranking_query=f"What recent evidence from the last 30 days is most relevant to {entity} in the comparison '{topic}'?", |
| 685 | sources=list(source_weights), |
| 686 | weight=0.65, |
| 687 | ) |
| 688 | ) |
| 689 | elif depth != "quick" and intent == "prediction": |
| 690 | subqueries.append( |
| 691 | schema.SubQuery( |
| 692 | label="odds", |
| 693 | search_query=f"{base_search} odds forecast", |
| 694 | ranking_query=f"What are the current odds, forecasts, or market signals about {topic}?", |
| 695 | sources=[source for source in source_weights if source in {"polymarket", "grounding", "x", "reddit"}] or list(source_weights), |
| 696 | weight=0.7, |
| 697 | ) |
| 698 | ) |
| 699 | elif depth != "quick" and intent == "breaking_news": |
| 700 | subqueries.append( |
| 701 | schema.SubQuery( |
| 702 | label="reaction", |
| 703 | search_query=f"{base_search} reaction update", |
| 704 | ranking_query=f"What new reactions or follow-up reporting from the last 30 days matter for {topic}?", |
| 705 | sources=[source for source in source_weights if source in {"x", "reddit", "grounding", "hackernews"}] or list(source_weights), |
| 706 | weight=0.7, |
| 707 | ) |
| 708 | ) |
| 709 | |
| 710 | # Intent-modifier fanout: when topic contains a phrase like "use cases", |
| 711 | # "workflows", "examples", "review" (see _INTENT_MODIFIER_PATTERNS), |
| 712 | # paraphrase the intent across 3 extra subqueries rather than echoing |
| 713 | # the literal phrase. Fixes 2026-04-19 Hermes Agent Use Cases failure. |
| 714 | # Excluded for comparison/prediction since those already have dedicated |
| 715 | # fanout (entity-per-subquery / odds). |
| 716 | if depth != "quick" and intent not in {"comparison", "prediction"} and _has_intent_modifier(topic): |
| 717 | subqueries.extend(_intent_modifier_subqueries(topic, core, base_search, source_weights)) |
| 718 | |
| 719 | return schema.QueryPlan( |
| 720 | intent=intent, |
| 721 | freshness_mode=_default_freshness(intent), |
| 722 | cluster_mode=_default_cluster_mode(intent), |
| 723 | raw_topic=topic, |
| 724 | subqueries=_normalize_subquery_weights( |
| 725 | _trim_subqueries_for_depth( |
| 726 | subqueries[:_max_subqueries(intent, topic)], |
| 727 | intent, |
| 728 | depth, |
| 729 | list(source_weights), |
| 730 | requested_sources=requested_sources, |
| 731 | ) |
| 732 | ), |
| 733 | source_weights=_normalize_weights(source_weights), |
| 734 | notes=[note], |
| 735 | ) |
| 736 | |
| 737 | |
| 738 | def _infer_intent(topic: str) -> str: |
| 739 | text = topic.lower().strip() |
| 740 | if re.search(r"\b(vs|versus|compare|compared to|difference between)\b", text): |
| 741 | return "comparison" |
| 742 | # Slash-separated proper nouns: "React/Vue/Svelte" (not URLs, not acronyms like CI/CD or I/O) |
| 743 | if not re.search(r"https?://", topic) and re.search(r"\b[A-Z][a-z]{2,}(?:/[A-Z][a-z]{2,})+\b", topic): |
| 744 | return "comparison" |
| 745 | if re.search(r"\b(odds|predict|prediction|forecast|chance|probability|will .* win)\b", text): |
| 746 | return "prediction" |
| 747 | if re.search(r"\b(how to|tutorial|guide|setup|step by step|deploy|install)\b", text): |
| 748 | return "how_to" |
| 749 | if re.search(r"\b(what is|what are|who is|who acquired|when did|parameter count|release date)\b", text): |
| 750 | return "factual" |
| 751 | if re.search(r"\b(thoughts on|worth it|should i|opinion|review)\b", text): |
| 752 | return "opinion" |
| 753 | if re.search(r"\b(latest|news|announced|just shipped|launched|released|update)\b", text): |
| 754 | return "breaking_news" |
| 755 | if re.search(r"\b(pricing|feature|features|best .* for|top .* for)\b", text): |
| 756 | return "product" |
| 757 | if re.search(r"\b(explain|concept|protocol|architecture|what does)\b", text): |
| 758 | return "concept" |
| 759 | if re.search(r"\b(tournament|championship|playoffs|march madness|world cup|olympics|super bowl|final four|ceremony|awards|keynote)\b", text): |
| 760 | return "breaking_news" |
| 761 | # Recency signals take priority when nothing more specific matched. |
| 762 | if re.search(r"\b(trending|this week|right now|today|this month)\b", text): |
| 763 | return "breaking_news" |
| 764 | # Default changed from "breaking_news" to "concept" on 2026-04-19 after |
| 765 | # the Hermes Agent Use Cases failure: unclassified topics were getting |
| 766 | # strict_recent freshness, which over-weighted the last 7 days and |
| 767 | # under-weighted older relevant material. "concept" defaults to |
| 768 | # evergreen_ok freshness, a safer posture for unknown topics. |
| 769 | return "concept" |
| 770 | |
| 771 | |
| 772 | def _default_freshness(intent: str) -> str: |
| 773 | if intent in {"breaking_news", "prediction"}: |
| 774 | return "strict_recent" |
| 775 | if intent in {"concept", "how_to"}: |
| 776 | return "evergreen_ok" |
| 777 | return "balanced_recent" |
| 778 | |
| 779 | |
| 780 | def _default_cluster_mode(intent: str) -> str: |
| 781 | return { |
| 782 | "breaking_news": "story", |
| 783 | "comparison": "debate", |
| 784 | "opinion": "debate", |
| 785 | "prediction": "market", |
| 786 | "how_to": "workflow", |
| 787 | "factual": "none", |
| 788 | "product": "none", |
| 789 | "concept": "none", |
| 790 | }.get(intent, "none") |
| 791 | |
| 792 | |
| 793 | def _default_source_weights(intent: str, sources: list[str]) -> dict[str, float]: |
| 794 | base = {source: 1.0 for source in sources} |
| 795 | if intent == "prediction": |
| 796 | for source, bonus in {"polymarket": 2.5, "x": 1.3}.items(): |
| 797 | if source in base: |
| 798 | base[source] += bonus |
| 799 | elif intent == "breaking_news": |
| 800 | for source, bonus in {"x": 1.5, "reddit": 1.3, "hackernews": 0.8}.items(): |
| 801 | if source in base: |
| 802 | base[source] += bonus |
| 803 | elif intent == "how_to": |
| 804 | for source, bonus in {"youtube": 2.0, "hackernews": 0.8}.items(): |
| 805 | if source in base: |
| 806 | base[source] += bonus |
| 807 | elif intent == "factual": |
| 808 | for source, bonus in {"reddit": 0.8, "x": 0.5}.items(): |
| 809 | if source in base: |
| 810 | base[source] += bonus |
| 811 | elif intent == "product": |
| 812 | for source, bonus in {"jobs": 0.8, "youtube": 0.5}.items(): |
| 813 | if source in base: |
| 814 | base[source] += bonus |
| 815 | return base |
| 816 | |
| 817 | |
| 818 | def _keyword_query(topic: str, core: str) -> str: |
| 819 | """Build a search_query string for the deterministic fallback. |
| 820 | |
| 821 | Quote ONLY title-cased multi-word proper nouns ("Hermes Agent", |
| 822 | "Claude Code", "Nous Research") so platform search engines preserve the |
| 823 | name as a phrase. Hyphenated compounds and lowercase terms are left as |
| 824 | bare keywords, which broadens retrieval instead of narrowing it. |
| 825 | |
| 826 | Prior behavior quoted the entire compound including the user's typed |
| 827 | topic, producing searches like `"Hermes Agent Actual Use Cases" hermes agent actual` |
| 828 | that returned near-zero matches on X and Reddit because nobody posts |
| 829 | that exact phrase. See 2026-04-19 Hermes Agent Use Cases failure. |
| 830 | """ |
| 831 | compounds = query.extract_compound_terms(topic) |
| 832 | # Only quote title-cased proper nouns (multi-word names). Hyphenated |
| 833 | # compounds go unquoted so platform tokenizers can split and match. |
| 834 | title_cased = [ |
| 835 | term for term in compounds |
| 836 | if re.match(r"^(?:[A-Z][a-z]+\s+){1,}[A-Z][a-z]+$", term) |
| 837 | ] |
| 838 | quoted = " ".join(f'"{term}"' for term in title_cased[:2]) |
| 839 | keywords = [quoted.strip(), core.strip() or topic.strip()] |
| 840 | return " ".join(part for part in keywords if part).strip() |
| 841 | |
| 842 | |
| 843 | def _ranking_query(topic: str, core: str) -> str: |
| 844 | if topic.strip().endswith("?"): |
| 845 | return topic.strip() |
| 846 | if core and core.lower() != topic.lower(): |
| 847 | return f"What recent evidence from the last 30 days is most relevant to {topic}, especially about {core}?" |
| 848 | return f"What recent evidence from the last 30 days is most relevant to {topic}?" |
| 849 | |
| 850 | |
| 851 | _TRAILING_CONTEXT = re.compile( |
| 852 | r"\s+\b(?:for|in|on|at|to|with|about|from|by|during|since|after|before|using|via)\b.*$", |
| 853 | re.I, |
| 854 | ) |
| 855 | |
| 856 | |
| 857 | def _comparison_entities(topic: str, *, uncapped: bool = False) -> list[str]: |
| 858 | """Split a comparison topic into entity names. |
| 859 | |
| 860 | Caps at ``competitors.COMPARISON_ENTITY_MAX`` unless ``uncapped`` (caller |
| 861 | truncates and may warn about dropped entities). |
| 862 | """ |
| 863 | # "difference between X and Y" -> "X vs Y" (replace "and" only in this context) |
| 864 | normalized = re.sub( |
| 865 | r"\bdifference between\s+(.+?)\s+and\s+", |
| 866 | r"\1 vs ", |
| 867 | topic, |
| 868 | flags=re.I, |
| 869 | ) |
| 870 | normalized = re.sub(r"\b(compared to)\b", " vs ", normalized, flags=re.I) |
| 871 | parts = [ |
| 872 | part.strip(" \t\r\n?.,:;!()[]{}\"'") |
| 873 | for part in re.split(r"\bvs\.?\b|\bversus\b|/", normalized, flags=re.I) |
| 874 | if part.strip(" \t\r\n?.,:;!()[]{}\"'") |
| 875 | ] |
| 876 | # Strip trailing context from parts ("Svelte for frontend in 2026" -> "Svelte") |
| 877 | if len(parts) < 2: |
| 878 | return [] |
| 879 | parts = [_TRAILING_CONTEXT.sub("", part).strip() or part for part in parts] |
| 880 | deduped: list[str] = [] |
| 881 | for part in parts: |
| 882 | if part and part not in deduped: |
| 883 | deduped.append(part) |
| 884 | if uncapped: |
| 885 | return deduped |
| 886 | return deduped[: competitors.COMPARISON_ENTITY_MAX] |
| 887 | |
| 888 | |
| 889 | def _should_force_deterministic_plan(topic: str) -> bool: |
| 890 | return _infer_intent(topic) == "comparison" and len(_comparison_entities(topic)) >= 2 |
| 891 | |
| 892 | |
| 893 | _INTENT_MODIFIER_PATTERNS = ( |
| 894 | "use cases", "use case", "workflows", "workflow", |
| 895 | "examples", "example", "tutorial", "tutorials", |
| 896 | "review", "reviews", "comparison", "applications", |
| 897 | "in practice", "production use", "production", |
| 898 | "how i use", |
| 899 | ) |
| 900 | |
| 901 | |
| 902 | def _has_intent_modifier(topic: str) -> bool: |
| 903 | """Return True if the topic contains an intent modifier phrase. |
| 904 | |
| 905 | See 2026-04-19 Hermes Agent Use Cases failure: a literal "Hermes Agent |
| 906 | use cases" search returns near-zero matches because nobody posts that |
| 907 | exact phrase. Intent modifiers should be stripped from search_query |
| 908 | and paraphrased across multiple subqueries. |
| 909 | """ |
| 910 | text = topic.lower() |
| 911 | return any(pattern in text for pattern in _INTENT_MODIFIER_PATTERNS) |
| 912 | |
| 913 | |
| 914 | def _intent_modifier_subqueries( |
| 915 | topic: str, |
| 916 | core: str, |
| 917 | base_search: str, |
| 918 | source_weights: dict[str, float], |
| 919 | ) -> list[schema.SubQuery]: |
| 920 | """Produce paraphrased subqueries for intent-modifier topics. |
| 921 | |
| 922 | The deterministic fallback used to echo the user's literal phrase |
| 923 | (e.g., "hermes agent use cases") into every search_query. This helper |
| 924 | fans out 3 extra subqueries that each express the intent differently |
| 925 | so retrieval pulls a broader corpus for reranking. |
| 926 | """ |
| 927 | entity = core or topic.strip() |
| 928 | sources = list(source_weights) |
| 929 | return [ |
| 930 | schema.SubQuery( |
| 931 | label="workflows", |
| 932 | search_query=f"{entity} workflow pipeline", |
| 933 | ranking_query=f"What real-world workflows or pipelines are people running with {entity}?", |
| 934 | sources=sources, |
| 935 | weight=0.6, |
| 936 | ), |
| 937 | schema.SubQuery( |
| 938 | label="production", |
| 939 | search_query=f"{entity} production real-world", |
| 940 | ranking_query=f"What production deployments or real-world use cases of {entity} are people describing?", |
| 941 | sources=sources, |
| 942 | weight=0.55, |
| 943 | ), |
| 944 | schema.SubQuery( |
| 945 | label="experience", |
| 946 | search_query=f"{entity} experience review", |
| 947 | ranking_query=f"What hands-on experience reports or reviews of {entity} exist in the last 30 days?", |
| 948 | sources=sources, |
| 949 | weight=0.5, |
| 950 | ), |
| 951 | ] |
| 952 | |
| 953 | |
| 954 | def _max_subqueries(intent: str, topic: str | None = None) -> int: |
| 955 | # how_to/opinion/product/breaking_news/prediction benefit from 4-5 |
| 956 | # paraphrased subqueries when the topic carries an intent modifier |
| 957 | # (use cases, workflows, examples, review, etc.). See 2026-04-19 |
| 958 | # Hermes Agent Use Cases failure: prior cap of 3 produced near-literal |
| 959 | # echoes of the topic instead of a paraphrase fanout. |
| 960 | if intent == "comparison": |
| 961 | # primary + one dedicated subquery per entity (up to COMPARISON_ENTITY_MAX) |
| 962 | return competitors.COMPARISON_ENTITY_MAX + 1 |
| 963 | # Intent-modifier topics get headroom for paraphrase fanout even when |
| 964 | # the intent itself is factual/concept. Without this, a "Hermes Agent |
| 965 | # use cases" query (classified "concept" after the 2026-04-19 default |
| 966 | # change) would be capped at 2 and drop the fanout. |
| 967 | if topic and _has_intent_modifier(topic): |
| 968 | return 5 |
| 969 | if intent in {"factual", "concept"}: |
| 970 | return 2 |
| 971 | return 5 |
| 972 | |
| 973 | |
| 974 | def _default_sources_for_intent(intent: str, available_sources: list[str]) -> list[str]: |
| 975 | if intent == "how_to": |
| 976 | sources = _how_to_sources(available_sources) |
| 977 | else: |
| 978 | target_capabilities = DEFAULT_INTENT_CAPABILITIES.get(intent) |
| 979 | if not target_capabilities: |
| 980 | sources = list(available_sources) |
| 981 | else: |
| 982 | matched = [ |
| 983 | source |
| 984 | for source in available_sources |
| 985 | if SOURCE_CAPABILITIES.get(source, set()) & target_capabilities |
| 986 | ] |
| 987 | sources = matched or list(available_sources) |
| 988 | excluded = INTENT_SOURCE_EXCLUSIONS.get(intent, set()) |
| 989 | if excluded: |
| 990 | filtered = [s for s in sources if s not in excluded] |
| 991 | return filtered or sources |
| 992 | return sources |
| 993 | |
| 994 | |
| 995 | def _how_to_sources(available_sources: list[str]) -> list[str]: |
| 996 | """Pick one source per role: web/reference, video (prefer longform), discussion.""" |
| 997 | selected: set[str] = set() |
| 998 | has_video = False |
| 999 | # Order matters: web first, then longform video, generic video, discussion. |
| 1000 | role_capabilities = [ |
| 1001 | {"web", "reference"}, |
| 1002 | {"video_longform"}, |
| 1003 | {"video"}, |
| 1004 | {"discussion"}, |
| 1005 | ] |
| 1006 | for role in role_capabilities: |
| 1007 | is_video_role = role & {"video", "video_longform"} |
| 1008 | if is_video_role and has_video: |
| 1009 | continue |
| 1010 | for source in available_sources: |
| 1011 | if source in selected: |
| 1012 | continue |
| 1013 | if SOURCE_CAPABILITIES.get(source, set()) & role: |
| 1014 | selected.add(source) |
| 1015 | if is_video_role: |
| 1016 | has_video = True |
| 1017 | break |
| 1018 | # After core role-based selection, include remaining sources with any |
| 1019 | # how_to-relevant capability (video, discussion, web, reference, link). |
| 1020 | how_to_caps = DEFAULT_INTENT_CAPABILITIES.get("how_to", set()) |
| 1021 | for source in available_sources: |
| 1022 | if source not in selected and SOURCE_CAPABILITIES.get(source, set()) & how_to_caps: |
| 1023 | selected.add(source) |
| 1024 | if not selected: |
| 1025 | return list(available_sources) |
| 1026 | return [source for source in available_sources if source in selected] |
| 1027 |