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:
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user