feat: --competitors flag for auto-discovered comparison fan-out (#308)
Pass `--competitors` on a single-entity topic and the engine auto-discovers 2-6 peer entities via web search, runs the full pipeline on each in parallel, and returns one N-way comparison reusing the existing 9-axis Head-to-Head scaffold. `last30days OpenAI --competitors` resolves to Anthropic + xAI + Google Gemini; `last30days Kanye West --competitors` resolves to Drake + Kendrick Lamar + one more peer. - New CLI flags: --competitors, --competitors=N, --competitors-list - New scripts/lib/competitors.py — mirrors resolve.auto_resolve pattern (web search + deterministic text extraction, no internal LLM) - New scripts/lib/fanout.py — ThreadPoolExecutor orchestrator; per-entity failures degrade gracefully as long as >=2 entities survive - Multi-report render in scripts/lib/render.py reuses the comparison scaffold for the synthesis table - LAW 7-style stderr when no backend and no list, pointing the hosting reasoning model at --competitors-list - 38 new tests across CLI parsing, discovery, fanout, and rendering 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,131 @@
|
||||
# ruff: noqa: E402
|
||||
"""CLI parsing and validation for --competitors / --competitors-list."""
|
||||
|
||||
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"))
|
||||
|
||||
import last30days as cli
|
||||
|
||||
|
||||
def _parse(*argv: str):
|
||||
parser = cli.build_parser()
|
||||
args, _extra = parser.parse_known_args(argv)
|
||||
return args
|
||||
|
||||
|
||||
class CompetitorsCliTests(unittest.TestCase):
|
||||
def test_flag_absent_returns_disabled(self):
|
||||
args = _parse("Kanye West")
|
||||
enabled, count, explicit = cli.resolve_competitors_args(args)
|
||||
self.assertFalse(enabled)
|
||||
self.assertEqual(count, 0)
|
||||
self.assertEqual(explicit, [])
|
||||
|
||||
def test_bare_flag_defaults_to_three(self):
|
||||
args = _parse("Kanye West", "--competitors")
|
||||
enabled, count, explicit = cli.resolve_competitors_args(args)
|
||||
self.assertTrue(enabled)
|
||||
self.assertEqual(count, 3)
|
||||
self.assertEqual(explicit, [])
|
||||
|
||||
def test_explicit_count(self):
|
||||
args = _parse("OpenAI", "--competitors", "4")
|
||||
enabled, count, explicit = cli.resolve_competitors_args(args)
|
||||
self.assertTrue(enabled)
|
||||
self.assertEqual(count, 4)
|
||||
self.assertEqual(explicit, [])
|
||||
|
||||
def test_explicit_list_preferred_over_discovery(self):
|
||||
args = _parse(
|
||||
"OpenAI",
|
||||
"--competitors",
|
||||
"--competitors-list",
|
||||
"Anthropic,xAI,Google Gemini",
|
||||
)
|
||||
enabled, count, explicit = cli.resolve_competitors_args(args)
|
||||
self.assertTrue(enabled)
|
||||
self.assertEqual(count, 3)
|
||||
self.assertEqual(explicit, ["Anthropic", "xAI", "Google Gemini"])
|
||||
|
||||
def test_explicit_list_without_flag_implies_enabled(self):
|
||||
args = _parse("OpenAI", "--competitors-list", "Anthropic,xAI")
|
||||
enabled, count, explicit = cli.resolve_competitors_args(args)
|
||||
self.assertTrue(enabled)
|
||||
self.assertEqual(count, 2)
|
||||
self.assertEqual(explicit, ["Anthropic", "xAI"])
|
||||
|
||||
def test_list_whitespace_normalized(self):
|
||||
args = _parse("OpenAI", "--competitors-list", " Anthropic , xAI , Gemini ")
|
||||
_enabled, count, explicit = cli.resolve_competitors_args(args)
|
||||
self.assertEqual(count, 3)
|
||||
self.assertEqual(explicit, ["Anthropic", "xAI", "Gemini"])
|
||||
|
||||
def test_zero_count_rejected(self):
|
||||
args = _parse("Topic", "--competitors", "0")
|
||||
with self.assertRaises(SystemExit) as cm, redirect_stderr(io.StringIO()) as err:
|
||||
cli.resolve_competitors_args(args)
|
||||
self.assertEqual(cm.exception.code, 2)
|
||||
self.assertIn("--competitors must be >= 1", err.getvalue())
|
||||
|
||||
def test_negative_count_rejected(self):
|
||||
args = _parse("Topic", "--competitors", "-1")
|
||||
with self.assertRaises(SystemExit), redirect_stderr(io.StringIO()):
|
||||
cli.resolve_competitors_args(args)
|
||||
|
||||
def test_over_max_count_clamps_with_warning(self):
|
||||
args = _parse("Topic", "--competitors", "99")
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
enabled, count, explicit = cli.resolve_competitors_args(args)
|
||||
self.assertTrue(enabled)
|
||||
self.assertEqual(count, cli.COMPETITORS_MAX)
|
||||
self.assertEqual(explicit, [])
|
||||
self.assertIn("clamping", err.getvalue())
|
||||
|
||||
def test_overlong_list_clamps_with_warning(self):
|
||||
args = _parse(
|
||||
"Topic",
|
||||
"--competitors-list",
|
||||
"A,B,C,D,E,F,G,H",
|
||||
)
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
enabled, count, explicit = cli.resolve_competitors_args(args)
|
||||
self.assertTrue(enabled)
|
||||
self.assertEqual(count, cli.COMPETITORS_MAX)
|
||||
self.assertEqual(len(explicit), cli.COMPETITORS_MAX)
|
||||
self.assertIn("clamping to", err.getvalue())
|
||||
|
||||
def test_list_count_mismatch_warns(self):
|
||||
args = _parse(
|
||||
"Topic",
|
||||
"--competitors",
|
||||
"5",
|
||||
"--competitors-list",
|
||||
"A,B",
|
||||
)
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
enabled, count, explicit = cli.resolve_competitors_args(args)
|
||||
self.assertTrue(enabled)
|
||||
self.assertEqual(count, 2)
|
||||
self.assertEqual(explicit, ["A", "B"])
|
||||
self.assertIn("--competitors=5 ignored", err.getvalue())
|
||||
|
||||
def test_empty_list_rejected(self):
|
||||
args = _parse("Topic", "--competitors-list", ",, ,")
|
||||
with self.assertRaises(SystemExit) as cm, redirect_stderr(io.StringIO()):
|
||||
cli.resolve_competitors_args(args)
|
||||
self.assertEqual(cm.exception.code, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,160 @@
|
||||
# ruff: noqa: E402
|
||||
"""Tests for scripts/lib/fanout.run_competitor_fanout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
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"))
|
||||
|
||||
from lib import fanout
|
||||
|
||||
|
||||
def _fake_report(topic: str):
|
||||
"""Build a lightweight Report stand-in. Tests only check identity."""
|
||||
class _R:
|
||||
pass
|
||||
|
||||
r = _R()
|
||||
r.topic = topic
|
||||
return r
|
||||
|
||||
|
||||
class FanoutOrchestratorTests(unittest.TestCase):
|
||||
def test_main_plus_two_competitors_all_succeed(self):
|
||||
def main_runner():
|
||||
return _fake_report("OpenAI")
|
||||
|
||||
def comp_runner(entity):
|
||||
return _fake_report(entity)
|
||||
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
results = fanout.run_competitor_fanout(
|
||||
main_topic="OpenAI",
|
||||
main_runner=main_runner,
|
||||
competitors=["Anthropic", "xAI"],
|
||||
competitor_runner=comp_runner,
|
||||
)
|
||||
labels = [label for label, _ in results]
|
||||
self.assertEqual(labels, ["OpenAI", "Anthropic", "xAI"])
|
||||
self.assertEqual(results[0][1].topic, "OpenAI")
|
||||
self.assertEqual(results[1][1].topic, "Anthropic")
|
||||
|
||||
def test_one_competitor_failure_degrades_gracefully(self):
|
||||
def main_runner():
|
||||
return _fake_report("OpenAI")
|
||||
|
||||
def comp_runner(entity):
|
||||
if entity == "BrokenCo":
|
||||
raise RuntimeError("upstream offline")
|
||||
return _fake_report(entity)
|
||||
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
results = fanout.run_competitor_fanout(
|
||||
main_topic="OpenAI",
|
||||
main_runner=main_runner,
|
||||
competitors=["Anthropic", "BrokenCo", "xAI"],
|
||||
competitor_runner=comp_runner,
|
||||
)
|
||||
labels = [label for label, _ in results]
|
||||
self.assertEqual(labels, ["OpenAI", "Anthropic", "xAI"])
|
||||
self.assertIn("BrokenCo", err.getvalue())
|
||||
self.assertIn("upstream offline", err.getvalue())
|
||||
|
||||
def test_main_topic_failure_leaves_only_competitors(self):
|
||||
def main_runner():
|
||||
raise RuntimeError("main exploded")
|
||||
|
||||
def comp_runner(entity):
|
||||
return _fake_report(entity)
|
||||
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
results = fanout.run_competitor_fanout(
|
||||
main_topic="OpenAI",
|
||||
main_runner=main_runner,
|
||||
competitors=["Anthropic", "xAI"],
|
||||
competitor_runner=comp_runner,
|
||||
)
|
||||
labels = [label for label, _ in results]
|
||||
self.assertEqual(labels, ["Anthropic", "xAI"])
|
||||
self.assertIn("main exploded", err.getvalue())
|
||||
|
||||
def test_empty_competitor_list_runs_only_main(self):
|
||||
def main_runner():
|
||||
return _fake_report("OpenAI")
|
||||
|
||||
def comp_runner(_entity):
|
||||
raise AssertionError("should not be called when competitors=[]")
|
||||
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
results = fanout.run_competitor_fanout(
|
||||
main_topic="OpenAI",
|
||||
main_runner=main_runner,
|
||||
competitors=[],
|
||||
competitor_runner=comp_runner,
|
||||
)
|
||||
self.assertEqual([label for label, _ in results], ["OpenAI"])
|
||||
|
||||
def test_sub_runs_execute_in_parallel(self):
|
||||
"""Wall clock should be closer to max(latency) than sum(latency)."""
|
||||
delay = 0.2
|
||||
call_count = 3 # main + 2 competitors
|
||||
|
||||
def make_runner(_label):
|
||||
def runner():
|
||||
time.sleep(delay)
|
||||
return _fake_report(_label)
|
||||
return runner
|
||||
|
||||
def comp_runner(entity):
|
||||
return make_runner(entity)()
|
||||
|
||||
start = time.monotonic()
|
||||
with redirect_stderr(io.StringIO()):
|
||||
results = fanout.run_competitor_fanout(
|
||||
main_topic="OpenAI",
|
||||
main_runner=make_runner("OpenAI"),
|
||||
competitors=["Anthropic", "xAI"],
|
||||
competitor_runner=comp_runner,
|
||||
)
|
||||
elapsed = time.monotonic() - start
|
||||
self.assertEqual(len(results), 3)
|
||||
# Generous margin: parallel execution should finish well under
|
||||
# sum(call_count * delay) == 0.6s. We accept anything under 0.5s.
|
||||
self.assertLess(
|
||||
elapsed, delay * call_count,
|
||||
f"Expected parallel execution < {delay * call_count:.2f}s, "
|
||||
f"got {elapsed:.2f}s (sub-runs likely serialized)",
|
||||
)
|
||||
|
||||
def test_all_competitors_fail_leaves_main_only(self):
|
||||
def main_runner():
|
||||
return _fake_report("OpenAI")
|
||||
|
||||
def comp_runner(_entity):
|
||||
raise RuntimeError("all offline")
|
||||
|
||||
with redirect_stderr(io.StringIO()):
|
||||
results = fanout.run_competitor_fanout(
|
||||
main_topic="OpenAI",
|
||||
main_runner=main_runner,
|
||||
competitors=["A", "B", "C"],
|
||||
competitor_runner=comp_runner,
|
||||
)
|
||||
self.assertEqual([label for label, _ in results], ["OpenAI"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,152 @@
|
||||
# ruff: noqa: E402
|
||||
"""Tests for scripts/lib/competitors.discover_competitors."""
|
||||
|
||||
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"))
|
||||
|
||||
from lib import competitors
|
||||
|
||||
|
||||
def _serp(items: list[tuple[str, str]]) -> list[dict]:
|
||||
"""Build a minimal SERP items list from (title, snippet) pairs."""
|
||||
return [
|
||||
{"title": title, "snippet": snippet, "url": "https://example.test/"}
|
||||
for title, snippet in items
|
||||
]
|
||||
|
||||
|
||||
OPENAI_SERP = _serp(
|
||||
[
|
||||
("OpenAI vs Anthropic vs xAI: which is better?", "xAI and Anthropic now compete directly with OpenAI."),
|
||||
("Top OpenAI alternatives in 2026", "Anthropic, Google Gemini, and xAI are the leading alternatives this year."),
|
||||
("xAI and Anthropic challenge OpenAI dominance", "xAI and Anthropic push Google Gemini hard; xAI keeps shipping."),
|
||||
("Anthropic vs xAI: head to head", "Anthropic and xAI trade punches; Google Gemini is not far behind."),
|
||||
]
|
||||
)
|
||||
|
||||
KANYE_SERP = _serp(
|
||||
[
|
||||
("Kanye West vs Drake: the feud explained", "Drake responded to Kanye with a diss track."),
|
||||
("Top rappers of the decade: Kendrick Lamar, Drake, J Cole", "Kendrick Lamar released a new album; Drake toured Europe."),
|
||||
("Drake and Kendrick Lamar trade shots", "J Cole stayed out of the Drake vs Kendrick Lamar feud."),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class CompetitorDiscoveryTests(unittest.TestCase):
|
||||
def _run(self, serp: list[dict], topic: str, count: int = 3) -> list[str]:
|
||||
config = {"BRAVE_API_KEY": "test-key"}
|
||||
with mock.patch.object(
|
||||
competitors.grounding, "web_search", return_value=(serp, {})
|
||||
):
|
||||
with redirect_stderr(io.StringIO()):
|
||||
return competitors.discover_competitors(topic, count, config)
|
||||
|
||||
def test_openai_surfaces_anthropic_and_peers(self):
|
||||
results = self._run(OPENAI_SERP, "OpenAI", count=3)
|
||||
self.assertEqual(len(results), 3)
|
||||
joined = " ".join(results)
|
||||
self.assertIn("Anthropic", joined)
|
||||
self.assertIn("xAI", joined)
|
||||
# Should not surface the topic itself
|
||||
self.assertNotIn("OpenAI", results)
|
||||
self.assertFalse(
|
||||
any("OpenAI" in entity for entity in results),
|
||||
f"Topic token leaked into results: {results}",
|
||||
)
|
||||
|
||||
def test_kanye_surfaces_rap_peers(self):
|
||||
results = self._run(KANYE_SERP, "Kanye West", count=2)
|
||||
self.assertEqual(len(results), 2)
|
||||
joined = " ".join(results)
|
||||
self.assertTrue(
|
||||
"Drake" in joined and "Kendrick Lamar" in joined,
|
||||
f"Expected Drake and Kendrick Lamar in {results}",
|
||||
)
|
||||
|
||||
def test_empty_serp_returns_empty(self):
|
||||
results = self._run([], "OpenAI", count=3)
|
||||
self.assertEqual(results, [])
|
||||
|
||||
def test_no_backend_returns_empty(self):
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
results = competitors.discover_competitors("OpenAI", 3, config={})
|
||||
self.assertEqual(results, [])
|
||||
self.assertIn("No web search backend", err.getvalue())
|
||||
|
||||
def test_backend_error_returns_empty(self):
|
||||
config = {"BRAVE_API_KEY": "test-key"}
|
||||
|
||||
def boom(*_args, **_kwargs):
|
||||
raise RuntimeError("SERP provider offline")
|
||||
|
||||
err = io.StringIO()
|
||||
with mock.patch.object(competitors.grounding, "web_search", side_effect=boom):
|
||||
with redirect_stderr(err):
|
||||
results = competitors.discover_competitors("OpenAI", 3, config)
|
||||
self.assertEqual(results, [])
|
||||
self.assertIn("Search failed", err.getvalue())
|
||||
|
||||
def test_topic_tokens_filtered(self):
|
||||
"""Candidates overlapping topic tokens are rejected."""
|
||||
serp = _serp(
|
||||
[
|
||||
("Open AI vs Anthropic", "Open AI, Anthropic, and Google lead."),
|
||||
("OpenAI Alternatives: Anthropic", "Anthropic is a competitor to Open AI."),
|
||||
]
|
||||
)
|
||||
results = self._run(serp, "OpenAI", count=3)
|
||||
# "Open AI" shares the "openai" lowercased-concatenation? Actually tokenizer
|
||||
# splits "Open AI" into ["open", "ai"]. Topic "OpenAI" tokenizes to ["openai"].
|
||||
# They do not overlap at the token level, which is fine — the filter is
|
||||
# best-effort. We only assert that bare "OpenAI" is filtered and real
|
||||
# competitors still surface.
|
||||
self.assertNotIn("OpenAI", results)
|
||||
self.assertIn("Anthropic", results)
|
||||
|
||||
def test_deduplicates_case_insensitively(self):
|
||||
serp = _serp(
|
||||
[
|
||||
("Anthropic vs Gemini", "anthropic is strong."),
|
||||
("ANTHROPIC makes Claude", "Anthropic announced Claude 4."),
|
||||
]
|
||||
)
|
||||
results = self._run(serp, "OpenAI", count=3)
|
||||
# "Anthropic" should appear exactly once (first-seen capitalization wins).
|
||||
anthropic_matches = [r for r in results if r.lower() == "anthropic"]
|
||||
self.assertEqual(len(anthropic_matches), 1)
|
||||
|
||||
def test_count_one_returns_single(self):
|
||||
results = self._run(OPENAI_SERP, "OpenAI", count=1)
|
||||
self.assertEqual(len(results), 1)
|
||||
|
||||
def test_stopword_only_candidates_rejected(self):
|
||||
serp = _serp(
|
||||
[
|
||||
("Top Alternatives", "Best Competitors and Top Tools."),
|
||||
("Free Software Reviews", "Complete Guide to The Options."),
|
||||
]
|
||||
)
|
||||
results = self._run(serp, "Widget", count=5)
|
||||
self.assertEqual(
|
||||
results, [],
|
||||
f"Stopword-only phrases should not be returned: got {results}",
|
||||
)
|
||||
|
||||
def test_count_zero_returns_empty(self):
|
||||
results = self._run(OPENAI_SERP, "OpenAI", count=0)
|
||||
self.assertEqual(results, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,201 @@
|
||||
# ruff: noqa: E402
|
||||
"""Tests for render.render_comparison_multi and emit_comparison_output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import last30days as cli
|
||||
from lib import render, schema
|
||||
|
||||
|
||||
def _build_report(topic: str, cluster_titles: list[str]) -> schema.Report:
|
||||
query_plan = schema.QueryPlan(
|
||||
intent="comparison",
|
||||
freshness_mode="balanced_recent",
|
||||
cluster_mode="debate",
|
||||
raw_topic=topic,
|
||||
subqueries=[
|
||||
schema.SubQuery(
|
||||
label="primary",
|
||||
search_query=topic,
|
||||
ranking_query=topic,
|
||||
sources=["grounding"],
|
||||
)
|
||||
],
|
||||
source_weights={"grounding": 1.0},
|
||||
)
|
||||
clusters: list[schema.Cluster] = []
|
||||
candidates: list[schema.Candidate] = []
|
||||
for idx, title in enumerate(cluster_titles):
|
||||
candidate_id = f"{topic.lower().replace(' ', '-')}-c{idx}"
|
||||
item = schema.SourceItem(
|
||||
source="grounding",
|
||||
item_id=f"g-{candidate_id}",
|
||||
title=f"{title} evidence",
|
||||
body=f"Body for {title}",
|
||||
url=f"https://example.test/{candidate_id}",
|
||||
snippet=f"Snippet for {title}",
|
||||
published_at="2026-04-20",
|
||||
)
|
||||
candidate = schema.Candidate(
|
||||
candidate_id=candidate_id,
|
||||
item_id=item.item_id,
|
||||
source="grounding",
|
||||
title=item.title,
|
||||
url=item.url,
|
||||
snippet=item.snippet,
|
||||
subquery_labels=["primary"],
|
||||
native_ranks={"grounding": idx + 1},
|
||||
local_relevance=0.8 - idx * 0.1,
|
||||
freshness=5,
|
||||
engagement=10,
|
||||
source_quality=0.9,
|
||||
rrf_score=0.6 - idx * 0.05,
|
||||
sources=["grounding"],
|
||||
source_items=[item],
|
||||
final_score=80.0 - idx * 5,
|
||||
)
|
||||
candidates.append(candidate)
|
||||
clusters.append(
|
||||
schema.Cluster(
|
||||
cluster_id=f"cl-{idx}",
|
||||
title=title,
|
||||
candidate_ids=[candidate_id],
|
||||
representative_ids=[candidate_id],
|
||||
score=80.0 - idx * 5,
|
||||
sources=["grounding"],
|
||||
)
|
||||
)
|
||||
return schema.Report(
|
||||
topic=topic,
|
||||
range_from="2026-03-23",
|
||||
range_to="2026-04-22",
|
||||
generated_at="2026-04-22T00:00:00+00:00",
|
||||
provider_runtime=schema.ProviderRuntime(
|
||||
reasoning_provider="mock",
|
||||
planner_model="mock-planner",
|
||||
rerank_model="mock-rerank",
|
||||
),
|
||||
query_plan=query_plan,
|
||||
clusters=clusters,
|
||||
ranked_candidates=candidates,
|
||||
items_by_source={"grounding": [c.source_items[0] for c in candidates]},
|
||||
errors_by_source={},
|
||||
)
|
||||
|
||||
|
||||
class RenderComparisonMultiTests(unittest.TestCase):
|
||||
def test_three_entity_table(self):
|
||||
reports = [
|
||||
("OpenAI", _build_report("OpenAI", ["GPT-5 drop", "API pricing cut"])),
|
||||
("Anthropic", _build_report("Anthropic", ["Claude 4.7 ship", "MCP rollout"])),
|
||||
("xAI", _build_report("xAI", ["Grok 4 release", "Memphis cluster"])),
|
||||
]
|
||||
rendered = render.render_comparison_multi(reports)
|
||||
# All three entities appear in the header
|
||||
self.assertIn("OpenAI vs Anthropic vs xAI", rendered)
|
||||
# Each entity has its own evidence section
|
||||
self.assertIn("## OpenAI", rendered)
|
||||
self.assertIn("## Anthropic", rendered)
|
||||
self.assertIn("## xAI", rendered)
|
||||
# Scaffold table header has a column per entity
|
||||
self.assertIn("| Dimension | OpenAI | Anthropic | xAI |", rendered)
|
||||
# Envelope scaffolding present
|
||||
self.assertIn("EVIDENCE FOR SYNTHESIS", rendered)
|
||||
self.assertIn("END OF last30days CANONICAL OUTPUT", rendered)
|
||||
|
||||
def test_two_entity_table_has_two_columns(self):
|
||||
reports = [
|
||||
("Kanye West", _build_report("Kanye West", ["Donda 2 release"])),
|
||||
("Drake", _build_report("Drake", ["For All The Dogs"])),
|
||||
]
|
||||
rendered = render.render_comparison_multi(reports)
|
||||
self.assertIn("| Dimension | Kanye West | Drake |", rendered)
|
||||
self.assertIn("## Kanye West", rendered)
|
||||
self.assertIn("## Drake", rendered)
|
||||
|
||||
def test_empty_clusters_renders_placeholder(self):
|
||||
reports = [
|
||||
("OpenAI", _build_report("OpenAI", ["GPT-5 drop"])),
|
||||
("ObscureCompetitor", _build_report("ObscureCompetitor", [])),
|
||||
]
|
||||
rendered = render.render_comparison_multi(reports)
|
||||
self.assertIn("## ObscureCompetitor", rendered)
|
||||
self.assertIn("no significant discussion this month", rendered)
|
||||
# Main still has its cluster
|
||||
self.assertIn("GPT-5 drop", rendered)
|
||||
|
||||
def test_warnings_aggregated_and_labeled(self):
|
||||
report_a = _build_report("OpenAI", ["GPT-5 drop"])
|
||||
report_b = _build_report("Anthropic", ["Claude 4.7"])
|
||||
report_a.warnings.append("Brave quota exhausted")
|
||||
report_b.warnings.append("Exa returned 0 results")
|
||||
rendered = render.render_comparison_multi(
|
||||
[("OpenAI", report_a), ("Anthropic", report_b)]
|
||||
)
|
||||
self.assertIn("[OpenAI] Brave quota exhausted", rendered)
|
||||
self.assertIn("[Anthropic] Exa returned 0 results", rendered)
|
||||
|
||||
def test_raises_on_empty_input(self):
|
||||
with self.assertRaises(ValueError):
|
||||
render.render_comparison_multi([])
|
||||
|
||||
def test_context_emit(self):
|
||||
reports = [
|
||||
("OpenAI", _build_report("OpenAI", ["GPT-5 drop"])),
|
||||
("Anthropic", _build_report("Anthropic", ["Claude 4.7"])),
|
||||
]
|
||||
out = render.render_comparison_multi_context(reports)
|
||||
self.assertIn("Comparison: OpenAI vs Anthropic", out)
|
||||
self.assertIn("## OpenAI", out)
|
||||
self.assertIn("## Anthropic", out)
|
||||
self.assertIn("GPT-5 drop", out)
|
||||
|
||||
|
||||
class EmitComparisonOutputTests(unittest.TestCase):
|
||||
def test_json_emit_nests_per_entity(self):
|
||||
reports = [
|
||||
("OpenAI", _build_report("OpenAI", ["GPT-5 drop"])),
|
||||
("Anthropic", _build_report("Anthropic", ["Claude 4.7"])),
|
||||
]
|
||||
out = cli.emit_comparison_output(reports, emit="json")
|
||||
payload = json.loads(out)
|
||||
self.assertTrue(payload["comparison"])
|
||||
self.assertEqual(payload["entities"], ["OpenAI", "Anthropic"])
|
||||
self.assertEqual(len(payload["reports"]), 2)
|
||||
self.assertEqual(payload["reports"][0]["entity"], "OpenAI")
|
||||
self.assertIn("topic", payload["reports"][0]["report"])
|
||||
|
||||
def test_compact_and_md_both_route_to_multi(self):
|
||||
reports = [
|
||||
("A", _build_report("A", ["Thing A"])),
|
||||
("B", _build_report("B", ["Thing B"])),
|
||||
]
|
||||
compact = cli.emit_comparison_output(reports, emit="compact")
|
||||
md = cli.emit_comparison_output(reports, emit="md")
|
||||
self.assertIn("| Dimension | A | B |", compact)
|
||||
self.assertEqual(compact, md)
|
||||
|
||||
def test_context_emit_goes_to_context_renderer(self):
|
||||
reports = [
|
||||
("A", _build_report("A", ["Thing A"])),
|
||||
("B", _build_report("B", ["Thing B"])),
|
||||
]
|
||||
out = cli.emit_comparison_output(reports, emit="context")
|
||||
self.assertIn("Comparison: A vs B", out)
|
||||
|
||||
def test_unsupported_emit_raises(self):
|
||||
reports = [("A", _build_report("A", ["Thing A"]))]
|
||||
with self.assertRaises(SystemExit):
|
||||
cli.emit_comparison_output(reports, emit="xml")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user