返回 last30days-skill
planner.py
根目录 / skills / last30days / scripts / lib / planner.py
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
1027 lines PYTHON