feat: vs mode N full passes + --competitors auto-discovery + (/Last30Days) title (#312)

* feat: vs mode runs N full passes; --competitors wraps vs with auto-discovery

Unifies vs-mode and --competitors onto one fanout architecture. A topic
containing "vs" / "versus" now runs N full pipeline.run() calls in parallel
(reverting the one-pass latency optimization that removed per-entity
depth); --competitors becomes a SKILL.md-level shortcut where the hosting
reasoning model (Claude Code, Codex, Hermes, Gemini) discovers N peers via
its own WebSearch, runs Step 0.55 per entity, and invokes the engine with
a vs-topic + --competitors-plan JSON.

Changed:
- vs-mode: N full passes in parallel via fanout (was 1 merged pass).
- --competitors: SKILL.md shortcut for vs-mode-with-discovery. Engine flag
  kept for headless/cron use. LAW 7-style stderr reframed to lead with the
  hosting-model path (use WebSearch + --competitors-plan) instead of
  BRAVE_API_KEY. Footer BRAVE/SERPER nudge suppressed when --plan or
  --competitors-plan present (hosting model already has WebSearch).

Added:
- --competitors-plan JSON flag: per-entity {x_handle, x_related, subreddits,
  github_user, github_repos, context}. Accepts inline JSON or file path.
  subrun_kwargs_for helper is the single source of truth for per-entity
  kwargs — no closure-default fallthrough from main scope.
- Per-entity save files: each entity's sub-run produces its own
  {slug}-raw.md with a single-row Resolved Entities block.
- --polymarket-keywords filter for ambiguous single-token topics.

Fixed:
- test_competitor_subrun_isolation regression suite locks in 3.0.12's
  no-leak invariant (main flags do not inherit into peer sub-runs).
- Updates test_regression.py for the new comparison-mode payload shape.

Bumps plugin.json to 3.0.13. 1,219 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: comparison title attribution — (Last 30 Days) → (/Last30Days)

User feedback on 3.0.13 dogfood runs (Kanye vs Drake, Mercer Island,
Figma): the comparison-mode synthesis title should attribute to the
slash command rather than restate the date range.

Three SKILL.md occurrences updated. Pure documentation change. Bumps to
3.0.14.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Van Horn
2026-04-22 21:31:00 -07:00
committed by GitHub
parent 00d01933e0
commit 949bcf8942
18 changed files with 2022 additions and 71 deletions
+196
View File
@@ -0,0 +1,196 @@
# ruff: noqa: E402
"""Regression tests: main-topic flags must not leak into competitor sub-runs.
Based on 2026-04-22 Kanye West --competitors receipt where Drake and
Kendrick Lamar sub-runs logged Kanye's resolved subreddit list as their own
targeted search. Per-entity sub-runs must never inherit main-topic targeting
via closure capture, config mutation, or any other path.
"""
from __future__ import annotations
import io
import sys
import unittest
from contextlib import redirect_stderr
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
def _fake_report(topic: str):
class _R:
pass
r = _R()
r.topic = topic
r.artifacts = {}
return r
class SubRunIsolationTests(unittest.TestCase):
"""Exercise the _competitor_runner closure pattern from main() directly.
Builds the same closure shape main() uses, then invokes it with
captured-in-scope main-topic flags to verify they do NOT leak into
sub-run pipeline.run kwargs.
"""
def _run_closure(self, main_flags, competitors, config=None, mock_flag=False):
"""Replicate _competitor_runner closure from last30days.py main().
main_flags: dict of {x_handle, x_related, subreddits, tiktok_hashtags,
tiktok_creators, ig_creators, github_user, github_repos}
as they would exist in outer scope after argparse.
competitors: list of entity names to run.
Returns the list of kwargs dicts pipeline.run was called with.
"""
from lib import pipeline, resolve as resolve_mod
captured: list[dict] = []
def fake_run(**kwargs):
captured.append(kwargs)
return _fake_report(kwargs["topic"])
# Simulate main scope variables
outer_subreddits = main_flags.get("subreddits")
outer_x_handle = main_flags.get("x_handle")
outer_x_related = main_flags.get("x_related")
outer_tiktok_hashtags = main_flags.get("tiktok_hashtags")
outer_tiktok_creators = main_flags.get("tiktok_creators")
outer_ig_creators = main_flags.get("ig_creators")
outer_github_user = main_flags.get("github_user")
outer_github_repos = main_flags.get("github_repos")
class _Args:
pass
args = _Args()
args.mock = mock_flag
args.web_backend = "auto"
args.lookback_days = 30
cfg = config or {}
# This mirrors the real _competitor_runner closure structure.
def competitor_runner(entity):
entity_config = dict(cfg)
resolved = {
"entity": entity,
"x_handle": "",
"subreddits": [],
"github_user": "",
"github_repos": [],
"context": "",
}
if not args.mock and resolve_mod._has_backend(entity_config):
try:
r = resolve_mod.auto_resolve(entity, entity_config)
except Exception:
r = {}
resolved["x_handle"] = r.get("x_handle", "") or ""
resolved["subreddits"] = list(r.get("subreddits") or [])
resolved["github_user"] = r.get("github_user", "") or ""
resolved["github_repos"] = list(r.get("github_repos") or [])
resolved["context"] = r.get("context", "") or ""
if resolved["context"]:
entity_config["_auto_resolve_context"] = resolved["context"]
pipeline.run(
topic=entity,
config=entity_config,
depth="default",
requested_sources=None,
mock=args.mock,
x_handle=resolved["x_handle"] or None,
subreddits=resolved["subreddits"] or None,
github_user=resolved["github_user"] or None,
github_repos=resolved["github_repos"] or None,
web_backend=args.web_backend,
lookback_days=args.lookback_days,
internal_subrun=True,
)
with mock.patch.object(pipeline, "run", side_effect=fake_run):
for entity in competitors:
competitor_runner(entity)
return captured
def test_main_subreddits_do_not_leak_to_peers(self):
"""Kanye receipt: main --subreddits=Kanye,hiphopheads leaked to Drake/Kendrick."""
main_flags = {
"subreddits": ["Kanye", "hiphopheads", "Music", "popheads", "kanyewest"],
"x_handle": "kanyewest",
}
captured = self._run_closure(main_flags, ["Drake", "Kendrick Lamar"])
self.assertEqual(len(captured), 2)
for kwargs in captured:
self.assertIsNone(
kwargs["subreddits"],
f"Main subreddits leaked into {kwargs['topic']!r}'s sub-run: "
f"{kwargs['subreddits']}",
)
def test_main_x_handle_does_not_leak(self):
main_flags = {"x_handle": "kanyewest"}
captured = self._run_closure(main_flags, ["Drake"])
self.assertIsNone(captured[0]["x_handle"])
def test_main_github_does_not_leak(self):
main_flags = {
"github_user": "someuser",
"github_repos": ["someuser/someproject"],
}
captured = self._run_closure(main_flags, ["Drake"])
self.assertIsNone(captured[0]["github_user"])
self.assertIsNone(captured[0]["github_repos"])
def test_auto_resolve_context_does_not_leak_across_peers(self):
"""Per-entity auto_resolve context must not bleed between sub-runs."""
from lib import resolve as resolve_mod
def fake_resolve(entity, _cfg):
per_topic = {
"Drake": {"x_handle": "Drake", "subreddits": [], "github_user": "",
"github_repos": [], "context": "Drake ICEMAN rollout",
"category": None, "searches_run": 4},
"Kendrick Lamar": {"x_handle": "kendricklamar", "subreddits": [],
"github_user": "", "github_repos": [],
"context": "Meet The Grahams revival",
"category": None, "searches_run": 4},
}
return per_topic.get(entity, {})
with mock.patch.object(resolve_mod, "auto_resolve", side_effect=fake_resolve), \
mock.patch.object(resolve_mod, "_has_backend", return_value=True):
captured = self._run_closure(
main_flags={},
competitors=["Drake", "Kendrick Lamar"],
config={"BRAVE_API_KEY": "test"},
)
by_topic = {kw["topic"]: kw for kw in captured}
# Each sub-run's config got its own context string.
self.assertEqual(
by_topic["Drake"]["config"].get("_auto_resolve_context"),
"Drake ICEMAN rollout",
)
self.assertEqual(
by_topic["Kendrick Lamar"]["config"].get("_auto_resolve_context"),
"Meet The Grahams revival",
)
# Cross-entity check: neither config contains the other's context.
self.assertNotIn(
"Meet The Grahams",
by_topic["Drake"]["config"].get("_auto_resolve_context", ""),
)
self.assertNotIn(
"ICEMAN",
by_topic["Kendrick Lamar"]["config"].get("_auto_resolve_context", ""),
)
if __name__ == "__main__":
unittest.main()
+180
View File
@@ -0,0 +1,180 @@
# ruff: noqa: E402
"""Tests for --competitors-plan JSON parsing and per-entity kwargs threading."""
from __future__ import annotations
import io
import json
import sys
import tempfile
import unittest
from contextlib import redirect_stderr
from pathlib import Path
from unittest import mock
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
import last30days as cli
class ParseCompetitorsPlanTests(unittest.TestCase):
def test_none_returns_empty(self):
self.assertEqual(cli.parse_competitors_plan(None), {})
def test_empty_string_returns_empty(self):
self.assertEqual(cli.parse_competitors_plan(""), {})
def test_inline_json_parsed(self):
raw = '{"Drake": {"x_handle": "Drake", "subreddits": ["Drizzy"]}}'
out = cli.parse_competitors_plan(raw)
self.assertIn("drake", out)
self.assertEqual(out["drake"]["x_handle"], "Drake")
self.assertEqual(out["drake"]["subreddits"], ["Drizzy"])
def test_file_path_accepted(self):
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False,
) as f:
json.dump(
{"Anthropic": {"x_handle": "AnthropicAI", "github_user": "anthropics"}},
f,
)
path = f.name
try:
out = cli.parse_competitors_plan(path)
self.assertEqual(out["anthropic"]["x_handle"], "AnthropicAI")
self.assertEqual(out["anthropic"]["github_user"], "anthropics")
finally:
Path(path).unlink(missing_ok=True)
def test_case_insensitive_key_normalization(self):
raw = '{"DRAKE": {"x_handle": "Drake"}}'
out = cli.parse_competitors_plan(raw)
self.assertIn("drake", out)
self.assertNotIn("DRAKE", out)
def test_unknown_fields_warned_and_ignored(self):
raw = '{"Drake": {"x_handle": "Drake", "bogus_field": 42}}'
err = io.StringIO()
with redirect_stderr(err):
out = cli.parse_competitors_plan(raw)
self.assertIn("drake", out)
self.assertNotIn("bogus_field", out["drake"])
self.assertIn("Unknown fields", err.getvalue())
def test_malformed_json_exits_2(self):
with self.assertRaises(SystemExit) as cm, redirect_stderr(io.StringIO()) as err:
cli.parse_competitors_plan("{not valid json")
self.assertEqual(cm.exception.code, 2)
self.assertIn("Invalid JSON", err.getvalue())
def test_top_level_list_rejected(self):
with self.assertRaises(SystemExit) as cm, redirect_stderr(io.StringIO()):
cli.parse_competitors_plan('["Drake", "Kendrick"]')
self.assertEqual(cm.exception.code, 2)
def test_entry_non_dict_skipped_with_warning(self):
raw = '{"Drake": "not-a-dict", "Kendrick": {"x_handle": "kendricklamar"}}'
err = io.StringIO()
with redirect_stderr(err):
out = cli.parse_competitors_plan(raw)
self.assertNotIn("drake", out)
self.assertIn("kendrick", out)
self.assertIn("must be a dict", err.getvalue())
def test_all_six_fields_accepted(self):
raw = json.dumps({
"OpenAI": {
"x_handle": "OpenAI",
"x_related": ["sama", "gdb"],
"subreddits": ["OpenAI", "MachineLearning"],
"github_user": "openai",
"github_repos": ["openai/gpt-5"],
"context": "GPT-5 launch imminent",
}
})
out = cli.parse_competitors_plan(raw)
entry = out["openai"]
self.assertEqual(entry["x_handle"], "OpenAI")
self.assertEqual(entry["x_related"], ["sama", "gdb"])
self.assertEqual(entry["subreddits"], ["OpenAI", "MachineLearning"])
self.assertEqual(entry["github_user"], "openai")
self.assertEqual(entry["github_repos"], ["openai/gpt-5"])
self.assertEqual(entry["context"], "GPT-5 launch imminent")
class SubrunKwargsForTests(unittest.TestCase):
def test_plan_wins_over_auto_resolve(self):
plan_entry = {"x_handle": "Drake", "subreddits": ["Drizzy"]}
resolved = {"x_handle": "wrong", "subreddits": ["wrong"]}
kwargs = cli.subrun_kwargs_for("Drake", plan_entry, resolved=resolved)
self.assertEqual(kwargs["x_handle"], "Drake")
self.assertEqual(kwargs["subreddits"], ["Drizzy"])
def test_auto_resolve_used_when_plan_missing(self):
resolved = {
"x_handle": "Drake",
"subreddits": ["Drizzy", "hiphopheads"],
"github_user": "",
"github_repos": [],
}
kwargs = cli.subrun_kwargs_for("Drake", {}, resolved=resolved)
self.assertEqual(kwargs["x_handle"], "Drake")
self.assertEqual(kwargs["subreddits"], ["Drizzy", "hiphopheads"])
def test_both_empty_yields_all_none(self):
kwargs = cli.subrun_kwargs_for("Drake", {}, resolved={})
self.assertIsNone(kwargs["x_handle"])
self.assertIsNone(kwargs["subreddits"])
self.assertIsNone(kwargs["github_user"])
self.assertIsNone(kwargs["github_repos"])
self.assertIsNone(kwargs["x_related"])
self.assertEqual(kwargs["_context"], "")
def test_x_handle_strips_at_sign(self):
kwargs = cli.subrun_kwargs_for(
"Drake", {"x_handle": "@Drake"}, resolved={},
)
self.assertEqual(kwargs["x_handle"], "Drake")
def test_subreddits_strip_r_prefix(self):
kwargs = cli.subrun_kwargs_for(
"Drake", {"subreddits": ["r/Drizzy", "hiphopheads"]}, resolved={},
)
self.assertEqual(kwargs["subreddits"], ["Drizzy", "hiphopheads"])
def test_github_repos_filter_non_slash(self):
kwargs = cli.subrun_kwargs_for(
"Drake",
{"github_repos": ["drake/ovo", "not-a-repo"]},
resolved={},
)
self.assertEqual(kwargs["github_repos"], ["drake/ovo"])
def test_x_related_list_normalized(self):
kwargs = cli.subrun_kwargs_for(
"Drake",
{"x_related": ["@pnd", "drakefan"]},
resolved={},
)
self.assertEqual(kwargs["x_related"], ["pnd", "drakefan"])
def test_github_user_lowercased(self):
kwargs = cli.subrun_kwargs_for(
"OpenAI", {"github_user": "@OpenAI"}, resolved={},
)
self.assertEqual(kwargs["github_user"], "openai")
def test_context_from_plan_or_resolved(self):
plan_entry = {"context": "Plan context"}
resolved = {"context": "Resolved context"}
kwargs = cli.subrun_kwargs_for("X", plan_entry, resolved=resolved)
self.assertEqual(kwargs["_context"], "Plan context")
kwargs = cli.subrun_kwargs_for("X", {}, resolved=resolved)
self.assertEqual(kwargs["_context"], "Resolved context")
if __name__ == "__main__":
unittest.main()
+76
View File
@@ -0,0 +1,76 @@
# ruff: noqa: E402
"""Tests for the BRAVE/SERPER web-promo suppression when hosting-model-driven."""
from __future__ import annotations
import os
import subprocess
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
def _engine() -> Path:
return REPO_ROOT / "scripts" / "last30days.py"
class FooterNudgeSuppressionTests(unittest.TestCase):
def _run(self, *argv: str, topic: str) -> subprocess.CompletedProcess:
cmd = [
sys.executable,
str(_engine()),
topic,
"--mock",
"--emit=md",
*argv,
]
env = {**os.environ, "LAST30DAYS_SKIP_PREFLIGHT": "1"}
# Strip any grounded-web keys the host might have so the promo path
# triggers deterministically in mock + no-backend.
for key in ("BRAVE_API_KEY", "EXA_API_KEY", "SERPER_API_KEY",
"PARALLEL_API_KEY", "OPENROUTER_API_KEY"):
env.pop(key, None)
return subprocess.run(cmd, capture_output=True, text=True, env=env)
def test_bare_run_emits_web_promo(self):
result = self._run(topic="OpenAI")
combined = result.stdout + result.stderr
# Mock mode still shows the promo when nothing indicates a hosting
# model is driving. Check both streams since the UI may emit to stderr.
self.assertIn("BRAVE_API_KEY", combined)
def test_competitors_plan_suppresses_web_promo(self):
result = self._run(
"--competitors-list", "Anthropic",
"--competitors-plan",
'{"Anthropic":{"x_handle":"AnthropicAI","subreddits":["ClaudeAI"]}}',
topic="OpenAI",
)
combined = result.stdout + result.stderr
self.assertNotIn(
"unlock native grounded web search",
combined,
msg="web promo should be suppressed when --competitors-plan is passed",
)
def test_plan_suppresses_web_promo(self):
plan = (
'{"intent":"concept","freshness_mode":"balanced_recent",'
'"cluster_mode":"none","subqueries":[{"label":"primary",'
'"search_query":"OpenAI","ranking_query":"OpenAI",'
'"sources":["grounding"]}],"source_weights":{"grounding":1.0}}'
)
result = self._run("--plan", plan, topic="OpenAI")
combined = result.stdout + result.stderr
self.assertNotIn(
"unlock native grounded web search",
combined,
msg="web promo should be suppressed when --plan is passed",
)
if __name__ == "__main__":
unittest.main()
+74
View File
@@ -0,0 +1,74 @@
# ruff: noqa: E402
"""Tests for --polymarket-keywords filter and filter_items_against_keywords."""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
from lib import polymarket
def _item(title: str) -> dict:
return {"title": title}
class FilterItemsAgainstKeywordsTests(unittest.TestCase):
def test_no_keywords_returns_all(self):
items = [_item("NBA Finals"), _item("Glasgow Warriors")]
out = polymarket.filter_items_against_keywords(items, [])
self.assertEqual(out, items)
def test_single_keyword_filters(self):
items = [
_item("Golden State Warriors win title"),
_item("Glasgow Warriors rugby"),
_item("Honor of Kings: Rogue Warriors"),
]
out = polymarket.filter_items_against_keywords(items, ["golden"])
self.assertEqual(len(out), 1)
self.assertIn("Golden State", out[0]["title"])
def test_multiple_keywords_any_match(self):
items = [
_item("NBA Finals: Warriors vs Celtics"),
_item("Glasgow rugby"),
_item("GSW schedule"),
]
out = polymarket.filter_items_against_keywords(items, ["nba", "gsw"])
self.assertEqual(len(out), 2)
def test_case_insensitive_match(self):
items = [_item("Golden State Warriors"), _item("GLASGOW WARRIORS")]
out = polymarket.filter_items_against_keywords(items, ["GOLDEN"])
self.assertEqual(len(out), 1)
self.assertIn("Golden State", out[0]["title"])
def test_empty_keyword_strings_ignored(self):
items = [_item("NBA Finals")]
out = polymarket.filter_items_against_keywords(items, ["", " ", ""])
# All keywords are empty → treated as no filter
self.assertEqual(out, items)
def test_sourceitem_like_objects(self):
class _SI:
def __init__(self, t):
self.title = t
items = [_SI("NBA Finals"), _SI("Glasgow Warriors rugby")]
out = polymarket.filter_items_against_keywords(items, ["nba"])
self.assertEqual(len(out), 1)
self.assertEqual(out[0].title, "NBA Finals")
def test_no_match_returns_empty(self):
items = [_item("Glasgow Warriors"), _item("Rogue Warriors")]
out = polymarket.filter_items_against_keywords(items, ["nba", "gsw"])
self.assertEqual(out, [])
if __name__ == "__main__":
unittest.main()
+48 -18
View File
@@ -29,19 +29,38 @@ class RegressionTests(unittest.TestCase):
self.assertIn("clusters", payload)
self.assertIn("items_by_source", payload)
def assert_comparison_shape(self, payload: dict) -> None:
"""Post-3.0.13: vs-topics produce N full passes, merged output has
comparison=True + entities list + per-entity report wrapper."""
self.assertTrue(payload.get("comparison"))
self.assertIn("entities", payload)
self.assertIn("reports", payload)
self.assertEqual(len(payload["entities"]), len(payload["reports"]))
# Each report entry wraps a single-topic report
for entry in payload["reports"]:
self.assertIn("entity", entry)
self.assertIn("report", entry)
# Inner report still has the single-topic shape
inner = entry["report"]
self.assertIn("topic", inner)
self.assertIn("query_plan", inner)
self.assertIn("clusters", inner)
def test_openclaw_three_way_comparison_preserves_entities(self):
payload = run_mock_json("openclaw vs. nanoclaw vs. ironclaw")
self.assert_common_shape(payload)
plan = payload["query_plan"]
self.assertEqual("comparison", plan["intent"])
joined_queries = "\n".join(subquery["search_query"] for subquery in plan["subqueries"]).lower()
self.assertIn("openclaw", joined_queries)
self.assertIn("nanoclaw", joined_queries)
self.assertIn("ironclaw", joined_queries)
self.assertNotIn("corsair", joined_queries)
self.assertNotIn("mouse", joined_queries)
for subquery in plan["subqueries"]:
self.assertGreaterEqual(len(subquery["sources"]), 4)
self.assert_comparison_shape(payload)
entities = [e.lower() for e in payload["entities"]]
self.assertIn("openclaw", entities)
self.assertIn("nanoclaw", entities)
self.assertIn("ironclaw", entities)
# No cross-entity keyword pollution in any per-entity report's plan
for entry in payload["reports"]:
plan = entry["report"]["query_plan"]
joined = "\n".join(
sq["search_query"] for sq in plan["subqueries"]
).lower()
self.assertNotIn("corsair", joined)
self.assertNotIn("mouse", joined)
def test_how_to_keeps_web_video_and_discussion_sources(self):
payload = run_mock_json("how to deploy on Fly.io")
@@ -64,13 +83,24 @@ class RegressionTests(unittest.TestCase):
def test_two_way_comparison_preserves_exact_strings(self):
payload = run_mock_json("DeepSeek R1 vs GPT-5")
self.assert_common_shape(payload)
plan = payload["query_plan"]
self.assertEqual("comparison", plan["intent"])
joined_queries = "\n".join(subquery["search_query"] for subquery in plan["subqueries"]).lower()
self.assertIn("deepseek r1", joined_queries)
self.assertIn("gpt-5", joined_queries)
self.assertNotIn("corsair", joined_queries)
self.assert_comparison_shape(payload)
entities_lower = [e.lower() for e in payload["entities"]]
self.assertIn("deepseek r1", entities_lower)
self.assertIn("gpt-5", entities_lower)
# Each per-entity pass has its own entity in its plan
topics_by_entity = {
entry["entity"].lower(): entry["report"]["topic"].lower()
for entry in payload["reports"]
}
self.assertEqual(topics_by_entity["deepseek r1"], "deepseek r1")
self.assertEqual(topics_by_entity["gpt-5"], "gpt-5")
# No cross-entity pollution
for entry in payload["reports"]:
plan = entry["report"]["query_plan"]
joined = "\n".join(
sq["search_query"] for sq in plan["subqueries"]
).lower()
self.assertNotIn("corsair", joined)
if __name__ == "__main__":
+84
View File
@@ -0,0 +1,84 @@
# ruff: noqa: E402
"""Tests for per-entity save files when running vs-mode or --competitors.
Each entity's sub-run produces its own {entity-slug}-raw.md. Single-entity
runs unchanged.
"""
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
def _engine_path() -> Path:
return REPO_ROOT / "scripts" / "last30days.py"
class PerEntitySaveFilesTests(unittest.TestCase):
def _run(self, *argv: str, topic: str) -> tuple[subprocess.CompletedProcess, Path]:
save_dir = Path(tempfile.mkdtemp(prefix="last30days-test-"))
cmd = [
sys.executable,
str(_engine_path()),
topic,
"--mock",
"--emit=md",
"--save-dir", str(save_dir),
*argv,
]
env = {**os.environ, "LAST30DAYS_SKIP_PREFLIGHT": "1"}
result = subprocess.run(cmd, capture_output=True, text=True, env=env)
return result, save_dir
def test_vs_mode_produces_per_entity_files(self):
result, save_dir = self._run(topic="Kanye West vs Drake vs Kendrick Lamar")
self.assertEqual(result.returncode, 0, msg=result.stderr)
files = sorted(save_dir.glob("*-raw.md"))
names = [f.name for f in files]
# Each entity slug should produce a file
self.assertIn("kanye-west-raw.md", names)
self.assertIn("drake-raw.md", names)
self.assertIn("kendrick-lamar-raw.md", names)
def test_competitors_list_produces_per_entity_files(self):
result, save_dir = self._run(
"--competitors-list", "Anthropic,xAI",
topic="OpenAI",
)
self.assertEqual(result.returncode, 0, msg=result.stderr)
files = sorted(save_dir.glob("*-raw.md"))
names = [f.name for f in files]
self.assertIn("openai-raw.md", names)
self.assertIn("anthropic-raw.md", names)
self.assertIn("xai-raw.md", names)
def test_single_entity_run_produces_one_file(self):
result, save_dir = self._run(topic="OpenAI")
self.assertEqual(result.returncode, 0, msg=result.stderr)
files = sorted(save_dir.glob("*-raw.md"))
self.assertEqual(len(files), 1)
self.assertEqual(files[0].name, "openai-raw.md")
def test_per_entity_file_has_resolved_block(self):
result, save_dir = self._run(
"--competitors-list", "Anthropic",
topic="OpenAI",
)
self.assertEqual(result.returncode, 0, msg=result.stderr)
anthropic_file = save_dir / "anthropic-raw.md"
self.assertTrue(anthropic_file.exists())
content = anthropic_file.read_text()
self.assertIn("## Resolved Entities", content)
self.assertIn("**Anthropic**", content)
if __name__ == "__main__":
unittest.main()
+63
View File
@@ -0,0 +1,63 @@
# ruff: noqa: E402
"""Tests for vs-mode routing into the competitor fanout.
A topic containing " vs " / " versus " triggers N-pass fanout (not the
old single-pipeline comparison plan). Each entity gets its own full
pipeline.run() with its own Step 0.55 targeting.
"""
from __future__ import annotations
import io
import sys
import unittest
from contextlib import redirect_stderr
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "scripts"))
from lib import planner
class VsModeEntityDetectionTests(unittest.TestCase):
"""The planner's _comparison_entities helper is the detector we use."""
def test_two_entity_vs(self):
self.assertEqual(
planner._comparison_entities("OpenAI vs Anthropic"),
["OpenAI", "Anthropic"],
)
def test_three_entity_vs(self):
self.assertEqual(
planner._comparison_entities("Kanye West vs Drake vs Kendrick Lamar"),
["Kanye West", "Drake", "Kendrick Lamar"],
)
def test_versus_alt_spelling(self):
result = planner._comparison_entities("A versus B")
self.assertEqual(result, ["A", "B"])
def test_dotted_vs(self):
result = planner._comparison_entities("A vs. B")
self.assertEqual(result, ["A", "B"])
def test_no_vs_returns_empty(self):
self.assertEqual(planner._comparison_entities("OpenAI"), [])
def test_trailing_vs_returns_empty_or_single(self):
# "OpenAI vs" with nothing after — should not trigger vs-mode
result = planner._comparison_entities("OpenAI vs")
# _comparison_entities caps at _max_subqueries("comparison") and
# requires >=2 parts. Single "OpenAI" with empty after vs -> []
self.assertLess(len(result), 2)
def test_dedup_identical_entities(self):
# Defense against silly input — two "Drake"s should collapse.
result = planner._comparison_entities("Drake vs Drake")
self.assertEqual(result, ["Drake"])
if __name__ == "__main__":
unittest.main()