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:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "last30days",
|
"name": "last30days",
|
||||||
"version": "3.0.12",
|
"version": "3.0.14",
|
||||||
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.",
|
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources. AI agent scores by upvotes, likes, and real money - not editors.",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Matt Van Horn",
|
"name": "Matt Van Horn",
|
||||||
|
|||||||
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [3.0.14] - 2026-04-22
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Comparison-mode title attribution.** The synthesis title for vs-mode and `--competitors` outputs changes from `What the Community Says (Last 30 Days)` to `What the Community Says (/Last30Days)`. Surfaces the slash-command identity instead of restating the date range. Three SKILL.md occurrences updated; pure documentation change.
|
||||||
|
|
||||||
|
## [3.0.13] - 2026-04-22
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **vs mode runs N full passes in parallel, one per entity.** Architectural revert of the 3-pass → 1-pass latency optimization from an earlier version. `/last30days "OpenAI vs Anthropic vs xAI"` now runs three full `pipeline.run()` calls in parallel via the same fanout `--competitors` uses, producing three `*-raw.md` save files plus a merged comparison output. Each entity gets its own Step 0.55-grade targeting, own primary X handle weight, own subreddit scoping — apples-to-apples depth instead of the one-pool merged retrieval the single-pass path produced. Parallel execution keeps wall clock ≈ single pass.
|
||||||
|
- **`--competitors` is now a SKILL.md-level shortcut for vs-mode with auto-discovery.** The hosting reasoning model (Claude Code, Codex, Hermes, Gemini, any agent with WebSearch) performs discovery and Step 0.55 per entity via its own WebSearch tool, then invokes the engine with a vs-topic and `--competitors-plan` JSON. The engine flag remains for headless/cron use with BRAVE/EXA/SERPER/PARALLEL/OPENROUTER keys (engine-internal `auto_resolve` stays as fallback).
|
||||||
|
- **LAW 7-style stderr for `--competitors` with no backend** now leads with the hosting-model path (WebSearch + Step 0.55 + `--competitors-plan`) instead of `BRAVE_API_KEY`. API-key framing moved to a secondary "headless" section.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`--competitors-plan` JSON flag** for per-entity Step 0.55 targeting. Schema: `{entity_name: {x_handle?, x_related?, subreddits?, github_user?, github_repos?, context?}}`. Accepts inline JSON or a file path (matches `--plan`). When present for an entity, skips engine-internal `auto_resolve` and uses the provided values; missing fields fall back to `auto_resolve` (if backend) or planner defaults. Case-insensitive entity matching. The `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** when `--save-dir` is set on a vs-mode or `--competitors` run. Each entity's sub-run produces its own `{slug}-raw.md` with a single-row Resolved Entities block — matches historical vs-mode behavior (N passes → N save files).
|
||||||
|
- **`--polymarket-keywords "kw1,kw2"`** to filter Polymarket matches for ambiguous single-token topics (e.g., "Warriors" → `nba,gsw,golden-state` kills Glasgow Warriors rugby and Honor of Kings Rogue Warriors noise).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **BRAVE/SERPER footer nudge suppressed** when `--plan` or `--competitors-plan` is present. The nudge told Claude Code users to set an API key when they already have WebSearch via the hosting model. Nudge still fires for true headless runs (no `--plan`, no backend) where the advice is correct.
|
||||||
|
- **Override-leak regression testing.** 3.0.12 already fixed the main-topic `--subreddits` / `--x-handle` / `--github-*` from leaking into peer sub-runs via explicit per-entity kwargs scrubbing. This release adds a 4-test regression suite (`test_competitor_subrun_isolation.py`) locking in the invariant.
|
||||||
|
|
||||||
## [3.0.12] - 2026-04-22
|
## [3.0.12] - 2026-04-22
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ When the same story appears on Reddit, X, and YouTube, v3 merges them into one c
|
|||||||
|
|
||||||
### Auto-discovered competitor comparisons
|
### Auto-discovered competitor comparisons
|
||||||
|
|
||||||
`/last30days OpenAI --competitors` discovers the top 2 peers via web search (Anthropic, xAI), runs the full pipeline on each in parallel, and returns one 3-way comparison report. Override with `--competitors=N` (range 1..6) or `--competitors-list="A,B,C"`.
|
`/last30days OpenAI --competitors` tells the hosting reasoning model to discover the top 2 peers via WebSearch (Anthropic, xAI), run Step 0.55 per entity, and invoke the engine with `"OpenAI vs Anthropic vs xAI"` and a per-entity `--competitors-plan` JSON. The engine fans out 3 full pipelines in parallel, saves a `*-raw.md` file per entity, and merges them into a 3-way comparison. Same mechanics power `/last30days "OpenAI vs Anthropic vs xAI"` directly.
|
||||||
|
|
||||||
### GitHub person-mode
|
### GitHub person-mode
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ Replace `{VERSION}` with the installed plugin version (`jq -r '.version' "$SKILL
|
|||||||
|
|
||||||
**Placement by query type:**
|
**Placement by query type:**
|
||||||
- GENERAL / NEWS / PROMPTING / RECOMMENDATIONS: badge on line 1, blank line 2, `What I learned:` on line 3, then bold-lead-in paragraphs
|
- GENERAL / NEWS / PROMPTING / RECOMMENDATIONS: badge on line 1, blank line 2, `What I learned:` on line 3, then bold-lead-in paragraphs
|
||||||
- COMPARISON: badge on line 1, blank line 2, `# {TOPIC_A} vs {TOPIC_B} [vs {TOPIC_C}]: What the Community Says (Last 30 Days)` on line 3, then Quick Verdict section
|
- COMPARISON: badge on line 1, blank line 2, `# {TOPIC_A} vs {TOPIC_B} [vs {TOPIC_C}]: What the Community Says (/Last30Days)` on line 3, then Quick Verdict section
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -128,7 +128,7 @@ These LAWs dominate every other rule in this file. If you find yourself about to
|
|||||||
|
|
||||||
**LAW 2 - NO INVENTED TITLE LINE (with COMPARISON exception).** For QUERY_TYPE GENERAL, NEWS, PROMPTING, RECOMMENDATIONS: the first line of your synthesis body (after the badge and one blank line) is the prose label `What I learned:` on its own line. Not `What I learned about {Topic}`, not `{Topic} - Last 30 Days`, not `{Topic}: What People Are Saying`, not `# {Topic}`, not `The headline`, not `Why he is everywhere this month`. Nothing above `What I learned:` except the badge. If you are tempted to write a title or a `##`-prefixed section name, the rule is: the badge IS the title, and section headers are forbidden (see LAW 4).
|
**LAW 2 - NO INVENTED TITLE LINE (with COMPARISON exception).** For QUERY_TYPE GENERAL, NEWS, PROMPTING, RECOMMENDATIONS: the first line of your synthesis body (after the badge and one blank line) is the prose label `What I learned:` on its own line. Not `What I learned about {Topic}`, not `{Topic} - Last 30 Days`, not `{Topic}: What People Are Saying`, not `# {Topic}`, not `The headline`, not `Why he is everywhere this month`. Nothing above `What I learned:` except the badge. If you are tempted to write a title or a `##`-prefixed section name, the rule is: the badge IS the title, and section headers are forbidden (see LAW 4).
|
||||||
|
|
||||||
**COMPARISON exception:** For QUERY_TYPE=COMPARISON (topics containing `vs` or `versus`), the title `# {TOPIC_A} vs {TOPIC_B} [vs {TOPIC_C}]: What the Community Says (Last 30 Days)` is REQUIRED, not a violation. Comparison queries do NOT use the `What I learned:` prose label at all.
|
**COMPARISON exception:** For QUERY_TYPE=COMPARISON (topics containing `vs` or `versus`), the title `# {TOPIC_A} vs {TOPIC_B} [vs {TOPIC_C}]: What the Community Says (/Last30Days)` is REQUIRED, not a violation. Comparison queries do NOT use the `What I learned:` prose label at all.
|
||||||
|
|
||||||
**Global-preference override:** The skill-authored template for GENERAL / NEWS / PROMPTING / RECOMMENDATIONS queries uses `**bold**` for KEY PATTERNS items and for mid-paragraph lead-ins. Do NOT strip this bold on the grounds of a personal "no bold" memory. The skill's voice contract is the formatting authority here.
|
**Global-preference override:** The skill-authored template for GENERAL / NEWS / PROMPTING / RECOMMENDATIONS queries uses `**bold**` for KEY PATTERNS items and for mid-paragraph lead-ins. Do NOT strip this bold on the grounds of a personal "no bold" memory. The skill's voice contract is the formatting authority here.
|
||||||
|
|
||||||
@@ -570,43 +570,61 @@ Generated: {date} | Sources: Reddit, X, Bluesky, YouTube, TikTok, HN, Polymarket
|
|||||||
|
|
||||||
## If QUERY_TYPE = COMPARISON
|
## If QUERY_TYPE = COMPARISON
|
||||||
|
|
||||||
When the user asks "X vs Y", run ONE research pass with a comparison-optimized plan that covers both entities AND their rivalry. This replaces the old 3-pass approach (which took 13+ minutes and produced tangential content).
|
When the user asks "X vs Y" (or "X vs Y vs Z"), the engine fans out N full `pipeline.run()` calls in parallel — one per entity — each with its own Step 0.55-grade targeting. This restored the old N-pass architecture (reverted the one-pass latency optimization that removed per-entity depth); parallel execution keeps wall clock ≈ a single pass.
|
||||||
|
|
||||||
**IMPORTANT: Include BOTH X handles (`--x-handle={TOPIC_A_HANDLE} --x-related={TOPIC_B_HANDLE},{COMPANY_HANDLES},{COMMENTATOR_HANDLES}`), `--subreddits={RESOLVED_SUBREDDITS}`, `--tiktok-hashtags={RESOLVED_HASHTAGS}`, `--tiktok-creators={RESOLVED_TIKTOK_CREATORS}`, and `--ig-creators={RESOLVED_IG_CREATORS}` from Step 0.55. Omit any flag where the value was not resolved (empty).**
|
**MANDATORY per-entity resolution.** For each entity, resolve the full Step 0.55 stack (X handle, subreddits, GitHub user/repos, news context). Then assemble a `--competitors-plan` JSON mapping each entity to its targeting, and invoke the engine ONCE with the vs-topic string.
|
||||||
|
|
||||||
**Single pass with entity-aware subqueries:**
|
**Output shape per run:**
|
||||||
|
- Main topic saves to `{main-slug}-raw.md`.
|
||||||
|
- Each peer saves to `{peer-slug}-raw.md`.
|
||||||
|
- Stdout shows a merged comparison with the `## Head-to-Head` scaffold + per-entity Resolved Entities block.
|
||||||
|
|
||||||
|
**Invocation:**
|
||||||
```bash
|
```bash
|
||||||
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" "{TOPIC_A} vs {TOPIC_B}" --emit=compact --save-dir="${LAST30DAYS_MEMORY_DIR}" --save-suffix=v3 --plan 'COMPARISON_PLAN_JSON' --x-handle={TOPIC_A_HANDLE} --x-related={TOPIC_B_HANDLE},{COMPANY_A_HANDLE},{COMPANY_B_HANDLE},{COMMENTATOR_HANDLES} --subreddits={RESOLVED_SUBREDDITS} --tiktok-hashtags={RESOLVED_HASHTAGS} --tiktok-creators={RESOLVED_TIKTOK_CREATORS} --ig-creators={RESOLVED_IG_CREATORS}
|
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" "{TOPIC_A} vs {TOPIC_B} vs {TOPIC_C}" \
|
||||||
|
--emit=compact \
|
||||||
|
--save-dir="${LAST30DAYS_MEMORY_DIR}" \
|
||||||
|
--save-suffix=v3 \
|
||||||
|
--x-handle={TOPIC_A_HANDLE} \
|
||||||
|
--subreddits={TOPIC_A_SUBS} \
|
||||||
|
--competitors-plan '{
|
||||||
|
"{TOPIC_B}": {"x_handle":"{TOPIC_B_HANDLE}","subreddits":["{TOPIC_B_SUB_1}","{TOPIC_B_SUB_2}"],"github_user":"{TOPIC_B_GH}","context":"{TOPIC_B_CONTEXT}"},
|
||||||
|
"{TOPIC_C}": {"x_handle":"{TOPIC_C_HANDLE}","subreddits":["{TOPIC_C_SUB_1}"],"github_user":"{TOPIC_C_GH}","context":"{TOPIC_C_CONTEXT}"}
|
||||||
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
**The `--plan` JSON for comparisons should include 3-4 subqueries:**
|
Topic A (the main topic, first in the vs-string) uses outer `--x-handle`, `--x-related`, `--subreddits`, `--github-user`, `--github-repo`, `--tiktok-*`, `--ig-creators` as usual. Topics B and C get their targeting from `--competitors-plan` entries (keyed by entity name, case-insensitive).
|
||||||
1. **Head-to-head:** `"{TOPIC_A} vs {TOPIC_B}"` - catches rivalry content, direct comparisons
|
|
||||||
2. **Entity A news:** `"{TOPIC_A} news {MONTH} {YEAR}"` - catches entity-specific developments
|
|
||||||
3. **Entity B news:** `"{TOPIC_B} news {MONTH} {YEAR}"` - catches entity-specific developments
|
|
||||||
4. (Optional) **Domain context:** `"{COMPANY_A} {COMPANY_B} {DOMAIN} news"` - catches industry context (e.g., "OpenAI Anthropic AI news")
|
|
||||||
|
|
||||||
ALL subqueries include ALL sources. The fusion engine handles deduplication across subqueries. **At least one subquery MUST include YouTube-specific search terms** (e.g., "{PERSON} interview 2026", "{PRODUCT_A} vs {PRODUCT_B} review") to ensure YouTube content is found. Without YouTube-specific terms, the engine may only find 0-1 videos for comparison queries.
|
**Step 0.55 for N entities.** The same pre-research protocol that applies to a single-entity topic applies to EACH entity in a vs-run. For N=3, that means 3 WebSearches for X handles, 3 for subreddits, 3 for GitHub, 3 for news context — or equivalent batched queries. A `## Resolved Entities` block with dashes for any entity means you skipped Step 0.55 for that one. Re-run with a corrected plan.
|
||||||
|
|
||||||
Then do WebSearch for: `{TOPIC_A} vs {TOPIC_B} comparison {YEAR}` and `{TOPIC_A} vs {TOPIC_B} which is better` and `{COMPANY_A} vs {COMPANY_B} news {MONTH} {YEAR}`.
|
**Then do WebSearch supplements** for: `{TOPIC_A} vs {TOPIC_B} comparison {YEAR}` and `{TOPIC_A} vs {TOPIC_B} which is better` — these catch rivalry articles that per-entity passes might not surface.
|
||||||
|
|
||||||
**Skip the normal Step 1 below** - go directly to the comparison synthesis format (see "If QUERY_TYPE = COMPARISON" in the synthesis section).
|
**Skip the normal Step 1 below** - go directly to the comparison synthesis format (see "If QUERY_TYPE = COMPARISON" in the synthesis section).
|
||||||
|
|
||||||
**COMPARISON TABLE SCAFFOLD (engine-emitted, pass through verbatim):** For comparison topics, the engine's compact output includes a `## Head-to-Head Comparison` block with an empty markdown table (columns = entities, rows = axes like "Core pitch", "Who it's for", "Community stance", "Trajectory") plus a "Choose X if / Choose Y if" prose block. Your synthesis MUST include this block verbatim with filled cells, positioned between the narrative and the emoji-tree footer. Keep each cell to 5-15 words. Use ' - ' (hyphen with spaces) not em-dashes inside cells. The block is the canonical comparison output shape - do not invent your own table structure.
|
**COMPARISON TABLE SCAFFOLD (engine-emitted, pass through verbatim):** For comparison topics, the engine's compact output includes a `## Head-to-Head` block with an empty markdown table (columns = entities, rows = axes like "What it is", "Community sentiment", "Trajectory"). Your synthesis MUST include this block verbatim with filled cells, positioned between the narrative and the emoji-tree footer. Keep each cell to 5-15 words. Use ' - ' (hyphen with spaces) not em-dashes inside cells.
|
||||||
|
|
||||||
### Competitor mode (`--competitors`)
|
### Competitor mode (`--competitors`)
|
||||||
|
|
||||||
When the user passes `--competitors` on a single-entity topic, the engine auto-discovers 1-6 peer entities and fans out the full pipeline over the topic plus each competitor in parallel. Example: `last30days Kanye West --competitors` resolves to a 3-way comparison against Drake and Kendrick Lamar; `last30days OpenAI --competitors=3` resolves against Anthropic, xAI, and Google Gemini.
|
`--competitors` is a SKILL.md-level shortcut for vs-mode with auto-discovery. The engine flag itself just signals intent; YOU (the hosting reasoning model) do the discovery and Step 0.55 via your own WebSearch tool, then invoke the vs-topic path above.
|
||||||
|
|
||||||
**Flag surface:**
|
**The four-step protocol:**
|
||||||
- `--competitors` (bare) - discover and compare against 2 peers (3-way comparison: original + 2).
|
1. **Discover peers** via WebSearch: `"{topic} competitors"` / `"{topic} alternatives"`. Pick N=2 by default (match the flag's default), N=argument value if the user passed `--competitors=N`.
|
||||||
- `--competitors=N` - discover N peers (range 1..6; out-of-range clamps with a stderr warning).
|
2. **Run Step 0.55 for the main topic AND each peer** — same protocol you use for a single-entity topic, just N times. X handle, subreddits, GitHub, news context, per entity.
|
||||||
- `--competitors-list="A,B,C"` - skip discovery and use the explicit list. Implies `--competitors`.
|
3. **Build the vs-topic string**: `"{main} vs {peer1} vs {peer2}"`.
|
||||||
|
4. **Invoke the engine** with the vs-topic, `--competitors-plan` JSON covering both peers (and the main topic if you want to override the outer flags), and the outer `--x-handle`/`--subreddits`/`--github-*` for the main topic.
|
||||||
|
|
||||||
**Discovery path:** web search plus deterministic text mining (same backends as `--auto-resolve`). No internal LLM call. When no web search backend is configured and no list is passed, the engine emits a LAW 7-style stderr telling the hosting reasoning model to generate the list and re-invoke with `--competitors-list="A,B,C"`, then exits non-zero.
|
**Flag surface (engine):**
|
||||||
|
- `--competitors` (bare) - signals the hosting model to discover 2 peers (3-way total).
|
||||||
|
- `--competitors=N` - N peers (1..6; out-of-range clamps with stderr warning).
|
||||||
|
- `--competitors-list="A,B,C"` - minimum escape hatch; names only, no per-entity targeting. Peer sub-runs fall back to planner defaults (visibly thinner data).
|
||||||
|
- `--competitors-plan '{entity: {x_handle, subreddits, github_user, github_repos, context}}'` - full per-entity targeting; implies vs-mode; preferred.
|
||||||
|
- `--polymarket-keywords "kw1,kw2"` - disambiguate Polymarket for ambiguous single-token topics ("Warriors" → `nba,gsw,golden-state`).
|
||||||
|
|
||||||
**Sub-run behavior:** each entity runs `pipeline.run()` in parallel inheriting the main run's `--quick` / `--deep` / `--web-backend` / `--days`. Topic-specific overrides (`--x-handle`, `--subreddits`, `--github-user`, `--github-repo`) apply to the main topic only - competitor sub-runs use planner defaults. Per-entity failures degrade to a warning; the run continues as long as at least 2 entities survive.
|
**Why --competitors-plan over --competitors-list:** without per-entity handles/subs, peer sub-runs run with deterministic single-word planner queries and produce visibly thinner evidence than the main topic. The Resolved Entities block in stdout makes the gap visible — dashes for a peer = you skipped its Step 0.55.
|
||||||
|
|
||||||
**Output:** one comparison report covering all entities, reusing the same `## Head-to-Head` scaffold as explicit `A vs B` topics. Synthesis contract identical to the COMPARISON query type above.
|
**Engine-internal auto-resolve (headless fallback):** if the engine detects BRAVE_API_KEY / EXA_API_KEY / SERPER_API_KEY / PARALLEL_API_KEY / OPENROUTER_API_KEY, it runs its own per-entity `resolve.auto_resolve()` before each sub-run. The hosting-model path does NOT need those keys — you are the WebSearch. The engine's auto-resolve is the cron/CI fallback for when no reasoning model is driving.
|
||||||
|
|
||||||
|
**Output:** one `{slug}-raw.md` per entity in `--save-dir` plus the merged comparison on stdout. Synthesis contract identical to the vs-mode protocol above.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1187,7 +1205,7 @@ Voice contract LAWs 1, 3, 5 apply to comparisons unchanged (no `Sources:` block,
|
|||||||
```
|
```
|
||||||
🌐 last30days v{VERSION} · synced {YYYY-MM-DD}
|
🌐 last30days v{VERSION} · synced {YYYY-MM-DD}
|
||||||
|
|
||||||
# {TOPIC_A} vs {TOPIC_B} [vs {TOPIC_C}]: What the Community Says (Last 30 Days)
|
# {TOPIC_A} vs {TOPIC_B} [vs {TOPIC_C}]: What the Community Says (/Last30Days)
|
||||||
|
|
||||||
## Quick Verdict
|
## Quick Verdict
|
||||||
|
|
||||||
|
|||||||
+394
@@ -0,0 +1,394 @@
|
|||||||
|
---
|
||||||
|
title: "fix: --competitors runs a full last30days per entity with hosting-model pre-resolve"
|
||||||
|
type: fix
|
||||||
|
status: active
|
||||||
|
date: 2026-04-22
|
||||||
|
origin: docs/plans/2026-04-22-003-fix-competitors-per-entity-resolution-plan.md
|
||||||
|
---
|
||||||
|
|
||||||
|
# fix: --competitors runs a full last30days per entity with hosting-model pre-resolve
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
User intent confirmed 2026-04-22: `--competitors` should run a full single-entity `last30days` pipeline for the main topic AND for each discovered peer — three independent full-depth passes, each with its own Step 0.55 resolution, own X handle primary weight, own subreddit targeting, own GitHub repo scoping. Then merge them into the comparison output.
|
||||||
|
|
||||||
|
3.0.12 already built the N-parallel-pipelines orchestration (`scripts/lib/fanout.py`). What it got wrong: it tried to do per-entity Step 0.55 engine-side via `resolve.auto_resolve()`, which requires a web search backend key (BRAVE/EXA/SERPER/PARALLEL/OPENROUTER). Matt runs from Claude Code, which has its own WebSearch tool. The engine has none of those keys, so per-entity auto_resolve silently no-ops and all peer sub-runs fall through to deterministic single-word planner queries.
|
||||||
|
|
||||||
|
Four 2026-04-22 test runs (Warriors, Seattle, Arizona Wildcats, Kanye West) confirmed this via engine receipts:
|
||||||
|
|
||||||
|
- Compact Resolved Entities block shows peers as `X - | Subs - | GitHub - | Context: -`.
|
||||||
|
- Sub-run planner lines show `source=deterministic, subqueries=1` — the "I gave up and keyword-searched" shape.
|
||||||
|
- Engine footer keeps nudging `💡 You can unlock native grounded web search with BRAVE_API_KEY or SERPER_API_KEY`, which is wrong advice for a Claude Code user who already has WebSearch.
|
||||||
|
- Kanye run leaked main topic's `--subreddits` into Drake's and Kendrick's sub-runs (regression bug).
|
||||||
|
|
||||||
|
The fix is to flip the resolution responsibility: the hosting model (Claude Code, Codex, Hermes, Gemini) does Step 0.55 via its own WebSearch tool for every entity, then passes the resolved targeting to the engine via a new `--competitors-plan` JSON flag. Engine fan-out remains — each peer still runs a full `pipeline.run()`. The difference is the peers now arrive with full targeting, equivalent to the main topic, so retrieval is apples-to-apples.
|
||||||
|
|
||||||
|
Why not just reuse vs-mode? vs-mode is a SINGLE `pipeline.run()` with a comparison-optimized plan. It pre-resolves Step 0.55 per entity but merges everything into one retrieval pool with lower-weight `--x-related` for peers, merged subreddits, and cross-entity keyword noise. That is not "three full passes." The user explicitly wants three full passes.
|
||||||
|
|
||||||
|
## Problem Frame
|
||||||
|
|
||||||
|
3.0.12's architecture was correct; its data dependency was wrong.
|
||||||
|
|
||||||
|
| Capability | 3.0.12 path | Target path (this plan) |
|
||||||
|
|---|---|---|
|
||||||
|
| Fan out to N parallel pipelines | Yes (`fanout.run_competitor_fanout`) | Same — keep |
|
||||||
|
| Per-entity Step 0.55 resolution | Engine-internal `resolve.auto_resolve()` — needs BRAVE/EXA/SERPER/PARALLEL key | Hosting model does it via its own WebSearch, passes to engine |
|
||||||
|
| Per-entity targeting threaded into `pipeline.run()` | Main topic only via outer flags; peers via auto_resolve (failing) or nothing | Main topic via outer flags; peers via `--competitors-plan` JSON |
|
||||||
|
| Footer nudge | Unconditional BRAVE/SERPER | Suppressed when `--plan` or `--competitors-plan` present |
|
||||||
|
| Resolved Entities block in raw save file | Stdout only | Also in `--save-dir` raw file |
|
||||||
|
| Override-leak from main into peers | Present (Kanye receipt) | Fixed via explicit per-entity kwargs scrub |
|
||||||
|
| Polymarket noise on ambiguous topics | Present (Warriors, Arizona receipts) | `--polymarket-keywords` + auto-skip for single-token-ambiguous |
|
||||||
|
|
||||||
|
The key architectural change is who owns per-entity resolution. The engine stops trying to do it itself; the hosting model does it upstream (it already has WebSearch) and passes results in.
|
||||||
|
|
||||||
|
This is the same pattern `--plan` already uses for the main topic: hosting model generates the plan via its own reasoning, passes it in, engine accepts. We apply the pattern to peers.
|
||||||
|
|
||||||
|
## Requirements Trace
|
||||||
|
|
||||||
|
- R1. New `--competitors-plan` JSON flag accepting per-entity targeting: `x_handle`, `x_related`, `subreddits`, `github_user`, `github_repos`, `context`. Implies `--competitors`. Per-entity values thread into that entity's `pipeline.run()`. Bypasses engine-internal `auto_resolve` for covered entities.
|
||||||
|
- R2. SKILL.md "Competitor mode" rewritten to make the hosting-model path canonical: (a) discover N peers via WebSearch, (b) run Step 0.55 per entity (main + peers) via WebSearch, (c) assemble `--competitors-plan` JSON, (d) invoke engine. Engine-internal auto_resolve remains as headless fallback.
|
||||||
|
- R3. The LAW 7-style stderr emitted when `--competitors` has no list, no plan, no backend is reframed: leads with "hosting reasoning model, use your WebSearch to run Step 0.55 per entity and pass `--competitors-plan`." Does not lead with BRAVE_API_KEY.
|
||||||
|
- R4. Footer nudge `💡 You can unlock native grounded web search with BRAVE_API_KEY...` is suppressed when `--plan` OR `--competitors-plan` was passed. Signal: hosting model is driving and already has WebSearch.
|
||||||
|
- R5. Override-leak fix: competitor sub-runs do not inherit main topic's `--subreddits`, `--x-handle`, `--x-related`, `--tiktok-hashtags`, `--tiktok-creators`, `--ig-creators`, `--github-user`, `--github-repo`. Sub-runs use only their own per-entity targeting (from `--competitors-plan` if provided, else engine-internal auto_resolve if backend, else planner defaults).
|
||||||
|
- R6. The `## Resolved Entities` block is also appended to the saved raw file when `--save-dir` is in use. Each entity's effective targeting (whatever was actually passed to its `pipeline.run()`) is visible on audit.
|
||||||
|
- R6b. When `--save-dir` is in use with a comparison run, each entity's sub-run ALSO saves its own standalone raw file — same format as a single-entity run. `/last30days Kanye West --competitors` produces `kanye-west-raw.md`, `drake-raw.md`, `kendrick-lamar-raw.md` (one per entity) plus the merged comparison file. Matches the historical vs-mode behavior when it ran as N passes.
|
||||||
|
- R7. Polymarket disambiguation: support `--polymarket-keywords "kw1,kw2"` to filter market matches; auto-skip Polymarket when topic is single-token-ambiguous and no override is provided.
|
||||||
|
- R8. Default `--competitors` count remains 2 (3-way: main + 2 peers). Unchanged from 3.0.12.
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
- No changes to `scripts/lib/fanout.py` architecture. N parallel pipelines stays. Only the data each sub-run receives changes.
|
||||||
|
- No changes to the vs-mode (topic contains "vs" / "versus") behavior. That path is independent.
|
||||||
|
- No new emit modes. Comparison output format unchanged.
|
||||||
|
- No deprecation of `--competitors-list`. Stays as the minimum escape hatch for hosting models that skip per-entity Step 0.55 (names-only).
|
||||||
|
|
||||||
|
### Deferred to Separate Tasks
|
||||||
|
|
||||||
|
- Cache layer for hosting-model competitor resolution: separate plan once cost evidence exists.
|
||||||
|
- Cross-source disambiguation beyond Polymarket: separate plan.
|
||||||
|
|
||||||
|
## Context & Research
|
||||||
|
|
||||||
|
### Relevant Code and Patterns
|
||||||
|
|
||||||
|
- `scripts/last30days.py` — `--competitors` / `--competitors-list` argparse block, `resolve_competitors_args` validator, `_main_runner` closure, `_competitor_runner` closure, the `[Competitors] --competitors requires...` stderr block. Primary file for this plan.
|
||||||
|
- `scripts/lib/fanout.py` — `run_competitor_fanout` orchestrator. Signature unchanged; `_competitor_runner` closure now builds kwargs from `--competitors-plan`.
|
||||||
|
- `scripts/lib/pipeline.py` — `pipeline.run()` signature; no changes required (all per-entity flags already exist as kwargs).
|
||||||
|
- `scripts/lib/planner.py` — existing `--plan` parsing and validation, pattern to mirror for `--competitors-plan`.
|
||||||
|
- `scripts/lib/render.py` `_render_resolved_entities_block` (added in 3.0.12) — already reads `report.artifacts["resolved"]`; no change needed.
|
||||||
|
- `scripts/last30days.py` `save_output` / `render.render_full` — the save path. Needs to include the Resolved Entities block for comparison runs.
|
||||||
|
- `scripts/lib/quality_nudge.py` — where the BRAVE/SERPER footer nudge is emitted. Needs a context-aware suppression check.
|
||||||
|
- `scripts/lib/polymarket.py` — source adapter. Entry point for `--polymarket-keywords` filter and single-token-ambiguous auto-skip.
|
||||||
|
|
||||||
|
### Institutional Learnings
|
||||||
|
|
||||||
|
- 3.0.11 plan (`2026-04-22-002`): built the initial fanout, deferred per-entity resolve as "v1 simplification."
|
||||||
|
- 3.0.12 plan (`2026-04-22-003`): tried to close the gap via engine-internal `auto_resolve`. Works only with backend keys. Fails silently without.
|
||||||
|
- 2026-04-22 test session receipts: confirmed all four fixes in this plan are real, reproducible bugs.
|
||||||
|
- User's architectural steer 2026-04-22: "runs a full last30days on all 3 topics" — this plan encodes that explicitly as N full `pipeline.run()` calls with pre-resolved targeting per entity.
|
||||||
|
|
||||||
|
### External References
|
||||||
|
|
||||||
|
- None. All patterns in-repo.
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
|
||||||
|
- **`--competitors-plan` is a single JSON flag, not a fan of separate flags.** Mirrors `--plan`. Stable schema: `{entity_name: {x_handle, x_related, subreddits, github_user, github_repos, context}}`. Accept inline JSON or a file path (matches `--plan`).
|
||||||
|
- **Hosting-model-driven resolution is the documented default.** Engine-internal `auto_resolve` is the headless / cron fallback. SKILL.md routes hosting models to the JSON-flag path; engine keeps auto_resolve alive for BRAVE/EXA/SERPER users running CI.
|
||||||
|
- **Override-leak fix is call-site scrubbing, not a signature change.** `_competitor_runner` builds an explicit kwargs dict per entity from `_subrun_kwargs(entity, plan_entry)`. No closure-default fallthrough from main scope. The 3.0.12 `entity_config = dict(config)` deep-copy pattern extends to every per-entity flag.
|
||||||
|
- **Footer nudge becomes context-aware.** Suppressed when `--plan` or `--competitors-plan` present. Not suppressed for bare `--competitors-list` or bare invocations. Headless cron without keys still sees the nudge.
|
||||||
|
- **Polymarket disambiguation is additive and conservative.** `--polymarket-keywords` is explicit; auto-skip only fires for a known list of single-token-ambiguous names (states, common nouns). Stderr notes the skip so it is observable and overridable.
|
||||||
|
- **Per-entity sub-runs get the full `pipeline.run()` pass.** Same depth, same sources, same API cost per entity as a single-topic run. This is the explicit user intent — three full passes, not one merged pass.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
### Resolved During Planning
|
||||||
|
|
||||||
|
- **JSON or multi-flag?** JSON. Matches `--plan`.
|
||||||
|
- **Default count?** 2 peers (3-way comparison). Unchanged from 3.0.12.
|
||||||
|
- **Does engine-internal auto_resolve stay alive?** Yes, for entities not covered by `--competitors-plan` when a backend is configured. Headless/cron users with keys keep the current 3.0.12 behavior.
|
||||||
|
- **vs-mode or fanout?** Fanout. User's explicit ask: three full passes, not one merged pass. vs-mode merges into one pipeline with lower peer weighting, which is not what the user wants.
|
||||||
|
- **Does the save file need per-entity clusters?** Start with the Resolved block appended. Per-entity cluster sections can follow in a separate task; they are nice-to-have, not blocking.
|
||||||
|
|
||||||
|
### Deferred to Implementation
|
||||||
|
|
||||||
|
- Exact trace of override-leak source. Candidates: closure capture of `subreddits` in `_competitor_runner`, shared `_auto_resolve_context` leak, Reddit adapter inheriting global config. Test-first; trace at implementation time.
|
||||||
|
- Heuristic for "single-token-ambiguous topic" auto-skip. Start with a short hard-coded list (US state names, US city names, common nouns like "Warriors", "Suns", "Jets"); revisit after dogfood.
|
||||||
|
- Whether per-entity coverage warnings fire when `--competitors-plan` under-resolves an entity (e.g., only `x_handle`, no subreddits). Start with stderr logging; revisit UX.
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
- [ ] **Unit 1: `--competitors-plan` JSON flag + per-entity kwargs threading**
|
||||||
|
|
||||||
|
**Goal:** New CLI flag accepting per-entity targeting JSON. Each covered entity's `pipeline.run()` receives its own `x_handle` / `x_related` / `subreddits` / `github_user` / `github_repos` / `context`. Skips engine-internal `auto_resolve` for covered entities.
|
||||||
|
|
||||||
|
**Requirements:** R1, R5 (primary leak fix site)
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/last30days.py` (argparse + parse + `_competitor_runner`)
|
||||||
|
- Possibly modify: `scripts/lib/fanout.py` (no signature change expected; verify)
|
||||||
|
- Test: `tests/test_cli_competitors.py` (extend)
|
||||||
|
- Test: `tests/test_competitors_plan_threading.py` (new)
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Add `--competitors-plan` argparse flag. Accepts inline JSON OR a file path (mirror `--plan`).
|
||||||
|
- Validation: parse JSON; must be a dict; each value must be a dict; unknown fields log warnings; malformed input exits 2.
|
||||||
|
- Schema per entity: optional fields `x_handle` (str), `x_related` (list), `subreddits` (list), `github_user` (str), `github_repos` (list), `context` (str).
|
||||||
|
- Case-insensitive matching against `--competitors-list` / discovered entities.
|
||||||
|
- Build `_subrun_kwargs(entity, plan_entry)` helper. Returns a complete, explicit kwargs dict for `pipeline.run()` with no closure-default fallthrough from main scope. This helper is the single source of truth for per-entity call args. It also fixes the override-leak (R5) by scrubbing all per-entity flags to None unless the plan (or auto_resolve) sets them.
|
||||||
|
- `_competitor_runner(entity)`:
|
||||||
|
1. Look up `plan_entry` from `--competitors-plan` (if any).
|
||||||
|
2. If plan covers entity fully, build kwargs from it; skip `auto_resolve`.
|
||||||
|
3. If plan partially covers or is absent, fall back to `auto_resolve` (3.0.12 behavior) when a backend is configured. Plan values win over auto_resolve values on conflict.
|
||||||
|
4. If neither plan nor backend, fall through to `pipeline.run()` with per-entity kwargs all None — engine uses planner defaults for that entity only (no leak).
|
||||||
|
- Deep-copy config per sub-run (already done in 3.0.12); merge per-entity `context` into `entity_config["_auto_resolve_context"]` only.
|
||||||
|
|
||||||
|
**Execution note:** Test-first for the override-leak regression (pass `--subreddits=A,B` on main + a peer, assert peer's `pipeline.run(subreddits=...)` is None or peer-specific).
|
||||||
|
|
||||||
|
**Patterns to follow:**
|
||||||
|
- `--plan` parsing at `scripts/last30days.py` (inline JSON or file path).
|
||||||
|
- 3.0.12's `_competitor_runner` closure for scope; extract the kwargs-build into `_subrun_kwargs` helper.
|
||||||
|
- `entity_config = dict(config)` deep-copy pattern from 3.0.12.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Happy path: `--competitors-plan '{"Drake": {"x_handle":"Drake","subreddits":["Drizzy"]}}'` → Drake's `pipeline.run` receives `x_handle="Drake"` and `subreddits=["Drizzy"]`; no `auto_resolve` call for Drake.
|
||||||
|
- Happy path: plan covers 2 of 3 entities, backend configured → covered entities skip auto_resolve; third falls back to auto_resolve.
|
||||||
|
- Happy path: plan file path accepted like `--plan` file path.
|
||||||
|
- Happy path: case-insensitive entity match (`Drake` in plan, `drake` in list).
|
||||||
|
- Edge case: unknown fields in plan entry → logged, ignored, run continues.
|
||||||
|
- Edge case: plan entry for entity not in list → ignored with warning.
|
||||||
|
- Error path: malformed JSON → exit 2.
|
||||||
|
- Error path: top-level JSON is list not dict → exit 2.
|
||||||
|
- Regression (leak fix): main `--subreddits=A,B` + `--competitors-list "Drake"` + no plan → Drake's `pipeline.run` receives `subreddits=None` (no leak).
|
||||||
|
- Regression (leak fix): same for `--x-handle`, `--x-related`, `--tiktok-*`, `--ig-creators`, `--github-*`.
|
||||||
|
- Regression (leak fix): main `--x-handle=kanyewest` + plan `{"Drake":{"x_handle":"Drake"}}` → Drake's sub-run gets `x_handle="Drake"`, NOT `"kanyewest"`.
|
||||||
|
- Integration: full main + 2 peers run via `--competitors-plan`; assert each sub-run's effective kwargs match expected per-entity values.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- All new and regression tests pass.
|
||||||
|
- Smoke run (mock mode + `--competitors-plan`): stderr shows `[Competitors] Drake: x=@Drake subs=Drizzy` line per entity; no `[AutoResolve]` calls for plan-covered entities; no leak of main topic's flags.
|
||||||
|
|
||||||
|
- [ ] **Unit 2: Reframe LAW 7-style stderr for hosting-model context**
|
||||||
|
|
||||||
|
**Goal:** When `--competitors` has no `--competitors-list`, no `--competitors-plan`, and no backend, stderr tells the hosting reasoning model to use its WebSearch tool for Step 0.55 per entity and pass `--competitors-plan`. Stops leading with BRAVE_API_KEY.
|
||||||
|
|
||||||
|
**Requirements:** R3
|
||||||
|
|
||||||
|
**Dependencies:** Unit 1 (flag must exist)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/last30days.py` (the existing `[Competitors] --competitors requires...` block)
|
||||||
|
- Test: `tests/test_competitors_no_backend_message.py` (new)
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Rewrite stderr in this order:
|
||||||
|
1. "If you are the hosting reasoning model (Claude Code, Codex, Hermes, Gemini, or any agent runtime with a WebSearch tool), YOU should: (a) discover N peers via WebSearch, (b) run Step 0.55 per entity (main + peers), (c) assemble a `--competitors-plan` JSON, (d) re-invoke. Skip this step and quality degrades — peer entities will run with planner defaults."
|
||||||
|
2. "If you are running headless (cron, CI, no hosting model), set BRAVE_API_KEY / EXA_API_KEY / SERPER_API_KEY / PARALLEL_API_KEY / OPENROUTER_API_KEY and re-run."
|
||||||
|
3. "Minimum escape hatch: `--competitors-list "A,B,C"` skips discovery but does not pre-resolve peers. Use only for quick tests."
|
||||||
|
- Exits non-zero as today.
|
||||||
|
|
||||||
|
**Patterns to follow:**
|
||||||
|
- Existing LAW 7 stderr in `planner.plan_query` for tone.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Happy path: stderr leads with "If you are the hosting reasoning model" and names `--competitors-plan` before any backend key.
|
||||||
|
- Happy path: stderr explicitly names `--competitors-plan` as the preferred override.
|
||||||
|
- Happy path: stderr does NOT say "requires either a configured web search backend OR an explicit --competitors-list" (the current 3.0.12 wording).
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- Test asserts ordering and required phrases.
|
||||||
|
|
||||||
|
- [ ] **Unit 3: Suppress BRAVE/SERPER footer nudge when hosting-model-driven**
|
||||||
|
|
||||||
|
**Goal:** The `💡 You can unlock native grounded web search with BRAVE_API_KEY or SERPER_API_KEY` footer is suppressed when `--plan` or `--competitors-plan` was passed (signal: hosting model is driving and already has WebSearch).
|
||||||
|
|
||||||
|
**Requirements:** R4
|
||||||
|
|
||||||
|
**Dependencies:** Unit 1
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/lib/quality_nudge.py` (or wherever nudge is emitted; verify during implementation)
|
||||||
|
- Test: `tests/test_footer_nudge_suppression.py` (new)
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Locate the nudge emission point.
|
||||||
|
- Add a suppression check: if `--plan` OR `--competitors-plan` was passed, skip the nudge. Otherwise, current behavior.
|
||||||
|
- Don't suppress the nudge for bare `--competitors-list` alone — that path isn't necessarily hosting-model-driven.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Happy path: `--plan` passed, no backend → nudge does NOT fire.
|
||||||
|
- Happy path: `--competitors-plan` passed, no backend → nudge does NOT fire.
|
||||||
|
- Happy path: `--competitors-list` only, no backend → nudge fires (current behavior).
|
||||||
|
- Happy path: no `--competitors`, no `--plan`, no backend → nudge fires (current behavior unchanged).
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- All four scenarios produce expected nudge presence/absence.
|
||||||
|
|
||||||
|
- [ ] **Unit 4: Per-entity save files + Resolved block in each**
|
||||||
|
|
||||||
|
**Goal:** When `--save-dir` is in use with a comparison run, each entity's sub-run saves its own standalone raw file (same format as a single-entity run), and each file includes the `## Resolved Entities` block so audits can see what targeting that entity received. Matches the historical vs-mode behavior when it was N passes.
|
||||||
|
|
||||||
|
**Requirements:** R6, R6b
|
||||||
|
|
||||||
|
**Dependencies:** Unit 1
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/last30days.py` (`save_output`, the save loop after fanout completes)
|
||||||
|
- Possibly modify: `scripts/lib/render.py` (`render_full` branch to include Resolved block when artifact is present)
|
||||||
|
- Test: `tests/test_save_raw_competitor_files.py` (new)
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- After fanout completes, iterate `report.artifacts["competitor_reports"]`. For each `(entity, entity_report)` tuple, call `save_output(entity_report, emit="md", save_dir=args.save_dir, suffix=args.save_suffix)` — same path a single-entity run takes.
|
||||||
|
- Each saved file uses its entity's slug as the filename (`drake-raw.md`, `kendrick-lamar-raw.md`). Main topic keeps the existing `kanye-west-raw.md` filename.
|
||||||
|
- Each file includes its own `## Resolved Entities` block (single-entity variant: one row for that entity only). This makes each sub-run's file self-describing — you can see what targeting was used without opening the comparison file.
|
||||||
|
- The merged comparison output (stdout) still includes the 3-row Resolved Entities block.
|
||||||
|
- Optional: also save a comparison summary file (e.g., `kanye-west-comparison-raw.md`) holding the merged multi-entity render. Start with per-entity files only; comparison summary is a follow-up if stdout-plus-individual-files is insufficient.
|
||||||
|
- Single-entity runs unchanged (no additional files, no block change).
|
||||||
|
|
||||||
|
**Patterns to follow:**
|
||||||
|
- Existing `save_output` invocation for single-entity runs (line 501 of current `scripts/last30days.py`).
|
||||||
|
- Existing slug generation (`slugify(topic)`) for filename consistency.
|
||||||
|
- `_render_resolved_entities_block` from 3.0.12 for the single-entity variant.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Happy path: `--competitors-list "Drake,Kendrick Lamar"` + `--save-dir=/tmp/x` → `/tmp/x/kanye-west-raw.md`, `/tmp/x/drake-raw.md`, `/tmp/x/kendrick-lamar-raw.md` all exist.
|
||||||
|
- Happy path: each peer file's first sections include that entity's Resolved Entities block with its own row only.
|
||||||
|
- Happy path: single-entity run with `--save-dir` → one file, unchanged from today's behavior.
|
||||||
|
- Edge case: entity slug collides with existing file → overwrite (matches single-entity behavior).
|
||||||
|
- Edge case: `--save-suffix=v3` → all 3 files get the suffix (`kanye-west-raw-v3.md`, `drake-raw-v3.md`, `kendrick-lamar-raw-v3.md`).
|
||||||
|
- Edge case: comparison run with one peer whose sub-run failed → that entity's file is NOT saved; others are.
|
||||||
|
- Integration: stderr after save shows three `[last30days] Saved output to <path>` lines, one per entity.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- After `/last30days Kanye West --competitors-list "Drake,Kendrick Lamar" --save-dir=/tmp/x`: `ls /tmp/x/*-raw.md` shows 3 files. Each contains its entity's Resolved block.
|
||||||
|
|
||||||
|
- [ ] **Unit 5: SKILL.md "Competitor mode" rewrite — hosting-model Step 0.55 canonical**
|
||||||
|
|
||||||
|
**Goal:** SKILL.md documents the hosting-model-driven path as canonical: discover N peers via WebSearch, run Step 0.55 per entity, assemble `--competitors-plan`, invoke engine. Engine-internal `auto_resolve` is labeled the headless fallback.
|
||||||
|
|
||||||
|
**Requirements:** R2
|
||||||
|
|
||||||
|
**Dependencies:** Unit 1 (flag must exist before documented)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `SKILL.md` (Competitor mode subsection)
|
||||||
|
- Modify: `README.md` (one-line example update)
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Replace the 3.0.12 Competitor mode subsection with a clear flow:
|
||||||
|
1. User invokes with `--competitors` or `--competitors=N`.
|
||||||
|
2. Hosting model runs WebSearch for "[topic] competitors" / "[topic] alternatives" → picks top N peers.
|
||||||
|
3. Hosting model runs Step 0.55 for main + each peer (x_handle, subreddits, github_user, github_repos, context) — same protocol as vs-mode per SKILL.md §679.
|
||||||
|
4. Hosting model assembles a `--competitors-plan` JSON object.
|
||||||
|
5. Hosting model invokes the engine with `--competitors-list "A,B,C" --competitors-plan '{...}'`.
|
||||||
|
6. Engine fans out N full pipelines (main + peers), each with its own full Step 0.55-grade targeting. Each entity also saves its own `*-raw.md` file when `--save-dir` is set (three full passes → three save files, matching the historical vs-mode behavior). Comparison output merges them for display.
|
||||||
|
- Concrete JSON example in SKILL.md showing the schema.
|
||||||
|
- Failure-mode warning: a `## Resolved Entities` block with dashes for any entity means hosting model skipped Step 0.55 for that one. Re-run with corrected plan.
|
||||||
|
- "Headless fallback" sub-subsection: when BRAVE/EXA/SERPER/PARALLEL/OPENROUTER is set, engine's internal `auto_resolve` handles peers and `--competitors-plan` is optional.
|
||||||
|
|
||||||
|
**Patterns to follow:**
|
||||||
|
- SKILL.md "Step 0.55" section for per-entity resolve protocol.
|
||||||
|
- SKILL.md "If QUERY_TYPE = COMPARISON" section for the same-protocol-as-vs-mode reference.
|
||||||
|
- Tone of existing 3.0.12 Competitor mode prose.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Test expectation: none — documentation. Verification is a fresh Claude Code window dogfood run.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- `/last30days Kanye West --competitors` in a new window: hosting model does Step 0.55 for Kanye + 2 discovered peers; passes `--competitors-plan`; rendered Resolved block shows non-empty fields for all 3; top voices include at least one peer-specific handle.
|
||||||
|
|
||||||
|
- [ ] **Unit 6: Polymarket disambiguation guard**
|
||||||
|
|
||||||
|
**Goal:** Support `--polymarket-keywords "kw1,kw2"` to filter market matches; auto-skip Polymarket when topic is single-token-ambiguous and no override is provided.
|
||||||
|
|
||||||
|
**Requirements:** R7
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/last30days.py` argparse (`--polymarket-keywords`)
|
||||||
|
- Modify: `scripts/lib/polymarket.py`
|
||||||
|
- Test: `tests/test_polymarket_disambiguation.py` (new)
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Add `--polymarket-keywords "kw1,kw2"` flag. When provided, Polymarket adapter filters market titles to those whose normalized text contains at least one keyword.
|
||||||
|
- Auto-skip rule: if topic is one token AND token matches a known-ambiguous list (US state names, US city names, common sports/color/animal words) AND no `--polymarket-keywords` provided, skip Polymarket with a stderr note.
|
||||||
|
- SKILL.md Step 0.55 protocol gets a small addition: for ambiguous topics, hosting model passes `--polymarket-keywords` with topic-specific qualifiers.
|
||||||
|
|
||||||
|
**Patterns to follow:**
|
||||||
|
- Existing Polymarket adapter match logic.
|
||||||
|
- Single-token detection heuristic.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Happy path: topic "Warriors", no override → Polymarket skipped; stderr notes the skip.
|
||||||
|
- Happy path: topic "Warriors", `--polymarket-keywords "nba,gsw"` → Polymarket runs; matches filtered.
|
||||||
|
- Happy path: topic "OpenAI" (no ambiguity) → Polymarket runs as before.
|
||||||
|
- Happy path: topic "Arizona Wildcats" (multi-token) → Polymarket runs as before.
|
||||||
|
- Edge case: `--polymarket-keywords ""` → treated as empty, no filter.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- Warriors smoke run → Polymarket footer absent OR filtered to nba/gsw markets.
|
||||||
|
|
||||||
|
- [ ] **Unit 7: Version 3.0.13, CHANGELOG, sync, hot-copy**
|
||||||
|
|
||||||
|
**Goal:** Ship 3.0.13 to all local targets.
|
||||||
|
|
||||||
|
**Requirements:** Closes R1-R7
|
||||||
|
|
||||||
|
**Dependencies:** Units 1-6
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.claude-plugin/plugin.json`
|
||||||
|
- Modify: `CHANGELOG.md`
|
||||||
|
- Run: `bash scripts/sync.sh`
|
||||||
|
- Hot-copy: `~/.claude/plugins/cache/last30days-skill/last30days/3.0.13/`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- CHANGELOG entry groups the fixes: Added `--competitors-plan` JSON flag for per-entity hosting-model pre-resolve. Fixed override-leak from main into peer sub-runs. Changed: LAW 7 stderr framing for hosting-model context. Changed: BRAVE/SERPER footer nudge suppressed when `--plan` / `--competitors-plan` is present. Added: Resolved Entities block persists to saved raw file. Added: `--polymarket-keywords` + auto-skip for ambiguous single-token topics.
|
||||||
|
- Beta channel first per CLAUDE.md.
|
||||||
|
- Hot-copy so public `/last30days` picks up 3.0.13 immediately.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Test expectation: none — packaging.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- `grep version .claude-plugin/plugin.json` returns 3.0.13.
|
||||||
|
- `sync.sh` exits 0.
|
||||||
|
- Hot-copy contains the new files with competitors.py, fanout.py, the updated SKILL.md, and plugin.json 3.0.13.
|
||||||
|
|
||||||
|
## System-Wide Impact
|
||||||
|
|
||||||
|
- **Interaction graph:** `_competitor_runner` becomes the single source of truth for sub-run kwargs via `_subrun_kwargs(entity, plan_entry)`. Every per-entity flag flows through one helper. No closure-default leaks.
|
||||||
|
- **Error propagation:** `--competitors-plan` JSON parse errors exit 2 with stderr (same as `--plan`). Per-entity plan entries with malformed values log warnings and fall back; don't abort the whole run.
|
||||||
|
- **State lifecycle risks:** `entity_config = dict(config)` already deep-copies for `_auto_resolve_context`; extend the isolation discipline to every per-entity flag. Verified in Unit 1 regression tests.
|
||||||
|
- **API surface parity:** `--competitors-plan` is additive. `--competitors` and `--competitors-list` unchanged. `--plan` unchanged. `--polymarket-keywords` additive.
|
||||||
|
- **Integration coverage:** New regression tests for override-leak. New integration test for plan-driven sub-run threading. New nudge-suppression test. New Polymarket disambiguation test.
|
||||||
|
- **Unchanged invariants:** `pipeline.run()` signature unchanged. `planner.plan_query` LAW 7 behavior for the default path unchanged. Single-entity render path unchanged. vs-mode behavior unchanged.
|
||||||
|
|
||||||
|
## Risks & Dependencies
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| Hosting model takes the lazy path and uses `--competitors-list` names-only. | Unit 2 stderr explicitly steers to `--competitors-plan` with Step 0.55 protocol named. Unit 5 SKILL.md docs. Resolved Entities dashes in output make the gap visible. |
|
||||||
|
| JSON gets verbose for the hosting model to construct repeatedly. | Schema is small (≤6 fields per entity). Hosting model already runs Step 0.55 for main topic in every comparison run; peers use the same protocol. One JSON block replaces N CLI flags. |
|
||||||
|
| Override-leak source is deeper than `_competitor_runner` closure. | Test-first per Unit 1. Receipts from 2026-04-22 Kanye run are reproducible. Trace methodically from call site. |
|
||||||
|
| Plan-covered entity bypasses auto_resolve but plan data is incomplete (e.g., no subreddits). | Hosting model's own SKILL.md contract says Step 0.55 must cover all fields. Stderr logs per-entity coverage so under-resolved entities are visible. Next-run correction, not engine-side rescue. |
|
||||||
|
| Polymarket auto-skip false-positives on legitimate ambiguous topics with real markets. | Conservative match (single-token + known list). `--polymarket-keywords` override is explicit and unambiguous. Stderr notes the skip. |
|
||||||
|
| Footer nudge suppression hides the message from headless users who genuinely need it. | Suppression only fires when `--plan` or `--competitors-plan` is present. Cron / CI runs that pass neither still see the nudge. |
|
||||||
|
|
||||||
|
## Documentation / Operational Notes
|
||||||
|
|
||||||
|
- Beta channel first per CLAUDE.md (private repo `/last30days-beta`).
|
||||||
|
- After merge: hot-copy to `~/.claude/plugins/cache/last30days-skill/last30days/3.0.13/`.
|
||||||
|
- CHANGELOG voice should call this out as the feedback-driven follow-up to 3.0.12. Reader should see "we tried engine-internal resolve in 3.0.12; it needs backend keys we don't have; we moved resolution to the hosting model in 3.0.13."
|
||||||
|
|
||||||
|
## Sources & References
|
||||||
|
|
||||||
|
- Origin plan (3.0.12): `docs/plans/2026-04-22-003-fix-competitors-per-entity-resolution-plan.md`
|
||||||
|
- Earlier plan (3.0.11): `docs/plans/2026-04-22-002-feat-competitors-flag-comparison-fanout-plan.md`
|
||||||
|
- 2026-04-22 test session receipts: Warriors, Seattle, Arizona Wildcats, Kanye West
|
||||||
|
- SKILL.md §551 "If QUERY_TYPE = COMPARISON" and §679 per-entity Step 0.55 protocol
|
||||||
|
- Related code: `scripts/lib/fanout.py`, `scripts/last30days.py` `_competitor_runner`, `scripts/lib/render.py` `_render_resolved_entities_block`, `scripts/lib/polymarket.py`, `scripts/lib/quality_nudge.py`
|
||||||
|
- Related PRs: #308 (3.0.11), #309 (3.0.12)
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
---
|
||||||
|
title: "feat: vs mode runs N full passes and --competitors is vs with auto-discovery"
|
||||||
|
type: feat
|
||||||
|
status: active
|
||||||
|
date: 2026-04-22
|
||||||
|
origin: docs/plans/2026-04-22-004-fix-competitors-hosting-model-resolve-and-leak-plan.md.superseded
|
||||||
|
---
|
||||||
|
|
||||||
|
# feat: vs mode runs N full passes and --competitors is vs with auto-discovery
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Architectural unification driven by user correction 2026-04-22: vs mode and `--competitors` are the same thing. A user typing `/last30days OpenAI vs Anthropic vs xAI` should get a full single-entity last30days pass for each of the three entities — three full pipelines, three saved `*-raw.md` files, merged into one comparison output. A user typing `/last30days OpenAI --competitors` should get the same output after the hosting model auto-picks 2 peers; i.e., `--competitors` is a thin shortcut that expands "topic + `--competitors`" into "topic vs peer1 vs peer2" and then runs the unified vs pipeline.
|
||||||
|
|
||||||
|
Current state diverges from this:
|
||||||
|
|
||||||
|
- **vs mode today**: one `pipeline.run()` with a comparison-optimized plan that merges all entities' targeting into a single retrieval pool. Lower-weight `--x-related` for peers, merged subreddits, cross-entity keyword noise. One saved file.
|
||||||
|
- **`--competitors` today (3.0.12)**: N parallel `pipeline.run()` calls via `scripts/lib/fanout.py`, but per-entity Step 0.55 depends on an engine-side web backend key Matt doesn't have. Silently degrades to planner defaults for peers. One saved file (main topic only). Override-leak from main into peers.
|
||||||
|
|
||||||
|
After this plan:
|
||||||
|
|
||||||
|
- **vs mode**: N parallel `pipeline.run()` calls, one per entity, each with its own full Step 0.55-grade targeting, each saving its own `*-raw.md`. Merged into one comparison output.
|
||||||
|
- **`--competitors`**: SKILL.md shortcut. Hosting model discovers N peers, builds `"topic vs peer1 vs peer2"`, and invokes the same vs pipeline. No separate orchestration path.
|
||||||
|
- **Same fanout machinery (`scripts/lib/fanout.py`)** serves both. One fix, both behaviors improve.
|
||||||
|
|
||||||
|
## Problem Frame
|
||||||
|
|
||||||
|
The product insight from 2026-04-22 test runs is simple: the user wants three full last30days reports plus a comparison merge. Not one comparison pass with N-way targeting merged into a single retrieval pool. Not one save file. Not "main gets Step 0.55, peers get planner defaults." Three full passes. Three save files. Merged output.
|
||||||
|
|
||||||
|
The historical vs mode did that (it ran as 3 passes, saving 3 files). SKILL.md §551 currently says:
|
||||||
|
|
||||||
|
> "When the user asks 'X vs Y', run ONE research pass with a comparison-optimized plan that covers both entities AND their rivalry. This replaces the old 3-pass approach (which took 13+ minutes and produced tangential content)."
|
||||||
|
|
||||||
|
That change was a latency optimization that removed the user-visible behavior the user wants. The fix is to revert the architectural direction: N passes per entity, in parallel rather than serial (parallelism lowers wall-clock to ~1× a single pass, not N×), with per-entity save files.
|
||||||
|
|
||||||
|
The 3.0.11 `--competitors` flag already introduced parallel N-pass machinery (`fanout.run_competitor_fanout`). The 3.0.12 follow-up tried to wire per-entity Step 0.55 into it but failed when no web backend was configured. The elegant move: stop maintaining two architectures. vs-mode and `--competitors` both use `fanout.py`. `--competitors` becomes a SKILL.md-level shortcut that discovers 2 peers and hands off to vs-mode.
|
||||||
|
|
||||||
|
Four 2026-04-22 test receipts (Warriors, Seattle, Arizona Wildcats, Kanye West) all confirmed the user's pain points:
|
||||||
|
|
||||||
|
- Peers thin because they ran without per-entity handle/sub targeting.
|
||||||
|
- Only one `*-raw.md` per run — no per-entity audit.
|
||||||
|
- Kanye peers leaked main topic's `--subreddits`.
|
||||||
|
- Engine footer nudging `BRAVE_API_KEY` to Claude Code users who already have WebSearch.
|
||||||
|
- Polymarket noise on ambiguous topics (Warriors → Glasgow rugby; Arizona → Diamondbacks).
|
||||||
|
|
||||||
|
This plan closes all of them by unifying the architecture and making hosting-model-driven Step 0.55 per entity the canonical path.
|
||||||
|
|
||||||
|
## Requirements Trace
|
||||||
|
|
||||||
|
- R1. vs mode (any topic containing ` vs ` / ` versus `) runs N full `pipeline.run()` calls in parallel, one per entity. Each sub-run uses its entity's own Step 0.55 targeting (from the hosting model's pre-resolution, passed via a new `--competitors-plan` JSON).
|
||||||
|
- R2. `--competitors` (and `--competitors=N`) becomes a SKILL.md-level shortcut: the hosting model (a) discovers N peers via WebSearch, (b) runs Step 0.55 per entity (main + peers), (c) rewrites the topic to `"main vs peer1 vs peer2"`, (d) invokes the engine with `--competitors-plan` containing each entity's targeting.
|
||||||
|
- R3. New `--competitors-plan` JSON flag. Schema: `{entity_name: {x_handle, x_related, subreddits, github_user, github_repos, context}}`. Implies vs mode when present with a single-entity topic. Applies per-entity targeting to each sub-run. Accepts inline JSON or a file path (matches `--plan`).
|
||||||
|
- R4. Each entity's sub-run saves its own `*-raw.md` file when `--save-dir` is in use. Example: `/last30days "Kanye West vs Drake vs Kendrick Lamar" --save-dir=~/Documents/Last30Days` produces `kanye-west-raw.md`, `drake-raw.md`, `kendrick-lamar-raw.md`. Same filenames a single-entity run of each topic would produce. Matches historical vs-mode behavior.
|
||||||
|
- R5. Each per-entity saved file includes its own single-row `## Resolved Entities` block so the audit survives. The merged comparison stdout still shows the full 3-row block.
|
||||||
|
- R6. Override-leak fix: no main-topic flags (`--subreddits`, `--x-handle`, `--x-related`, `--tiktok-*`, `--ig-creators`, `--github-*`) leak into peer sub-runs. Every per-entity kwarg is scrubbed at the sub-run call site.
|
||||||
|
- R7. LAW 7-style stderr for `--competitors` invocations with no list, no plan, no backend is reframed for hosting-model context: leads with "use your WebSearch to discover peers, resolve Step 0.55 per entity, re-invoke with `topic vs peer1 vs peer2 --competitors-plan '...'`." Does not lead with BRAVE_API_KEY.
|
||||||
|
- R8. Footer nudge `💡 You can unlock native grounded web search with BRAVE_API_KEY...` is suppressed when `--plan` or `--competitors-plan` was passed.
|
||||||
|
- R9. Polymarket disambiguation: support `--polymarket-keywords "kw1,kw2"` to filter market matches; auto-skip Polymarket when topic is single-token-ambiguous and no override is provided.
|
||||||
|
- R10. Default `--competitors` count stays 2 peers (3-way comparison). Unchanged from 3.0.12.
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
- No changes to single-entity `pipeline.run()` semantics. Each sub-run in vs mode behaves identically to a bare `/last30days {entity}` invocation.
|
||||||
|
- No changes to the planner's comparison-intent logic for single-entity-containing topics. The `_should_force_deterministic_plan` shortcut for vs-topics routes to fanout, not to its current single-pipeline path.
|
||||||
|
- No new emit modes. Comparison output format unchanged.
|
||||||
|
- No removal of `--competitors-list`. Stays as a minimum escape hatch (names-only, no per-entity targeting) for scripted headless use.
|
||||||
|
- No removal of engine-internal `resolve.auto_resolve()` in fanout. Remains as headless / cron fallback for users with BRAVE/EXA/SERPER/PARALLEL/OPENROUTER keys. The dominant Claude Code path bypasses it via `--competitors-plan`.
|
||||||
|
|
||||||
|
### Deferred to Separate Tasks
|
||||||
|
|
||||||
|
- Explicit "head-to-head" rivalry pass in vs-mode (a supplemental subquery like `"A vs B"` that catches rivalry articles missing from pure entity-scoped passes). Start with N independent passes; add a head-to-head supplemental pass if the rivalry-content gap shows up in dogfood.
|
||||||
|
- Cache layer for hosting-model pre-resolution.
|
||||||
|
- Cross-source disambiguation (not just Polymarket).
|
||||||
|
- Latency knob for users who want the old one-pass vs behavior (probably not needed; parallel N-pass is ~1× wall clock).
|
||||||
|
|
||||||
|
## Context & Research
|
||||||
|
|
||||||
|
### Relevant Code and Patterns
|
||||||
|
|
||||||
|
- `scripts/last30days.py` — main(), `_main_runner`, `_competitor_runner`, the competitor enable/discovery branch. Primary file.
|
||||||
|
- `scripts/lib/fanout.py` — existing orchestrator (3.0.11). Reused as-is; `competitor_runner` closure is where per-entity kwargs apply.
|
||||||
|
- `scripts/lib/planner.py` — `_should_force_deterministic_plan` detects vs-topics via regex. Current path synthesizes ONE comparison plan; new path routes to fanout.
|
||||||
|
- `scripts/lib/render.py` — `render_comparison_multi` (3.0.12) + `_render_resolved_entities_block`. Both reused. `render_full` needs a per-entity variant when saving sub-run files.
|
||||||
|
- `scripts/last30days.py` `save_output` — where raw files are written. Needs to iterate per entity when competitor_reports artifact present.
|
||||||
|
- `scripts/lib/quality_nudge.py` — BRAVE/SERPER nudge emission.
|
||||||
|
- `scripts/lib/polymarket.py` — source adapter for `--polymarket-keywords` and ambiguous-topic auto-skip.
|
||||||
|
- SKILL.md §551 "If QUERY_TYPE = COMPARISON" and §679 per-entity Step 0.55 protocol — the hosting-model contract that drives per-entity pre-resolution for both vs mode and `--competitors`.
|
||||||
|
|
||||||
|
### Institutional Learnings
|
||||||
|
|
||||||
|
- 3.0.11 plan (`2026-04-22-002`): built fanout.
|
||||||
|
- 3.0.12 plan (`2026-04-22-003`): tried engine-internal per-entity auto_resolve; failed without backend keys.
|
||||||
|
- 3.0.13 plan draft (`2026-04-22-004-...superseded`): proposed `--competitors-plan` JSON + vs-mode-shortcut path but kept them separate. User's 2026-04-22 correction unifies them.
|
||||||
|
- 2026-04-22 test receipts: Warriors, Seattle, Arizona Wildcats, Kanye West runs all reproduced the per-entity resolve gap.
|
||||||
|
- User's architectural steer: "vs mode should work that way too" + "--competitors is just vs mode with auto-discovery." This plan encodes that.
|
||||||
|
|
||||||
|
### External References
|
||||||
|
|
||||||
|
- None. All patterns in-repo.
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
|
||||||
|
- **Unify vs-mode and --competitors on one orchestrator.** `fanout.run_competitor_fanout` serves both. vs-mode is "topic contains ' vs '" detection → fanout. `--competitors` is "SKILL.md shortcut → hosting model rewrites topic to vs form → fanout." One code path.
|
||||||
|
- **Per-entity targeting via `--competitors-plan` JSON.** Schema `{entity_name: {x_handle, x_related, subreddits, github_user, github_repos, context}}`. Mirrors `--plan`. Applies to both vs-mode and `--competitors` paths. Hosting model passes it after running Step 0.55 per entity.
|
||||||
|
- **N save files, one per entity.** Each sub-run writes a `{entity-slug}-raw.md` file when `--save-dir` is set. Matches historical vs-mode behavior. Single-entity runs unchanged.
|
||||||
|
- **Revert the "one pass for latency" optimization that removed per-entity passes.** Parallel execution via `ThreadPoolExecutor` means wall-clock is ~max(per-entity-latency), not sum. The old latency concern (13+ minutes for 3 serial passes) does not apply to a parallel fan-out.
|
||||||
|
- **Override-leak fix at the call site.** `_subrun_kwargs(entity, plan_entry)` helper returns fully explicit per-entity kwargs; no closure-default fallthrough from main scope.
|
||||||
|
- **LAW 7 stderr reframed, not just updated.** Current message treats BRAVE_API_KEY as the solution. New message treats hosting-model Step 0.55 as the solution, with backend keys listed only as the headless fallback.
|
||||||
|
- **Polymarket disambiguation is additive and conservative.** `--polymarket-keywords` is explicit; auto-skip only fires for a known-ambiguous single-token list.
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
### Resolved During Planning
|
||||||
|
|
||||||
|
- **vs mode N passes or single-pass?** N passes. User's architectural correction.
|
||||||
|
- **Should --competitors still be an engine flag at all?** Yes, kept for headless / cron contexts with backend keys. Dominant Claude Code path is SKILL.md shortcut → vs-mode fanout. Engine flag stays as compatibility surface.
|
||||||
|
- **`--competitors-plan` JSON or multi-flag?** JSON. Matches `--plan`.
|
||||||
|
- **Default count?** 2 peers → 3-way comparison. Unchanged.
|
||||||
|
- **Saved-file naming?** `{entity-slug}-raw.md` per entity, same as single-entity runs would produce.
|
||||||
|
|
||||||
|
### Deferred to Implementation
|
||||||
|
|
||||||
|
- Exact trace of override-leak path (closure capture vs shared config vs Reddit adapter fallback). Test-first per Unit 2; patch at the right layer.
|
||||||
|
- Heuristic for single-token-ambiguous Polymarket auto-skip. Start with a short hard-coded list; iterate.
|
||||||
|
- Whether to include a head-to-head rivalry supplemental pass in vs-mode. Ship N-independent passes first; revisit after dogfood if rivalry content is missing.
|
||||||
|
- Exact filename convention when the comparison merged output is saved (if saved at all). Not blocking — per-entity files are the primary save artifact.
|
||||||
|
|
||||||
|
## High-Level Technical Design
|
||||||
|
|
||||||
|
> *This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
|
||||||
|
|
||||||
|
```
|
||||||
|
User invokes:
|
||||||
|
/last30days "OpenAI vs Anthropic vs xAI"
|
||||||
|
OR
|
||||||
|
/last30days OpenAI --competitors (hosting model rewrites to vs form)
|
||||||
|
OR
|
||||||
|
/last30days OpenAI --competitors-list "Anthropic,xAI"
|
||||||
|
OR
|
||||||
|
/last30days "OpenAI vs Anthropic vs xAI" --competitors-plan '{...per-entity...}'
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
scripts/last30days.py main():
|
||||||
|
- Detect: topic has " vs " OR --competitors enabled
|
||||||
|
- If --competitors and no list/plan: emit LAW 7-style stderr with hosting-model instruction
|
||||||
|
- If --competitors with list or discovery: rewrite topic to vs form, continue
|
||||||
|
- Parse --competitors-plan JSON, map to entities
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
fanout.run_competitor_fanout (shared path):
|
||||||
|
- For each entity (main + peers):
|
||||||
|
- entity_config = dict(config) [deep copy to prevent leak]
|
||||||
|
- kwargs = _subrun_kwargs(entity, plan_entry) [explicit; no main-topic leak]
|
||||||
|
- If plan_entry missing a field AND backend available: auto_resolve() fill
|
||||||
|
- pipeline.run(topic=entity, **kwargs, internal_subrun=True)
|
||||||
|
- Parallel ThreadPoolExecutor
|
||||||
|
- Collect per-entity Reports
|
||||||
|
- Attach resolved targeting to each Report.artifacts["resolved"]
|
||||||
|
|
||||||
|
↓
|
||||||
|
|
||||||
|
scripts/last30days.py after fanout:
|
||||||
|
- If --save-dir: save each entity's Report as {entity-slug}-raw.md
|
||||||
|
Each file includes its own single-row Resolved Entities block
|
||||||
|
- emit_comparison_output → render_comparison_multi (merged stdout)
|
||||||
|
Includes full N-row Resolved Entities block
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
- [ ] **Unit 1: vs-topic detection routes to fanout (not single-pipeline)**
|
||||||
|
|
||||||
|
**Goal:** A topic containing ` vs ` / ` versus ` triggers `fanout.run_competitor_fanout` with the parsed entities. Each entity runs a full `pipeline.run()`. Replace the current single-pipeline-with-comparison-plan behavior.
|
||||||
|
|
||||||
|
**Requirements:** R1
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/last30days.py` (main() — detect vs-topic, route to fanout)
|
||||||
|
- Modify: `scripts/lib/planner.py` (remove / bypass the `_should_force_deterministic_plan` special case for vs topics; vs topics no longer go through `plan_query` as a single comparison plan)
|
||||||
|
- Test: `tests/test_vs_mode_fanout.py` (new)
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Parse the incoming topic: if it contains ` vs ` or ` versus ` (case-insensitive), split into entities (reuse `planner._comparison_entities`-style logic or move that utility into main()).
|
||||||
|
- When vs-entities are detected, route to the same fanout branch `--competitors` uses today. The entity list comes from the topic string; no discovery step needed.
|
||||||
|
- Each entity runs `pipeline.run()` with its own plan (either from `--competitors-plan[entity]` or from the engine's per-entity fallback path).
|
||||||
|
- For back-compat, if the user passes both a vs-topic AND `--plan`, honor `--plan` for the main (first) entity and use per-entity defaults for peers unless `--competitors-plan` is also provided.
|
||||||
|
|
||||||
|
**Execution note:** Start with an integration test that runs `"A vs B"` via mock mode and asserts fanout was called with two entities + two pipeline.run calls.
|
||||||
|
|
||||||
|
**Patterns to follow:**
|
||||||
|
- 3.0.11 fanout wiring in `scripts/last30days.py`'s `--competitors` branch.
|
||||||
|
- `planner._comparison_entities` for the split logic.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Happy path: topic `"A vs B"` → two pipeline.run calls, two Reports returned, merged render.
|
||||||
|
- Happy path: topic `"A vs B vs C"` → three pipeline.run calls.
|
||||||
|
- Happy path: topic `"A versus B"` → matches the same regex, two pipelines.
|
||||||
|
- Edge case: topic `"OpenAI vs"` (trailing empty entity) → treated as single-entity `"OpenAI"`, not vs mode.
|
||||||
|
- Edge case: topic contains "vs." (dot, no trailing space) → existing regex tolerates it; verify.
|
||||||
|
- Edge case: topic `"A vs B"` plus `--plan` → plan applies to first entity only, peers use per-entity defaults.
|
||||||
|
- Integration: full vs-mode run end-to-end in mock mode; verify rendered output, stderr has one `[Competitors] Comparing: A vs B vs ...` line.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- Test assertions pass.
|
||||||
|
- Mock-mode smoke of `/last30days "OpenAI vs Anthropic"` shows fanout invocation, per-entity Reports, merged comparison output.
|
||||||
|
|
||||||
|
- [ ] **Unit 2: `--competitors-plan` JSON flag + `_subrun_kwargs` helper + override-leak fix**
|
||||||
|
|
||||||
|
**Goal:** New JSON flag threads per-entity targeting into each sub-run's `pipeline.run()`. A `_subrun_kwargs(entity, plan_entry)` helper is the single source of truth for per-entity kwargs, eliminating override-leak.
|
||||||
|
|
||||||
|
**Requirements:** R3, R6
|
||||||
|
|
||||||
|
**Dependencies:** None (can land alongside or before Unit 1)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/last30days.py` (argparse + parse + `_competitor_runner` + `_subrun_kwargs` helper)
|
||||||
|
- Possibly modify: `scripts/lib/fanout.py` (no signature change expected; the competitor_runner contract is unchanged)
|
||||||
|
- Test: `tests/test_cli_competitors.py` (extend)
|
||||||
|
- Test: `tests/test_competitors_plan_threading.py` (new)
|
||||||
|
- Test: `tests/test_competitor_subrun_isolation.py` (new, regression)
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Add `--competitors-plan` argparse flag. Accepts inline JSON or file path (mirror `--plan`).
|
||||||
|
- Validation: top-level dict; each value is a dict; unknown fields log warnings; malformed input exits 2. Case-insensitive entity matching.
|
||||||
|
- Schema: `{entity_name: {x_handle?, x_related?, subreddits?, github_user?, github_repos?, context?}}`.
|
||||||
|
- Build `_subrun_kwargs(entity, plan_entry)` — returns an explicit dict with every per-entity flag. No closure-default fallthrough. This is the leak fix.
|
||||||
|
- `_competitor_runner(entity)`:
|
||||||
|
1. Get `plan_entry` from `--competitors-plan` if present.
|
||||||
|
2. Build base kwargs with `_subrun_kwargs(entity, plan_entry)`.
|
||||||
|
3. Fill missing fields via `resolve.auto_resolve(entity, entity_config)` only if backend is configured (3.0.12 fallback path).
|
||||||
|
4. Call `pipeline.run(topic=entity, internal_subrun=True, **kwargs)`.
|
||||||
|
5. Attach `resolved` dict to `report.artifacts`.
|
||||||
|
- Verify no per-entity flag from main() leaks via closure. The helper is the only source of per-entity values.
|
||||||
|
|
||||||
|
**Execution note:** Test-first for the override-leak regression. Use the Kanye 2026-04-22 receipt as the failing test input (main `--subreddits=Kanye,hiphopheads` + `--competitors-list "Drake"` → assert Drake's pipeline.run receives `subreddits=None`).
|
||||||
|
|
||||||
|
**Patterns to follow:**
|
||||||
|
- `--plan` parsing block in `scripts/last30days.py`.
|
||||||
|
- 3.0.12's `entity_config = dict(config)` deep-copy pattern.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Happy path: `--competitors-plan '{"Drake":{"x_handle":"Drake","subreddits":["Drizzy"]}}'` → Drake's pipeline.run receives `x_handle="Drake"`, `subreddits=["Drizzy"]`. No auto_resolve call for Drake.
|
||||||
|
- Happy path: plan covers 2 of 3 entities, backend configured → covered skip auto_resolve; third falls back.
|
||||||
|
- Happy path: plan file path accepted like `--plan`.
|
||||||
|
- Happy path: case-insensitive entity match.
|
||||||
|
- Edge case: unknown fields → warn, ignore.
|
||||||
|
- Edge case: plan entry for entity not in list → warn, ignore.
|
||||||
|
- Error path: malformed JSON → exit 2.
|
||||||
|
- Error path: top-level JSON is list → exit 2.
|
||||||
|
- Regression (leak): main `--subreddits=A,B` + `--competitors-list "X"` + no plan → X's pipeline.run gets `subreddits=None`.
|
||||||
|
- Regression (leak): same for `--x-handle`, `--x-related`, `--tiktok-hashtags`, `--tiktok-creators`, `--ig-creators`, `--github-user`, `--github-repo`.
|
||||||
|
- Regression (leak): main `--x-handle=kanye` + plan `{"Drake":{"x_handle":"Drake"}}` → Drake's sub-run gets `x_handle="Drake"`, NOT `"kanye"`.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- All regression tests pass.
|
||||||
|
- Smoke run (mock mode + plan): stderr shows per-entity `[Competitors] {entity}: x=... subs=...` line; no leak from main topic's flags.
|
||||||
|
|
||||||
|
- [ ] **Unit 3: Per-entity save files**
|
||||||
|
|
||||||
|
**Goal:** When `--save-dir` is set in a vs-mode or `--competitors` run, each entity's sub-run saves its own `{entity-slug}-raw.md` file — same format as a single-entity run would produce.
|
||||||
|
|
||||||
|
**Requirements:** R4, R5
|
||||||
|
|
||||||
|
**Dependencies:** Unit 1, Unit 2
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/last30days.py` (`save_output` iteration after fanout)
|
||||||
|
- Modify: `scripts/lib/render.py` (`render_full` includes single-row Resolved Entities block when that entity's `artifacts["resolved"]` is present)
|
||||||
|
- Test: `tests/test_save_raw_per_entity.py` (new)
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- After fanout completes, iterate `report.artifacts["competitor_reports"]` (or equivalent). For each `(entity, entity_report)`:
|
||||||
|
- Call `save_output(entity_report, emit="md", save_dir=args.save_dir, suffix=args.save_suffix)`.
|
||||||
|
- Uses entity's `slugify(entity)` for the filename. Same pattern a single-entity run uses.
|
||||||
|
- Each saved file invokes `render_full` (or the save-variant). `render_full` now checks for `report.artifacts["resolved"]` and prepends a single-row Resolved Entities block.
|
||||||
|
- Stderr logs one `[last30days] Saved output to <path>` line per entity.
|
||||||
|
- Single-entity runs unchanged (no extra files, render_full unchanged for them).
|
||||||
|
|
||||||
|
**Patterns to follow:**
|
||||||
|
- Existing `save_output` invocation in main() for single-entity runs.
|
||||||
|
- `slugify(topic)` for filename.
|
||||||
|
- 3.0.12's `_render_resolved_entities_block` (reused, single-row mode).
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Happy path: `/last30days "A vs B vs C" --save-dir=/tmp/x` → `/tmp/x/a-raw.md`, `/tmp/x/b-raw.md`, `/tmp/x/c-raw.md` exist.
|
||||||
|
- Happy path: `--competitors-list "Drake,Kendrick" --save-dir=/tmp/x` on topic Kanye → three files: `kanye-west-raw.md`, `drake-raw.md`, `kendrick-lamar-raw.md`.
|
||||||
|
- Happy path: each file includes a single-row Resolved Entities block for its entity.
|
||||||
|
- Happy path: single-entity run with `--save-dir` → one file, no Resolved block (unchanged).
|
||||||
|
- Edge case: `--save-suffix=v3` → all N files get the suffix.
|
||||||
|
- Edge case: one entity sub-run failed → its file is NOT saved; the others are.
|
||||||
|
- Integration: `ls {save-dir}/*-raw.md` returns N files after a vs-mode run.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- Test assertions pass.
|
||||||
|
- Manual vs-mode smoke saves N files.
|
||||||
|
|
||||||
|
- [ ] **Unit 4: LAW 7-style stderr reframe + footer-nudge suppression**
|
||||||
|
|
||||||
|
**Goal:** The `--competitors`-with-no-backend stderr tells the hosting model to do Step 0.55 per entity and pass `--competitors-plan`. The BRAVE/SERPER footer nudge is suppressed when `--plan` or `--competitors-plan` is present.
|
||||||
|
|
||||||
|
**Requirements:** R7, R8
|
||||||
|
|
||||||
|
**Dependencies:** Unit 2 (flag must exist)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/last30days.py` (the `[Competitors] --competitors requires...` stderr block)
|
||||||
|
- Modify: `scripts/lib/quality_nudge.py` (or wherever footer nudge emits; verify during implementation)
|
||||||
|
- Test: `tests/test_competitors_no_backend_message.py` (new)
|
||||||
|
- Test: `tests/test_footer_nudge_suppression.py` (new)
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Rewrite stderr in this order:
|
||||||
|
1. "If you are the hosting reasoning model (Claude Code, Codex, Hermes, Gemini, or any agent with WebSearch), the recommended path: (a) discover N peers via WebSearch, (b) run Step 0.55 for main + each peer, (c) re-invoke as `/last30days 'topic vs peer1 vs peer2' --competitors-plan '{...}'`. See SKILL.md 'Competitor mode'."
|
||||||
|
2. "Headless / cron path: set BRAVE_API_KEY / EXA_API_KEY / SERPER_API_KEY / PARALLEL_API_KEY / OPENROUTER_API_KEY and re-run."
|
||||||
|
3. "Minimum escape hatch: `--competitors-list 'A,B,C'` skips discovery but does not pre-resolve peers."
|
||||||
|
- Suppress footer nudge when `external_plan` OR `competitors_plan` was passed.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Happy path: `--competitors` with no backend, no list, no plan → stderr leads with "If you are the hosting reasoning model" and references `--competitors-plan` before naming API keys.
|
||||||
|
- Happy path: `--plan` passed → footer nudge does NOT fire.
|
||||||
|
- Happy path: `--competitors-plan` passed → footer nudge does NOT fire.
|
||||||
|
- Happy path: `--competitors-list` only (no plan, no backend) → footer nudge still fires (hosting model didn't fully engage).
|
||||||
|
- Happy path: no `--competitors`, no `--plan` → footer nudge unchanged.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- Tests pass.
|
||||||
|
|
||||||
|
- [ ] **Unit 5: Polymarket disambiguation guard**
|
||||||
|
|
||||||
|
**Goal:** `--polymarket-keywords "kw1,kw2"` filters market matches; auto-skip Polymarket on single-token-ambiguous topics without override.
|
||||||
|
|
||||||
|
**Requirements:** R9
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/last30days.py` (argparse)
|
||||||
|
- Modify: `scripts/lib/polymarket.py`
|
||||||
|
- Test: `tests/test_polymarket_disambiguation.py` (new)
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Add `--polymarket-keywords "kw1,kw2"`. When provided, Polymarket adapter filters market titles to those whose normalized text contains at least one keyword.
|
||||||
|
- Auto-skip: if topic is one token AND matches a known-ambiguous list (US state names, US city names, common sports/color/animal words) AND no `--polymarket-keywords`, skip Polymarket with stderr note.
|
||||||
|
- SKILL.md update (small): mention `--polymarket-keywords` in Step 0.55 instructions for ambiguous topics.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Happy path: topic "Warriors", no override → Polymarket skipped; stderr note.
|
||||||
|
- Happy path: topic "Warriors", `--polymarket-keywords "nba,gsw"` → Polymarket runs, filtered.
|
||||||
|
- Happy path: topic "OpenAI" → Polymarket runs as before.
|
||||||
|
- Happy path: topic "Arizona Wildcats" (multi-token) → Polymarket runs as before.
|
||||||
|
- Edge case: `--polymarket-keywords ""` → treated as empty, no filter.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- Warriors smoke → Polymarket footer absent or filtered.
|
||||||
|
|
||||||
|
- [ ] **Unit 6: SKILL.md rewrite — vs mode is the canonical path, `--competitors` is a shortcut**
|
||||||
|
|
||||||
|
**Goal:** SKILL.md documents the unified architecture. vs mode runs N full passes. `--competitors` is a SKILL.md-level shortcut that discovers 2 peers and invokes vs mode with `--competitors-plan`.
|
||||||
|
|
||||||
|
**Requirements:** R1, R2, R10 (surfaces them)
|
||||||
|
|
||||||
|
**Dependencies:** Units 1-4
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `SKILL.md` (§551 "If QUERY_TYPE = COMPARISON" rewrite; Competitor mode subsection rewrite)
|
||||||
|
- Modify: `README.md` (one-line example)
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- Rewrite §551 to describe the N-pass architecture: "When the user asks 'X vs Y' (or 'X vs Y vs Z'), run Step 0.55 per entity, then invoke the engine. The engine fans out N full pipelines in parallel. Each entity gets its own single-entity-grade coverage. Wall clock is close to a single run."
|
||||||
|
- Remove the "ONE research pass with a comparison-optimized plan that replaces the old 3-pass approach" language.
|
||||||
|
- Add a `--competitors-plan` JSON example.
|
||||||
|
- Rewrite the Competitor mode subsection: "`--competitors` is a shortcut. The hosting model: (1) runs WebSearch to discover N=2 peers, (2) runs Step 0.55 for main + each peer, (3) rewrites topic to `'main vs peer1 vs peer2'`, (4) invokes engine with `--competitors-plan '{...}'`. Engine flag `--competitors` and `--competitors-list` remain for headless fallback."
|
||||||
|
- Cross-reference §679 (per-entity Step 0.55 protocol).
|
||||||
|
- Warning: a thin `## Resolved Entities` block (dashes for any entity) means the hosting model skipped Step 0.55 for that one.
|
||||||
|
|
||||||
|
**Patterns to follow:**
|
||||||
|
- Existing §679 per-entity Step 0.55 protocol for tone.
|
||||||
|
- 3.0.12 Competitor mode prose for terseness.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Test expectation: none — documentation. Verification is dogfood.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- `/last30days "OpenAI vs Anthropic vs xAI"` in a fresh Claude Code window produces 3 save files with populated Resolved blocks and non-dash per-entity targeting.
|
||||||
|
- `/last30days OpenAI --competitors` produces same after discovery step.
|
||||||
|
|
||||||
|
- [ ] **Unit 7: Version 3.0.13, CHANGELOG, sync, hot-copy**
|
||||||
|
|
||||||
|
**Goal:** Ship 3.0.13 to all local targets.
|
||||||
|
|
||||||
|
**Requirements:** Closes R1-R10
|
||||||
|
|
||||||
|
**Dependencies:** Units 1-6
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.claude-plugin/plugin.json`
|
||||||
|
- Modify: `CHANGELOG.md`
|
||||||
|
- Run: `bash scripts/sync.sh`
|
||||||
|
- Hot-copy: `~/.claude/plugins/cache/last30days-skill/last30days/3.0.13/`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- CHANGELOG: group the changes. "Changed: vs mode now runs N full passes in parallel, one per entity — reverting the one-pass optimization to restore per-entity depth. Added: --competitors-plan JSON for per-entity Step 0.55 targeting (applies to vs mode and --competitors). Changed: --competitors is now a SKILL.md shortcut for vs-with-discovery. Added: per-entity *-raw.md save files. Fixed: override-leak from main to peer sub-runs. Changed: LAW 7 stderr framing for hosting-model context. Changed: BRAVE/SERPER footer nudge suppressed when --plan / --competitors-plan present. Added: --polymarket-keywords + auto-skip for ambiguous topics."
|
||||||
|
- Beta channel first per CLAUDE.md.
|
||||||
|
- Hot-copy so public `/last30days` picks up 3.0.13.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Test expectation: none — packaging.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- `grep version .claude-plugin/plugin.json` → 3.0.13.
|
||||||
|
- `sync.sh` exits 0.
|
||||||
|
- Hot-copy contains the new files.
|
||||||
|
|
||||||
|
## System-Wide Impact
|
||||||
|
|
||||||
|
- **Interaction graph:** vs-mode and `--competitors` share one orchestrator (`fanout.run_competitor_fanout`). `_subrun_kwargs` is the single source of per-entity kwargs. Save loop iterates per entity.
|
||||||
|
- **Error propagation:** Per-entity sub-run failure → logged, dropped, continue (3.0.11 behavior unchanged). `--competitors-plan` JSON parse errors exit 2 (same shape as `--plan`).
|
||||||
|
- **State lifecycle risks:** `entity_config = dict(config)` deep-copy pattern extends to every per-entity flag (Unit 2 fix). No cross-entity context leak.
|
||||||
|
- **API surface parity:** `--competitors-plan` is additive. `--competitors`, `--competitors-list`, `--plan` unchanged. `--polymarket-keywords` additive. vs-mode keeps its topic-string surface.
|
||||||
|
- **Integration coverage:** New vs-mode-fanout integration test. New override-leak regression test. New plan-threading test. New nudge-suppression test. New per-entity-save test. New Polymarket disambiguation test.
|
||||||
|
- **Unchanged invariants:** `pipeline.run()` signature unchanged. Single-entity render path unchanged. LAW 7 on the default path unchanged (still fires when a single-entity run lacks `--plan`).
|
||||||
|
|
||||||
|
## Risks & Dependencies
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| vs-mode N-pass latency feels slower for users who remember the one-pass shortcut. | Parallel execution keeps wall-clock ~= max(per-entity-latency), not sum. `--quick` on a vs-topic still applies to each sub-run. CHANGELOG calls out the revert + parallelism. |
|
||||||
|
| API cost scales linearly with N (per source). | Default count 2 caps it. Hard max 6 on `--competitors`. vs-mode users opted into N entities explicitly. |
|
||||||
|
| Rivalry content ("A vs B" articles) missed in N-independent passes. | Deferred to separate task (head-to-head supplemental pass). Start shipping and observe whether this is actually a gap. |
|
||||||
|
| Hosting model skips `--competitors-plan` and uses `--competitors-list` only. | Unit 4 stderr reframe steers explicitly. SKILL.md Unit 6 makes the plan-path canonical. Thin Resolved block in output makes skipped-Step-0.55 visible. |
|
||||||
|
| Override-leak fix misses a subtle closure path. | Unit 2 is test-first with the Kanye receipt as the failing input. Regression test asserts every per-entity flag is None unless plan provides it. |
|
||||||
|
|
||||||
|
## Documentation / Operational Notes
|
||||||
|
|
||||||
|
- Beta channel first per CLAUDE.md.
|
||||||
|
- After merge: hot-copy to `~/.claude/plugins/cache/last30days-skill/last30days/3.0.13/`.
|
||||||
|
- CHANGELOG explicitly frames the vs-mode change as an architectural revert-with-parallelism, not a regression to the old serial N-pass.
|
||||||
|
|
||||||
|
## Sources & References
|
||||||
|
|
||||||
|
- Superseded plan: `docs/plans/2026-04-22-004-fix-competitors-hosting-model-resolve-and-leak-plan.md.superseded`
|
||||||
|
- Previous plan (3.0.12): `docs/plans/2026-04-22-003-fix-competitors-per-entity-resolution-plan.md`
|
||||||
|
- Initial plan (3.0.11): `docs/plans/2026-04-22-002-feat-competitors-flag-comparison-fanout-plan.md`
|
||||||
|
- 2026-04-22 test session receipts (Warriors, Seattle, Arizona Wildcats, Kanye West)
|
||||||
|
- SKILL.md §551 + §679 — the per-entity Step 0.55 protocol the hosting model uses for both paths
|
||||||
|
- Related code: `scripts/lib/fanout.py`, `scripts/last30days.py` `_competitor_runner`, `scripts/lib/planner.py` vs-topic special-case, `scripts/lib/render.py` `_render_resolved_entities_block`, `scripts/lib/polymarket.py`, `scripts/lib/quality_nudge.py`
|
||||||
|
- Related PRs: #308 (3.0.11), #309 (3.0.12)
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
---
|
||||||
|
title: "fix: comparison title says (/Last30Days) instead of (Last 30 Days)"
|
||||||
|
type: fix
|
||||||
|
status: active
|
||||||
|
date: 2026-04-22
|
||||||
|
---
|
||||||
|
|
||||||
|
# fix: comparison title says (/Last30Days) instead of (Last 30 Days)
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
User feedback 2026-04-22 on the 3.0.13 release runs (Kanye vs Drake, Mercer Island, Figma): the comparison title currently reads `# Kanye West vs Drake: What the Community Says (Last 30 Days)`. It should read `# Kanye West vs Drake: What the Community Says (/Last30Days)` — attributing the output to the slash command rather than describing the date range generically.
|
||||||
|
|
||||||
|
Single-line change in SKILL.md, three occurrences. No code change.
|
||||||
|
|
||||||
|
## Requirements Trace
|
||||||
|
|
||||||
|
- R1. Comparison title pattern in SKILL.md changes from `(Last 30 Days)` to `(/Last30Days)` so synthesis outputs read `... What the Community Says (/Last30Days)`.
|
||||||
|
- R2. Both the rule statement (line 113) and the COMPARISON-exception statement (line 131) and the synthesis template example (line 1208) all use the new suffix.
|
||||||
|
- R3. Version bumps to 3.0.14, CHANGELOG entry, sync, hot-copy. Public cache picks up the new title pattern.
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
- No changes to the single-entity output title (no `(/Last30Days)` suffix there — only comparison topics carry it).
|
||||||
|
- No changes to engine code. Pure SKILL.md content.
|
||||||
|
- No changes to anything else surfaced in the test runs.
|
||||||
|
|
||||||
|
## Key Technical Decisions
|
||||||
|
|
||||||
|
- **Replace all three occurrences of the suffix string in one pass.** They are identical strings; changing one without the others would cause synthesis-time confusion when the model reaches a different reference.
|
||||||
|
- **Ship as 3.0.14, not 3.0.13.x.** Patch-level bump matches the small scope and keeps the release log clean.
|
||||||
|
|
||||||
|
## Implementation Units
|
||||||
|
|
||||||
|
- [ ] **Unit 1: Replace `(Last 30 Days)` → `(/Last30Days)` in SKILL.md**
|
||||||
|
|
||||||
|
**Goal:** All three SKILL.md references to the comparison title use the new suffix.
|
||||||
|
|
||||||
|
**Requirements:** R1, R2
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `SKILL.md`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- `replace_all` swap of `What the Community Says (Last 30 Days)` → `What the Community Says (/Last30Days)`. Three occurrences, no other strings overlap.
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Test expectation: none — pure documentation. Verification by inspection + dogfood run.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- `grep -c "What the Community Says (/Last30Days)" SKILL.md` returns 3.
|
||||||
|
- `grep -c "What the Community Says (Last 30 Days)" SKILL.md` returns 0.
|
||||||
|
|
||||||
|
- [ ] **Unit 2: Version 3.0.14 + CHANGELOG + sync + hot-copy**
|
||||||
|
|
||||||
|
**Goal:** Ship 3.0.14 to all local targets.
|
||||||
|
|
||||||
|
**Requirements:** R3
|
||||||
|
|
||||||
|
**Dependencies:** Unit 1
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.claude-plugin/plugin.json`
|
||||||
|
- Modify: `CHANGELOG.md`
|
||||||
|
- Run: `bash scripts/sync.sh`
|
||||||
|
- Hot-copy: `~/.claude/plugins/cache/last30days-skill/last30days/3.0.14/`
|
||||||
|
|
||||||
|
**Approach:**
|
||||||
|
- CHANGELOG: "Changed: comparison-mode title attribution — `What the Community Says (Last 30 Days)` → `What the Community Says (/Last30Days)`. Surfaces the slash-command identity instead of restating the date range."
|
||||||
|
|
||||||
|
**Test scenarios:**
|
||||||
|
- Test expectation: none — packaging.
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
- `grep version .claude-plugin/plugin.json` → 3.0.14.
|
||||||
|
- Hot-copy contains the updated SKILL.md.
|
||||||
|
|
||||||
|
## Risks & Dependencies
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| Hosting model has the old title pattern memorized from a prior run and re-emits `(Last 30 Days)`. | SKILL.md is read top-to-bottom each invocation. STEP 0 canonical-path self-check (3.0.12) ensures the model loads the new SKILL.md, not the marketplace stale copy. |
|
||||||
|
|
||||||
|
## Sources & References
|
||||||
|
|
||||||
|
- 2026-04-22 dogfood runs (Kanye West vs Drake, Mercer Island --competitors, Figma --competitors)
|
||||||
|
- Related code: `SKILL.md` lines 113, 131, 1208
|
||||||
+250
-27
@@ -241,9 +241,138 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
dest="competitors_list",
|
dest="competitors_list",
|
||||||
help="Comma-separated competitor entities to skip discovery (e.g., 'Anthropic,xAI,Google Gemini'). Implies --competitors.",
|
help="Comma-separated competitor entities to skip discovery (e.g., 'Anthropic,xAI,Google Gemini'). Implies --competitors.",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--polymarket-keywords",
|
||||||
|
dest="polymarket_keywords",
|
||||||
|
help=(
|
||||||
|
"Comma-separated keywords that Polymarket market titles must match "
|
||||||
|
"to be included. Use for ambiguous single-token topics like 'Warriors' "
|
||||||
|
"(nba,gsw,golden-state) to filter out Glasgow Warriors rugby, Honor "
|
||||||
|
"of Kings Rogue Warriors, etc. When omitted, Polymarket returns all "
|
||||||
|
"matching markets — so expect cross-entity noise on generic topics."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--competitors-plan",
|
||||||
|
dest="competitors_plan",
|
||||||
|
help=(
|
||||||
|
"JSON mapping of per-entity Step 0.55 targeting for competitor / vs-mode "
|
||||||
|
"sub-runs. Schema: {entity_name: {x_handle?, x_related?, subreddits?, "
|
||||||
|
"github_user?, github_repos?, context?}}. Accepts inline JSON or a file "
|
||||||
|
"path. Implies --competitors. Preferred over --competitors-list when the "
|
||||||
|
"hosting model has already resolved per-entity handles and subs."
|
||||||
|
),
|
||||||
|
)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def parse_competitors_plan(raw: str | None) -> dict[str, dict]:
|
||||||
|
"""Parse a --competitors-plan argument into a {entity_name_lower: plan_entry} dict.
|
||||||
|
|
||||||
|
Accepts inline JSON or a file path (matches --plan). Returns {} on None/empty.
|
||||||
|
Validation: top-level must be a dict; each value must be a dict. Unknown fields
|
||||||
|
in entry values log a warning but do not abort. Invalid JSON or non-dict shape
|
||||||
|
raises SystemExit(2) with a clear stderr message.
|
||||||
|
"""
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
plan_str = raw
|
||||||
|
if os.path.isfile(plan_str):
|
||||||
|
try:
|
||||||
|
plan_str = open(plan_str).read()
|
||||||
|
except OSError as exc:
|
||||||
|
sys.stderr.write(f"[CompetitorsPlan] Cannot read plan file: {exc}\n")
|
||||||
|
raise SystemExit(2)
|
||||||
|
try:
|
||||||
|
parsed = json.loads(plan_str)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
sys.stderr.write(f"[CompetitorsPlan] Invalid JSON: {exc}\n")
|
||||||
|
raise SystemExit(2)
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
sys.stderr.write(
|
||||||
|
f"[CompetitorsPlan] Top-level must be a dict of "
|
||||||
|
f"{{entity: {{targeting}}}}, got {type(parsed).__name__}\n"
|
||||||
|
)
|
||||||
|
raise SystemExit(2)
|
||||||
|
known_fields = {
|
||||||
|
"x_handle", "x_related", "subreddits",
|
||||||
|
"github_user", "github_repos", "context",
|
||||||
|
}
|
||||||
|
normalized: dict[str, dict] = {}
|
||||||
|
for entity, entry in parsed.items():
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
sys.stderr.write(
|
||||||
|
f"[CompetitorsPlan] Entry for {entity!r} must be a dict, "
|
||||||
|
f"got {type(entry).__name__}; skipping.\n"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
unknown = set(entry.keys()) - known_fields
|
||||||
|
if unknown:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"[CompetitorsPlan] Unknown fields in {entity!r}: "
|
||||||
|
f"{sorted(unknown)}; ignoring.\n"
|
||||||
|
)
|
||||||
|
normalized[entity.strip().lower()] = {
|
||||||
|
k: v for k, v in entry.items() if k in known_fields
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def subrun_kwargs_for(
|
||||||
|
entity: str,
|
||||||
|
plan_entry: dict,
|
||||||
|
*,
|
||||||
|
resolved: dict,
|
||||||
|
) -> dict:
|
||||||
|
"""Build an explicit per-entity kwargs dict for pipeline.run().
|
||||||
|
|
||||||
|
Plan values win over auto_resolve values. Returns keys for all per-entity
|
||||||
|
targeting flags so callers never fall through to closure defaults.
|
||||||
|
|
||||||
|
This helper is the single source of truth for sub-run kwargs — main-topic
|
||||||
|
flags can only leak if a caller bypasses it.
|
||||||
|
"""
|
||||||
|
def _choose(plan_key: str, resolved_key: str | None = None):
|
||||||
|
if plan_key in plan_entry and plan_entry[plan_key]:
|
||||||
|
return plan_entry[plan_key]
|
||||||
|
if resolved_key is not None and resolved.get(resolved_key):
|
||||||
|
return resolved[resolved_key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
x_handle = _choose("x_handle", "x_handle")
|
||||||
|
if isinstance(x_handle, str):
|
||||||
|
x_handle = x_handle.lstrip("@") or None
|
||||||
|
|
||||||
|
subreddits = _choose("subreddits", "subreddits")
|
||||||
|
if isinstance(subreddits, list):
|
||||||
|
subreddits = [s.strip().lstrip("r/") for s in subreddits if s.strip()] or None
|
||||||
|
|
||||||
|
x_related = plan_entry.get("x_related")
|
||||||
|
if isinstance(x_related, list):
|
||||||
|
x_related = [h.strip().lstrip("@") for h in x_related if h.strip()] or None
|
||||||
|
else:
|
||||||
|
x_related = None
|
||||||
|
|
||||||
|
github_user = _choose("github_user", "github_user")
|
||||||
|
if isinstance(github_user, str):
|
||||||
|
github_user = github_user.lstrip("@").lower() or None
|
||||||
|
|
||||||
|
github_repos = _choose("github_repos", "github_repos")
|
||||||
|
if isinstance(github_repos, list):
|
||||||
|
github_repos = [r.strip() for r in github_repos if r.strip() and "/" in r.strip()] or None
|
||||||
|
|
||||||
|
context = plan_entry.get("context") or resolved.get("context") or ""
|
||||||
|
|
||||||
|
return {
|
||||||
|
"x_handle": x_handle,
|
||||||
|
"x_related": x_related,
|
||||||
|
"subreddits": subreddits,
|
||||||
|
"github_user": github_user,
|
||||||
|
"github_repos": github_repos,
|
||||||
|
"_context": context,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
COMPETITORS_MIN = 1
|
COMPETITORS_MIN = 1
|
||||||
COMPETITORS_MAX = 6
|
COMPETITORS_MAX = 6
|
||||||
COMPETITORS_DEFAULT = 2
|
COMPETITORS_DEFAULT = 2
|
||||||
@@ -322,7 +451,12 @@ def _missing_sources_for_promo(diag: dict[str, object]) -> str | None:
|
|||||||
return missing[0]
|
return missing[0]
|
||||||
|
|
||||||
|
|
||||||
def _show_runtime_ui(report: schema.Report, progress: ui.ProgressDisplay, diag: dict[str, object]) -> None:
|
def _show_runtime_ui(
|
||||||
|
report: schema.Report,
|
||||||
|
progress: ui.ProgressDisplay,
|
||||||
|
diag: dict[str, object],
|
||||||
|
suppress_web_promo: bool = False,
|
||||||
|
) -> None:
|
||||||
counts = {source: len(items) for source, items in report.items_by_source.items()}
|
counts = {source: len(items) for source, items in report.items_by_source.items()}
|
||||||
display_sources = list(
|
display_sources = list(
|
||||||
dict.fromkeys(
|
dict.fromkeys(
|
||||||
@@ -339,7 +473,19 @@ def _show_runtime_ui(report: schema.Report, progress: ui.ProgressDisplay, diag:
|
|||||||
display_sources=display_sources,
|
display_sources=display_sources,
|
||||||
)
|
)
|
||||||
promo = _missing_sources_for_promo(diag)
|
promo = _missing_sources_for_promo(diag)
|
||||||
|
# The `web` promo nudges users to set BRAVE_API_KEY / SERPER_API_KEY, which
|
||||||
|
# is wrong advice when a hosting reasoning model (Claude Code, Codex,
|
||||||
|
# Hermes, Gemini) is driving — those already have WebSearch and can
|
||||||
|
# pre-resolve Step 0.55 themselves. Suppress the web promo when a hosting
|
||||||
|
# model signal is present (--plan or --competitors-plan was passed).
|
||||||
if promo:
|
if promo:
|
||||||
|
if suppress_web_promo and promo == "web":
|
||||||
|
return
|
||||||
|
if suppress_web_promo and promo == "both":
|
||||||
|
# "both" means reddit + web both missing; still nudge reddit but
|
||||||
|
# skip the web line. show_promo has a per-source variant.
|
||||||
|
progress.show_promo("reddit", diag=diag)
|
||||||
|
return
|
||||||
progress.show_promo(promo, diag=diag)
|
progress.show_promo(promo, diag=diag)
|
||||||
|
|
||||||
|
|
||||||
@@ -461,6 +607,36 @@ def main() -> int:
|
|||||||
config["INCLUDE_SOURCES"] = f"{include},perplexity" if include else "perplexity"
|
config["INCLUDE_SOURCES"] = f"{include},perplexity" if include else "perplexity"
|
||||||
|
|
||||||
comp_enabled, comp_count, comp_explicit = resolve_competitors_args(args)
|
comp_enabled, comp_count, comp_explicit = resolve_competitors_args(args)
|
||||||
|
comp_plan = parse_competitors_plan(args.competitors_plan)
|
||||||
|
|
||||||
|
# Polymarket disambiguation: if user passed --polymarket-keywords,
|
||||||
|
# store on config so the polymarket adapter can filter matches.
|
||||||
|
if args.polymarket_keywords:
|
||||||
|
keywords = [
|
||||||
|
k.strip().lower()
|
||||||
|
for k in args.polymarket_keywords.split(",")
|
||||||
|
if k.strip()
|
||||||
|
]
|
||||||
|
if keywords:
|
||||||
|
config["_polymarket_keywords"] = keywords
|
||||||
|
|
||||||
|
# vs-mode: if the topic string contains " vs " / " versus " and the
|
||||||
|
# planner can split it into >=2 entities, route through the same
|
||||||
|
# N-pass fanout path as --competitors. The first entity becomes the
|
||||||
|
# main topic; remaining entities become the competitor list. User's
|
||||||
|
# outer --x-handle / --subreddits apply to the first entity unless
|
||||||
|
# --competitors-plan covers it.
|
||||||
|
from lib import planner as _planner
|
||||||
|
vs_entities = _planner._comparison_entities(topic)
|
||||||
|
if len(vs_entities) >= 2 and not comp_enabled:
|
||||||
|
topic = vs_entities[0]
|
||||||
|
comp_enabled = True
|
||||||
|
comp_count = len(vs_entities) - 1
|
||||||
|
comp_explicit = vs_entities[1:]
|
||||||
|
sys.stderr.write(
|
||||||
|
f"[Competitors] vs-mode: routing to N-pass fanout: "
|
||||||
|
f"{' vs '.join(vs_entities)}\n"
|
||||||
|
)
|
||||||
|
|
||||||
def _main_runner() -> schema.Report:
|
def _main_runner() -> schema.Report:
|
||||||
r = pipeline.run(
|
r = pipeline.run(
|
||||||
@@ -500,15 +676,26 @@ def main() -> int:
|
|||||||
else:
|
else:
|
||||||
if not resolve_mod._has_backend(config) and not args.mock:
|
if not resolve_mod._has_backend(config) and not args.mock:
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
"[Competitors] --competitors requires either a configured web "
|
"[Competitors] Cannot auto-discover peers without help.\n"
|
||||||
"search backend (BRAVE_API_KEY / EXA_API_KEY / SERPER_API_KEY / "
|
"\n"
|
||||||
"PARALLEL_API_KEY) or an explicit --competitors-list. If you are "
|
"RECOMMENDED PATH (hosting reasoning models — Claude Code, Codex, "
|
||||||
"the hosting reasoning model (Claude Code, Codex, Hermes, "
|
"Hermes, Gemini, any agent with a WebSearch tool): YOU have "
|
||||||
"Gemini, or any agent runtime), generate a comma-separated list "
|
"WebSearch. Use it to run full Step 0.55 per entity, then invoke "
|
||||||
"of 2-6 peer entities for the topic and re-invoke with "
|
"the engine with a vs-topic plus --competitors-plan:\n"
|
||||||
"--competitors-list=\"A,B,C\". You do not need API keys; you ARE "
|
" 1. WebSearch for '{topic} competitors' or '{topic} alternatives'.\n"
|
||||||
"the LLM. The web-search discovery path below is the headless / "
|
" 2. For each peer, WebSearch for handles/subs/github (Step 0.55).\n"
|
||||||
"credentialed path only.\n"
|
" 3. Re-invoke: /last30days '{topic} vs {peer1} vs {peer2}' "
|
||||||
|
"--competitors-plan '{\"Peer1\":{\"x_handle\":\"h1\",\"subreddits\":"
|
||||||
|
"[\"s1\"],...},\"Peer2\":{...}}'.\n"
|
||||||
|
"See SKILL.md 'Competitor mode' for the full protocol.\n"
|
||||||
|
"\n"
|
||||||
|
"HEADLESS / CRON PATH (no hosting model available): set "
|
||||||
|
"BRAVE_API_KEY / EXA_API_KEY / SERPER_API_KEY / PARALLEL_API_KEY / "
|
||||||
|
"OPENROUTER_API_KEY and re-run.\n"
|
||||||
|
"\n"
|
||||||
|
"MINIMUM ESCAPE HATCH: pass --competitors-list 'A,B,C' to skip "
|
||||||
|
"discovery. Without --competitors-plan, peer sub-runs fall back to "
|
||||||
|
"planner defaults and produce visibly thinner data than the main.\n"
|
||||||
)
|
)
|
||||||
return 2
|
return 2
|
||||||
discovered = competitors_mod.discover_competitors(
|
discovered = competitors_mod.discover_competitors(
|
||||||
@@ -530,6 +717,7 @@ def main() -> int:
|
|||||||
# leak across sub-runs. Each sub-run writes its own
|
# leak across sub-runs. Each sub-run writes its own
|
||||||
# `_auto_resolve_context` into its local config copy.
|
# `_auto_resolve_context` into its local config copy.
|
||||||
entity_config = dict(config)
|
entity_config = dict(config)
|
||||||
|
plan_entry = comp_plan.get(entity.strip().lower(), {})
|
||||||
resolved = {
|
resolved = {
|
||||||
"entity": entity,
|
"entity": entity,
|
||||||
"x_handle": "",
|
"x_handle": "",
|
||||||
@@ -538,7 +726,18 @@ def main() -> int:
|
|||||||
"github_repos": [],
|
"github_repos": [],
|
||||||
"context": "",
|
"context": "",
|
||||||
}
|
}
|
||||||
if not args.mock and resolve_mod._has_backend(entity_config):
|
# Skip engine-internal auto_resolve when the hosting model
|
||||||
|
# pre-resolved via --competitors-plan (saves a redundant
|
||||||
|
# round-trip and makes per-entity Step 0.55 purely
|
||||||
|
# hosting-model-driven).
|
||||||
|
plan_covers_fully = bool(plan_entry.get("x_handle")) and bool(
|
||||||
|
plan_entry.get("subreddits")
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not args.mock
|
||||||
|
and not plan_covers_fully
|
||||||
|
and resolve_mod._has_backend(entity_config)
|
||||||
|
):
|
||||||
try:
|
try:
|
||||||
r = resolve_mod.auto_resolve(entity, entity_config)
|
r = resolve_mod.auto_resolve(entity, entity_config)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -552,29 +751,41 @@ def main() -> int:
|
|||||||
resolved["github_user"] = r.get("github_user", "") or ""
|
resolved["github_user"] = r.get("github_user", "") or ""
|
||||||
resolved["github_repos"] = list(r.get("github_repos") or [])
|
resolved["github_repos"] = list(r.get("github_repos") or [])
|
||||||
resolved["context"] = r.get("context", "") or ""
|
resolved["context"] = r.get("context", "") or ""
|
||||||
if resolved["context"]:
|
kwargs = subrun_kwargs_for(entity, plan_entry, resolved=resolved)
|
||||||
entity_config["_auto_resolve_context"] = resolved["context"]
|
# Record effective per-entity targeting for the Resolved block.
|
||||||
sys.stderr.write(
|
resolved_effective = {
|
||||||
f"[Competitors] {entity}: "
|
"entity": entity,
|
||||||
f"x=@{resolved['x_handle'] or '-'} "
|
"x_handle": kwargs["x_handle"] or "",
|
||||||
f"subs={len(resolved['subreddits'])} "
|
"subreddits": kwargs["subreddits"] or [],
|
||||||
f"gh={resolved['github_user'] or '-'}\n"
|
"github_user": kwargs["github_user"] or "",
|
||||||
)
|
"github_repos": kwargs["github_repos"] or [],
|
||||||
|
"context": kwargs["_context"],
|
||||||
|
}
|
||||||
|
if kwargs["_context"]:
|
||||||
|
entity_config["_auto_resolve_context"] = kwargs["_context"]
|
||||||
|
sys.stderr.write(
|
||||||
|
f"[Competitors] {entity}: "
|
||||||
|
f"x=@{resolved_effective['x_handle'] or '-'} "
|
||||||
|
f"subs={len(resolved_effective['subreddits'])} "
|
||||||
|
f"gh={resolved_effective['github_user'] or '-'} "
|
||||||
|
f"({'plan' if plan_entry else 'auto'})\n"
|
||||||
|
)
|
||||||
report = pipeline.run(
|
report = pipeline.run(
|
||||||
topic=entity,
|
topic=entity,
|
||||||
config=entity_config,
|
config=entity_config,
|
||||||
depth=depth,
|
depth=depth,
|
||||||
requested_sources=requested_sources,
|
requested_sources=requested_sources,
|
||||||
mock=args.mock,
|
mock=args.mock,
|
||||||
x_handle=resolved["x_handle"] or None,
|
x_handle=kwargs["x_handle"],
|
||||||
subreddits=resolved["subreddits"] or None,
|
x_related=kwargs["x_related"],
|
||||||
github_user=resolved["github_user"] or None,
|
subreddits=kwargs["subreddits"],
|
||||||
github_repos=resolved["github_repos"] or None,
|
github_user=kwargs["github_user"],
|
||||||
|
github_repos=kwargs["github_repos"],
|
||||||
web_backend=args.web_backend,
|
web_backend=args.web_backend,
|
||||||
lookback_days=args.lookback_days,
|
lookback_days=args.lookback_days,
|
||||||
internal_subrun=True,
|
internal_subrun=True,
|
||||||
)
|
)
|
||||||
report.artifacts["resolved"] = resolved
|
report.artifacts["resolved"] = resolved_effective
|
||||||
return report
|
return report
|
||||||
|
|
||||||
entity_reports = fanout.run_competitor_fanout(
|
entity_reports = fanout.run_competitor_fanout(
|
||||||
@@ -592,14 +803,17 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
report = entity_reports[0][1]
|
report = entity_reports[0][1]
|
||||||
report.artifacts["competitor_reports"] = entity_reports
|
|
||||||
else:
|
else:
|
||||||
|
entity_reports = None
|
||||||
report = _main_runner()
|
report = _main_runner()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
progress.end_processing()
|
progress.end_processing()
|
||||||
progress.show_error(str(exc))
|
progress.show_error(str(exc))
|
||||||
raise
|
raise
|
||||||
_show_runtime_ui(report, progress, diag)
|
_show_runtime_ui(
|
||||||
|
report, progress, diag,
|
||||||
|
suppress_web_promo=bool(external_plan or comp_plan),
|
||||||
|
)
|
||||||
if args.store:
|
if args.store:
|
||||||
counts = persist_report(report)
|
counts = persist_report(report)
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
@@ -638,7 +852,6 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
report.artifacts["pre_research_flags_present"] = pre_research_flags_present
|
report.artifacts["pre_research_flags_present"] = pre_research_flags_present
|
||||||
|
|
||||||
entity_reports = report.artifacts.get("competitor_reports") if hasattr(report, "artifacts") else None
|
|
||||||
if entity_reports:
|
if entity_reports:
|
||||||
rendered = emit_comparison_output(
|
rendered = emit_comparison_output(
|
||||||
entity_reports, args.emit, fun_level=fun_level, save_path=footer_save_path,
|
entity_reports, args.emit, fun_level=fun_level, save_path=footer_save_path,
|
||||||
@@ -648,8 +861,18 @@ def main() -> int:
|
|||||||
report, args.emit, fun_level=fun_level, save_path=footer_save_path,
|
report, args.emit, fun_level=fun_level, save_path=footer_save_path,
|
||||||
)
|
)
|
||||||
if args.save_dir:
|
if args.save_dir:
|
||||||
|
# Save the main topic's raw file (single-entity or comparison main).
|
||||||
save_path = save_output(report, args.emit, args.save_dir, suffix=args.save_suffix or "")
|
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")
|
sys.stderr.write(f"[last30days] Saved output to {save_path}\n")
|
||||||
|
# Competitor / vs-mode: also save a per-entity raw file for each peer.
|
||||||
|
# Matches historical vs-mode behavior (N passes → N save files).
|
||||||
|
if entity_reports and len(entity_reports) > 1:
|
||||||
|
for label, entity_report in entity_reports[1:]:
|
||||||
|
peer_path = save_output(
|
||||||
|
entity_report, args.emit, args.save_dir,
|
||||||
|
suffix=args.save_suffix or "",
|
||||||
|
)
|
||||||
|
sys.stderr.write(f"[last30days] Saved output to {peer_path}\n")
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
print(rendered)
|
print(rendered)
|
||||||
return 0
|
return 0
|
||||||
|
|||||||
@@ -443,7 +443,7 @@ def run(
|
|||||||
if bundle.items_by_source.get(source):
|
if bundle.items_by_source.get(source):
|
||||||
del bundle.errors_by_source[source]
|
del bundle.errors_by_source[source]
|
||||||
|
|
||||||
items_by_source = _finalize_items_by_source(bundle.items_by_source, topic=topic)
|
items_by_source = _finalize_items_by_source(bundle.items_by_source, topic=topic, config=config)
|
||||||
candidates = weighted_rrf(bundle.items_by_source_and_query, plan, pool_limit=settings["pool_limit"])
|
candidates = weighted_rrf(bundle.items_by_source_and_query, plan, pool_limit=settings["pool_limit"])
|
||||||
ranked_candidates = rerank.rerank_candidates(
|
ranked_candidates = rerank.rerank_candidates(
|
||||||
topic=topic,
|
topic=topic,
|
||||||
@@ -511,6 +511,7 @@ def _normalize_score_dedupe(
|
|||||||
def _finalize_items_by_source(
|
def _finalize_items_by_source(
|
||||||
items_by_source_raw: dict[str, list[schema.SourceItem]],
|
items_by_source_raw: dict[str, list[schema.SourceItem]],
|
||||||
topic: str = "",
|
topic: str = "",
|
||||||
|
config: dict | None = None,
|
||||||
) -> dict[str, list[schema.SourceItem]]:
|
) -> dict[str, list[schema.SourceItem]]:
|
||||||
finalized = {}
|
finalized = {}
|
||||||
for source, items in items_by_source_raw.items():
|
for source, items in items_by_source_raw.items():
|
||||||
@@ -523,6 +524,11 @@ def _finalize_items_by_source(
|
|||||||
# (e.g., WTI crude oil, Elon tweet counts) before footer emission.
|
# (e.g., WTI crude oil, Elon tweet counts) before footer emission.
|
||||||
if source == "polymarket" and topic:
|
if source == "polymarket" and topic:
|
||||||
items = polymarket.filter_items_against_topic(topic, items)
|
items = polymarket.filter_items_against_topic(topic, items)
|
||||||
|
# --polymarket-keywords (via config): additional keyword filter
|
||||||
|
# for ambiguous single-token topics (e.g., "Warriors" → nba,gsw).
|
||||||
|
keywords = config.get("_polymarket_keywords") if isinstance(config, dict) else None
|
||||||
|
if keywords:
|
||||||
|
items = polymarket.filter_items_against_keywords(items, keywords)
|
||||||
finalized[source] = items
|
finalized[source] = items
|
||||||
return finalized
|
return finalized
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,39 @@ def filter_items_against_topic(topic: str, items: List[Any]) -> List[Any]:
|
|||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
|
def filter_items_against_keywords(items: List[Any], keywords: List[str]) -> List[Any]:
|
||||||
|
"""Keep only items whose title contains at least one keyword (case-insensitive).
|
||||||
|
|
||||||
|
Intended for disambiguating ambiguous single-token topics like 'Warriors'
|
||||||
|
via --polymarket-keywords (e.g., 'nba,gsw,golden-state') to filter out
|
||||||
|
Glasgow Warriors rugby, Honor of Kings Rogue Warriors markets that share
|
||||||
|
the 'Warriors' token but are not the target entity.
|
||||||
|
"""
|
||||||
|
if not keywords:
|
||||||
|
return items
|
||||||
|
normalized_keywords = [kw.strip().lower() for kw in keywords if kw and kw.strip()]
|
||||||
|
if not normalized_keywords:
|
||||||
|
return items
|
||||||
|
|
||||||
|
filtered = []
|
||||||
|
for item in items:
|
||||||
|
title = getattr(item, "title", None)
|
||||||
|
if title is None and isinstance(item, dict):
|
||||||
|
title = item.get("title", "")
|
||||||
|
title = (title or "").lower()
|
||||||
|
if any(kw in title for kw in normalized_keywords):
|
||||||
|
filtered.append(item)
|
||||||
|
|
||||||
|
dropped = len(items) - len(filtered)
|
||||||
|
if dropped:
|
||||||
|
_log(
|
||||||
|
f"Keyword filter dropped {dropped} Polymarket items; "
|
||||||
|
f"kept {len(filtered)} matching {normalized_keywords}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
|
||||||
|
|
||||||
def _extract_domain_queries(topic: str, events: List[Dict]) -> List[str]:
|
def _extract_domain_queries(topic: str, events: List[Dict]) -> List[str]:
|
||||||
"""Extract domain-indicator search terms from first-pass event tags.
|
"""Extract domain-indicator search terms from first-pass event tags.
|
||||||
|
|
||||||
|
|||||||
@@ -624,6 +624,17 @@ def render_full(report: schema.Report) -> str:
|
|||||||
lines.extend(f"- {warning}" for warning in report.warnings)
|
lines.extend(f"- {warning}" for warning in report.warnings)
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
|
# When this Report is a per-entity sub-run from vs-mode / --competitors,
|
||||||
|
# include the single-row Resolved Entities block so the saved file is
|
||||||
|
# self-describing. The artifact is populated by last30days.py's
|
||||||
|
# _competitor_runner and _main_runner closures.
|
||||||
|
resolved = report.artifacts.get("resolved")
|
||||||
|
if isinstance(resolved, dict) and resolved.get("entity"):
|
||||||
|
single_row = _render_resolved_entities_block([(resolved["entity"], report)])
|
||||||
|
if single_row:
|
||||||
|
lines.extend(single_row)
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
# ALL clusters (no limit)
|
# ALL clusters (no limit)
|
||||||
lines.append("## Ranked Evidence Clusters")
|
lines.append("## Ranked Evidence Clusters")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|||||||
@@ -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("clusters", payload)
|
||||||
self.assertIn("items_by_source", 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):
|
def test_openclaw_three_way_comparison_preserves_entities(self):
|
||||||
payload = run_mock_json("openclaw vs. nanoclaw vs. ironclaw")
|
payload = run_mock_json("openclaw vs. nanoclaw vs. ironclaw")
|
||||||
self.assert_common_shape(payload)
|
self.assert_comparison_shape(payload)
|
||||||
plan = payload["query_plan"]
|
entities = [e.lower() for e in payload["entities"]]
|
||||||
self.assertEqual("comparison", plan["intent"])
|
self.assertIn("openclaw", entities)
|
||||||
joined_queries = "\n".join(subquery["search_query"] for subquery in plan["subqueries"]).lower()
|
self.assertIn("nanoclaw", entities)
|
||||||
self.assertIn("openclaw", joined_queries)
|
self.assertIn("ironclaw", entities)
|
||||||
self.assertIn("nanoclaw", joined_queries)
|
# No cross-entity keyword pollution in any per-entity report's plan
|
||||||
self.assertIn("ironclaw", joined_queries)
|
for entry in payload["reports"]:
|
||||||
self.assertNotIn("corsair", joined_queries)
|
plan = entry["report"]["query_plan"]
|
||||||
self.assertNotIn("mouse", joined_queries)
|
joined = "\n".join(
|
||||||
for subquery in plan["subqueries"]:
|
sq["search_query"] for sq in plan["subqueries"]
|
||||||
self.assertGreaterEqual(len(subquery["sources"]), 4)
|
).lower()
|
||||||
|
self.assertNotIn("corsair", joined)
|
||||||
|
self.assertNotIn("mouse", joined)
|
||||||
|
|
||||||
def test_how_to_keeps_web_video_and_discussion_sources(self):
|
def test_how_to_keeps_web_video_and_discussion_sources(self):
|
||||||
payload = run_mock_json("how to deploy on Fly.io")
|
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):
|
def test_two_way_comparison_preserves_exact_strings(self):
|
||||||
payload = run_mock_json("DeepSeek R1 vs GPT-5")
|
payload = run_mock_json("DeepSeek R1 vs GPT-5")
|
||||||
self.assert_common_shape(payload)
|
self.assert_comparison_shape(payload)
|
||||||
plan = payload["query_plan"]
|
entities_lower = [e.lower() for e in payload["entities"]]
|
||||||
self.assertEqual("comparison", plan["intent"])
|
self.assertIn("deepseek r1", entities_lower)
|
||||||
joined_queries = "\n".join(subquery["search_query"] for subquery in plan["subqueries"]).lower()
|
self.assertIn("gpt-5", entities_lower)
|
||||||
self.assertIn("deepseek r1", joined_queries)
|
# Each per-entity pass has its own entity in its plan
|
||||||
self.assertIn("gpt-5", joined_queries)
|
topics_by_entity = {
|
||||||
self.assertNotIn("corsair", joined_queries)
|
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__":
|
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