返回 last30days-skill
test_planner_v3.py
根目录 / tests / test_planner_v3.py
1 import unittest
2
3 from lib import planner
4
5
6 class PlannerV3Tests(unittest.TestCase):
7 def test_external_plan_rejects_valid_json_with_wrong_structure(self):
8 with self.assertRaisesRegex(ValueError, "intent"):
9 planner.validate_external_plan({"queries": {"web": ["Berlin"]}})
10
11 def test_external_plan_accepts_documented_shape_without_source_weights(self):
12 planner.validate_external_plan(
13 {
14 "intent": "breaking_news",
15 "freshness_mode": "strict_recent",
16 "cluster_mode": "story",
17 "subqueries": [
18 {
19 "label": "primary",
20 "search_query": "kanye west",
21 "ranking_query": "What happened with Kanye West?",
22 "sources": ["reddit", "x"],
23 "weight": 1.0,
24 }
25 ],
26 }
27 )
28
29 def test_external_plan_rejects_non_numeric_weight(self):
30 base_plan = {
31 "intent": "breaking_news",
32 "freshness_mode": "strict_recent",
33 "cluster_mode": "story",
34 "subqueries": [
35 {
36 "label": "primary",
37 "search_query": "kanye west",
38 "ranking_query": "What happened with Kanye West?",
39 "sources": ["reddit", "x"],
40 "weight": 1.0,
41 }
42 ],
43 }
44 for invalid_weight in ("heavy", True):
45 with self.subTest(subquery_weight=invalid_weight):
46 invalid_plan = dict(base_plan)
47 invalid_plan["subqueries"] = [
48 dict(base_plan["subqueries"][0], weight=invalid_weight)
49 ]
50 with self.assertRaisesRegex(ValueError, "weight"):
51 planner.validate_external_plan(invalid_plan)
52
53 invalid_source_weight = dict(base_plan, source_weights={"x": True})
54 with self.assertRaisesRegex(ValueError, "source_weights"):
55 planner.validate_external_plan(invalid_source_weight)
56
57 def test_default_how_to_expands_past_llm_narrow_source_weights(self):
58 raw = {
59 "intent": "how_to",
60 "freshness_mode": "balanced_recent",
61 "cluster_mode": "workflow",
62 "source_weights": {"hackernews": 0.7, "reddit": 0.3},
63 "subqueries": [
64 {
65 "label": "primary",
66 "search_query": "deploy app to Fly.io guide",
67 "ranking_query": "How do I deploy an app to Fly.io?",
68 "sources": ["hackernews"],
69 "weight": 1.0,
70 }
71 ],
72 }
73 plan = planner._sanitize_plan(
74 raw,
75 "how to deploy on Fly.io",
76 ["reddit", "x", "youtube", "hackernews"],
77 None,
78 "default",
79 )
80 sources = plan.subqueries[0].sources
81 # how_to capability routing selects video + discussion
82 self.assertIn("reddit", sources)
83 self.assertIn("youtube", sources)
84 self.assertIn("reddit", plan.source_weights)
85 self.assertIn("youtube", plan.source_weights)
86 self.assertEqual("evergreen_ok", plan.freshness_mode)
87
88 def test_comparison_uses_deterministic_plan_and_preserves_entities(self):
89 plan = planner.plan_query(
90 topic="openclaw vs nanoclaw vs ironclaw",
91 available_sources=["reddit", "x", "youtube", "hackernews", "polymarket"],
92 requested_sources=None,
93 depth="default",
94 provider=object(),
95 model="ignored",
96 )
97 self.assertEqual("comparison", plan.intent)
98 self.assertEqual(["deterministic-comparison-plan"], plan.notes)
99 self.assertEqual(4, len(plan.subqueries))
100 joined_queries = "\n".join(subquery.search_query for subquery in plan.subqueries).lower()
101 self.assertIn("openclaw", joined_queries)
102 self.assertIn("nanoclaw", joined_queries)
103 self.assertIn("ironclaw", joined_queries)
104
105 def test_fallback_plan_emits_dual_query_fields(self):
106 plan = planner.plan_query(
107 topic="codex vs claude code",
108 available_sources=["reddit", "x"],
109 requested_sources=None,
110 depth="default",
111 provider=None,
112 model=None,
113 )
114 self.assertEqual("comparison", plan.intent)
115 self.assertGreaterEqual(len(plan.subqueries), 2)
116 for subquery in plan.subqueries:
117 self.assertTrue(subquery.search_query)
118 self.assertTrue(subquery.ranking_query)
119
120 def test_factual_topic_uses_no_cluster_mode(self):
121 plan = planner.plan_query(
122 topic="what is the parameter count of claude code",
123 available_sources=["reddit", "hackernews"],
124 requested_sources=None,
125 depth="default",
126 provider=None,
127 model=None,
128 )
129 self.assertEqual("factual", plan.intent)
130 self.assertEqual("none", plan.cluster_mode)
131
132 def test_quick_mode_collapses_fallback_to_single_subquery(self):
133 plan = planner.plan_query(
134 topic="codex vs claude code",
135 available_sources=["reddit", "x"],
136 requested_sources=None,
137 depth="quick",
138 provider=None,
139 model=None,
140 )
141 self.assertEqual("comparison", plan.intent)
142 self.assertEqual(1, len(plan.subqueries))
143 self.assertEqual(["reddit", "x"], plan.subqueries[0].sources)
144
145 def test_quick_mode_prioritizes_explicit_requested_sources_within_cap(self):
146 raw = {
147 "intent": "product",
148 "freshness_mode": "balanced_recent",
149 "cluster_mode": "debate",
150 "subqueries": [
151 {
152 "label": "primary",
153 "search_query": "AI coding agents",
154 "ranking_query": "What are people saying about AI coding agents?",
155 "sources": ["reddit", "youtube", "grounding", "digg"],
156 "weight": 1.0,
157 }
158 ],
159 }
160 plan = planner._sanitize_plan(
161 raw,
162 "AI coding agents",
163 ["reddit", "youtube", "grounding", "digg"],
164 ["digg", "reddit", "youtube", "grounding"],
165 "quick",
166 )
167 self.assertIn("digg", plan.subqueries[0].sources)
168 self.assertLessEqual(len(plan.subqueries[0].sources), 2)
169
170 def test_quick_mode_preserves_explicit_requested_sources_in_fallback_plan(self):
171 plan = planner.plan_query(
172 topic="AI coding agents",
173 available_sources=["reddit", "youtube", "github"],
174 requested_sources=["reddit", "github"],
175 depth="quick",
176 provider=None,
177 model=None,
178 )
179 self.assertIn("github", plan.subqueries[0].sources)
180
181 def test_default_comparison_uses_all_capable_sources(self):
182 plan = planner.plan_query(
183 topic="codex vs claude code",
184 available_sources=["reddit", "x", "youtube", "hackernews", "polymarket"],
185 requested_sources=None,
186 depth="default",
187 provider=None,
188 model=None,
189 )
190 self.assertEqual("comparison", plan.intent)
191 for subquery in plan.subqueries:
192 # Default depth should not artificially cap sources
193 self.assertGreaterEqual(len(subquery.sources), 4)
194
195 def test_default_how_to_keeps_youtube_in_source_mix(self):
196 plan = planner.plan_query(
197 topic="how to deploy remotion animations for claude code",
198 available_sources=["reddit", "x", "youtube", "hackernews"],
199 requested_sources=None,
200 depth="default",
201 provider=None,
202 model=None,
203 )
204 self.assertEqual("how_to", plan.intent)
205 sources = plan.subqueries[0].sources
206 self.assertIn("youtube", sources)
207 self.assertIn("reddit", sources)
208
209 def test_how_to_sources_includes_capability_matched_extras(self):
210 """how_to routing should include additional sources beyond the core ones."""
211 plan = planner.plan_query(
212 topic="how to deploy on Fly.io",
213 available_sources=["reddit", "tiktok", "instagram", "youtube", "hackernews"],
214 requested_sources=None,
215 depth="default",
216 provider=None,
217 model=None,
218 )
219 self.assertEqual("how_to", plan.intent)
220 sources = plan.subqueries[0].sources
221 self.assertIn("youtube", sources)
222 self.assertIn("reddit", sources)
223 # Additional capability-matched sources should also be included
224 self.assertGreater(len(sources), 2,
225 f"how_to should include >2 sources, got {len(sources)}: {sources}")
226
227 def test_ncaa_tournament_is_breaking_news(self):
228 intent = planner._infer_intent("NCAA tournament brackets")
229 self.assertEqual("breaking_news", intent)
230
231 def test_march_madness_is_breaking_news(self):
232 intent = planner._infer_intent("2026 March Madness")
233 self.assertEqual("breaking_news", intent)
234
235 def test_factual_plan_has_at_most_2_subqueries(self):
236 plan = planner.plan_query(
237 topic="who acquired Wiz",
238 available_sources=["reddit", "x", "hackernews"],
239 requested_sources=None,
240 depth="default",
241 provider=None,
242 model=None,
243 )
244 self.assertEqual("factual", plan.intent)
245 self.assertLessEqual(len(plan.subqueries), 2)
246
247 def test_default_how_to_prefers_longform_video_over_shortform(self):
248 plan = planner.plan_query(
249 topic="how to deploy on Fly.io",
250 available_sources=["reddit", "tiktok", "instagram", "youtube", "hackernews"],
251 requested_sources=None,
252 depth="default",
253 provider=None,
254 model=None,
255 )
256 self.assertEqual("how_to", plan.intent)
257 sources = plan.subqueries[0].sources
258 # how_to routing should include youtube (longform) over tiktok/instagram
259 self.assertIn("youtube", sources)
260 self.assertIn("reddit", sources)
261
262 def test_product_plan_can_include_jobs_source(self):
263 plan = planner.plan_query(
264 topic="Listen Labs features",
265 available_sources=["reddit", "youtube", "jobs", "hackernews"],
266 requested_sources=None,
267 depth="default",
268 provider=None,
269 model=None,
270 )
271 self.assertEqual("product", plan.intent)
272 self.assertIn("jobs", plan.subqueries[0].sources)
273 self.assertGreater(plan.source_weights["jobs"], plan.source_weights["youtube"])
274
275 def test_prediction_includes_tiktok_and_instagram(self):
276 """TikTok and Instagram are no longer excluded from prediction intent."""
277 plan = planner.plan_query(
278 topic="odds of US recession 2026",
279 available_sources=["reddit", "x", "tiktok", "instagram", "youtube", "hackernews", "polymarket"],
280 requested_sources=None,
281 depth="default",
282 provider=None,
283 model=None,
284 )
285 self.assertEqual("prediction", plan.intent)
286 all_sources = set()
287 for subquery in plan.subqueries:
288 all_sources.update(subquery.sources)
289 self.assertIn("tiktok", all_sources)
290 self.assertIn("instagram", all_sources)
291
292 def test_opinion_includes_tiktok_and_instagram(self):
293 """TikTok and Instagram are no longer excluded from opinion intent."""
294 plan = planner.plan_query(
295 topic="thoughts on OpenAI Codex pricing",
296 available_sources=["reddit", "x", "tiktok", "instagram", "youtube", "hackernews"],
297 requested_sources=None,
298 depth="default",
299 provider=None,
300 model=None,
301 )
302 self.assertEqual("opinion", plan.intent)
303 all_sources = set()
304 for subquery in plan.subqueries:
305 all_sources.update(subquery.sources)
306 self.assertIn("tiktok", all_sources)
307 self.assertIn("instagram", all_sources)
308
309 def test_comparison_includes_polymarket(self):
310 """Polymarket should not be excluded from comparison intent plans."""
311 plan = planner.plan_query(
312 topic="Sam Altman vs Dario Amodei",
313 available_sources=["reddit", "x", "youtube", "hackernews", "polymarket"],
314 requested_sources=None,
315 depth="default",
316 provider=None,
317 model=None,
318 )
319 self.assertEqual("comparison", plan.intent)
320 all_sources = set()
321 for subquery in plan.subqueries:
322 all_sources.update(subquery.sources)
323 self.assertIn("polymarket", all_sources)
324
325 def test_polymarket_excluded_from_how_to_and_concept(self):
326 """Polymarket should remain excluded from how_to and concept intents."""
327 for topic, expected_intent in [
328 ("how to deploy on Fly.io", "how_to"),
329 ("explain transformer architecture", "concept"),
330 ]:
331 plan = planner.plan_query(
332 topic=topic,
333 available_sources=["reddit", "x", "youtube", "hackernews", "polymarket"],
334 requested_sources=None,
335 depth="default",
336 provider=None,
337 model=None,
338 )
339 self.assertEqual(expected_intent, plan.intent)
340 all_sources = set()
341 for subquery in plan.subqueries:
342 all_sources.update(subquery.sources)
343 self.assertNotIn("polymarket", all_sources,
344 f"polymarket should be excluded from {expected_intent}")
345
346 def test_opinion_includes_polymarket(self):
347 """Polymarket should not be excluded from opinion intent plans."""
348 plan = planner.plan_query(
349 topic="thoughts on OpenAI future",
350 available_sources=["reddit", "x", "youtube", "hackernews", "polymarket"],
351 requested_sources=None,
352 depth="default",
353 provider=None,
354 model=None,
355 )
356 self.assertEqual("opinion", plan.intent)
357 all_sources = set()
358 for subquery in plan.subqueries:
359 all_sources.update(subquery.sources)
360 self.assertIn("polymarket", all_sources)
361
362 def test_breaking_news_includes_tiktok_and_instagram(self):
363 plan = planner.plan_query(
364 topic="2026 March Madness",
365 available_sources=["reddit", "x", "tiktok", "instagram", "youtube", "hackernews"],
366 requested_sources=None,
367 depth="default",
368 provider=None,
369 model=None,
370 )
371 self.assertEqual("breaking_news", plan.intent)
372 all_sources = set()
373 for subquery in plan.subqueries:
374 all_sources.update(subquery.sources)
375 self.assertIn("tiktok", all_sources)
376 self.assertIn("instagram", all_sources)
377
378
379 class IntentModifierBreadthTests(unittest.TestCase):
380 """Unit 2: Topics with intent modifiers (use cases, workflows, examples,
381 review, comparison) must fan out across paraphrased subqueries rather
382 than echo the literal phrase. 2026-04-19 Hermes Agent Use Cases failure.
383 """
384
385 def test_max_subqueries_raised_to_5_for_how_to(self):
386 self.assertEqual(5, planner._max_subqueries("how_to"))
387
388 def test_max_subqueries_raised_to_5_for_opinion(self):
389 self.assertEqual(5, planner._max_subqueries("opinion"))
390
391 def test_max_subqueries_raised_to_5_for_product(self):
392 self.assertEqual(5, planner._max_subqueries("product"))
393
394 def test_max_subqueries_unchanged_for_comparison(self):
395 from lib import competitors
396 self.assertEqual(
397 competitors.COMPARISON_ENTITY_MAX + 1,
398 planner._max_subqueries("comparison"),
399 )
400
401 def test_max_subqueries_unchanged_for_factual_and_concept(self):
402 self.assertEqual(2, planner._max_subqueries("factual"))
403 self.assertEqual(2, planner._max_subqueries("concept"))
404
405 def test_has_intent_modifier_detects_use_cases(self):
406 self.assertTrue(planner._has_intent_modifier("Hermes Agent use cases"))
407 self.assertTrue(planner._has_intent_modifier("Hermes Agent Actual Use Cases"))
408
409 def test_has_intent_modifier_detects_workflows(self):
410 self.assertTrue(planner._has_intent_modifier("Claude Code workflows"))
411
412 def test_has_intent_modifier_detects_review_and_tutorial(self):
413 self.assertTrue(planner._has_intent_modifier("Ollama review"))
414 self.assertTrue(planner._has_intent_modifier("DSPy tutorial"))
415
416 def test_has_intent_modifier_false_for_bare_entity(self):
417 self.assertFalse(planner._has_intent_modifier("Kanye West"))
418 self.assertFalse(planner._has_intent_modifier("hermes agent"))
419
420 def test_fallback_fans_out_when_intent_modifier_present(self):
421 plan = planner.plan_query(
422 topic="Hermes Agent use cases",
423 available_sources=["reddit", "x", "youtube", "hackernews"],
424 requested_sources=None,
425 depth="default",
426 provider=None,
427 model=None,
428 )
429 # Expect at least 3 subqueries total (primary + fanout); cap is 5 for
430 # how_to/opinion/product/breaking_news. Label set should include at
431 # least one of the paraphrase labels.
432 labels = {sq.label for sq in plan.subqueries}
433 self.assertGreaterEqual(len(plan.subqueries), 3)
434 self.assertTrue(
435 labels & {"workflows", "production", "experience"},
436 f"Expected paraphrase labels in {labels}",
437 )
438
439 def test_fallback_does_not_fan_out_for_bare_entity(self):
440 plan = planner.plan_query(
441 topic="Kanye West",
442 available_sources=["reddit", "x", "grounding"],
443 requested_sources=None,
444 depth="default",
445 provider=None,
446 model=None,
447 )
448 # Bare entity without intent modifier should not trigger the paraphrase
449 # fanout (those labels are not in the plan).
450 labels = {sq.label for sq in plan.subqueries}
451 self.assertFalse(labels & {"workflows", "production", "experience"})
452
453 def test_prompt_includes_intent_modifier_rule(self):
454 prompt = planner._build_prompt(
455 topic="Hermes Agent use cases",
456 available_sources=["reddit", "x", "youtube"],
457 requested_sources=None,
458 depth="default",
459 )
460 self.assertIn("INTENT-MODIFIER HANDLING", prompt)
461 self.assertIn("use cases", prompt)
462 self.assertIn("STRIP that phrase", prompt)
463
464
465 class FallbackDefaultsTests(unittest.TestCase):
466 """Unit 3: Deterministic fallback defaults and keyword_query quoting.
467 2026-04-19 Hermes Agent Use Cases failure.
468 """
469
470 def test_unclassified_topic_defaults_to_concept_not_breaking_news(self):
471 # Prior default was "breaking_news" with strict_recent freshness,
472 # which biased against older relevant material on unfamiliar topics.
473 self.assertEqual("concept", planner._infer_intent("some unfamiliar topic"))
474 self.assertEqual("concept", planner._infer_intent("Hermes Agent"))
475
476 def test_recency_signals_still_break_out_to_breaking_news(self):
477 self.assertEqual("breaking_news", planner._infer_intent("trending AI tools"))
478 self.assertEqual("breaking_news", planner._infer_intent("what's happening today"))
479 self.assertEqual("breaking_news", planner._infer_intent("this week in AI"))
480
481 def test_specific_intents_still_classify_correctly(self):
482 # Regression: other regex branches still fire as before.
483 self.assertEqual("how_to", planner._infer_intent("how to deploy Docker"))
484 self.assertEqual("factual", planner._infer_intent("who acquired Wiz"))
485 self.assertEqual("opinion", planner._infer_intent("thoughts on OpenAI Codex"))
486 self.assertEqual("comparison", planner._infer_intent("Codex vs Claude Code"))
487
488 def test_keyword_query_quotes_only_title_cased_proper_nouns(self):
489 # "Hermes Agent" is a multi-word title-cased proper noun — keep quoted.
490 # "Use Cases" is also title-cased BUT we only quote the first 2
491 # title-cased compounds; the first extracted is "Hermes Agent".
492 search = planner._keyword_query("Hermes Agent use cases", "hermes agent")
493 self.assertIn('"Hermes Agent"', search)
494 # The old behavior quoted the entire typed topic; confirm it does not.
495 self.assertNotIn('"Hermes Agent Actual Use Cases"', search)
496
497 def test_keyword_query_does_not_quote_bare_lowercase_topic(self):
498 search = planner._keyword_query("kanye west bully", "kanye west bully")
499 # Lowercase topics have no title-cased compound to quote.
500 self.assertNotIn('"', search)
501
502 def test_fallback_logs_warning_when_no_provider(self):
503 import io
504 import contextlib
505 buf = io.StringIO()
506 with contextlib.redirect_stderr(buf):
507 planner.plan_query(
508 topic="Hermes Agent use cases",
509 available_sources=["reddit", "x"],
510 requested_sources=None,
511 depth="default",
512 provider=None,
513 model=None,
514 )
515 output = buf.getvalue()
516 # New language: "No --plan passed" + "YOU ARE the planner" +
517 # runtime enumeration. Unit 4 (2026-04-19) rewrite to stop the
518 # "no provider = no LLM = I need a key" misread.
519 self.assertIn("No --plan passed", output)
520 self.assertIn("YOU ARE the planner", output)
521 self.assertIn("you ARE the LLM", output)
522 # Runtime-agnostic: each supported runtime name should appear.
523 for runtime_name in ("Claude Code", "Codex", "Hermes", "Gemini"):
524 self.assertIn(runtime_name, output)
525 # The old misleading phrasing must NOT appear.
526 self.assertNotIn("No --plan and no LLM provider configured", output)
527
528 def test_fallback_does_not_log_new_warning_when_provider_present(self):
529 # When a provider is configured, the provider path runs; if it
530 # errors, we get the "LLM planning failed" message, NOT the
531 # "No --plan passed" guidance (which is specifically for the
532 # no-provider-no-plan caller path).
533 import io
534 import contextlib
535 buf = io.StringIO()
536
537 class _NoopProvider:
538 def generate_json(self, model, prompt):
539 raise ValueError("force fallback for test")
540
541 with contextlib.redirect_stderr(buf):
542 planner.plan_query(
543 topic="Kanye West",
544 available_sources=["reddit", "x"],
545 requested_sources=None,
546 depth="default",
547 provider=_NoopProvider(),
548 model="some-model",
549 )
550 output = buf.getvalue()
551 self.assertIn("LLM planning failed", output)
552 self.assertNotIn("No --plan passed", output)
553
554 if __name__ == "__main__":
555 unittest.main()
556
556 lines PYTHON