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:
+195
-19
@@ -122,6 +122,31 @@ def emit_output(report: schema.Report, emit: str, fun_level: str = "medium", sav
|
||||
raise SystemExit(f"Unsupported emit mode: {emit}")
|
||||
|
||||
|
||||
def emit_comparison_output(
|
||||
entity_reports: list[tuple[str, schema.Report]],
|
||||
emit: str,
|
||||
fun_level: str = "medium",
|
||||
save_path: str | None = None,
|
||||
) -> str:
|
||||
if emit == "json":
|
||||
payload = {
|
||||
"comparison": True,
|
||||
"entities": [label for label, _ in entity_reports],
|
||||
"reports": [
|
||||
{"entity": label, "report": schema.to_dict(report)}
|
||||
for label, report in entity_reports
|
||||
],
|
||||
}
|
||||
return json.dumps(payload, indent=2, sort_keys=True)
|
||||
if emit in {"compact", "md"}:
|
||||
return render.render_comparison_multi(
|
||||
entity_reports, fun_level=fun_level, save_path=save_path,
|
||||
)
|
||||
if emit == "context":
|
||||
return render.render_comparison_multi_context(entity_reports)
|
||||
raise SystemExit(f"Unsupported emit mode: {emit}")
|
||||
|
||||
|
||||
def compute_save_path_display(save_dir: str, topic: str, suffix: str, emit: str) -> str:
|
||||
"""Compute the user-friendly save path string that will be shown in the footer.
|
||||
|
||||
@@ -202,9 +227,85 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
help="Use web search to discover subreddits/handles before planning (for platforms without WebSearch)")
|
||||
parser.add_argument("--github-user", help="GitHub username for person-mode search (e.g., steipete)")
|
||||
parser.add_argument("--github-repo", help="Comma-separated owner/repo for project-mode search (e.g., openclaw/openclaw,paperclipai/paperclip)")
|
||||
parser.add_argument(
|
||||
"--competitors",
|
||||
nargs="?",
|
||||
const=3,
|
||||
type=int,
|
||||
default=None,
|
||||
metavar="N",
|
||||
help="Auto-discover N competitor entities and fan out last30days across all of them as a comparison (default N=3, range 1..6). Use --competitors-list to override discovery.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--competitors-list",
|
||||
dest="competitors_list",
|
||||
help="Comma-separated competitor entities to skip discovery (e.g., 'Anthropic,xAI,Google Gemini'). Implies --competitors.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
COMPETITORS_MIN = 1
|
||||
COMPETITORS_MAX = 6
|
||||
COMPETITORS_DEFAULT = 3
|
||||
|
||||
|
||||
def resolve_competitors_args(args: argparse.Namespace) -> tuple[bool, int, list[str]]:
|
||||
"""Normalize --competitors / --competitors-list into (enabled, count, explicit_list).
|
||||
|
||||
- (False, 0, []) when neither flag is set.
|
||||
- An explicit list always wins; count is derived from list length.
|
||||
- A numeric count outside [1, 6] is clamped with a stderr warning.
|
||||
- count <= 0 (explicit) raises SystemExit(2).
|
||||
"""
|
||||
explicit_list: list[str] = []
|
||||
list_flag_provided = args.competitors_list is not None
|
||||
if list_flag_provided:
|
||||
explicit_list = [
|
||||
entity.strip()
|
||||
for entity in args.competitors_list.split(",")
|
||||
if entity.strip()
|
||||
]
|
||||
if not explicit_list:
|
||||
sys.stderr.write("[Competitors] --competitors-list is empty.\n")
|
||||
raise SystemExit(2)
|
||||
|
||||
competitors_flag = args.competitors
|
||||
list_present = bool(explicit_list)
|
||||
flag_present = competitors_flag is not None
|
||||
|
||||
if not list_present and not flag_present:
|
||||
return False, 0, []
|
||||
|
||||
if list_present:
|
||||
count = len(explicit_list)
|
||||
if flag_present and competitors_flag != count:
|
||||
sys.stderr.write(
|
||||
f"[Competitors] --competitors={competitors_flag} ignored; using "
|
||||
f"{count} entries from --competitors-list.\n"
|
||||
)
|
||||
if count > COMPETITORS_MAX:
|
||||
sys.stderr.write(
|
||||
f"[Competitors] --competitors-list has {count} entries, clamping to {COMPETITORS_MAX}.\n"
|
||||
)
|
||||
explicit_list = explicit_list[:COMPETITORS_MAX]
|
||||
count = COMPETITORS_MAX
|
||||
return True, count, explicit_list
|
||||
|
||||
# flag_present, no explicit list
|
||||
count = competitors_flag
|
||||
if count < COMPETITORS_MIN:
|
||||
sys.stderr.write(
|
||||
f"[Competitors] --competitors must be >= {COMPETITORS_MIN} (got {count}).\n"
|
||||
)
|
||||
raise SystemExit(2)
|
||||
if count > COMPETITORS_MAX:
|
||||
sys.stderr.write(
|
||||
f"[Competitors] --competitors={count} exceeds max {COMPETITORS_MAX}; clamping.\n"
|
||||
)
|
||||
count = COMPETITORS_MAX
|
||||
return True, count, []
|
||||
|
||||
|
||||
def _missing_sources_for_promo(diag: dict[str, object]) -> str | None:
|
||||
available = set(diag.get("available_sources") or [])
|
||||
missing = []
|
||||
@@ -359,24 +460,91 @@ def main() -> int:
|
||||
if "perplexity" not in include.lower():
|
||||
config["INCLUDE_SOURCES"] = f"{include},perplexity" if include else "perplexity"
|
||||
|
||||
report = pipeline.run(
|
||||
topic=topic,
|
||||
config=config,
|
||||
depth=depth,
|
||||
requested_sources=requested_sources,
|
||||
mock=args.mock,
|
||||
x_handle=args.x_handle,
|
||||
x_related=x_related,
|
||||
web_backend=args.web_backend,
|
||||
external_plan=external_plan,
|
||||
subreddits=subreddits,
|
||||
tiktok_hashtags=tiktok_hashtags,
|
||||
tiktok_creators=tiktok_creators,
|
||||
ig_creators=ig_creators,
|
||||
lookback_days=args.lookback_days,
|
||||
github_user=github_user,
|
||||
github_repos=github_repos,
|
||||
)
|
||||
comp_enabled, comp_count, comp_explicit = resolve_competitors_args(args)
|
||||
|
||||
def _main_runner() -> schema.Report:
|
||||
return pipeline.run(
|
||||
topic=topic,
|
||||
config=config,
|
||||
depth=depth,
|
||||
requested_sources=requested_sources,
|
||||
mock=args.mock,
|
||||
x_handle=args.x_handle,
|
||||
x_related=x_related,
|
||||
web_backend=args.web_backend,
|
||||
external_plan=external_plan,
|
||||
subreddits=subreddits,
|
||||
tiktok_hashtags=tiktok_hashtags,
|
||||
tiktok_creators=tiktok_creators,
|
||||
ig_creators=ig_creators,
|
||||
lookback_days=args.lookback_days,
|
||||
github_user=github_user,
|
||||
github_repos=github_repos,
|
||||
)
|
||||
|
||||
if comp_enabled:
|
||||
from lib import competitors as competitors_mod
|
||||
from lib import fanout, resolve as resolve_mod
|
||||
|
||||
if comp_explicit:
|
||||
discovered = comp_explicit
|
||||
else:
|
||||
if not resolve_mod._has_backend(config) and not args.mock:
|
||||
sys.stderr.write(
|
||||
"[Competitors] --competitors requires either a configured web "
|
||||
"search backend (BRAVE_API_KEY / EXA_API_KEY / SERPER_API_KEY / "
|
||||
"PARALLEL_API_KEY) or an explicit --competitors-list. If you are "
|
||||
"the hosting reasoning model (Claude Code, Codex, Hermes, "
|
||||
"Gemini, or any agent runtime), generate a comma-separated list "
|
||||
"of 2-6 peer entities for the topic and re-invoke with "
|
||||
"--competitors-list=\"A,B,C\". You do not need API keys; you ARE "
|
||||
"the LLM. The web-search discovery path below is the headless / "
|
||||
"credentialed path only.\n"
|
||||
)
|
||||
return 2
|
||||
discovered = competitors_mod.discover_competitors(
|
||||
topic, comp_count, config, lookback_days=args.lookback_days,
|
||||
)
|
||||
if not discovered:
|
||||
sys.stderr.write(
|
||||
f"[Competitors] No peers discovered for {topic!r}; aborting "
|
||||
"comparison run. Pass --competitors-list to override.\n"
|
||||
)
|
||||
return 2
|
||||
|
||||
sys.stderr.write(
|
||||
f"[Competitors] Comparing: {topic} vs " + " vs ".join(discovered) + "\n"
|
||||
)
|
||||
|
||||
def _competitor_runner(entity: str) -> schema.Report:
|
||||
return pipeline.run(
|
||||
topic=entity,
|
||||
config=config,
|
||||
depth=depth,
|
||||
requested_sources=requested_sources,
|
||||
mock=args.mock,
|
||||
web_backend=args.web_backend,
|
||||
lookback_days=args.lookback_days,
|
||||
)
|
||||
|
||||
entity_reports = fanout.run_competitor_fanout(
|
||||
main_topic=topic,
|
||||
main_runner=_main_runner,
|
||||
competitors=discovered,
|
||||
competitor_runner=_competitor_runner,
|
||||
)
|
||||
if len(entity_reports) < 2:
|
||||
progress.end_processing()
|
||||
sys.stderr.write(
|
||||
f"[Competitors] Fewer than 2 sub-runs survived ({len(entity_reports)}); "
|
||||
"cannot render a comparison. Re-run without --competitors or check the "
|
||||
"warnings above.\n"
|
||||
)
|
||||
return 1
|
||||
report = entity_reports[0][1]
|
||||
report.artifacts["competitor_reports"] = entity_reports
|
||||
else:
|
||||
report = _main_runner()
|
||||
except Exception as exc:
|
||||
progress.end_processing()
|
||||
progress.show_error(str(exc))
|
||||
@@ -420,7 +588,15 @@ def main() -> int:
|
||||
)
|
||||
report.artifacts["pre_research_flags_present"] = pre_research_flags_present
|
||||
|
||||
rendered = emit_output(report, args.emit, fun_level=fun_level, save_path=footer_save_path)
|
||||
entity_reports = report.artifacts.get("competitor_reports") if hasattr(report, "artifacts") else None
|
||||
if entity_reports:
|
||||
rendered = emit_comparison_output(
|
||||
entity_reports, args.emit, fun_level=fun_level, save_path=footer_save_path,
|
||||
)
|
||||
else:
|
||||
rendered = emit_output(
|
||||
report, args.emit, fun_level=fun_level, save_path=footer_save_path,
|
||||
)
|
||||
if args.save_dir:
|
||||
save_path = save_output(report, args.emit, args.save_dir, suffix=args.save_suffix or "")
|
||||
sys.stderr.write(f"[last30days] Saved output to {save_path}\n")
|
||||
|
||||
@@ -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