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,199 @@
|
||||
"""Discover peer entities ("competitors") for a topic via web search.
|
||||
|
||||
Mirrors the `resolve.auto_resolve()` pattern: fan out 2-3 web searches via
|
||||
`grounding.web_search()`, then extract capitalized entity candidates from
|
||||
titles and snippets with deterministic text mining. No LLM call — the
|
||||
hosting reasoning model can always override discovery via
|
||||
`--competitors-list`.
|
||||
|
||||
Returned list is ordered by score (frequency across queries) and capped to
|
||||
the caller's requested count.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from . import dates, grounding
|
||||
from .resolve import _has_backend
|
||||
|
||||
# A "brand-shaped" token starts with uppercase OR is camelCase with an
|
||||
# uppercase letter later. Catches "Anthropic", "OpenAI", "xAI", "iPhone",
|
||||
# "eBay", "Hugging", "Face".
|
||||
_BRAND_TOKEN = (
|
||||
r"(?:[A-Z][A-Za-z0-9&.\-]*"
|
||||
r"|[a-z][A-Za-z0-9&.\-]*[A-Z][A-Za-z0-9&.\-]*)"
|
||||
)
|
||||
|
||||
# A capitalized phrase of 1-4 brand tokens separated by whitespace.
|
||||
_CAPITALIZED_PHRASE = re.compile(
|
||||
rf"\b{_BRAND_TOKEN}(?:\s+{_BRAND_TOKEN}){{0,3}}\b"
|
||||
)
|
||||
|
||||
# Title-case fillers common in listicle SERPs. Kept flat — extraction
|
||||
# rejects a candidate whose entire tokens are stopwords, not candidates
|
||||
# that merely contain one.
|
||||
_STOPWORD_TOKENS: frozenset[str] = frozenset(
|
||||
token.lower()
|
||||
for token in (
|
||||
# Listicle fillers
|
||||
"Top", "Best", "Worst", "Popular", "Leading", "Similar",
|
||||
"Alternatives", "Alternative", "Competitor", "Competitors",
|
||||
"vs", "Vs", "Versus", "Review", "Reviews", "Comparison",
|
||||
"Guide", "List", "Lists", "Full", "Complete", "Free", "Paid",
|
||||
"Tools", "Tool", "Options", "Rivals", "Rival", "Similar",
|
||||
"Pick", "Picks", "Ranking", "Ranked", "Recommended",
|
||||
# Grammar / time
|
||||
"The", "A", "An", "Of", "In", "For", "To", "With", "On", "At",
|
||||
"By", "From", "Is", "Are", "And", "Or", "But", "Than", "As",
|
||||
"This", "That", "These", "Those", "Our", "Your", "Their",
|
||||
"January", "February", "March", "April", "May", "June", "July",
|
||||
"August", "September", "October", "November", "December",
|
||||
# Years likely to appear as standalone tokens
|
||||
*(str(year) for year in range(2018, 2031)),
|
||||
# Miscellaneous SERP noise
|
||||
"AI", "Apps", "App", "Software", "Platform", "Service", "Startups",
|
||||
"Companies", "Company", "Products", "Product", "Brands", "Brand",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(f"[Competitors] {msg}", file=sys.stderr)
|
||||
|
||||
|
||||
def _topic_tokens(topic: str) -> set[str]:
|
||||
"""Return lowercase alphanumeric tokens of the topic for filtering."""
|
||||
return {tok for tok in re.findall(r"[A-Za-z0-9]+", topic.lower()) if tok}
|
||||
|
||||
|
||||
def _candidate_ok(candidate: str, topic_tokens: set[str]) -> bool:
|
||||
"""Filter a candidate phrase against stopwords and topic overlap."""
|
||||
tokens = [t for t in re.findall(r"[A-Za-z0-9&.\-]+", candidate) if t]
|
||||
if not tokens:
|
||||
return False
|
||||
# Reject candidates made entirely of stopwords (e.g., "Top Alternatives").
|
||||
if all(tok.lower() in _STOPWORD_TOKENS for tok in tokens):
|
||||
return False
|
||||
# Reject candidates that overlap with the topic (e.g., topic="OpenAI"
|
||||
# should not return "OpenAI Alternatives" or "OpenAI").
|
||||
lower_tokens = {tok.lower() for tok in tokens}
|
||||
if lower_tokens & topic_tokens:
|
||||
return False
|
||||
# Reject too-short one-letter tokens like "I" or single digits.
|
||||
if len(tokens) == 1 and len(tokens[0]) < 2:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _normalize_candidate(candidate: str) -> str:
|
||||
"""Collapse whitespace and strip trailing punctuation."""
|
||||
return re.sub(r"\s+", " ", candidate).strip(".,;:!?'\"()[] ")
|
||||
|
||||
|
||||
def _extract_peer_entities(
|
||||
items: list[dict], topic: str, limit: int,
|
||||
) -> list[str]:
|
||||
"""Score capitalized candidates across SERP items and return top `limit`.
|
||||
|
||||
Scoring is bag-of-phrases frequency across all items in the input. Ties
|
||||
are broken by first-seen order so the output is deterministic.
|
||||
"""
|
||||
topic_tokens = _topic_tokens(topic)
|
||||
counts: Counter[str] = Counter()
|
||||
first_seen: dict[str, int] = {}
|
||||
order = 0
|
||||
# Group candidates into a frequency map keyed by lowercased normalized
|
||||
# form so "xAI" and "xAI" count together regardless of case.
|
||||
canonical: dict[str, str] = {}
|
||||
for item in items:
|
||||
text = f"{item.get('title', '')} {item.get('snippet', '')}"
|
||||
for raw in _CAPITALIZED_PHRASE.findall(text):
|
||||
candidate = _normalize_candidate(raw)
|
||||
if not _candidate_ok(candidate, topic_tokens):
|
||||
continue
|
||||
key = candidate.lower()
|
||||
if key not in canonical:
|
||||
canonical[key] = candidate
|
||||
first_seen[key] = order
|
||||
order += 1
|
||||
counts[key] += 1
|
||||
|
||||
ranked_keys = sorted(
|
||||
counts.keys(),
|
||||
key=lambda k: (-counts[k], first_seen[k]),
|
||||
)
|
||||
return [canonical[k] for k in ranked_keys[:limit]]
|
||||
|
||||
|
||||
def _queries_for(topic: str) -> dict[str, str]:
|
||||
return {
|
||||
"competitors": f"{topic} competitors",
|
||||
"alternatives": f"{topic} alternatives",
|
||||
"vs": f"{topic} vs",
|
||||
}
|
||||
|
||||
|
||||
def discover_competitors(
|
||||
topic: str,
|
||||
count: int,
|
||||
config: dict,
|
||||
*,
|
||||
lookback_days: int = 30,
|
||||
) -> list[str]:
|
||||
"""Discover `count` peer entities for `topic` via web search.
|
||||
|
||||
Args:
|
||||
topic: The primary research topic.
|
||||
count: Desired number of competitor entities (1..N).
|
||||
config: Runtime config dict — expects the same shape as the engine
|
||||
config (BRAVE_API_KEY / EXA_API_KEY / SERPER_API_KEY / etc.).
|
||||
lookback_days: Date range for freshness. Defaults to 30.
|
||||
|
||||
Returns:
|
||||
A list of up to `count` entity names, deduped and ordered by score.
|
||||
Empty list when no web backend is configured or every search fails
|
||||
or returns zero usable candidates.
|
||||
"""
|
||||
if count < 1:
|
||||
return []
|
||||
if not _has_backend(config):
|
||||
_log("No web search backend available, skipping competitor discovery")
|
||||
return []
|
||||
|
||||
date_range = dates.get_date_range(lookback_days)
|
||||
queries = _queries_for(topic)
|
||||
collected: list[dict] = []
|
||||
searches_run = 0
|
||||
|
||||
def _search(label: str, query: str) -> tuple[str, list[dict]]:
|
||||
items, _artifact = grounding.web_search(query, date_range, config)
|
||||
return label, items
|
||||
|
||||
with ThreadPoolExecutor(max_workers=len(queries)) as executor:
|
||||
futures = {
|
||||
executor.submit(_search, label, q): label
|
||||
for label, q in queries.items()
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
label = futures[future]
|
||||
try:
|
||||
_label, items = future.result()
|
||||
collected.extend(items)
|
||||
searches_run += 1
|
||||
except Exception as exc:
|
||||
_log(f"Search failed for {label}: {exc}")
|
||||
|
||||
if not collected:
|
||||
_log(f"No SERP results for {topic!r} across {searches_run}/{len(queries)} queries")
|
||||
return []
|
||||
|
||||
entities = _extract_peer_entities(collected, topic, limit=count)
|
||||
_log(
|
||||
f"Discovered {len(entities)} competitor(s) for {topic!r} "
|
||||
f"from {searches_run}/{len(queries)} queries: {entities}"
|
||||
)
|
||||
return entities
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Parallel multi-entity fan-out for the --competitors flag.
|
||||
|
||||
The orchestrator accepts a `main_runner()` for the topic and a
|
||||
`competitor_runner(entity)` for each peer. It parallelizes their execution
|
||||
via a `ThreadPoolExecutor` and collects per-entity Reports. Per-entity
|
||||
failures are logged and dropped; the run survives as long as the main topic
|
||||
plus at least one competitor succeed.
|
||||
|
||||
This module owns no business logic about pipeline arguments — the caller
|
||||
(scripts/last30days.py main) builds the closures with the appropriate
|
||||
config, depth, and overrides for each entity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Callable
|
||||
|
||||
from . import schema
|
||||
|
||||
# Sub-runs hit the same upstream APIs as the main topic. Cap parallelism so a
|
||||
# 6-way fan-out does not stampede a single backend's rate limit.
|
||||
MAX_PARALLEL_SUBRUNS = 6
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(f"[Fanout] {msg}", file=sys.stderr)
|
||||
|
||||
|
||||
def run_competitor_fanout(
|
||||
*,
|
||||
main_topic: str,
|
||||
main_runner: Callable[[], schema.Report],
|
||||
competitors: list[str],
|
||||
competitor_runner: Callable[[str], schema.Report],
|
||||
) -> list[tuple[str, schema.Report]]:
|
||||
"""Run main + competitor pipelines in parallel; return surviving reports.
|
||||
|
||||
Args:
|
||||
main_topic: Display label for the user's primary topic.
|
||||
main_runner: Zero-arg callable returning the main topic's Report.
|
||||
competitors: Ordered list of competitor entity names.
|
||||
competitor_runner: Callable(entity_name) -> Report for each peer.
|
||||
|
||||
Returns:
|
||||
Ordered list of (entity_name, Report) tuples for runs that succeeded.
|
||||
Empty list if every run raised; the caller decides how to surface
|
||||
partial-failure modes.
|
||||
"""
|
||||
if not competitors:
|
||||
report = main_runner()
|
||||
return [(main_topic, report)]
|
||||
|
||||
workers = min(len(competitors) + 1, MAX_PARALLEL_SUBRUNS)
|
||||
|
||||
def _run_one(label: str, fn: Callable[[], schema.Report]) -> tuple[str, schema.Report | None, Exception | None]:
|
||||
try:
|
||||
return label, fn(), None
|
||||
except Exception as exc:
|
||||
return label, None, exc
|
||||
|
||||
submissions: list[tuple[str, Callable[[], schema.Report]]] = [
|
||||
(main_topic, main_runner),
|
||||
]
|
||||
for entity in competitors:
|
||||
submissions.append((entity, lambda e=entity: competitor_runner(e)))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
futures = {
|
||||
executor.submit(_run_one, label, fn): label
|
||||
for label, fn in submissions
|
||||
}
|
||||
results: dict[str, schema.Report] = {}
|
||||
for future in as_completed(futures):
|
||||
label, report, exc = future.result()
|
||||
if exc is not None:
|
||||
_log(f"Sub-run failed for {label!r}: {type(exc).__name__}: {exc}")
|
||||
continue
|
||||
assert report is not None
|
||||
results[label] = report
|
||||
|
||||
# Preserve the original submission order rather than completion order so
|
||||
# the comparison render is deterministic across runs.
|
||||
return [(label, results[label]) for label, _ in submissions if label in results]
|
||||
@@ -392,6 +392,165 @@ def _render_comparison_scaffold(topic: str) -> list[str]:
|
||||
]
|
||||
|
||||
|
||||
def render_comparison_multi(
|
||||
entity_reports: list[tuple[str, schema.Report]],
|
||||
*,
|
||||
cluster_limit: int = 4,
|
||||
fun_level: str = "medium",
|
||||
save_path: str | None = None,
|
||||
) -> str:
|
||||
"""Render N (entity, Report) pairs as a single comparison output.
|
||||
|
||||
Reuses _render_comparison_scaffold for the synthesis table and emits
|
||||
per-entity evidence sections inside one EVIDENCE FOR SYNTHESIS envelope.
|
||||
The single-Report render_compact path is unchanged.
|
||||
|
||||
Args:
|
||||
entity_reports: Ordered (label, Report) pairs. The first pair is the
|
||||
user's main topic; the remainder are discovered/explicit competitors.
|
||||
cluster_limit: Max clusters to surface per entity (kept lower than the
|
||||
single-entity default to keep N-way comparisons readable).
|
||||
fun_level: Same fun-level knob as render_compact, applied to each
|
||||
entity's best-takes block.
|
||||
save_path: Optional save-path display string for the footer.
|
||||
"""
|
||||
if not entity_reports:
|
||||
raise ValueError("render_comparison_multi requires at least one report")
|
||||
|
||||
entities = [label for label, _ in entity_reports]
|
||||
main_label, main_report = entity_reports[0]
|
||||
synthesized_topic = " vs ".join(entities)
|
||||
|
||||
lines: list[str] = [
|
||||
*_render_badge(),
|
||||
f"# last30days v3.0.0: {synthesized_topic}",
|
||||
"",
|
||||
*_assistant_safety_lines(),
|
||||
f"- Comparison mode: {len(entities)} entities ({', '.join(entities)})",
|
||||
f"- Date range: {main_report.range_from} to {main_report.range_to}",
|
||||
"",
|
||||
]
|
||||
|
||||
aggregated_warnings: list[str] = []
|
||||
for label, report in entity_reports:
|
||||
aggregated_warnings.extend(f"[{label}] {w}" for w in report.warnings)
|
||||
if aggregated_warnings:
|
||||
lines.append("## Warnings")
|
||||
lines.extend(f"- {w}" for w in aggregated_warnings)
|
||||
lines.append("")
|
||||
|
||||
lines.append(
|
||||
"<!-- EVIDENCE FOR SYNTHESIS: read this, do not emit verbatim. Transform into "
|
||||
"`What I learned:` prose per LAW 2. Each entity has its own evidence subsection. -->"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
fun_params = _FUN_LEVELS.get(fun_level, _FUN_LEVELS["medium"])
|
||||
for label, report in entity_reports:
|
||||
lines.extend(_render_entity_evidence_block(
|
||||
label=label,
|
||||
report=report,
|
||||
cluster_limit=cluster_limit,
|
||||
fun_params=fun_params,
|
||||
))
|
||||
|
||||
lines.append("<!-- END EVIDENCE FOR SYNTHESIS -->")
|
||||
lines.append("")
|
||||
|
||||
# Reuse the existing comparison scaffold by feeding it the synthesized
|
||||
# topic. _parse_comparison_entities splits on " vs " so the scaffold
|
||||
# picks up all N entities automatically.
|
||||
scaffold = _render_comparison_scaffold(synthesized_topic)
|
||||
lines.extend(scaffold)
|
||||
|
||||
footer = _render_emoji_footer(main_report, save_path)
|
||||
if footer:
|
||||
lines.append("")
|
||||
lines.append("<!-- PASS-THROUGH FOOTER: emit verbatim in the model response per LAW 5. -->")
|
||||
lines.extend(footer)
|
||||
lines.append("<!-- END PASS-THROUGH FOOTER -->")
|
||||
|
||||
lines.extend(_render_canonical_boundary())
|
||||
|
||||
return "\n".join(lines).strip() + "\n"
|
||||
|
||||
|
||||
def _render_entity_evidence_block(
|
||||
*,
|
||||
label: str,
|
||||
report: schema.Report,
|
||||
cluster_limit: int,
|
||||
fun_params: dict,
|
||||
) -> list[str]:
|
||||
"""Render one entity's clusters and best-takes inside the evidence envelope."""
|
||||
candidate_by_id = {c.candidate_id: c for c in report.ranked_candidates}
|
||||
out: list[str] = [f"## {label}", ""]
|
||||
|
||||
if not report.clusters:
|
||||
out.append("(no significant discussion this month)")
|
||||
out.append("")
|
||||
return out
|
||||
|
||||
out.append("### Ranked Evidence Clusters")
|
||||
out.append("")
|
||||
for index, cluster in enumerate(report.clusters[:cluster_limit], start=1):
|
||||
out.append(
|
||||
f"#### {index}. {cluster.title} "
|
||||
f"(score {cluster.score:.0f}, {len(cluster.candidate_ids)} item"
|
||||
f"{'s' if len(cluster.candidate_ids) != 1 else ''}, "
|
||||
f"sources: {', '.join(_source_label(s) for s in cluster.sources)})"
|
||||
)
|
||||
if cluster.uncertainty:
|
||||
out.append(f"- Uncertainty: {cluster.uncertainty}")
|
||||
for rep_index, candidate_id in enumerate(cluster.representative_ids, start=1):
|
||||
candidate = candidate_by_id.get(candidate_id)
|
||||
if not candidate:
|
||||
continue
|
||||
out.extend(_render_candidate(candidate, prefix=f"{rep_index}."))
|
||||
out.append("")
|
||||
|
||||
best_takes = _render_best_takes(
|
||||
report.ranked_candidates,
|
||||
limit=fun_params["limit"],
|
||||
threshold=fun_params["threshold"],
|
||||
)
|
||||
if best_takes:
|
||||
out.extend(best_takes)
|
||||
out.append("")
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def render_comparison_multi_context(
|
||||
entity_reports: list[tuple[str, schema.Report]],
|
||||
cluster_limit: int = 4,
|
||||
) -> str:
|
||||
"""Context-mode rendering for the multi-entity comparison."""
|
||||
if not entity_reports:
|
||||
raise ValueError("render_comparison_multi_context requires at least one report")
|
||||
|
||||
entities = [label for label, _ in entity_reports]
|
||||
lines = [
|
||||
f"Comparison: {' vs '.join(entities)}",
|
||||
f"Entities: {len(entities)}",
|
||||
_AI_SAFETY_NOTE,
|
||||
"",
|
||||
]
|
||||
for label, report in entity_reports:
|
||||
lines.append(f"## {label}")
|
||||
lines.append(f"Intent: {report.query_plan.intent}")
|
||||
if not report.clusters:
|
||||
lines.append("- (no significant discussion this month)")
|
||||
else:
|
||||
for cluster in report.clusters[:cluster_limit]:
|
||||
lines.append(
|
||||
f"- {cluster.title} "
|
||||
f"[{', '.join(_source_label(s) for s in cluster.sources)}]"
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines).strip() + "\n"
|
||||
|
||||
|
||||
def render_full(report: schema.Report) -> str:
|
||||
"""Full data dump: ALL clusters + ALL items by source. For saved files and debugging."""
|
||||
# Start with the same header as compact
|
||||
|
||||
Reference in New Issue
Block a user