Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09ed497804 | |||
| 49d45c2b42 |
@@ -10,7 +10,7 @@
|
||||
{
|
||||
"name": "last30days",
|
||||
"description": "Research any topic across Reddit, X, YouTube, TikTok, Instagram, HN, Polymarket, GitHub, and 5+ more sources.",
|
||||
"version": "3.0.9",
|
||||
"version": "3.0.0",
|
||||
"author": {
|
||||
"name": "Matt Van Horn",
|
||||
"url": "https://github.com/mvanhorn"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "last30days",
|
||||
"version": "3.0.14",
|
||||
"version": "3.0.0",
|
||||
"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": {
|
||||
"name": "Matt Van Horn",
|
||||
@@ -11,5 +11,6 @@
|
||||
"repository": "https://github.com/mvanhorn/last30days-skill",
|
||||
"license": "MIT",
|
||||
"keywords": ["research", "reddit", "twitter", "youtube", "tiktok", "instagram", "trends", "prompts", "polymarket", "github", "perplexity", "threads", "pinterest", "eli5", "hacker-news"],
|
||||
"skills": ["./"],
|
||||
"hooks": {}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"name": "last30days"
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
# Exclude non-runtime files from `git archive` output.
|
||||
# Used by scripts/build-skill.sh to produce a claude.ai-upload-ready .skill file.
|
||||
# See docs/plans/2026-04-14-001-fix-skill-upload-200-file-limit-plan.md.
|
||||
|
||||
# Anthropic canonical skill-packaging excludes
|
||||
# (mirrors anthropics/skills/skills/skill-creator/scripts/package_skill.py)
|
||||
__pycache__/ export-ignore
|
||||
node_modules/ export-ignore
|
||||
*.pyc export-ignore
|
||||
.DS_Store export-ignore
|
||||
evals/ export-ignore
|
||||
|
||||
# Dev, docs, test, and media - not needed at skill runtime
|
||||
tests/ export-ignore
|
||||
docs/ export-ignore
|
||||
fixtures/ export-ignore
|
||||
assets/ export-ignore
|
||||
|
||||
# NOTE: skills/ and .claude-plugin/ are NOT export-ignored here because
|
||||
# Claude Code's /plugin install fetches this same git archive tarball.
|
||||
# Removing those from the archive (as v3.0.1 did) silently breaks installs.
|
||||
# claude.ai-bundle-specific exclusions live in scripts/build-skill.sh.
|
||||
|
||||
# Historical + repo-only manifests
|
||||
SKILL-original.md export-ignore
|
||||
SPEC.md export-ignore
|
||||
TASKS.md export-ignore
|
||||
test-run.log export-ignore
|
||||
CONTRIBUTORS.md export-ignore
|
||||
HERMES_SETUP.md export-ignore
|
||||
release-notes.md export-ignore
|
||||
CHANGELOG.md export-ignore
|
||||
uv.lock export-ignore
|
||||
|
||||
# Platform adapters - skill-upload path is platform-agnostic
|
||||
.agents/ export-ignore
|
||||
.codex-plugin/ export-ignore
|
||||
.hermes-plugin/ export-ignore
|
||||
|
||||
# CI workflows - repo-only, not needed at skill runtime
|
||||
.github/ export-ignore
|
||||
|
||||
# Build config itself
|
||||
.clawhubignore export-ignore
|
||||
.gitignore export-ignore
|
||||
.gitattributes export-ignore
|
||||
@@ -1,31 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Build .skill artifact
|
||||
run: |
|
||||
bash scripts/build-skill.sh
|
||||
test -f dist/last30days.skill
|
||||
|
||||
- name: Create GitHub release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: dist/last30days.skill
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
prerelease: false
|
||||
-19
@@ -15,22 +15,3 @@ variants/open/references/research.md
|
||||
__pycache__/
|
||||
*.pyc
|
||||
mise.toml
|
||||
.memsearch/
|
||||
.venv/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Root vendor/ is accidental - real vendored client lives at scripts/lib/vendor/bird-search/
|
||||
/vendor/
|
||||
|
||||
# build artifact from scripts/build-skill.sh
|
||||
/dist/
|
||||
|
||||
# Internal planning docs (ce:plan output) — keep local, don't publish
|
||||
docs/plans/
|
||||
|
||||
# Marketing video build artifacts
|
||||
marketing/v3.1-launch/node_modules/
|
||||
marketing/v3.1-launch/out/
|
||||
marketing/*/node_modules/
|
||||
marketing/*/out/
|
||||
|
||||
+5
-202
@@ -5,196 +5,7 @@ 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/),
|
||||
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
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Per-entity Step 0.55 resolution for competitor sub-runs.** In 3.0.11, only the main topic got X handle / subreddit / GitHub resolution; competitor sub-runs ran with planner defaults and produced visibly thinner evidence (Reddit 403 fallbacks, single-word queries). Each competitor sub-run now calls `resolve.auto_resolve()` inside `fanout.run_competitor_fanout` when a web backend is available, mirroring the main topic's pre-flight resolution. Per-entity X handle, subreddit list, GitHub user/repos, and news context are threaded into each sub-run's `pipeline.run()` call. Deep-copied config per sub-run prevents `_auto_resolve_context` cross-leak. Surfaces in a new `## Resolved Entities` output block so the resolution coverage is visible without reading stderr.
|
||||
- **LAW 7 false-positive on internal fan-out sub-runs.** Each competitor sub-run was emitting the `[Planner] No --plan passed... YOU ARE the planner` stderr warning. LAW 7 targets the hosting-reasoning-model path, not engine-internal fan-out. New `internal_subrun=True` keyword on `planner.plan_query` and `pipeline.run` suppresses the warning for sub-runs only; the default path is unchanged.
|
||||
- **Marketplace-stale SKILL.md trap.** Added a STEP 0 canonical-path self-check at the top of SKILL.md. Two of three 2026-04-22 test runs loaded SKILL.md from `plugins/marketplaces/last30days-skill/` (Claude-Code-managed git clone pinned to origin/main, lagging the versioned cache), then ran `--help` against the same stale path, did not see `--competitors`, and fell back to a manual comparison plan. The STEP 0 block forces any reader to verify they loaded from `plugins/cache/last30days-skill/last30days/{VERSION}/SKILL.md` and re-read from the versioned cache if not.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Default `--competitors` count is now 2 (3-way total: original + 2 peers).** Previously 3. `--competitors=N` still customizes (range 1..6). Matches the feature description's canonical example (`Kanye vs Drake vs Kendrick`).
|
||||
|
||||
### Added
|
||||
|
||||
- **`## Resolved Entities` block** in `render_comparison_multi` output. Shows per-entity X handle, subreddits, GitHub user/repos, and truncated context for every entity in the comparison. Block is omitted entirely when no entity has a resolved payload (mock mode, no backend).
|
||||
|
||||
## [3.0.11] - 2026-04-22
|
||||
|
||||
### Added
|
||||
|
||||
- **`--competitors` flag for auto-discovered comparison fan-out.** Pass `--competitors` on a single-entity topic and the engine discovers 2-6 peer entities via web search, then runs the full pipeline on each in parallel and emits one N-way comparison. `last30days Kanye West --competitors` resolves Drake, Kendrick Lamar, and one more peer. `last30days OpenAI --competitors` resolves Anthropic, xAI, Google Gemini. `--competitors=N` controls count, `--competitors-list="A,B,C"` skips discovery and uses the explicit list. Discovery mirrors the `auto_resolve` pattern (Brave / Exa / Serper / Parallel) with deterministic text extraction - no internal LLM call. Sub-runs inherit the main `--quick`/`--deep`/`--days`, run in a `ThreadPoolExecutor`, and degrade gracefully when at least 2 entities survive. Output reuses the existing 9-axis `## Head-to-Head` scaffold.
|
||||
|
||||
## [3.0.10] - 2026-04-21
|
||||
|
||||
### Added
|
||||
|
||||
- **Commenter handles on evidence lines.** Top-comment rendering now includes the commenter's handle - `u/author` for Reddit, `@handle` for TikTok/YouTube/Instagram/Bluesky/X/Threads. The enrichment adapters already captured `author`; the render layer just was not using it. Evidence lines change from `- Comment (6822 upvotes): Finally, John Apple` to `- u/Cyrisaurus (6822 upvotes): Finally, John Apple`. Person-level citations make synthesis-side inline markdown links per LAW 8 much more natural. Both the compact and full render paths are covered.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **TikTok author preference.** `_fetch_post_comments` in `scripts/lib/tiktok.py` preferred `user.nickname` over `user.unique_id`, so the engine captured display names ("Moosa Noormahomed") instead of @handles ("moosanoormahomed"). Flipped to prefer `unique_id`. Nickname still wins as a fallback when `unique_id` is missing. Display names can contain emoji, spaces, and non-Latin characters that do not round-trip to a profile URL; the @handle is the stable identifier.
|
||||
|
||||
### Behavior fallback
|
||||
|
||||
- When an author is empty, `[deleted]`, or `[removed]`, the render falls back to the legacy `Comment (...)` shape - no `u/` or `@` prefix with an empty handle is ever emitted.
|
||||
|
||||
## [3.0.9] - 2026-04-18 - The Self-Debug Release
|
||||
|
||||
### Highlights
|
||||
|
||||
v3.0.9 adds the engine-side Class 1 keyword-trap refuse-gate ("birthday gift for 40 year old" now gets a clarifying question, not 5 minutes of junk), promotes TikTok and YouTube top comments to the same first-class rendering Reddit's got, lands Hermes AI Agent as a first-class deploy target, and moves the SKILL.md formatting contract from line 1094 to the top of the file.
|
||||
|
||||
"The Self-Debug Release" refers to how the fixes in 3.0.6-3.0.9 were written: 5 separate Opus 4.7 instances each debugged their own failed outputs. Three converged on "SKILL.md is too big and the LAWs are too deep." Two converged on "the engine should refuse demographic-shopping queries." I shipped exactly what they said. Validation: 5/5 canonical compliance.
|
||||
|
||||
### Added
|
||||
|
||||
- **Engine Class 1 keyword-trap refuse-gate** (`scripts/lib/preflight.py`, new). Pattern-matches demographic-shopping queries at main() front-door. Exit code 2 with structured REFUSE message. Escape hatch: `LAST30DAYS_SKIP_PREFLIGHT=1`. 29 tests in `tests/test_preflight.py`.
|
||||
- **TikTok + YouTube top comments** rendered with same `💬 Top comment` prominence as Reddit's. Shipped in [#260](https://github.com/mvanhorn/last30days-skill/pull/260); enrichment fixed in [#265](https://github.com/mvanhorn/last30days-skill/pull/265).
|
||||
- **Hermes AI Agent as a deploy target** - thanks @stephenmcconnachie ([#228](https://github.com/mvanhorn/last30days-skill/pull/228)). `scripts/sync.sh` detects `~/.hermes/skills/research` and deploys automatically.
|
||||
- **Multi-key SCRAPECREATORS_API_KEY rotation** - thanks @zaydiscold ([#268](https://github.com/mvanhorn/last30days-skill/pull/268)). Set `SCRAPECREATORS_API_KEY_1`, `_2`, etc. Engine rotates on rate-limit.
|
||||
- **Offline quality evaluation fixture** - thanks @j-sperling ([#233](https://github.com/mvanhorn/last30days-skill/pull/233)). `eval_topics.json` lets contributors run quality regressions without burning live API credits.
|
||||
- **END-OF-CANONICAL-OUTPUT boundary** in `render_compact()`. Engine now emits an explicit pass-through instruction so re-synthesis requires actively ignoring a visible boundary.
|
||||
- **LAW 1 verbatim-pattern override.** LAW 1 now quotes the exact WebSearch tool-result reminder ("CRITICAL REQUIREMENT: MUST include Sources: section") and declares it OVERRIDDEN inside last30days output.
|
||||
|
||||
### Changed
|
||||
|
||||
- **SKILL.md restructure.** VOICE CONTRACT LAWs and BADGE MANDATORY block moved from line 1094 to lines 75-150. Grounded in 3 separate Opus 4.7 self-debugs.
|
||||
- **Engine emits the badge as stdout.** `🌐 last30days v3.0.9 · synced YYYY-MM-DD` is the first line of every compact emit. Pass-through is now the default-correct behavior.
|
||||
- **Reddit client HTTP consolidation** - thanks @iliaal ([#207](https://github.com/mvanhorn/last30days-skill/pull/207)). Migrated to `http.get(params=...)` helper.
|
||||
- **ScrapeCreators header consolidation** - thanks @iliaal ([#209](https://github.com/mvanhorn/last30days-skill/pull/209)). `_sc_headers` refactored into `http.scrapecreators_headers`.
|
||||
- **Simpler Hermes sync.** `scripts/sync.sh` Hermes branch now always uses main SKILL.md (previously had a `.hermes-plugin/SKILL.md` fallback that created a wrong-file-capture hazard).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Peter Steinberger trailing Sources leak.** 2026-04-18 validation failure where the model appended a TechCrunch / TED / Fortune / Wikipedia Sources list after the invitation. Now structurally prevented at three layers: engine emits the canonical body, LAW 1 quotes the exact WebSearch reminder, closing boundary names the anti-pattern.
|
||||
- **Wrong-file SKILL.md capture.** Deleted `.agents/skills/last30days/SKILL.md` (1382 lines, April 13 snapshot) and `.hermes-plugin/SKILL.md` (269 lines). One SKILL.md per plugin now, at the plugin root.
|
||||
- **GitHub date parsing garbage** - thanks @iliaal ([#208](https://github.com/mvanhorn/last30days-skill/pull/208)). `_parse_date` now rejects invalid input cleanly.
|
||||
- **Windows Bird X stability** - thanks @Chelebii ([#227](https://github.com/mvanhorn/last30days-skill/pull/227)).
|
||||
- **Linux `check_perms` false-warn** - thanks @george231224 ([#216](https://github.com/mvanhorn/last30days-skill/pull/216)). Uses GNU stat first.
|
||||
- **UTF-8 saved output** - thanks @Gujiassh ([#225](https://github.com/mvanhorn/last30days-skill/pull/225)).
|
||||
- **Version metadata alignment** - thanks @Gujiassh ([#217](https://github.com/mvanhorn/last30days-skill/pull/217)) and @shalomma ([#229](https://github.com/mvanhorn/last30days-skill/pull/229)).
|
||||
- **`--days` alias backcompat** - thanks @BryanTegomoh ([#230](https://github.com/mvanhorn/last30days-skill/pull/230)).
|
||||
- **`INCLUDE_SOURCES` env default** - thanks @hnshah ([#223](https://github.com/mvanhorn/last30days-skill/pull/223)).
|
||||
- **Bird X all-None engagement** - thanks @j-sperling ([#234](https://github.com/mvanhorn/last30days-skill/pull/234)).
|
||||
|
||||
### Contributors
|
||||
|
||||
@j-sperling, @stephenmcconnachie, @zaydiscold, @iliaal, @Chelebii, @Gujiassh, @hnshah, @george231224, @shalomma, @BryanTegomoh for PRs since v3.0.0. @uppinote20, @zerone0x, @thinkun, @thomasmktong, @fanispoulinakisai-boop, @pejmanjohn, @zl190, @Jah-yee, @dannyshmueli, @Cody-Coyote for issues and PRs that shaped the v3 roadmap.
|
||||
|
||||
### Recovery
|
||||
|
||||
```
|
||||
/plugin update last30days
|
||||
/reload-plugins
|
||||
```
|
||||
|
||||
Verify: `cat ~/.claude/plugins/cache/last30days-skill/last30days/*/.claude-plugin/plugin.json | grep version` returns `"version": "3.0.9"`.
|
||||
|
||||
Smoke test: `/last30days birthday gift for 40 year old` should ask a clarifying question before running.
|
||||
|
||||
## [3.0.5] - 2026-04-15
|
||||
|
||||
### Added
|
||||
|
||||
- **`/last30days` slash command for plugin users.** New `commands/last30days.md` registers a Claude Code slash command. Users type `/last30days <topic>` and Claude Code's autocomplete prefix-matches it to the canonical `/last30days:last30days` form (the same way `/ce:plan` resolves to `/compound-engineering:ce-plan`). The command delegates to the existing `last30days` skill body — no skill behavior changes.
|
||||
|
||||
### Removed
|
||||
|
||||
- **`skills/last30days-nux/`** — byte-identical duplicate of root `SKILL.md` that created confusing `/last30days:last30days-nux` autocomplete entries via Claude Code's plugin namespacing. The root `SKILL.md` remains the canonical skill source.
|
||||
|
||||
### Recovery
|
||||
|
||||
```
|
||||
/plugin update last30days
|
||||
/reload-plugins
|
||||
```
|
||||
|
||||
Then type `/last30days <topic>` to invoke the skill via slash command. Natural-language invocation ("search the last 30 days for X") continues to work unchanged.
|
||||
|
||||
## [3.0.4] - 2026-04-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Cleared `/doctor` path-escape error on Claude Code v2.1.109+.** `.claude-plugin/plugin.json` previously declared `"skills": ["./"]`. That value shipped unchanged from v2.1.0 through v3.0.3 and worked on older Claude Code, but current versions reject `./` with `Path escapes plugin directory: ./ (skills)`. The `"skills"` key is now omitted entirely, matching the pattern used by every other plugin in the Claude Code marketplace ecosystem. Claude Code auto-discovers `skills/*/SKILL.md` when the key is absent.
|
||||
|
||||
### Recovery
|
||||
|
||||
If `/doctor` reports a path-escape error for last30days, run `/plugin update last30days` then `/reload-plugins`. If errors persist, uninstall and reinstall the plugin.
|
||||
|
||||
## [3.0.3] - 2026-04-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Restored `skills/` and `.claude-plugin/` to the plugin install tarball.** v3.0.1 added `.gitattributes` rules that excluded both directories from `git archive` output to shrink the claude.ai `.skill` bundle. Claude Code's `/plugin install` fetches the same archive, so users installing v3.0.1 or v3.0.2 received a tarball with no plugin manifest and no skill files. `git archive v3.0.0` contained 8 files under those paths; `v3.0.1` and `v3.0.2` contained 0. This release reverts those `.gitattributes` lines.
|
||||
- **Reverted `plugin.json` `"skills"` field to `["./"]`.** v3.0.2 changed this to `["skills"]` based on a misdiagnosis — the manifest change had no effect because the manifest wasn't in the tarball at all. The historical `["./"]` value shipped in every release from v2.1.0 through v3.0.0 without issues and is restored here.
|
||||
|
||||
### Recovery
|
||||
|
||||
Users on v3.0.1 or v3.0.2: run `/plugin update last30days` then `/reload-plugins`. If autoUpdate is enabled, the next session start will pull v3.0.3 automatically. Users on cached v3.0.0 or earlier installs were unaffected.
|
||||
|
||||
### Notes
|
||||
|
||||
- The claude.ai `.skill` bundle built by `scripts/build-skill.sh` still works — the archive grew from 89 to 97 files, well under the 200-file cap.
|
||||
- claude.ai-specific exclusions (avoiding duplicate `SKILL.md` files in the bundle) should move into `scripts/build-skill.sh` rather than `.gitattributes` in a future release, since `.gitattributes` cannot distinguish between the two distribution channels.
|
||||
|
||||
## [3.0.2] - 2026-04-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`/last30days` slash command now registers on Claude Code v2.1.105+.** `.claude-plugin/plugin.json` declared `"skills": ["./"]`, which newer Claude Code rejects with `Path escapes plugin directory: ./ (skills)`. The skill silently failed to register, so `/last30days <query>` returned "Unknown command" even though `/plugin list` showed the plugin as installed. Fix: `"skills": ["skills"]` so the loader scans the real skill subdirectory.
|
||||
- **Version drift between manifests.** `.claude-plugin/marketplace.json` was pinned to `3.0.0` while `.claude-plugin/plugin.json` advertised `3.0.1`. The `/plugin` resolver used the marketplace version and could install stale cached metadata alongside the correct build. Both manifests now agree on `3.0.2`.
|
||||
|
||||
### Recovery
|
||||
|
||||
If `/last30days` stopped working for you, run `/plugin update last30days` then `/reload-plugins`. If `/doctor` still reports errors, uninstall and reinstall the plugin from the marketplace.
|
||||
|
||||
## [3.0.1] - 2026-04-14
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Skill upload packaging** - `scripts/build-skill.sh` produces a claude.ai-upload-ready `.skill` file that fits under the 200-file cap. Previously, zipping the repo hit 406 files and the "Upload skill" UI rejected it outright.
|
||||
- **SKILL.md description length** - trimmed from 228 to 167 chars (Anthropic caps descriptions at 200).
|
||||
|
||||
### Removed
|
||||
|
||||
- Unused root `vendor/` directory (215 files from an accidental commit in PR #48 - the real vendored X client lives at `scripts/lib/vendor/bird-search/`).
|
||||
- Legacy top-level `plans/` directory (superseded by `docs/plans/`; both plans described work that was already shipped in v3).
|
||||
|
||||
### Added
|
||||
|
||||
- `.gitattributes` with `export-ignore` entries so `git archive` drops tests, docs, fixtures, assets, historical manifests, and internal skill subdirs. Mirrors Anthropic's canonical `package_skill.py` exclusions.
|
||||
- `scripts/build-skill.sh` - one-command path to produce `dist/last30days.skill` with a single top-level `last30days/` folder, defensive `=200` file check, and dirty-tree refusal.
|
||||
- `README.md` section documenting the claude.ai skill upload workflow.
|
||||
|
||||
## [3.0.0] - 2026-04-11
|
||||
## [3.0.0] - 2026-04
|
||||
|
||||
### Highlights
|
||||
|
||||
@@ -223,18 +34,10 @@ Intelligent search, fun judge, cross-source cluster merging, single-pass compari
|
||||
- Polymarket display shows % odds only; dollar volumes removed
|
||||
- 852 tests passing
|
||||
|
||||
### Fixed
|
||||
|
||||
- Marketplace validation: duplicate `name: last30days` collision in `skills/last30days/SKILL.md` caused strict validators to reject the plugin. Resolved by renaming the internal v3 architecture spec to `last30days-v3-spec` with `user-invocable: false`. Fixed in #214 (reported by @Cody-Coyote in #204).
|
||||
- Stale README link to the deleted `skills/last30days-v3/` path from the v3 directory rename. Fixed in #214.
|
||||
- OpenAI Codex CLI discoverability: added `.agents/skills/last30days/SKILL.md` as a real file (Codex's loader skips symlinked files) plus `.codex-plugin/plugin.json` as the namespace marker. The skill now registers as `last30days:last30days` when Codex runs in a checkout of the repo. Fixed in #219 (inspired by @Jah-yee in #153 and @dannyshmueli on X).
|
||||
|
||||
### Contributors
|
||||
|
||||
- @j-sperling -- v3 engine architecture, Python pre-research brain
|
||||
- @hnshah -- Watchlist features
|
||||
- @Cody-Coyote -- Marketplace validation bug report (#204)
|
||||
- @Jah-yee -- Codex CLI integration inspiration (#153)
|
||||
|
||||
## [2.9.4] - 2026-03-06
|
||||
|
||||
@@ -263,15 +66,15 @@ Intelligent search, fun judge, cross-source cluster merging, single-pass compari
|
||||
|
||||
### Highlights
|
||||
|
||||
Auto-save research briefings to the default memory directory as topic-named .md files. Every run now builds a personal research library automatically - no more manual copy-paste.
|
||||
Auto-save research briefings to `~/Documents/Last30Days/` as topic-named .md files. Every run now builds a personal research library automatically - no more manual copy-paste.
|
||||
|
||||
### Added
|
||||
|
||||
- Auto-save complete research briefings (synthesis, stats, follow-up suggestions) to the default memory directory after every run
|
||||
- Auto-save complete research briefings (synthesis, stats, follow-up suggestions) to `~/Documents/Last30Days/{topic-slug}.md` after every run
|
||||
- Kebab-case filename generation from topic (e.g., "Claude Code skills" -> `claude-code-skills.md`)
|
||||
- Duplicate topic handling: appends date suffix instead of overwriting (e.g., `claude-code-skills-2026-03-05.md`)
|
||||
- Agent mode (`--agent`) also saves research files
|
||||
- Brief confirmation after save with the saved file path
|
||||
- Brief confirmation after save: "Saved to ~/Documents/Last30Days/{slug}.md"
|
||||
|
||||
### Credits
|
||||
|
||||
@@ -378,6 +181,7 @@ Three headline features: watchlists for always-on bots, YouTube transcripts as a
|
||||
|
||||
### Credits
|
||||
|
||||
- @steipete -- Bird CLI (vendored X search) and yt-dlp/summarize inspiration for YouTube transcripts
|
||||
- @galligan -- Marketplace plugin inspiration
|
||||
- @hutchins -- Pushed for YouTube feature
|
||||
|
||||
@@ -385,7 +189,6 @@ Three headline features: watchlists for always-on bots, YouTube transcripts as a
|
||||
|
||||
Initial public release. Reddit + X search via OpenAI Responses API and xAI API.
|
||||
|
||||
[3.0.9]: https://github.com/mvanhorn/last30days-skill/compare/v3.0.5...v3.0.9
|
||||
[2.9.1]: https://github.com/mvanhorn/last30days-skill/compare/v2.9.0...v2.9.1
|
||||
[2.9.0]: https://github.com/mvanhorn/last30days-skill/compare/v2.8.0...v2.9.0
|
||||
[2.8.0]: https://github.com/mvanhorn/last30days-skill/compare/v2.6.0...v2.8.0
|
||||
|
||||
@@ -18,8 +18,4 @@ bash scripts/sync.sh # Deploy to ~/.claud
|
||||
## Rules
|
||||
- `lib/__init__.py` must be bare package marker (comment only, NO eager imports)
|
||||
- After edits: run `bash scripts/sync.sh` to deploy
|
||||
- Git remote: origin = public (`mvanhorn/last30days-skill`)
|
||||
|
||||
## Beta channel
|
||||
|
||||
Experimental changes get tested on `mvanhorn/last30days-skill-private`, which installs as a parallel `/last30days-beta` slash command. Beta-only changes never ship to public without a review PR here. Workflow guide lives at `BETA.md` in the private repo. Plan that established this setup: `docs/plans/2026-04-17-005-feat-beta-skill-from-private-repo-plan.md`.
|
||||
- Git remotes: origin=private, upstream=public
|
||||
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
# Hermes Setup Guide for last30days
|
||||
|
||||
This guide covers installing last30days on Hermes AI Agent.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Hermes installed** - See https://github.com/mercurial-tf/hermes
|
||||
2. **Python 3.12+** - `brew install python@3.12` or similar
|
||||
3. **yt-dlp** (optional, for YouTube) - `brew install yt-dlp`
|
||||
|
||||
## Installation
|
||||
|
||||
### Option 1: Via sync.sh (Recommended)
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://github.com/mvanhorn/last30days-skill.git
|
||||
cd last30days-skill
|
||||
|
||||
# Run the sync script
|
||||
bash scripts/sync.sh
|
||||
```
|
||||
|
||||
This will auto-detect Hermes and deploy to `~/.hermes/skills/research/last30days/`
|
||||
|
||||
### Option 2: Manual Copy
|
||||
|
||||
```bash
|
||||
# Create directory
|
||||
mkdir -p ~/.hermes/skills/research/last30days
|
||||
|
||||
# Copy files
|
||||
cp -r scripts ~/.hermes/skills/research/last30days/
|
||||
cp .hermes-plugin/SKILL.md ~/.hermes/skills/research/last30days/
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
In Hermes, invoke with:
|
||||
|
||||
```
|
||||
last30days "your research topic"
|
||||
```
|
||||
|
||||
Or with options:
|
||||
```
|
||||
last30days "best mechanical keyboards 2025" --search=reddit,youtube
|
||||
last30days "AI news" --days=7 --deep
|
||||
```
|
||||
|
||||
## First Run Setup
|
||||
|
||||
On first run, the skill will guide you through setup:
|
||||
|
||||
1. **Auto setup** (~30 seconds)
|
||||
- Scans browser cookies for X/Twitter
|
||||
- Checks/installs yt-dlp for YouTube
|
||||
- Configures free sources (Reddit, HN, Polymarket)
|
||||
|
||||
2. **Optional: ScrapeCreators**
|
||||
- Adds TikTok, Instagram, Reddit backup
|
||||
- 10,000 free API calls
|
||||
- Sign up at scrapecreators.com
|
||||
|
||||
3. **Optional: API Keys**
|
||||
- XAI_API_KEY for X/Twitter (alternative to browser cookies)
|
||||
- BRAVE_API_KEY for web search
|
||||
|
||||
## Available Sources
|
||||
|
||||
### Free (No API Key)
|
||||
- **Reddit** - Public discussions and comments
|
||||
- **Hacker News** - Tech discussions via Algolia
|
||||
- **Polymarket** - Prediction markets
|
||||
- **YouTube** - Search and transcripts (requires yt-dlp)
|
||||
|
||||
### Requires API Key
|
||||
- **X/Twitter** - xAI API key or browser cookies
|
||||
- **TikTok** - ScrapeCreators API
|
||||
- **Instagram** - ScrapeCreators API
|
||||
- **Web Search** - Brave Search API
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Python not found
|
||||
```bash
|
||||
# Find Python 3.12+
|
||||
which python3.12 python3.13 python3.14
|
||||
|
||||
# If not installed
|
||||
brew install python@3.12
|
||||
```
|
||||
|
||||
### yt-dlp not found
|
||||
```bash
|
||||
brew install yt-dlp
|
||||
# or
|
||||
pip install yt-dlp
|
||||
```
|
||||
|
||||
### Check what's configured
|
||||
```bash
|
||||
cd ~/.hermes/skills/research/last30days
|
||||
python3.12 scripts/last30days.py --diagnose
|
||||
```
|
||||
|
||||
## Updating
|
||||
|
||||
To update to the latest version:
|
||||
|
||||
```bash
|
||||
cd last30days-skill
|
||||
git pull
|
||||
bash scripts/sync.sh
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
- Original repo: https://github.com/mvanhorn/last30days-skill
|
||||
- Hermes: https://github.com/mercurial-tf/hermes
|
||||
- Issues: Please report in the original repo
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
**An AI agent-led search engine scored by upvotes, likes, and real money - not editors.**
|
||||
|
||||
This README tracks the current v3 pipeline. The runtime skill spec lives in [skills/last30days/SKILL.md](skills/last30days/SKILL.md), which is the source of truth for the latest command and setup behavior.
|
||||
This README tracks the current v3 pipeline. The runtime skill spec lives in [skills/last30days-v3/SKILL.md](skills/last30days-v3/SKILL.md), which is the source of truth for the latest command and setup behavior.
|
||||
|
||||
Claude Code:
|
||||
```
|
||||
@@ -24,12 +24,6 @@ OpenClaw:
|
||||
clawhub install last30days-official
|
||||
```
|
||||
|
||||
Hermes:
|
||||
```
|
||||
# The skill auto-deploys when you run sync.sh
|
||||
# Or manually copy to ~/.hermes/skills/research/last30days/
|
||||
```
|
||||
|
||||
Zero config. Reddit, HN, Polymarket, and GitHub work immediately. Run it once and the setup wizard unlocks X, YouTube, TikTok, and more in 30 seconds.
|
||||
|
||||
---
|
||||
@@ -114,10 +108,6 @@ When the same story appears on Reddit, X, and YouTube, v3 merges them into one c
|
||||
|
||||
"CLI vs MCP" used to run three serial passes (12+ minutes). v3 runs one pass with entity-aware subqueries for both sides simultaneously. Same depth, 3 minutes.
|
||||
|
||||
### Auto-discovered competitor comparisons
|
||||
|
||||
`/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
|
||||
|
||||
When the topic is a person, the engine switches from keyword search to author-scoped queries. Instead of "who mentioned this name in an issue body," it answers: what are they shipping and where is it landing?
|
||||
@@ -132,7 +122,7 @@ Say "eli5 on" after any research run. The synthesis rewrites in plain language.
|
||||
|
||||
- **Free Reddit comments.** Public JSON gives you threads + top comments with upvote counts. No API key, no ScrapeCreators. Just works.
|
||||
- **YouTube transcripts that actually work.** Widened candidate pool 3x past music videos to reach talk/review content with captions.
|
||||
- **Threads, Pinterest, YouTube + TikTok comments.** Opt-in sources via ScrapeCreators. Set `INCLUDE_SOURCES=tiktok,instagram` and add threads, pinterest, youtube_comments, tiktok_comments for more. `youtube_comments` and `tiktok_comments` surface top comments with vote counts the same way Reddit does.
|
||||
- **Threads, Pinterest, YouTube comments.** Opt-in sources via ScrapeCreators. Set `INCLUDE_SOURCES=tiktok,instagram` and add threads, pinterest, youtube_comments for more.
|
||||
- **Perplexity Sonar.** Grounded web search with citations via OpenRouter. Add `OPENROUTER_API_KEY` to unlock.
|
||||
- **Polymarket noise filtering.** Common-word disambiguation prevents "Apple" from matching "Will Apple release a car?"
|
||||
- **Resilient Reddit.** Timeout budgets and runtime fallback. One slow thread doesn't kill the whole run.
|
||||
@@ -145,52 +135,28 @@ Say "eli5 on" after any research run. The synthesis rewrites in plain language.
|
||||
|
||||
## Install
|
||||
|
||||
| Surface | Install |
|
||||
|---------|---------|
|
||||
| **claude.ai** (web) | [Download `last30days.skill`](https://github.com/mvanhorn/last30days-skill/releases/latest/download/last30days.skill) and upload via Settings > Capabilities > Skills > + |
|
||||
| **Claude Code** | `/plugin marketplace add mvanhorn/last30days-skill` |
|
||||
| **OpenClaw** | `clawhub install last30days-official` |
|
||||
| **Gemini CLI** | Clone then `gemini extensions install ./last30days-skill` (see below) |
|
||||
|
||||
### claude.ai (web)
|
||||
|
||||
1. [Download `last30days.skill`](https://github.com/mvanhorn/last30days-skill/releases/latest/download/last30days.skill) from the latest release
|
||||
2. Go to [claude.ai Settings > Capabilities > Skills](https://claude.ai/settings/capabilities)
|
||||
3. Click the `+` button in the Skills panel and drop the file in
|
||||
|
||||
Enable "Code execution and file creation" under Capabilities first - skills won't run without it.
|
||||
|
||||
### Claude Code
|
||||
|
||||
#### Install
|
||||
```
|
||||
/plugin marketplace add mvanhorn/last30days-skill
|
||||
```
|
||||
|
||||
Update later with `claude plugin update last30days@last30days-skill`.
|
||||
#### Update
|
||||
```
|
||||
claude plugin update last30days@last30days-skill
|
||||
```
|
||||
|
||||
### OpenClaw
|
||||
|
||||
```bash
|
||||
clawhub install last30days-official
|
||||
```
|
||||
|
||||
### Gemini CLI
|
||||
|
||||
Gemini CLI v0.9.0 has an upstream installer bug that can fail with `Configuration file not found at /tmp/gemini-extensionXXXXXX/gemini-extension.json` ([upstream issue](https://github.com/google-gemini/gemini-cli/issues/11452)). Workaround:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mvanhorn/last30days-skill
|
||||
gemini extensions install ./last30days-skill
|
||||
```
|
||||
|
||||
### Manual (developer)
|
||||
|
||||
### Manual
|
||||
```bash
|
||||
git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
|
||||
```
|
||||
|
||||
Or build the claude.ai `.skill` file from source: `bash scripts/build-skill.sh` produces `dist/last30days.skill`.
|
||||
|
||||
Reddit (with comments), Hacker News, Polymarket, and GitHub work immediately. Zero configuration. Run `/last30days` once and the setup wizard unlocks more sources in 30 seconds.
|
||||
|
||||
## Bring your own keys
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
description: Research what people actually say about any topic in the last 30 days across Reddit, X, YouTube, TikTok, Hacker News, Polymarket, GitHub, and the web.
|
||||
argument-hint: <topic> — e.g. "nvidia earnings reaction" or "best noise cancelling headphones"
|
||||
allowed-tools: [Bash, Read, Write, AskUserQuestion, WebSearch]
|
||||
---
|
||||
|
||||
Invoke the `last30days` skill with the user's arguments: $ARGUMENTS
|
||||
|
||||
Use the skill's canonical pipeline (plan → retrieve → normalize → fuse → rerank → cluster → render). If the user provided no arguments, ask them for a topic before proceeding.
|
||||
@@ -0,0 +1,319 @@
|
||||
---
|
||||
title: "feat: YouTube podcast source with transcript-first discovery"
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-04-10
|
||||
---
|
||||
|
||||
# feat: YouTube podcast source with transcript-first discovery
|
||||
|
||||
## Overview
|
||||
|
||||
Add a "podcasts" source to last30days that discovers podcast content on YouTube by scanning transcripts, not searching titles. The LLM planner resolves topic-relevant podcast channels (e.g., "NVIDIA" -> Acquired, Lex Fridman, Dwarkesh Patel, All-In). The engine fetches recent episodes from those channels, downloads their auto-captions (no video download), and greps for the search topic. Episodes with 5+ topic mentions become podcast results with transcript highlights.
|
||||
|
||||
This finds content invisible to any search engine. Acquired's "The NFL" episode mentions Taylor Swift 18 times, ESPN 117 times, Netflix 102 times - none in the title. A Dwarkesh Patel episode titled "The single biggest bottleneck to scaling AI compute" contains 156 mentions of NVIDIA. No YouTube search finds these. Transcript scanning does.
|
||||
|
||||
Zero new API keys. Zero new dependencies. Reuses existing yt-dlp + transcript pipeline. Podcasts get their own identity in stats and synthesis.
|
||||
|
||||
## Problem Frame
|
||||
|
||||
YouTube captures a lot of podcast content, but it's mixed with news clips, reaction videos, and shorts. The general YouTube search treats a 2:24:55 Drink Champs interview the same as a 0:30 TMZ clip. Worse, the highest-value podcast content is often invisible to search entirely because the topic is discussed within an episode titled something else.
|
||||
|
||||
Two insights make this solvable:
|
||||
1. Podcast episodes are identifiable by duration (>20 minutes) and channel.
|
||||
2. YouTube auto-captions are free, downloadable without the video (~7 seconds per episode via yt-dlp), and searchable. Transcript scanning discovers content that title-based search cannot.
|
||||
|
||||
The LLM already resolves subreddits and X handles per topic. Podcast channels are the same pattern.
|
||||
|
||||
## Requirements Trace
|
||||
|
||||
- R1. LLM resolves topic-relevant podcast YouTube channels dynamically (no hardcoded list)
|
||||
- R2. Engine scans recent episode transcripts for the search topic, not just titles
|
||||
- R3. Podcast results get their own source identity with own stats line and synthesis treatment
|
||||
- R4. Reuses existing yt-dlp transcript pipeline (no new dependencies)
|
||||
- R5. Does not duplicate regular YouTube results (dedup by video ID in fusion)
|
||||
- R6. Channel resolution works in both the agent layer (SKILL.md) and the Python planner
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- Not building a new API integration (reuses yt-dlp entirely)
|
||||
- Not adding PodcastIndex, AssemblyAI, or any podcast-specific API
|
||||
- Not changing how the regular YouTube source works
|
||||
- Not building a podcast channel database
|
||||
- Channels that can't be resolved are skipped silently (graceful degradation)
|
||||
|
||||
## Context & Research
|
||||
|
||||
### Relevant Code and Patterns
|
||||
|
||||
- `scripts/lib/youtube_yt.py` - YouTube search + transcript pipeline. Key functions: `search_youtube()`, `fetch_transcripts()`, `extract_transcript_highlights()`
|
||||
- `scripts/lib/youtube_yt.py` - `--write-auto-sub --skip-download` fetches captions without downloading video
|
||||
- Step 0.55 in `SKILL.md` - subreddit resolution pattern (WebSearch + LLM knowledge -> `--subreddits=`)
|
||||
- `scripts/lib/pipeline.py` - source dispatch via if/elif chain in `_retrieve_stream()`, 4-point registration pattern
|
||||
- `scripts/lib/normalize.py` - `_normalize_youtube()` handles transcript data, reusable for podcasts
|
||||
- `scripts/lib/signals.py` - `SOURCE_QUALITY` dict (YouTube is 0.85)
|
||||
- `scripts/lib/planner.py` - `QueryPlan` schema, `SOURCE_CAPABILITIES` dict
|
||||
|
||||
### Proof of Concept Results (2026-04-10)
|
||||
|
||||
**Transcript-first discovery test:** Fetched auto-captions for 5 recent Acquired episodes (35 seconds total, no video download). Grepped for topics not in any episode title:
|
||||
|
||||
| Topic | Mentions | Episode title | Discoverable by search? |
|
||||
|-------|----------|---------------|------------------------|
|
||||
| ESPN | 117 | The NFL | No |
|
||||
| Super Bowl | 108 | The NFL | No |
|
||||
| Netflix | 102 | The NFL | No |
|
||||
| Amazon | 87 | The NFL | No |
|
||||
| Costco | 63 | The NFL / others | No |
|
||||
| Disney | 48 | The NFL | No |
|
||||
| LVMH | 27 | Formula 1 / others | No |
|
||||
| Taylor Swift | 18 | The NFL | No |
|
||||
|
||||
**Full E2E test (topic: NVIDIA, 4 channels):** LLM resolved Acquired, Lex Fridman, Dwarkesh Patel, All-In. Scanned 14 episodes. Results:
|
||||
|
||||
| Podcast | Episode | NVIDIA mentions | Title mentions NVIDIA? |
|
||||
|---------|---------|----------------|----------------------|
|
||||
| Lex Fridman | Jensen Huang interview | 159 | Yes |
|
||||
| Dwarkesh Patel | Dylan Patel: AI compute bottleneck | 156 | No |
|
||||
| Acquired | 10 Years (w/ Michael Lewis) | 24 | No |
|
||||
| All-In | SpaceX IPO, Iran, Quantum... | 6 | No |
|
||||
|
||||
3 of 4 hits are invisible to YouTube search. The Dylan Patel episode (156 mentions!) is entirely about NVIDIA's GPU supply chain but the title never says "NVIDIA."
|
||||
|
||||
**Channel handle resolution test:** LLM resolves podcast name + @handle guess. Engine tries @handle first (fast), falls back to `ytsearch1:` if wrong. Tested across 12 channels (tech, hip-hop, knitting): 11/12 resolved on first @handle attempt, 12/12 with fallback. Even niche channels (Fruity Knitting, Grocery Girls Knit, Roxanne Richardson) resolved correctly.
|
||||
|
||||
**Rate limit test:** 4 channels x 3-4 episodes = 14 caption fetches took ~2 minutes sequential. Parallelized with 4 workers: ~30-40 seconds. No YouTube throttling observed. Runs concurrently with Reddit/X/everything else in a 3-minute research run.
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **Transcript-first discovery, not title/search-based:** The core innovation. Instead of searching YouTube for `{topic} {podcast_name}` (which only finds episodes titled about the topic), we fetch captions from recent episodes and grep for the topic. This discovers hidden mentions. The approach is validated by POC data showing 3/4 NVIDIA hits were invisible to search.
|
||||
|
||||
- **LLM-resolved channels, not hardcoded:** The LLM planner (agent layer or Python Gemini/OpenAI) resolves 6-12 channels per topic using two-dimensional reasoning: (1) domain podcasts that focus on the topic's area, (2) cross-domain podcasts that might cover it. Tested: the LLM correctly resolved channels for NVIDIA (tech), Kanye (hip-hop), and knitting (craft) - including niche channels like Fruity Knitting and Grocery Girls Knit. Three resolution paths mirror the existing planner architecture:
|
||||
- Path 1: Agent layer (SKILL.md with WebSearch) resolves channels in Step 0.55
|
||||
- Path 2: Python planner (Gemini/OpenAI) generates channels as a `podcast_channels` field in the QueryPlan
|
||||
- Path 3: Fallback (no LLM) uses a small default list of ~5 broad-appeal channels
|
||||
|
||||
- **Handle-first channel resolution with search fallback:** The LLM returns both the podcast name and its best guess at the @handle. The engine tries the @handle first (instant, 92% success rate in testing). If the handle fails, it falls back to `ytsearch1:"{podcast name}" podcast full episode` to find the channel URL. Channels that can't be resolved either way are skipped silently.
|
||||
|
||||
- **New source module wrapping YouTube functions:** `podcast_yt.py` imports `fetch_transcripts()` and `extract_transcript_highlights()` from `youtube_yt.py`. It adds the channel-fetching, caption-scanning, and mention-counting logic. This keeps the regular YouTube source untouched and gives podcasts their own pipeline identity.
|
||||
|
||||
- **Duration filter >= 1200 seconds (20 minutes):** Eliminates clips, shorts, and news segments. Tested empirically - only full podcast episodes survive this filter.
|
||||
|
||||
- **SOURCE_QUALITY: 0.88 (above YouTube's 0.85):** Podcast episodes contain long-form expert discussion with full context. The quality bonus ensures podcast results rank above equivalent YouTube clips when both exist.
|
||||
|
||||
- **Mention count threshold: 5+:** Episodes with fewer than 5 topic mentions are noise (passing references). 5+ indicates substantive discussion. Tested: Taylor Swift at 18 mentions in the NFL episode is substantive discussion of her impact on viewership. "Apple" at 3 mentions in a random episode is just name-dropping.
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved During Planning
|
||||
|
||||
- **Can yt-dlp fetch captions without downloading video?** Yes. `yt-dlp --write-auto-sub --sub-lang en --skip-download --sub-format vtt` fetches only the subtitle file. ~7 seconds per episode, ~2MB per 4-hour episode.
|
||||
- **Will this double-count YouTube content?** No. Fusion deduplicates by item ID. Both sources use `yt_{video_id}` format.
|
||||
- **Can LLMs resolve niche podcast channels?** Yes. Tested with knitting: Fruity Knitting, VeryPink Knits, Grocery Girls Knit, Roxanne Richardson all resolved correctly via @handle.
|
||||
- **What about rate limits?** 14 caption fetches across 4 channels showed no throttling. Running in parallel with 4 workers keeps total time under 40 seconds. yt-dlp doesn't use the YouTube Data API (no quota).
|
||||
- **How does the LLM know which podcasts to pick?** Two-dimensional prompt: (1) "What YouTube podcasts focus on {topic's domain}?" and (2) "What popular interview/deep-dive podcasts have likely discussed {topic}?" The LLM returns channel names + @handle guesses.
|
||||
|
||||
### Deferred to Implementation
|
||||
|
||||
- **Exact duration threshold:** Starting with 1200s (20 min). May tune to 900s (15 min) if testing shows missed content.
|
||||
- **Mention count threshold tuning:** Starting with 5. May need per-source calibration (a 30-minute podcast with 5 mentions is denser than a 4-hour one with 5 mentions).
|
||||
- **Caption language handling:** Starting with English (`--sub-lang en`). Multilingual support deferred.
|
||||
- **Parallel worker count:** Starting with 4 workers. May tune based on YouTube throttling behavior at scale.
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
> *This illustrates the intended approach and is directional guidance for review, not implementation specification.*
|
||||
|
||||
```
|
||||
PODCAST DISCOVERY FLOW:
|
||||
|
||||
User query: "NVIDIA"
|
||||
|
|
||||
LLM planner resolves podcast channels:
|
||||
"NVIDIA is a tech/AI company. Domain podcasts: none specific.
|
||||
Cross-domain: Acquired (@AcquiredFM), Lex Fridman (@lexfridman),
|
||||
Dwarkesh Patel (@DwarkeshPatel), All-In (@AllInPod)"
|
||||
|
|
||||
Engine receives: --podcast-channels=AcquiredFM,lexfridman,DwarkeshPatel,AllInPod
|
||||
|
|
||||
For each channel (parallel, 4 workers):
|
||||
|
|
||||
[1] Resolve @handle -> channel URL
|
||||
Try: https://youtube.com/@AcquiredFM/videos
|
||||
If fail: ytsearch1:"Acquired podcast full episode" -> extract channel_url
|
||||
If fail: skip channel
|
||||
|
|
||||
[2] Fetch last 3 episode IDs + metadata (duration, date, title)
|
||||
yt-dlp --flat-playlist --playlist-end 3
|
||||
|
|
||||
[3] Filter: duration >= 1200s AND upload_date in date range
|
||||
|
|
||||
[4] For each surviving episode:
|
||||
Fetch auto-captions: yt-dlp --write-auto-sub --skip-download
|
||||
Grep captions for "nvidia" (case-insensitive)
|
||||
If mentions >= 5: HIT - extract transcript highlights around mentions
|
||||
|
|
||||
Merge all hits, deduplicate by video_id
|
||||
Score: mention_count * log(views)
|
||||
Return as source="podcasts" items with transcript_snippet + mention_count
|
||||
```
|
||||
|
||||
## Implementation Units
|
||||
|
||||
- [ ] **Unit 1: Podcast transcript-scan module**
|
||||
|
||||
**Goal:** Create `scripts/lib/podcast_yt.py` with the channel-fetching, caption-scanning, mention-counting pipeline. Returns podcast episodes discovered via transcript scanning.
|
||||
|
||||
**Requirements:** R2, R3, R4
|
||||
|
||||
**Dependencies:** None (youtube_yt.py already exists)
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/lib/podcast_yt.py`
|
||||
- Test: `tests/test_podcast_yt.py`
|
||||
|
||||
**Approach:**
|
||||
- `search_podcast_youtube(topic, from_date, to_date, depth, channels)`:
|
||||
- For each channel handle (in parallel via ThreadPoolExecutor, max 4 workers):
|
||||
1. Resolve handle to channel URL (try @handle first, search fallback)
|
||||
2. Fetch last N episode IDs + metadata via `yt-dlp --flat-playlist --playlist-end N`
|
||||
3. Filter: `duration >= 1200` and `upload_date` within date range
|
||||
4. Fetch auto-captions via `yt-dlp --write-auto-sub --skip-download --sub-lang en`
|
||||
5. Grep captions for topic keywords (case-insensitive). Count mentions.
|
||||
6. If mentions >= MENTION_THRESHOLD: include as hit. Extract transcript highlights around mentions using `extract_transcript_highlights()` from `youtube_yt`.
|
||||
- Merge results, deduplicate by video_id
|
||||
- Score: `mention_count * log(views + 1)`
|
||||
- Skip channels that can't be resolved or have no recent episodes
|
||||
|
||||
- `resolve_channel(handle)`: Try `@{handle}` URL first. If 404, search `ytsearch1:"{handle}" podcast full episode`, extract channel_url. Return channel_url or None.
|
||||
|
||||
- EPISODES_PER_CHANNEL: quick=2, default=3, deep=4
|
||||
- MENTION_THRESHOLD: 5
|
||||
- RESULTS_CAP: quick=4, default=8, deep=20
|
||||
|
||||
**Patterns to follow:**
|
||||
- `scripts/lib/youtube_yt.py` `search_and_transcribe()` for search-then-enrich flow
|
||||
- `scripts/lib/youtube_yt.py` `extract_transcript_highlights()` for highlight extraction
|
||||
- `scripts/lib/hackernews.py` for clean module structure with `_log()`, `DEPTH_CONFIG`
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path (hidden mention): topic "Taylor Swift", channels=["AcquiredFM"] -> scans NFL episode, finds 18 mentions, returns episode with highlights about Taylor Swift's NFL viewership impact
|
||||
- Happy path (title match): topic "kanye west", channels=["RevoltTV"] -> scans Kanye interview, finds 500+ mentions, returns with highlights
|
||||
- Happy path (scoring): episode with 156 mentions and 205K views scores higher than one with 6 mentions and 145K views
|
||||
- Happy path (handle resolution): @AcquiredFM resolves directly. @SomeWrongHandle fails, search fallback finds correct channel.
|
||||
- Edge case: topic "quantum computing" has <5 mentions in all episodes -> returns empty (threshold not met)
|
||||
- Edge case: @handle doesn't exist AND search fallback fails -> channel skipped silently, other channels still scanned
|
||||
- Edge case: channel has no episodes in date range -> skipped
|
||||
- Edge case: episode has no auto-captions available -> skipped with log warning
|
||||
- Error path: yt-dlp not installed -> returns empty items with log warning
|
||||
- Error path: caption download times out -> skip that episode, continue
|
||||
|
||||
**Verification:**
|
||||
- Discovers episodes where topic is discussed but not in the title (Acquired/NFL/Taylor Swift)
|
||||
- Also discovers episodes where topic IS the subject (via same transcript scan)
|
||||
- All returned items have duration >= 1200
|
||||
- Each item has: video_id, title, channel, url, date, duration, engagement, transcript_snippet, mention_count
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 2: Pipeline integration**
|
||||
|
||||
**Goal:** Register "podcasts" as a new source in pipeline, normalizer, signals, planner, env, and render.
|
||||
|
||||
**Requirements:** R3, R5, R6
|
||||
|
||||
**Dependencies:** Unit 1
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/lib/pipeline.py` (import, MOCK_AVAILABLE_SOURCES, available_sources, _retrieve_stream)
|
||||
- Modify: `scripts/lib/normalize.py` (add normalizer - reuse `_normalize_youtube` with source override)
|
||||
- Modify: `scripts/lib/signals.py` (add SOURCE_QUALITY: 0.88)
|
||||
- Modify: `scripts/lib/planner.py` (add SOURCE_CAPABILITIES, extend QueryPlan schema with `podcast_channels` field, add prompt guidance for LLM channel resolution)
|
||||
- Modify: `scripts/lib/env.py` (add is_podcast_yt_available - checks yt-dlp installed + "podcasts" in INCLUDE_SOURCES)
|
||||
- Modify: `scripts/lib/render.py` (add SOURCE_LABELS: "podcasts" -> "Podcasts")
|
||||
- Test: `tests/test_podcast_yt.py` (pipeline dispatch test)
|
||||
|
||||
**Approach:**
|
||||
- Availability: yt-dlp installed + "podcasts" in INCLUDE_SOURCES. No API key needed.
|
||||
- SOURCE_CAPABILITIES: `{"podcasts": {"discussion", "longform", "expert", "interview"}}`
|
||||
- Normalizer: reuse `_normalize_youtube` via lambda wrapper, override source to "podcasts". Add `mention_count` to metadata.
|
||||
- CLI flag: `--podcast-channels=handle1,handle2,...` parsed from args
|
||||
- Planner: extend QueryPlan with `podcast_channels: list[str]`. Prompt guidance for LLM: "List 6-12 YouTube podcast channel @handles that would discuss this topic. Think in two dimensions: (1) domain podcasts that focus on this area, (2) popular cross-domain interview/deep-dive podcasts that might cover it. Return @handles. If unsure of exact handle, return your best guess."
|
||||
- Planner: include "podcasts" source for general/opinion/comparison intents
|
||||
- Dedup: podcast items use `yt_{video_id}` ID format (same as YouTube). Fusion dedup handles collisions.
|
||||
|
||||
**Patterns to follow:**
|
||||
- 4-point pipeline registration (same as all sources)
|
||||
- `_normalize_youtube` reuse via lambda (like tiktok/instagram share `_normalize_shortform_video`)
|
||||
- `scripts/lib/env.py` INCLUDE_SOURCES opt-in pattern
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: "podcasts" in available_sources when yt-dlp installed + INCLUDE_SOURCES contains "podcasts"
|
||||
- Happy path: pipeline dispatches to podcast_yt.search_podcast_youtube when source="podcasts"
|
||||
- Edge case: yt-dlp not installed -> podcasts not available
|
||||
- Edge case: "podcasts" not in INCLUDE_SOURCES -> not available even with yt-dlp
|
||||
- Integration: podcast video_id collides with YouTube result -> fusion deduplicates, keeps higher score
|
||||
|
||||
**Verification:**
|
||||
- `python3 scripts/last30days.py "NVIDIA" --podcast-channels=AcquiredFM,lexfridman` returns podcast results
|
||||
- Stats output shows "Podcasts" line separate from "YouTube"
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Unit 3: SKILL.md podcast channel resolution + synthesis**
|
||||
|
||||
**Goal:** Add podcast channel resolution to Step 0.55 and podcast-specific synthesis guidance to the Judge Agent section.
|
||||
|
||||
**Requirements:** R1, R3, R6
|
||||
|
||||
**Dependencies:** Unit 2
|
||||
|
||||
**Files:**
|
||||
- Modify: `SKILL.md`
|
||||
|
||||
**Approach:**
|
||||
- **Step 0.55 addition:** Add "Resolve podcast channels" alongside subreddit, X handle, and TikTok resolution. The agent resolves 6-12 @handles using two-dimensional reasoning (domain + cross-domain). For niche topics, supplement with `WebSearch("{TOPIC} podcast YouTube channel")`. Display resolved channels: "Podcasts: @AcquiredFM, @lexfridman, @DrinkChamps". Pass as `--podcast-channels=AcquiredFM,lexfridman,DrinkChamps`.
|
||||
|
||||
- **Step 0.75 addition:** Add "podcasts" to available sources list. Include in primary subquery sources.
|
||||
|
||||
- **Synthesis guidance addition:** "For podcasts: lead with the guest's name and the podcast name. Quote transcript highlights as direct quotes with speaker attribution. Podcast content represents considered opinion, not hot takes - a 2-hour interview has more nuance than a tweet. When both a podcast and a YouTube clip cover the same topic, prefer the podcast's longer-form analysis."
|
||||
|
||||
- **Stats format:** `├─ 🎙️ Podcasts: {N} episodes │ {N} views │ {N} with transcripts`
|
||||
|
||||
- **INCLUDE_SOURCES:** Add "podcasts" as an option. Note in setup: "Requires yt-dlp (already installed if YouTube works). No API key needed."
|
||||
|
||||
- **Invitation section:** Reference podcast episodes in follow-up suggestions ("Want me to pull more from that Lex Fridman episode?")
|
||||
|
||||
**Patterns to follow:**
|
||||
- Step 0.55 subreddit resolution pattern
|
||||
- Source-specific synthesis guidance (YouTube highlights, Reddit top comments)
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none - SKILL.md is an instruction document. Verification is manual E2E.
|
||||
|
||||
**Verification:**
|
||||
- `/last30days NVIDIA` resolves tech podcast channels and passes them to engine
|
||||
- `/last30days Kanye West` resolves hip-hop podcast channels
|
||||
- `/last30days knitting` resolves craft podcast channels (Fruity Knitting, etc.)
|
||||
- Stats show 🎙️ Podcasts line. Synthesis quotes podcast content with speaker attribution.
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| LLM guesses wrong @handle | Handle-first resolution with search fallback. 92% first-attempt success in testing, 100% with fallback. Wrong handles fail fast and skip silently. |
|
||||
| Transcript scanning adds latency | Runs in parallel with all other sources. 4 channels x 3 episodes = ~30-40s parallelized. Invisible in a 3-minute research run. |
|
||||
| Topic mentions below threshold (lots of misses) | LLM picks channels likely to discuss the topic. When it picks well, hit rate is high (4/14 episodes in NVIDIA test). Misses cost ~7s per episode in wasted caption download - acceptable. |
|
||||
| YouTube throttles caption downloads | 14 sequential downloads showed no throttling. Capping at 4 parallel workers adds safety margin. If throttled, degrade gracefully (fewer episodes scanned). |
|
||||
| Niche topics have no relevant podcast channels | LLM returns fewer channels (3-4 instead of 10-12). If none can be resolved, podcast source returns empty. Other sources (Reddit, X, YouTube) still run. |
|
||||
| Same video in both YouTube and podcast results | Fusion deduplicates by `yt_{video_id}`. Podcast version gets 0.88 quality score vs YouTube's 0.85, so podcast version wins dedup. |
|
||||
|
||||
## Sources & References
|
||||
|
||||
- POC: transcript scan of 5 Acquired episodes found ESPN (117), Netflix (102), Taylor Swift (18), LVMH (27) - all invisible to search
|
||||
- POC: E2E NVIDIA test across 4 channels found 5 hits, 3 invisible to search (including 156-mention Dwarkesh Patel episode)
|
||||
- POC: handle resolution tested 12 channels (tech, hip-hop, knitting) - 11/12 first-attempt, 12/12 with fallback
|
||||
- Related code: `scripts/lib/youtube_yt.py`, `scripts/lib/pipeline.py`, `scripts/lib/hackernews.py`
|
||||
- Pattern: SKILL.md Step 0.55 subreddit resolution
|
||||
- yt-dlp docs: https://github.com/yt-dlp/yt-dlp
|
||||
- Acquired FM: https://www.youtube.com/@AcquiredFM
|
||||
@@ -1,303 +0,0 @@
|
||||
---
|
||||
title: "feat: --competitors flag for auto-discovered comparison fan-out"
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-04-22
|
||||
---
|
||||
|
||||
# feat: --competitors flag for auto-discovered comparison fan-out
|
||||
|
||||
## Overview
|
||||
|
||||
Add a `--competitors` flag to the last30days engine that auto-discovers 2-4 peer entities for the topic, runs the full retrieval pipeline on each in parallel, and renders a multi-entity comparison. Invoking `last30days Kanye West --competitors` should resolve to "Kanye vs Drake vs Kendrick Lamar" and emit a comparison report covering all three. Invoking `last30days OpenAI --competitors` should resolve to "OpenAI vs Anthropic vs xAI vs Gemini" and emit a four-way comparison.
|
||||
|
||||
Discovery mirrors the existing `resolve.auto_resolve()` pattern used for X handles and subreddits at pipeline start — web search (Brave / Exa / Serper) plus deterministic extraction. Not an internal LLM call.
|
||||
|
||||
## Problem Frame
|
||||
|
||||
Users who want a comparison today must type "OpenAI vs Anthropic vs xAI" themselves. The `planner._comparison_entities()` path already handles explicit multi-entity topics and `render._render_comparison_scaffold()` already emits a 9-axis comparison table. What is missing is the discovery half — a user who types a single entity with `--competitors` should get the comparison for free.
|
||||
|
||||
This is also the natural next step after the Step 0.55 category-peer subreddit work (PR #305, merged 2026-04-22). That feature widens the subreddit set within a single topic; this feature widens the entity set into peer entities.
|
||||
|
||||
## Requirements Trace
|
||||
|
||||
- R1. New `--competitors` boolean flag that triggers competitor discovery and multi-entity fan-out.
|
||||
- R2. New `--competitors-list="A,B,C"` to explicitly skip discovery (mirrors `--plan`, `--subreddits`, `--x-handle` overrides).
|
||||
- R3. New `--competitors=N` short form to set competitor count inline (N in 1..6).
|
||||
- R4. Default count is 3 competitors (original + 3 = 4-way comparison).
|
||||
- R5. Competitor retrieval depth inherits the main run's depth (`--quick` / `--deep`); all entities run in parallel so wall clock stays close to a single run.
|
||||
- R6. Discovery mirrors `resolve.auto_resolve()`: web search for peers, deterministic text extraction. No internal LLM dependency.
|
||||
- R7. If no web search backend is configured and no `--competitors-list` was passed, engine emits a LAW 7-style stderr telling the host agent to pass `--competitors-list` and exits non-zero.
|
||||
- R8. Output rendering is a single comparison report covering all entities, reusing the existing 9-axis scaffold from `render._render_comparison_scaffold()` where applicable.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- Synthesis prompt changes beyond wiring N reports into the existing comparison scaffold are out of scope.
|
||||
- `--competitors` does not replace the existing explicit "A vs B vs C" topic parsing in `planner._comparison_entities()`; both paths coexist.
|
||||
- No caching layer for discovery results in v1.
|
||||
- No UI/SKILL.md rewrite of the entire comparison section; only the new flag is documented.
|
||||
- No new web search backend.
|
||||
|
||||
### Deferred to Separate Tasks
|
||||
|
||||
- Caching of competitor lookups: separate follow-up once hit rate justifies it.
|
||||
- Disambiguation UX for topics with multiple common entities ("Amazon" the company vs the river): separate brainstorm.
|
||||
|
||||
## Context & Research
|
||||
|
||||
### Relevant Code and Patterns
|
||||
|
||||
- `scripts/last30days.py:168-249` — `build_parser()` argparse definitions. Existing depth flags (`--quick`, `--deep`) and override flags (`--plan`, `--subreddits`, `--x-handle`, `--auto-resolve`) set the convention to mirror.
|
||||
- `scripts/lib/resolve.py:179-258` — `auto_resolve()` is the reference pattern: web search fan-out via `ThreadPoolExecutor`, per-query extraction functions, graceful empty-dict return when no backend is available.
|
||||
- `scripts/lib/resolve.py:98-140` — `_extract_x_handle()` and sibling extractors show the deterministic text-mining style competitor extraction should mirror.
|
||||
- `scripts/lib/pipeline.py:162-220` — `pipeline.run()` signature is the fan-out target. One call per entity, each returning a `schema.Report`.
|
||||
- `scripts/lib/planner.py:430-564` — Existing comparison-intent handling and `_comparison_entities()` entity extraction. The new flag feeds the same mental model but populates entities from discovery instead of from the topic string.
|
||||
- `scripts/lib/render.py:333-392` — `_render_comparison_scaffold()` already emits a 9-axis markdown comparison table. The new multi-report renderer should reuse this helper by assembling a synthetic "A vs B vs C" topic header for it.
|
||||
- `scripts/lib/grounding.py` + `scripts/lib/providers.py` — Web search backend resolution (Brave / Exa / Serper). Reused as-is.
|
||||
|
||||
### Institutional Learnings
|
||||
|
||||
- No existing `docs/solutions/` entries for competitor discovery or multi-entity fan-out.
|
||||
- Recent plan `docs/plans/2026-04-22-001-fix-category-peer-subreddit-resolution-plan.md` established the precedent of deterministic peer expansion; this plan extends that idea from subreddits to entities.
|
||||
|
||||
### External References
|
||||
|
||||
- None gathered — local patterns are strong. `resolve.auto_resolve()` is a direct template.
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **Discovery mirrors auto_resolve, not plan_query.** Web search + regex extraction, not an LLM call. Matches the user's explicit direction ("use the python brain the same way it searches for X handles"). Cheaper, no provider credential requirement, deterministic.
|
||||
- **Orchestration lives in `last30days.py` main, not inside `pipeline.run()`.** The fan-out is a top-level concern — one pipeline run per entity, each independent. Keeps `pipeline.run()` single-entity and unchanged except for sharing a `ThreadPoolExecutor` factory.
|
||||
- **Sub-runs inherit main depth and run in parallel.** Wall clock ≈ single run; token cost scales linearly with N. User-controlled via the existing `--quick`/`--deep` flags.
|
||||
- **New module `scripts/lib/competitors.py` instead of adding to `resolve.py`.** Keeps resolve focused on single-entity entity-bundle discovery (handles/subreddits/github); competitors.py owns peer-entity discovery. Similar shape, different responsibility.
|
||||
- **Multi-report render is additive in `render.py`.** New `render_comparison_multi(reports: list[Report]) -> str` composes a synthetic "A vs B vs C" topic and delegates to the existing scaffold + synthesis path where possible. No rewrite of the single-entity render path.
|
||||
- **Default count = 3 competitors (4-way comparison).** Hard cap at 6.
|
||||
- **LAW 7-style stderr when no backend and no list.** Matches how `planner.plan_query()` already tells the hosting agent to pass `--plan`.
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved During Planning
|
||||
|
||||
- **Discovery mechanism:** Web search via `grounding.web_search()`, not an internal LLM. User confirmed the auto_resolve pattern is the target.
|
||||
- **Default competitor count:** 3 (original + 3 = 4-way).
|
||||
- **Sub-run depth:** Inherit main depth, parallel execution.
|
||||
- **Flag naming:** `--competitors` (standard argparse double-dash). `--competitors=N` for inline count. `--competitors-list="A,B,C"` to skip discovery.
|
||||
|
||||
### Deferred to Implementation
|
||||
|
||||
- Exact extraction heuristics for competitor names across Brave / Exa / Serper result shapes. The SERP text varies (listicles, comparison pages, "vs" pages); the initial implementation will start with listicle parsing plus a "X vs Y" pattern match, and harden against real results in the test phase.
|
||||
- Handling of topic ambiguity ("Amazon", "Apple"). Initial behavior: trust whatever web search returns for the topic verbatim; disambiguation is a separate concern.
|
||||
- Merge strategy when two entities return overlapping URLs (e.g., an "OpenAI vs Anthropic" article shows up in both runs). Likely dedupe at the clustering step, but defer the exact policy until we see how often it happens.
|
||||
- Whether to expose competitor discovery artifacts (the raw web search results) as a debug emit. Follow the existing `--debug` conventions.
|
||||
|
||||
## Implementation Units
|
||||
|
||||
- [ ] **Unit 1: CLI flag parsing and validation**
|
||||
|
||||
**Goal:** Add `--competitors`, `--competitors=N`, and `--competitors-list` to the argparse surface, validate values, and thread them into the main orchestration.
|
||||
|
||||
**Requirements:** R1, R2, R3, R4
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/last30days.py`
|
||||
- Test: `tests/test_cli_competitors.py`
|
||||
|
||||
**Approach:**
|
||||
- Add three mutually cooperative flags near line 205 in `build_parser()`:
|
||||
- `--competitors` with `nargs="?"` and `const=3` so bare `--competitors` defaults to 3, `--competitors=4` is honored, and `--competitors=0` is rejected
|
||||
- `--competitors-list` free-text CSV
|
||||
- Normalize in `main()`: if `--competitors-list` is present, skip discovery and use the list. If `--competitors` is set and no list, trigger discovery with count = the flag value. Clamp count to 1..6 with a stderr warning at boundary.
|
||||
- Thread the resulting entity list into the orchestrator added in Unit 3.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `--plan` argument at `scripts/last30days.py:187` — same skip-discovery-when-explicit shape.
|
||||
- `--subreddits` / `--x-handle` at `scripts/last30days.py:180,189` — same override semantics.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: bare `--competitors` parses to count=3, empty list.
|
||||
- Happy path: `--competitors=4` parses to count=4.
|
||||
- Happy path: `--competitors-list="A,B,C"` parses to count=3, list=["A","B","C"], and is preferred over any discovery signal.
|
||||
- Edge case: `--competitors=0` and `--competitors=-1` are rejected with a clear error.
|
||||
- Edge case: `--competitors=99` clamps to 6 with a stderr warning.
|
||||
- Edge case: `--competitors` combined with `--competitors-list` uses the list and logs that discovery was skipped.
|
||||
- Edge case: `--competitors-list` value with whitespace ("A, B , C") normalizes correctly.
|
||||
|
||||
**Verification:**
|
||||
- Running the binary with each flag variation produces the expected post-parse state without calling out to the network.
|
||||
|
||||
- [ ] **Unit 2: `scripts/lib/competitors.py` discovery module**
|
||||
|
||||
**Goal:** Discover peer entities for a topic using web search + deterministic extraction, mirroring `resolve.auto_resolve()`.
|
||||
|
||||
**Requirements:** R6, R7
|
||||
|
||||
**Dependencies:** None (pure module; wired by Unit 3)
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/lib/competitors.py`
|
||||
- Test: `tests/test_competitors.py`
|
||||
|
||||
**Approach:**
|
||||
- Public entry point `discover_competitors(topic: str, count: int, config: dict) -> list[str]`.
|
||||
- Early return `[]` when `_has_backend(config)` is false (reuse the helper from `resolve.py`; factor if needed).
|
||||
- Fan out 2-3 web searches in a `ThreadPoolExecutor`:
|
||||
- `"{topic} competitors"`
|
||||
- `"{topic} alternatives"`
|
||||
- `"{topic} vs"` (captures "X vs Y" articles)
|
||||
- Feed results into a deterministic `_extract_peer_entities(results, topic)` that:
|
||||
- Mines titles and snippets for capitalized noun phrases other than the topic itself
|
||||
- Scores by frequency across results
|
||||
- Filters stopwords and the topic's own tokens
|
||||
- Returns top `count` unique entities ordered by score
|
||||
- Emit a single-line stderr log mirroring the `resolve._log` format.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `scripts/lib/resolve.py:179-258` for the function shape, executor usage, and empty-result fallback.
|
||||
- `scripts/lib/resolve.py:98-140` for extractor style (small, deterministic, no external state).
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: canned SERP fixtures for "OpenAI" return ["Anthropic", "xAI", "Google"] or close peers in the top 3.
|
||||
- Happy path: canned SERP fixtures for "Kanye West" return rap peers (Drake, Kendrick) in the top 3.
|
||||
- Edge case: empty SERP results return `[]` without raising.
|
||||
- Edge case: extractor filters out the topic itself (case- and punctuation-insensitive).
|
||||
- Edge case: near-duplicate entities ("OpenAI" vs "Open AI") dedupe to one slot.
|
||||
- Error path: web search backend raises — the failure is logged and the function returns `[]`.
|
||||
- Edge case: count=1 returns a single-element list; count=6 returns up to six entities.
|
||||
|
||||
**Verification:**
|
||||
- Unit tests pass with fixtures committed under `tests/fixtures/competitors-*.json`.
|
||||
- Manual run against a live backend for one topic confirms sensible output (recorded as a notes file, not a test assertion).
|
||||
|
||||
- [ ] **Unit 3: Parallel fan-out orchestrator**
|
||||
|
||||
**Goal:** Run `pipeline.run()` once per entity (topic + discovered competitors) in parallel, collect `schema.Report` per entity, and hand them to the comparison renderer.
|
||||
|
||||
**Requirements:** R5, R7
|
||||
|
||||
**Dependencies:** Unit 1, Unit 2
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/last30days.py`
|
||||
- Possibly create: `scripts/lib/fanout.py` if the orchestrator grows past ~60 lines
|
||||
- Test: `tests/test_competitor_fanout.py`
|
||||
|
||||
**Approach:**
|
||||
- After arg parsing and before the existing `pipeline.run()` call, branch on `args.competitors`:
|
||||
- If a list was provided or discovery returned entities, build `entities = [topic, *competitors]`.
|
||||
- Spawn one `pipeline.run()` per entity via `ThreadPoolExecutor(max_workers=len(entities))`, passing the same `config`, `depth`, and all sub-run-relevant args (mock, plan, etc.). Respect `--plan` — if a plan is passed it applies to the main topic only; competitors use the internal planner fallback for v1.
|
||||
- Collect `{entity: Report}` mapping. A per-entity failure logs a stderr warning and drops that entity from the comparison; the run continues as long as 2 entities succeed.
|
||||
- If fewer than 2 entities survive, exit with a clear error.
|
||||
- LAW 7-style stderr:
|
||||
- If `args.competitors` is set, no list was passed, no web search backend is configured, emit a LAW 7 stderr message pointing to the `--competitors-list` override and exit non-zero. Reuse the tone from `planner.plan_query()` fallback (`scripts/lib/planner.py:125-135`).
|
||||
|
||||
**Execution note:** Start with a failing integration test that exercises the full main → orchestrator → mocked pipeline.run path; the orchestrator is where bugs hide.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `scripts/lib/resolve.py:225-239` for ThreadPoolExecutor + as_completed + per-future error handling.
|
||||
- `scripts/lib/pipeline.py:310+` for how ThreadPoolExecutor is already used inside a single run (same idiom, outer layer).
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: main + 2 competitors, all three `pipeline.run()` calls succeed (mocked), orchestrator returns 3 Reports.
|
||||
- Happy path: discovery returns the competitor list; orchestrator fans out accordingly.
|
||||
- Edge case: one of three competitor pipelines raises — the run continues with the surviving 2 and emits a warning.
|
||||
- Edge case: all competitors fail but the main topic succeeds — orchestrator exits non-zero with a clear error rather than silently degrading to a single-entity render.
|
||||
- Edge case: `--competitors` set, no backend, no list — orchestrator emits the LAW 7 stderr and exits non-zero before any pipeline call.
|
||||
- Integration: wall-clock time for 3 mocked pipelines in parallel is close to the slowest single run, not the sum (timing assertion with generous margin).
|
||||
|
||||
**Verification:**
|
||||
- End-to-end test with mocked `pipeline.run()` and mocked competitors discovery produces 3 Reports and hands them to a stubbed renderer.
|
||||
|
||||
- [ ] **Unit 4: Multi-report comparison renderer**
|
||||
|
||||
**Goal:** Compose N `schema.Report`s into a single comparison-mode output, reusing the existing 9-axis scaffold.
|
||||
|
||||
**Requirements:** R8
|
||||
|
||||
**Dependencies:** Unit 3
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/lib/render.py`
|
||||
- Test: `tests/test_render_comparison_multi.py`
|
||||
|
||||
**Approach:**
|
||||
- Add `render_comparison_multi(reports: list[schema.Report], *, emit: str) -> str`.
|
||||
- Build a synthetic comparison topic: `f"{entity_a} vs {entity_b} vs {entity_c}"`.
|
||||
- Reuse `_render_comparison_scaffold()` for the table skeleton. Each entity column is populated from its own Report's top clusters and citations.
|
||||
- For the narrative synthesis block, concatenate per-entity highlights, clearly labeled by entity, under a shared "Comparison" header.
|
||||
- Preserve existing emit modes (`compact`, `md`, `json`, `context`). In `json` emit, return a `{"entities": [...], "reports": [...]}` shape; single-Report consumers remain unaffected because the single-report render path is untouched.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `scripts/lib/render.py:333-392` (`_parse_comparison_entities`, `_render_comparison_scaffold`) — the scaffold is the contract.
|
||||
- `scripts/lib/render.py` single-report rendering — for per-entity narrative blocks.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: 3 Reports with distinct clusters render into a 3-column table and a "Comparison" section that mentions each entity at least once.
|
||||
- Happy path: 2 Reports render as a 2-column table without breaking the scaffold.
|
||||
- Edge case: a Report with an empty cluster list renders as "(no significant discussion this month)" in its column rather than crashing.
|
||||
- Edge case: Reports with overlapping URLs (same article cited by two entities) dedupe citations at the footer but keep both column entries.
|
||||
- Emit variants: `--emit=compact`, `--emit=md`, `--emit=json`, `--emit=context` each produce valid output with all entities represented.
|
||||
- Integration: end-to-end snapshot test using fixture Reports, checked against a stored expected output (with a clear update path when the scaffold intentionally evolves).
|
||||
|
||||
**Verification:**
|
||||
- Snapshot tests pass. Manual review of one real 3-way comparison confirms readability.
|
||||
|
||||
- [ ] **Unit 5: Docs, SKILL.md mention, and sync**
|
||||
|
||||
**Goal:** Document the new flag so the hosting agent and human users both know it exists, and run the sync script.
|
||||
|
||||
**Requirements:** R1-R8 (surfaces them to users)
|
||||
|
||||
**Dependencies:** Units 1-4
|
||||
|
||||
**Files:**
|
||||
- Modify: `SKILL.md`
|
||||
- Modify: `README.md` (brief flag reference)
|
||||
- Modify: `CHANGELOG.md`
|
||||
- Run: `bash scripts/sync.sh`
|
||||
|
||||
**Approach:**
|
||||
- Add a compact "Competitor mode" subsection under the existing comparison docs in `SKILL.md`. Document the flag, the default count, the override flag, and the LAW 7 fallback stderr.
|
||||
- Keep `README.md` addition to a single example line.
|
||||
- CHANGELOG entry mirrors the voice of recent entries (imperative, outcome-first).
|
||||
- Sync via `scripts/sync.sh` per CLAUDE.md rules so `~/.claude/`, `~/.agents/`, `~/.codex/` pick up the new SKILL.md.
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none — documentation and sync only. Verification is by inspection and by running `sync.sh` and confirming target directories updated.
|
||||
|
||||
**Verification:**
|
||||
- `sync.sh` completes without errors.
|
||||
- `SKILL.md` rendered preview mentions `--competitors` in the comparison section.
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction graph:** `last30days.py main()` now orchestrates multiple `pipeline.run()` calls instead of one. No other callers of `pipeline.run()` are affected (it remains single-entity).
|
||||
- **Error propagation:** Per-entity failures degrade gracefully as long as ≥2 entities survive; fewer survivors exits non-zero. Discovery failure with `--competitors` and no list is fatal.
|
||||
- **State lifecycle risks:** Each sub-run uses its own `pipeline.run()` state; no shared mutable config. The `config` dict is read-only in `pipeline.run()` today — verify before committing to shared-reference passing, else deep-copy per sub-run.
|
||||
- **API surface parity:** `--competitors` coexists with the existing explicit "A vs B vs C" topic parsing in `planner._comparison_entities()`. Both produce comparable output formats; the only difference is where the entity list came from.
|
||||
- **Integration coverage:** The fan-out orchestrator crosses CLI → discovery → N pipelines → render; integration tests in Unit 3 and Unit 4 must exercise the full path end to end, not just unit-level.
|
||||
- **Unchanged invariants:** `pipeline.run()` signature and single-entity semantics are unchanged. The single-entity render path in `render.py` is unchanged. No changes to `planner.plan_query()`. No changes to existing flags.
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Competitor discovery returns garbage entities for niche topics. | `--competitors-list` override lets the user (or hosting agent) correct it. Unit tests with edge-case fixtures. Log discovery output to stderr under `--debug`. |
|
||||
| Token cost scales linearly with N sub-runs. | Default count capped at 3, hard max 6, inherit `--quick` to let users throttle. Wall clock stays parallel. Emit a cost hint to stderr when N ≥ 4. |
|
||||
| Merge conflicts against the single-entity render path during refactoring. | Keep the multi-report renderer strictly additive; do not modify the single-Report code path. |
|
||||
| Config dict mutation inside sub-runs could leak state between entities. | Verify read-only usage before sharing references. If any sub-component mutates, deep-copy per sub-run before spawning threads. |
|
||||
| A SERP extractor that works on Brave fixtures breaks on Exa/Serper result shapes. | Test fixtures for all three backends. Extractor operates on a normalized shape from `grounding.web_search()` (already the case), not raw provider output. |
|
||||
| Hosting agent (Claude Code, Codex) unaware of the new flag when it could usefully pass `--competitors-list`. | SKILL.md updated in Unit 5 documents the flag in the same style as `--plan` and `--auto-resolve`. |
|
||||
|
||||
## Documentation / Operational Notes
|
||||
|
||||
- Beta channel first: per `CLAUDE.md`, experimental changes go to `mvanhorn/last30days-skill-private` on the `/last30days-beta` command. Land this on the private repo first, shake out on real topics for a day or two, then cherry-pick to public.
|
||||
- After land-merge: run `scripts/sync.sh` to deploy SKILL.md + scripts to `~/.claude/`, `~/.agents/`, `~/.codex/`.
|
||||
- Release notes entry in CHANGELOG.md follows the v3.0.9 voice — outcome-first, one paragraph.
|
||||
|
||||
## Sources & References
|
||||
|
||||
- Related code: `scripts/lib/resolve.py:179` (`auto_resolve`), `scripts/lib/pipeline.py:162` (`pipeline.run`), `scripts/lib/planner.py:80` (`plan_query` LAW 7 fallback), `scripts/lib/render.py:333` (comparison scaffold)
|
||||
- Related PRs: #305 (Step 0.55 category-peer subreddit expansion — the precedent for deterministic peer expansion, merged 2026-04-22)
|
||||
- Related plan: `docs/plans/2026-04-22-001-fix-category-peer-subreddit-resolution-plan.md`
|
||||
@@ -1,349 +0,0 @@
|
||||
---
|
||||
title: "fix: per-entity resolution, default-2, and stale-path guard for --competitors"
|
||||
type: fix
|
||||
status: active
|
||||
date: 2026-04-22
|
||||
origin: docs/plans/2026-04-22-002-feat-competitors-flag-comparison-fanout-plan.md
|
||||
---
|
||||
|
||||
# fix: per-entity resolution, default-2, and stale-path guard for --competitors
|
||||
|
||||
## Overview
|
||||
|
||||
Three test runs of v3.0.11 `--competitors` surfaced four real bugs plus one product tweak. This plan fixes all of them in a single follow-up:
|
||||
|
||||
1. Competitor sub-runs get no Step 0.55 resolution (no X handle, no subreddits, no GitHub repo). Drake / Kendrick / Travis ran with deterministic-fallback single-word queries while Kanye had the full targeting package. User called it "lazy" and was right.
|
||||
2. Two of three test windows (Linear, Coinbase) never invoked the new flag at all. They loaded SKILL.md from `plugins/marketplaces/last30days-skill/` (a Claude-Code-managed git clone pinned to origin/main, which predates PR #308) instead of `plugins/cache/last30days-skill/last30days/3.0.11/`, so `--help` showed no `--competitors` flag and the model fell back to the manual comparison path.
|
||||
3. Each competitor sub-run emits a scary `[Planner] No --plan passed... deterministic fallback` stderr line because LAW 7 targets the hosting-model path, not internal fan-out sub-runs.
|
||||
4. Default competitor count is 3 (→ 4-way comparison). User wants default 2 (→ 3-way: original + 2 peers). Flag keeps `--competitors=N` to customize.
|
||||
|
||||
## Problem Frame
|
||||
|
||||
The 3 test runs (Kanye, Linear, Coinbase) showed a pattern:
|
||||
|
||||
| Window | Loaded SKILL.md from | Invoked --competitors? | Per-entity resolution? | Outcome |
|
||||
|--------|----------------------|-----------------------|------------------------|---------|
|
||||
| Kanye | cache/3.0.11/ (correct) | Yes | Only for main topic (Kanye) | Drake/Kendrick/Travis thin; Reddit 403 fallbacks |
|
||||
| Linear | marketplaces/ (stale) | No — fell back to manual comparison | No | Thin run with noisy subreddits |
|
||||
| Coinbase | marketplaces/ (stale) | No — fell back to manual comparison | Main only; keyword-search poisoned pool | Top subs: r/survivor, r/Airpodsmax (noise) |
|
||||
|
||||
Root causes:
|
||||
- **Per-entity resolution gap:** `scripts/lib/fanout.py` calls `pipeline.run()` with topic + depth + web_backend + lookback_days only. It does not call `resolve.auto_resolve()` per entity, so sub-runs have no X handle, subreddit, or GitHub targeting. The original plan (`2026-04-22-002`) acknowledged this as a deliberate v1 simplification ("competitor sub-runs use planner defaults"). In practice this produces visibly asymmetric output and triggers downstream retrieval issues (403 fallbacks, keyword-search noise).
|
||||
- **Stale-path loading:** Claude Code's skill loader alphabetizes `find` results with `marketplaces/` before `cache/`, and the model reads the first plausible SKILL.md it sees. SKILL.md line 823's `SKILL_ROOT` resolver is the correct path but only fires in engine-invocation blocks, not in the skill-load step.
|
||||
- **LAW 7 in sub-runs:** LAW 7 exists because the *hosting reasoning model* is supposed to pass `--plan`. For competitor sub-runs, there is no hosting-model planning — it's an engine-internal fan-out. The warning is a false positive there.
|
||||
|
||||
## Requirements Trace
|
||||
|
||||
- R1. Default `--competitors` count is 2 peers (3-way comparison: original + 2).
|
||||
- R2. Each competitor sub-run performs Step 0.55 resolution (X handle, subreddits, GitHub user/repos, news context) before its pipeline runs — not just the main topic.
|
||||
- R3. Sub-runs do not emit the LAW 7 `No --plan passed` warning; they are internal fan-out, not hosting-model calls.
|
||||
- R4. The rendered comparison output includes a visible "Resolved entities" block showing per-entity handles/subs/github for debug transparency (answers "did it resolve everyone?" without the user having to read stderr).
|
||||
- R5. SKILL.md has a canonical-path self-check at the top: if the reader loaded it from anywhere other than `plugins/cache/last30days-skill/last30days/{VERSION}/`, re-read from the versioned path before proceeding.
|
||||
- R6. Version bumps to 3.0.12; CHANGELOG entry; `scripts/sync.sh` deploys.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- No new discovery strategy. The web-search + regex extraction in `scripts/lib/competitors.py` stays as-is.
|
||||
- No new CLI flags beyond the behavior changes above. Specifically: no per-entity override flags like `--competitor-handles`. The hosting-model escape hatch remains `--competitors-list`.
|
||||
- No changes to the explicit `A vs B` comparison path (topic-string parsing in `planner._comparison_entities`).
|
||||
- No marketplace-clone auto-restore fix — that's Claude Code harness behavior. This plan only guards against the symptom on the skill side.
|
||||
|
||||
### Deferred to Separate Tasks
|
||||
|
||||
- Caching of per-entity resolution results: separate follow-up once hit rate justifies it.
|
||||
- Fan-out rate-limiting tuning (currently `max_workers=len(entities)+1`, capped at 6): defer until we see real-world quota exhaustion.
|
||||
- Pre-flight cost hint when N ≥ 4 (noted in `2026-04-22-002` risks): defer.
|
||||
|
||||
## Context & Research
|
||||
|
||||
### Relevant Code and Patterns
|
||||
|
||||
- `scripts/last30days.py:205-219` — `--competitors` / `--competitors-list` argparse definition (const=3 today; changing to 2).
|
||||
- `scripts/last30days.py:220-290` — `resolve_competitors_args()` validator; update `COMPETITORS_DEFAULT`.
|
||||
- `scripts/last30days.py:438-520` — main() fan-out orchestration; currently passes only topic/depth to each `_competitor_runner`.
|
||||
- `scripts/lib/fanout.py:40-95` — `run_competitor_fanout()` signature. The `competitor_runner` callable is where per-entity resolution needs to happen.
|
||||
- `scripts/lib/resolve.py:179-258` — `auto_resolve()` is the exact per-entity resolver to reuse. Already does X handle + subreddits + GitHub user/repos + news context in parallel via ThreadPoolExecutor.
|
||||
- `scripts/lib/planner.py:80-135` — `plan_query()` emits the LAW 7 stderr. A `quiet: bool` keyword or `internal_subrun: bool` flag will suppress it.
|
||||
- `scripts/lib/pipeline.py:162-220` — `pipeline.run()` signature. Needs a new keyword to propagate quiet-mode down to the planner.
|
||||
- `scripts/lib/render.py:render_comparison_multi` — where the "Resolved entities" block is inserted.
|
||||
- `SKILL.md` line 823 — canonical `SKILL_ROOT` resolver already exists but fires in engine bash, not at skill-load time.
|
||||
|
||||
### Institutional Learnings
|
||||
|
||||
- `docs/plans/2026-04-22-002-feat-competitors-flag-comparison-fanout-plan.md` acknowledged the per-entity-resolution gap as a v1 tradeoff. This plan closes that gap.
|
||||
- Kanye run stderr: `[Planner] No --plan passed... deterministic fallback` × 3 (once per competitor sub-run). That's the LAW 7 noise R3 targets.
|
||||
- Linear / Coinbase runs loaded `plugins/marketplaces/last30days-skill/CLAUDE.md` as the first hit. That's the stale-path issue R5 targets.
|
||||
|
||||
### External References
|
||||
|
||||
- None. All patterns are in-repo.
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **Per-entity resolve happens inside fanout, not in SKILL.md.** The user-facing promise of `--competitors` is "one flag, engine does the work." Pushing resolution onto the hosting model creates another path-of-least-resistance trap (model skips it, output looks lazy). Auto-resolve inside each sub-run when a web backend is available makes the feature self-contained.
|
||||
- **Stale-path guard is a SKILL.md self-check, not a code change.** We cannot stop Claude Code from auto-restoring the marketplace clone. But we can put a 3-line banner at the top of SKILL.md that forces any path-mismatched read to re-read from the versioned cache. Both the marketplace copy (once main catches up) and the cache copy carry the guard.
|
||||
- **LAW 7 suppression is opt-in via `internal_subrun=True` keyword.** Do not remove the warning from the default path — it's load-bearing for the hosting-model contract. Add an explicit bypass for engine-internal fan-out only.
|
||||
- **Default 2, hard max 6 unchanged.** "Original + 2" matches the Kanye/Drake/Kendrick mental model from the feature description. Still allow `--competitors=N` from 1 to 6.
|
||||
- **Resolved block is inside the EVIDENCE envelope, not above it.** Keeps the rendered output structure stable for the synthesis contract (LAW 1–8). The block is context, not output.
|
||||
- **Skip auto-resolve when `--mock` or no web backend.** Mirrors the existing `resolve.auto_resolve()` fast-fail and keeps the mock test path deterministic.
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved During Planning
|
||||
|
||||
- **Where does per-entity resolve live?** Inside `fanout.run_competitor_fanout`, not in `main()`. Each sub-run calls `auto_resolve()` just before `pipeline.run()`.
|
||||
- **Should the hosting model still be able to override?** Yes — `--competitors-list` remains the escape hatch. When an explicit list is passed, the engine still does auto-resolve per entity; the user's list just skips discovery.
|
||||
- **Should sub-runs run auto-resolve in parallel with each other?** Yes. The existing `ThreadPoolExecutor` in fanout already parallelizes sub-runs; auto-resolve happens inside each sub-run's thread, so resolve calls for different entities run concurrently.
|
||||
- **Default count:** 2 peers (3-way). Confirmed.
|
||||
|
||||
### Deferred to Implementation
|
||||
|
||||
- Whether to expose a `--no-auto-resolve-competitors` flag for power users who want the fast, shallow behavior. Probably not needed v2; ship auto-resolve always-on and revisit if someone complains about cost.
|
||||
- Whether to surface the per-entity resolution context back into the main topic's planner (cross-entity context sharing). Stays deferred.
|
||||
- Whether the Resolved block should be collapsible or always inline. Start inline; revisit based on output length feedback.
|
||||
|
||||
## Implementation Units
|
||||
|
||||
- [ ] **Unit 1: Default `--competitors` to 2 peers**
|
||||
|
||||
**Goal:** Change the bare `--competitors` default from 3 to 2 per user feedback. `--competitors=N` still overrides; range 1..6 unchanged.
|
||||
|
||||
**Requirements:** R1
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/last30days.py` (`COMPETITORS_DEFAULT`, `--competitors` const, stderr messages if any reference 3)
|
||||
- Modify: `SKILL.md` Competitor mode section ("discovered 2-6" wording, bare-flag default line)
|
||||
- Modify: `README.md` auto-discovered example line (if it references count)
|
||||
- Test: `tests/test_cli_competitors.py`
|
||||
|
||||
**Approach:**
|
||||
- Change `COMPETITORS_DEFAULT = 3` → `2` in `scripts/last30days.py`.
|
||||
- Change argparse `--competitors` `const=3` → `const=2`.
|
||||
- Update any SKILL.md / README copy referencing "3 peers" to "2 peers" (default) or "2-6 peers" (range).
|
||||
|
||||
**Patterns to follow:**
|
||||
- Existing default constants in `scripts/last30days.py` argparse block.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: bare `--competitors` yields count=2, enabled=True, empty explicit_list.
|
||||
- Edge case: `--competitors=3` still works (explicit override).
|
||||
- Edge case: existing `test_bare_flag_defaults_to_three` test is updated to `test_bare_flag_defaults_to_two` and asserts count=2.
|
||||
- Edge case: `--competitors=5` with a `--competitors-list` of length 2 still logs the mismatch warning and uses the list.
|
||||
|
||||
**Verification:**
|
||||
- `pytest tests/test_cli_competitors.py -v` passes with the updated default.
|
||||
|
||||
- [ ] **Unit 2: Per-entity Step 0.55 resolution inside fanout**
|
||||
|
||||
**Goal:** Each competitor sub-run auto-resolves its own X handle, subreddits, GitHub user/repos, and news context via `resolve.auto_resolve()` before its `pipeline.run()` call — just like the main topic.
|
||||
|
||||
**Requirements:** R2
|
||||
|
||||
**Dependencies:** None (but Unit 3 should land together so sub-runs don't emit LAW 7 stderr while the resolution context is being passed)
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/lib/fanout.py`
|
||||
- Modify: `scripts/last30days.py` (`_competitor_runner` closure builds the resolved args)
|
||||
- Test: `tests/test_competitor_fanout.py`
|
||||
- Test: `tests/test_competitors_resolve_integration.py` (new; covers the auto-resolve path)
|
||||
|
||||
**Approach:**
|
||||
- `_competitor_runner(entity)` in main() does:
|
||||
1. Call `resolve.auto_resolve(entity, config)` when `not args.mock` and a web backend is configured (reuse `_has_backend`).
|
||||
2. Extract resolved x_handle, subreddits, github_user, github_repos, context.
|
||||
3. Pass them to `pipeline.run()` for that sub-run.
|
||||
4. Inject resolved context into a per-entity config copy (so `_auto_resolve_context` does not leak across sub-runs — deep-copy the config or use a local dict).
|
||||
5. Store the resolved block on the Report's `artifacts` so the renderer can surface it (Unit 4).
|
||||
- When `args.mock` is True or no backend is available, skip auto-resolve (fall through to planner defaults, matching the existing `auto_resolve()` early-return contract).
|
||||
- Update `fanout.run_competitor_fanout` docstring to note that auto-resolve happens inside the caller-provided runner.
|
||||
|
||||
**Execution note:** Start with a failing integration test that exercises two-entity fanout + auto-resolve via a mocked `resolve.auto_resolve` and asserts that `pipeline.run` receives the resolved x_handle/subreddits for each entity.
|
||||
|
||||
**Patterns to follow:**
|
||||
- `scripts/last30days.py` main topic branch (`if args.auto_resolve and not external_plan`) already calls `resolve.auto_resolve` and propagates results — mirror the shape for competitors.
|
||||
- Config isolation: `scripts/lib/pipeline.py:162-220` reads config as-is; use `dict(config)` to avoid cross-sub-run mutation of `_auto_resolve_context`.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: 3 entities, mocked `auto_resolve` returns distinct handles per entity; `pipeline.run` receives `x_handle=@drake` for Drake, `x_handle=@kendricklamar` for Kendrick, etc.
|
||||
- Happy path: the main topic still uses the user-supplied `--x-handle` / `--subreddits` overrides (not overwritten by auto-resolve for the main). Competitors use their own auto-resolved values.
|
||||
- Edge case: `--mock` skips auto-resolve entirely for all sub-runs (no `resolve.auto_resolve` calls).
|
||||
- Edge case: `resolve.auto_resolve` returns empty dicts for one entity (low-signal topic) — the sub-run still executes with planner defaults; doesn't crash.
|
||||
- Edge case: no web backend configured — auto-resolve returns empty for every entity, sub-runs fall through to planner defaults, no stack trace.
|
||||
- Error path: `resolve.auto_resolve` raises — the sub-run logs a warning and continues with planner defaults (does not fail the whole comparison).
|
||||
- Integration: config `_auto_resolve_context` from entity A does not leak into entity B's `pipeline.run`. Assert each sub-run gets its own context string.
|
||||
|
||||
**Verification:**
|
||||
- New integration test passes.
|
||||
- End-to-end smoke (mock mode + explicit list): each sub-run's stderr shows `[AutoResolve]` lines per entity with distinct values.
|
||||
|
||||
- [ ] **Unit 3: Suppress LAW 7 warning for engine-internal sub-runs**
|
||||
|
||||
**Goal:** The `[Planner] No --plan passed... deterministic fallback` warning does not fire during competitor sub-runs. LAW 7 is load-bearing for hosting-model contracts and must stay on the default path; this is an opt-in bypass for internal fan-out only.
|
||||
|
||||
**Requirements:** R3
|
||||
|
||||
**Dependencies:** Unit 2 (so the sub-run call site is already being modified)
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/lib/planner.py` (`plan_query` signature + conditional stderr)
|
||||
- Modify: `scripts/lib/pipeline.py` (`run` signature + propagation)
|
||||
- Modify: `scripts/last30days.py` or `scripts/lib/fanout.py` (pass `internal_subrun=True` for competitor runners)
|
||||
- Test: `tests/test_planner_v3.py` (or new `tests/test_planner_quiet_mode.py`)
|
||||
- Test: `tests/test_competitor_fanout.py` (assert sub-runs don't emit LAW 7 stderr)
|
||||
|
||||
**Approach:**
|
||||
- Add a keyword `internal_subrun: bool = False` to `planner.plan_query`. When True, skip the two `print(..., file=sys.stderr)` blocks that emit the LAW 7 banner and the `[Planner] No --plan passed` capability message.
|
||||
- Add the same keyword to `pipeline.run()`; pass through to `plan_query`.
|
||||
- In main()/fanout, set `internal_subrun=True` for every competitor sub-run's pipeline.run call. The main topic's pipeline.run keeps the default (LAW 7 stays on for the hosting-model path).
|
||||
- Also suppress the LAW 7-triggered degraded-run warning block in the render layer for sub-reports when the envelope is going to be merged into a comparison output (or accept that the block is per-entity and surfaces once per entity).
|
||||
|
||||
**Patterns to follow:**
|
||||
- Existing keyword-only parameters on `pipeline.run` (`mock`, `x_handle`, etc.).
|
||||
- `planner.plan_query` signature is already keyword-only.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: `plan_query(..., internal_subrun=True, provider=None, model=None)` returns the deterministic fallback plan WITHOUT writing the LAW 7 stderr block.
|
||||
- Happy path: `plan_query(...)` with default `internal_subrun=False` still writes the LAW 7 warning (unchanged behavior).
|
||||
- Integration: end-to-end competitor fanout; assert captured stderr contains zero occurrences of `No --plan passed` and zero of `YOU ARE the planner`.
|
||||
- Integration: main topic is not part of competitor mode; if the user invokes bare `/last30days OpenAI` without `--plan`, LAW 7 stderr fires exactly once (regression test).
|
||||
|
||||
**Verification:**
|
||||
- Running the Kanye-style smoke test shows zero `[Planner] No --plan passed` lines for Drake / Kendrick / Travis sub-runs.
|
||||
|
||||
- [ ] **Unit 4: "Resolved entities" block in comparison output**
|
||||
|
||||
**Goal:** The rendered comparison output includes a visible block listing per-entity handles, subreddits, GitHub user, and resolved context. Answers "did it resolve everyone?" at a glance without reading stderr.
|
||||
|
||||
**Requirements:** R4
|
||||
|
||||
**Dependencies:** Unit 2 (needs resolved data on report artifacts)
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/lib/render.py` (`render_comparison_multi` and `render_comparison_multi_context`)
|
||||
- Test: `tests/test_render_comparison_multi.py`
|
||||
|
||||
**Approach:**
|
||||
- When each entity's `Report.artifacts` contains a `resolved` dict (populated by Unit 2), `render_comparison_multi` emits a `## Resolved Entities` block early in the EVIDENCE envelope:
|
||||
```
|
||||
## Resolved Entities
|
||||
- **Kanye West**: X @kanyewest | Subs r/Kanye, r/hiphopheads | GitHub: — | Context: BULLY released, UK ban…
|
||||
- **Drake**: X @Drake | Subs r/DrakeTheType, r/hiphopheads | GitHub: — | Context: ICEMAN rollout…
|
||||
- **Kendrick Lamar**: X @kendricklamar | Subs r/KendrickLamar | GitHub: — | Context: Grammy wins, dormant…
|
||||
```
|
||||
- Missing fields render as `—` not empty.
|
||||
- When no entity has a `resolved` payload (mock mode, no web backend), omit the block entirely rather than emit an empty section.
|
||||
- Context strings are truncated at 120 chars to keep the block scannable.
|
||||
|
||||
**Patterns to follow:**
|
||||
- Existing `render_comparison_multi` envelope structure (lines ~395-480 in render.py).
|
||||
- Existing per-entity evidence block format (`## {label}`) for consistency.
|
||||
|
||||
**Test scenarios:**
|
||||
- Happy path: 3 entities each with a `resolved` artifact → block lists all 3 with their fields.
|
||||
- Happy path: 2 entities, one with full resolution, one with partial (x_handle only) → missing fields render as `—`.
|
||||
- Edge case: no entity has a resolved artifact → block is omitted entirely.
|
||||
- Edge case: context string > 120 chars → truncated with ellipsis.
|
||||
- Integration: rendered output passes through the same EVIDENCE envelope comments and synthesis contract (LAW 1–8 unchanged).
|
||||
|
||||
**Verification:**
|
||||
- Snapshot tests confirm the block appears in the right spot with the right formatting.
|
||||
- End-to-end smoke shows a realistic 3-entity Resolved block in the rendered output.
|
||||
|
||||
- [ ] **Unit 5: SKILL.md canonical-path self-check**
|
||||
|
||||
**Goal:** A top-of-file SKILL.md directive forces any reader (Claude Code, Codex, Hermes, Gemini) to verify they loaded from `plugins/cache/last30days-skill/last30days/{VERSION}/SKILL.md` before proceeding. If loaded from `marketplaces/` or any other path, re-read from the pinned versioned cache.
|
||||
|
||||
**Requirements:** R5
|
||||
|
||||
**Dependencies:** None
|
||||
|
||||
**Files:**
|
||||
- Modify: `SKILL.md` (prepend a STEP 0 block before the existing STEP 0 / LAW list)
|
||||
|
||||
**Approach:**
|
||||
- Add a numbered first step at the top (before or bundled with existing "STEP 0: ToolSearch preload"):
|
||||
```
|
||||
## STEP 0: Canonical Path Self-Check (must run first)
|
||||
|
||||
Before reading anything else below, verify you loaded this SKILL.md from
|
||||
the versioned cache, not the marketplace clone:
|
||||
|
||||
CANONICAL=$HOME/.claude/plugins/cache/last30days-skill/last30days/
|
||||
CANONICAL_LATEST=$(ls -d "$CANONICAL"*/ 2>/dev/null | sort -V | tail -1)
|
||||
|
||||
If the SKILL.md you just read is not under $CANONICAL_LATEST, STOP. Re-read
|
||||
$CANONICAL_LATEST/SKILL.md and restart from here. Marketplace clones
|
||||
(`plugins/marketplaces/last30days-skill/`) are pinned to origin/main and
|
||||
can be stale; the versioned cache is the ground truth.
|
||||
```
|
||||
- Reinforce in the existing LAW 7 block that `--help` output must be read from the same pinned `SKILL_ROOT` to avoid flag-list skew.
|
||||
|
||||
**Patterns to follow:**
|
||||
- Existing STEP 0 ToolSearch preload (top of SKILL.md) for tone / imperative voice.
|
||||
- Existing `SKILL_ROOT` resolver snippet (line ~823).
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none — SKILL.md is documentation; no unit test, verified by follow-up user invocation.
|
||||
|
||||
**Verification:**
|
||||
- In a fresh Claude Code window, `/last30days Test --competitors` loads SKILL.md, the model executes the STEP 0 self-check, and (if it had loaded from marketplaces/) switches to the cache path before running `--help` or the engine. Observable via the model's announced reasoning / task list.
|
||||
|
||||
- [ ] **Unit 6: Version bump, CHANGELOG, sync**
|
||||
|
||||
**Goal:** Ship 3.0.12 and deploy to all local targets.
|
||||
|
||||
**Requirements:** R6
|
||||
|
||||
**Dependencies:** Units 1-5
|
||||
|
||||
**Files:**
|
||||
- Modify: `.claude-plugin/plugin.json` (version 3.0.11 → 3.0.12)
|
||||
- Modify: `CHANGELOG.md`
|
||||
- Run: `bash scripts/sync.sh`
|
||||
|
||||
**Approach:**
|
||||
- CHANGELOG entry under `## [3.0.12]` dated 2026-04-22 covering the four fixes (Fixed: per-entity resolution; Fixed: LAW 7 sub-run noise; Changed: default count 3→2; Added: Resolved entities block; Added: canonical-path self-check in SKILL.md).
|
||||
- `sync.sh` deploys to `~/.claude/plugins/cache/last30days-skill-private/...`, `~/.agents/`, `~/.codex/`, Hermes.
|
||||
- Manual hot-copy to `~/.claude/plugins/cache/last30days-skill/last30days/3.0.12/` so the public `/last30days` slash command picks up the new version before PR merge (matches the 3.0.11 testing pattern).
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none — packaging only. Verification is by inspection.
|
||||
|
||||
**Verification:**
|
||||
- `grep version .claude-plugin/plugin.json` returns `3.0.12`.
|
||||
- `sync.sh` exits 0 with "Import check: OK" for each target.
|
||||
- Hot-copied 3.0.12 directory contains the new files and `/last30days` picks up the new version (highest-version resolver).
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction graph:** Fanout sub-runs now call `resolve.auto_resolve` per entity. Each sub-run is independent; no shared mutable state with other sub-runs or with the main topic.
|
||||
- **Error propagation:** `auto_resolve` failures inside a sub-run log a warning and degrade to planner defaults; do not propagate up to abort the comparison. Same contract as today for the main topic.
|
||||
- **State lifecycle risks:** Config dict is mutated by `auto_resolve` (via `config["_auto_resolve_context"]`). Must deep-copy per sub-run or scope context to a local mapping — otherwise two sub-runs' context strings race.
|
||||
- **API surface parity:** `pipeline.run` gains a keyword (`internal_subrun`); callers that don't pass it get the existing behavior. `planner.plan_query` gains the same. Backward compatible.
|
||||
- **Integration coverage:** New integration test for the fanout + auto-resolve + render chain. Existing snapshot tests update to include the Resolved block.
|
||||
- **Unchanged invariants:** Single-entity `/last30days` invocations (no `--competitors`) behave identically. Explicit `A vs B` comparison topics behave identically. LAW 7 still fires on the default hosting-model path. `render_compact` path is untouched.
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Auto-resolving per competitor triples the WebSearch call volume (4 queries × 3 competitors = 12 extra web searches). | Fast-fail when no backend; user can pass `--competitors-list` to skip discovery but still get auto-resolve. Cost note in CHANGELOG. |
|
||||
| Config mutation across sub-runs via `_auto_resolve_context`. | Unit 2 deep-copies config per sub-run before each `auto_resolve` + `pipeline.run` call. Integration test asserts no cross-entity leak. |
|
||||
| LAW 7 suppression leaks onto the hosting-model path via a wrong default. | Default `internal_subrun=False`. Only fanout's competitor sub-runs set True. Unit test asserts bare-topic invocation still emits LAW 7. |
|
||||
| SKILL.md STEP 0 banner gets ignored by the model (same failure mode as line 823 today). | Put it in the guaranteed-read top band (before LAW 1, above all other content), imperative voice, concrete `STOP` verb. Still not bulletproof but strictly better than current. |
|
||||
| Default count change breaks assumptions in downstream tools or existing user muscle memory. | Changelog calls it out as Changed; `--competitors=3` still works for users who want the old default. |
|
||||
|
||||
## Documentation / Operational Notes
|
||||
|
||||
- Beta channel first: merge behind `/last30days-beta` via the private repo before cherry-picking to public. Follows the same process as 3.0.11.
|
||||
- Version 3.0.12 is a fix release; no marketing post required.
|
||||
- After merge, add a line to the PR description pointing at this plan.
|
||||
|
||||
## Sources & References
|
||||
|
||||
- Origin plan: `docs/plans/2026-04-22-002-feat-competitors-flag-comparison-fanout-plan.md`
|
||||
- Related PR: #308 (v3.0.11 shipping --competitors)
|
||||
- Test windows that surfaced the bugs: Kanye, Linear, Coinbase (2026-04-22 session)
|
||||
- Related code: `scripts/lib/fanout.py`, `scripts/lib/resolve.py` (`auto_resolve`), `scripts/lib/planner.py` (`plan_query`), `scripts/lib/render.py` (`render_comparison_multi`)
|
||||
-394
@@ -1,394 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,451 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,87 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,396 +0,0 @@
|
||||
---
|
||||
title: "marketing: v3.1 launch video — 30s Remotion piece for X"
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-04-22
|
||||
---
|
||||
|
||||
# marketing: v3.1 launch video — 30s Remotion piece for X
|
||||
|
||||
## Overview
|
||||
|
||||
Build a 30-second Remotion-rendered MP4 marketing video announcing **v3.1: Competitors mode** for `/last30days`. Posts to X. Frames the new vs-mode-with-auto-discovery as the headline — three full passes, three save files, one comparison — without burying it in CLI minutiae.
|
||||
|
||||
Marketing release tag is **v3.1** (rebrands the 3.0.11-3.0.14 bundle into one shippable narrative for the launch tweet). Engine version stays 3.0.14 — `3.1` is the marketing version, not a code version bump.
|
||||
|
||||
## Script (60 frames per second × 30 seconds = 900 frames; this version assumes 30fps × 30s = 900 frames at 30fps)
|
||||
|
||||
Total runtime: 30.0 seconds @ 30fps = 900 frames. Six scenes, on-screen text only (silent autoplay-friendly).
|
||||
|
||||
### Scene 1 — Hook (0:00 – 0:03 | frames 0-90)
|
||||
|
||||
**Visual:** Black background. The `/last30days` badge animates in (the literal `🌐 last30days v3.1 · synced 2026-04-22` line) with the spring scale-in used in slick devtool intros.
|
||||
|
||||
**Caption (overlay, large):**
|
||||
```
|
||||
What if one search
|
||||
ran 3 at once?
|
||||
```
|
||||
|
||||
### Scene 2 — Set the world (0:03 – 0:08 | frames 90-240)
|
||||
|
||||
**Visual:** Single mac terminal window center-stage. Type-on animation:
|
||||
```
|
||||
$ /last30days OpenAI
|
||||
```
|
||||
Below it, a simple result card stub appears (Reddit upvote count + X likes), then static.
|
||||
|
||||
**Caption (small bottom-left):**
|
||||
```
|
||||
The old way: one topic.
|
||||
```
|
||||
|
||||
### Scene 3 — The reveal (0:08 – 0:14 | frames 240-420)
|
||||
|
||||
**Visual:** The terminal types one more flag:
|
||||
```
|
||||
$ /last30days OpenAI --competitors
|
||||
```
|
||||
Hard cut → the single terminal **splits into 3 panes** side by side. Each pane shows a different topic header animating in, in this order:
|
||||
- Left: `OpenAI`
|
||||
- Middle: `vs Anthropic`
|
||||
- Right: `vs xAI`
|
||||
|
||||
Pane content scrolls fake "search progress" lines (Reddit, X, YouTube indicators) in parallel, like a live fan-out.
|
||||
|
||||
**Caption (top center):**
|
||||
```
|
||||
Now it discovers competitors
|
||||
and runs all 3.
|
||||
```
|
||||
|
||||
### Scene 4 — Result reveal (0:14 – 0:21 | frames 420-630)
|
||||
|
||||
**Visual:** The 3 panes collapse into a single comparison surface — the `## Head-to-Head` table from the actual engine output, with rows fading in one by one (What it is, Streams, Best for, Trajectory). Each entity column lights up as its row populates.
|
||||
|
||||
**Caption (bottom):**
|
||||
```
|
||||
3 full passes. 3 save files.
|
||||
1 comparison.
|
||||
```
|
||||
|
||||
### Scene 5 — How it's special (0:21 – 0:26 | frames 630-780)
|
||||
|
||||
**Visual:** Cut to a clean text card, large mono font:
|
||||
```
|
||||
You pick the topic.
|
||||
The agent picks the peers.
|
||||
The engine fans out.
|
||||
```
|
||||
Each line fades in 1.5s apart.
|
||||
|
||||
### Scene 6 — CTA (0:26 – 0:30 | frames 780-900)
|
||||
|
||||
**Visual:** Black background. Centered:
|
||||
- Top: `🌐 last30days v3.1`
|
||||
- Middle: `/last30days {topic} --competitors`
|
||||
- Bottom: `github.com/mvanhorn/last30days-skill`
|
||||
|
||||
Subtle pulse on the install line.
|
||||
|
||||
**End frame holds for ~0.5s.**
|
||||
|
||||
## Problem Frame
|
||||
|
||||
The 3.0.11-3.0.14 release bundle ships a transformative feature (per-entity vs-mode fanout + `--competitors` shortcut + per-entity save files), but the value lands flat in a tweet thread or screenshot. A 30s video does what static text cannot: shows the fan-out happening in real time and the 3 → 1 collapse into a comparison. Higher tweet engagement, easier to RT/QT.
|
||||
|
||||
## Requirements Trace
|
||||
|
||||
- R1. Final artifact: a single MP4 file, 1920×1080, 30fps, ~30 seconds (±0.5s), under 30MB, suitable for direct X upload.
|
||||
- R2. Six-scene script as defined above, with text/visuals/timing matching to within 5 frames.
|
||||
- R3. Branded look: matches the `🌐 last30days v3.1` badge style (terminal aesthetic, mono font, dark background).
|
||||
- R4. Silent — no voiceover, no music in v1. Captions baked in. Designed for autoplay-muted feeds.
|
||||
- R5. Reproducible: another contributor (or a future-me) can re-render with one command. Project lives in-repo so the source is versioned.
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
- No voiceover. Text-on-screen only. (Voice can be a v1.1 if engagement is high.)
|
||||
- No background music in the rendered MP4. (Music can be added in post via QuickTime/iMovie if desired before posting.)
|
||||
- No localization. English captions only.
|
||||
- No A/B test variants. One video.
|
||||
- No 9:16 vertical version. 16:9 only. (Vertical can be a separate render after launch validates the format.)
|
||||
|
||||
### Deferred to Separate Tasks
|
||||
|
||||
- Voiceover variant: defer to follow-up if v1 lands well.
|
||||
- 9:16 mobile cut: defer; same source compositions can re-render at 1080×1920 in a follow-up.
|
||||
- Animated GIF for embedding in README.md: defer; can be ffmpeg-extracted from the MP4.
|
||||
|
||||
## Context & Research
|
||||
|
||||
### Relevant Code and Patterns
|
||||
|
||||
- `SKILL.md` — the comparison render scaffold (`## Head-to-Head` table) is the visual reference for Scene 4's table look.
|
||||
- `scripts/lib/render.py` `_render_comparison_scaffold` — emits the 9-axis table whose visual style we're recreating in a more polished form.
|
||||
- `CHANGELOG.md` 3.0.11-3.0.14 entries — the prose source for the script's beats.
|
||||
- `.claude-plugin/plugin.json` version 3.0.14 — current code version (marketing version is 3.1 for the launch).
|
||||
|
||||
### External References
|
||||
|
||||
- Remotion 4.x docs (https://www.remotion.dev/docs/) — current API for compositions, sequences, springs, and render CLI.
|
||||
- X video specs 2026: max 2 min 20 s, ≤512MB, MP4 with H.264 + AAC, recommended 1920×1080 for landscape autoplay.
|
||||
|
||||
### Institutional Learnings
|
||||
|
||||
- No prior `marketing/` dir or video plans in `docs/plans/`. This is a greenfield asset directory.
|
||||
|
||||
## Key Technical Decisions
|
||||
|
||||
- **Remotion, not ffmpeg-only.** Remotion's React-based compositions handle the typed-on terminal effect, spring-animated badges, and scene transitions far more cleanly than raw ffmpeg filtergraphs. Render output is still MP4 via Remotion's bundled ffmpeg.
|
||||
- **In-repo asset directory at `marketing/v3.1-launch/`.** Lives with the product so future versions can fork the project. Adds `marketing/` to `.gitignore` exceptions only for source files; rendered MP4 stays out of git (uploaded separately).
|
||||
- **16:9 1920×1080 @ 30fps.** Best fit for X landscape autoplay on desktop and mobile feed. 30fps is plenty for typed-text + UI animation; 60fps doubles render time without obvious quality gain.
|
||||
- **Silent + captions.** X autoplay defaults to muted. Sound-off is the realistic viewing condition. Captions baked into the visual.
|
||||
- **Six scenes, one composition.** Single Remotion composition with sequenced child compositions per scene. Easier to re-time than scene-files. Frame-numbered timing in the script enables precise edits.
|
||||
- **Mono font (JetBrains Mono or Geist Mono).** Matches the terminal aesthetic of the actual `/last30days` output. Available via Google Fonts or @remotion/google-fonts.
|
||||
- **Marketing version 3.1 ≠ engine version 3.0.14.** `3.1` is the launch label. Engine stays 3.0.14. Avoids confusion in CHANGELOG.
|
||||
|
||||
## Open Questions
|
||||
|
||||
### Resolved During Planning
|
||||
|
||||
- **Aspect ratio?** 16:9 1080p. Best X autoplay format; vertical can be a follow-up.
|
||||
- **Voiceover or silent?** Silent + on-screen captions. Autoplay-muted is the realistic condition.
|
||||
- **In-repo or separate repo?** In-repo at `marketing/v3.1-launch/`. Rendered MP4 not committed; source compositions are.
|
||||
- **Length?** Exactly 30s (900 frames @ 30fps). No flex.
|
||||
- **Marketing version label?** `v3.1`. Engine code version stays 3.0.14.
|
||||
|
||||
### Deferred to Implementation
|
||||
|
||||
- Exact animation easing curves per scene — pick during build via Remotion preview iteration.
|
||||
- Whether the comparison-table mock in Scene 4 uses canned text or pulls from the actual saved `*-raw.md` files. Probably canned for visual control.
|
||||
- Whether Scene 3's "search progress" lines are typed individually or use a marquee scroll. Pick during preview.
|
||||
- Exact accent color palette beyond "terminal dark." Iterate against preview.
|
||||
|
||||
## Output Structure
|
||||
|
||||
marketing/
|
||||
v3.1-launch/
|
||||
package.json # Remotion dependency manifest
|
||||
tsconfig.json # TypeScript config
|
||||
remotion.config.ts # Remotion render config (codec, fps, resolution)
|
||||
src/
|
||||
index.ts # Remotion entry — registers compositions
|
||||
Root.tsx # Root composition definition
|
||||
LaunchVideo.tsx # Main 30s composition that sequences scenes
|
||||
scenes/
|
||||
Scene1Hook.tsx
|
||||
Scene2OldWay.tsx
|
||||
Scene3FanOut.tsx
|
||||
Scene4Comparison.tsx
|
||||
Scene5HowItWorks.tsx
|
||||
Scene6CTA.tsx
|
||||
components/
|
||||
TerminalWindow.tsx # Reusable mac-style terminal frame
|
||||
TypedLine.tsx # Type-on animation primitive
|
||||
ComparisonTable.tsx # The Head-to-Head table mock
|
||||
BadgeBar.tsx # The 🌐 last30days v3.1 badge
|
||||
lib/
|
||||
timing.ts # Frame ranges per scene (single source of truth)
|
||||
colors.ts # Brand palette
|
||||
public/ # Static assets (logo, fonts if local)
|
||||
out/ # Rendered MP4 lives here (gitignored)
|
||||
README.md # How to preview/render
|
||||
|
||||
## High-Level Technical Design
|
||||
|
||||
> *Directional guidance for review — not implementation specification.*
|
||||
|
||||
```
|
||||
LaunchVideo (durationInFrames = 900)
|
||||
├── <Sequence from={0} durationInFrames={90}> <Scene1Hook />
|
||||
├── <Sequence from={90} durationInFrames={150}> <Scene2OldWay />
|
||||
├── <Sequence from={240} durationInFrames={180}> <Scene3FanOut />
|
||||
├── <Sequence from={420} durationInFrames={210}> <Scene4Comparison />
|
||||
├── <Sequence from={630} durationInFrames={150}> <Scene5HowItWorks />
|
||||
└── <Sequence from={780} durationInFrames={120}> <Scene6CTA />
|
||||
```
|
||||
|
||||
Per-scene components use `useCurrentFrame()` + `interpolate()` + `spring()` for timing. `TerminalWindow` is the dominant motif across scenes 2-4.
|
||||
|
||||
## Implementation Units
|
||||
|
||||
- [ ] **Unit 1: Remotion project scaffold**
|
||||
|
||||
**Goal:** Spin up a working Remotion project at `marketing/v3.1-launch/` that previews a blank composition and renders to MP4.
|
||||
|
||||
**Requirements:** R1, R5
|
||||
|
||||
**Files:**
|
||||
- Create: `marketing/v3.1-launch/package.json`
|
||||
- Create: `marketing/v3.1-launch/tsconfig.json`
|
||||
- Create: `marketing/v3.1-launch/remotion.config.ts`
|
||||
- Create: `marketing/v3.1-launch/src/index.ts`
|
||||
- Create: `marketing/v3.1-launch/src/Root.tsx`
|
||||
- Create: `marketing/v3.1-launch/README.md`
|
||||
- Modify: `.gitignore` (add `marketing/v3.1-launch/out/`, `marketing/v3.1-launch/node_modules/`)
|
||||
|
||||
**Approach:**
|
||||
- Use `npx create-video@latest --blank` (Remotion 4.x scaffolding) targeting `marketing/v3.1-launch/`. Strip the demo composition.
|
||||
- Configure: 1920×1080, 30fps, H.264, AAC (audio codec needed even for silent — empty track).
|
||||
- README documents `npm install`, `npm run preview` (Remotion Studio), `npm run render` (one-command MP4).
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none for scaffold, but verification = `npm run preview` opens Remotion Studio with a blank 30s composition; `npm run render` produces a black MP4 at the right resolution.
|
||||
|
||||
**Verification:**
|
||||
- Studio loads at localhost:3000 with the empty `LaunchVideo` composition listed.
|
||||
- A render produces `out/launch-video.mp4` at 1920×1080, 30s, valid MP4.
|
||||
|
||||
- [ ] **Unit 2: Reusable components (Terminal, TypedLine, BadgeBar)**
|
||||
|
||||
**Goal:** Build the three primitive components scenes 2-6 will compose. Each is independently previewable.
|
||||
|
||||
**Requirements:** R3, R5
|
||||
|
||||
**Dependencies:** Unit 1
|
||||
|
||||
**Files:**
|
||||
- Create: `marketing/v3.1-launch/src/components/TerminalWindow.tsx`
|
||||
- Create: `marketing/v3.1-launch/src/components/TypedLine.tsx`
|
||||
- Create: `marketing/v3.1-launch/src/components/BadgeBar.tsx`
|
||||
- Create: `marketing/v3.1-launch/src/lib/colors.ts`
|
||||
- Create: `marketing/v3.1-launch/src/lib/timing.ts`
|
||||
|
||||
**Approach:**
|
||||
- `TerminalWindow`: mac-style traffic-light header, dark gradient background, mono content area. Accepts children.
|
||||
- `TypedLine`: takes a string and a `startFrame`, renders character-by-character at ~30 chars/sec. Reuses Remotion's `interpolate(useCurrentFrame() - startFrame, [0, lengthFrames], [0, text.length])` clamped.
|
||||
- `BadgeBar`: renders the literal `🌐 last30days v3.1 · synced 2026-04-22` line in mono with the same gradient color treatment as the engine's compact emit.
|
||||
- `colors.ts`: 5-7 brand colors (terminal-bg, terminal-fg, accent-cyan, accent-magenta, muted, success-green, warning-amber).
|
||||
- `timing.ts`: exports the scene frame ranges as named constants. Single source of truth for any retiming.
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none — visual components verified in Remotion Studio.
|
||||
|
||||
**Verification:**
|
||||
- Each component renders standalone in Studio when wrapped in a temporary preview composition.
|
||||
- TypedLine animates character-by-character without flicker.
|
||||
|
||||
- [ ] **Unit 3: Scenes 1-3 (Hook, Old way, Fan-out reveal)**
|
||||
|
||||
**Goal:** Build the first half of the video (frames 0-420). The narrative arc up through the visual fan-out.
|
||||
|
||||
**Requirements:** R2
|
||||
|
||||
**Dependencies:** Unit 2
|
||||
|
||||
**Files:**
|
||||
- Create: `marketing/v3.1-launch/src/scenes/Scene1Hook.tsx`
|
||||
- Create: `marketing/v3.1-launch/src/scenes/Scene2OldWay.tsx`
|
||||
- Create: `marketing/v3.1-launch/src/scenes/Scene3FanOut.tsx`
|
||||
- Modify: `marketing/v3.1-launch/src/Root.tsx` (register sequences)
|
||||
|
||||
**Approach:**
|
||||
- Scene 1: spring-in BadgeBar, large overlay caption, 3-second hold.
|
||||
- Scene 2: TerminalWindow with TypedLine (`$ /last30days OpenAI`), then a single result-card mock fading in.
|
||||
- Scene 3: typing animation appends `--competitors`, hard cut, three TerminalWindow components arranged in a row with staggered fade-in. Each pane shows a different entity header + scrolling progress lines.
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none — verified visually in Studio.
|
||||
|
||||
**Verification:**
|
||||
- Scrub the 0-14s range in Studio; visuals match the script timing within 5 frames.
|
||||
- The fan-out moment (frame 240) lands cleanly; no jank in the transition from 1 → 3 panes.
|
||||
|
||||
- [ ] **Unit 4: Scenes 4-6 (Comparison reveal, How it works, CTA)**
|
||||
|
||||
**Goal:** Build the back half of the video (frames 420-900). Resolution + payoff + call to action.
|
||||
|
||||
**Requirements:** R2
|
||||
|
||||
**Dependencies:** Unit 2
|
||||
|
||||
**Files:**
|
||||
- Create: `marketing/v3.1-launch/src/scenes/Scene4Comparison.tsx`
|
||||
- Create: `marketing/v3.1-launch/src/scenes/Scene5HowItWorks.tsx`
|
||||
- Create: `marketing/v3.1-launch/src/scenes/Scene6CTA.tsx`
|
||||
- Create: `marketing/v3.1-launch/src/components/ComparisonTable.tsx`
|
||||
|
||||
**Approach:**
|
||||
- Scene 4: ComparisonTable component renders 3-column markdown-style table; rows fade in one by one (stagger 15-20 frames). Uses canned data — OpenAI / Anthropic / xAI with believable cell content drawn from real 3.0.13 outputs.
|
||||
- Scene 5: three-line text card; lines fade in 45 frames apart.
|
||||
- Scene 6: three centered text blocks; install line gets a 1Hz subtle opacity pulse for emphasis.
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none — verified visually.
|
||||
|
||||
**Verification:**
|
||||
- Scrub 14-30s; table reveal feels paced (not too slow, not strobed); CTA holds long enough to read (~3-4s).
|
||||
- ComparisonTable cells are legible at 1920×1080 (mono font ≥ 28px).
|
||||
|
||||
- [ ] **Unit 5: Final composition wiring + render**
|
||||
|
||||
**Goal:** Wire the six scenes into the master `LaunchVideo` composition, render to MP4, verify against X upload constraints.
|
||||
|
||||
**Requirements:** R1, R2, R5
|
||||
|
||||
**Dependencies:** Units 3, 4
|
||||
|
||||
**Files:**
|
||||
- Modify: `marketing/v3.1-launch/src/LaunchVideo.tsx` (sequence all 6 scenes)
|
||||
- Modify: `marketing/v3.1-launch/README.md` (add render command + verification checklist)
|
||||
|
||||
**Approach:**
|
||||
- `LaunchVideo` is a single Composition that imports `Scene1Hook` … `Scene6CTA` and wraps each in `<Sequence from=… durationInFrames=…>` matching `lib/timing.ts`.
|
||||
- Run `npx remotion render LaunchVideo out/last30days-v3.1-launch.mp4 --codec=h264 --crf=18`.
|
||||
- Verify output: 30.0s ±0.1s, 1920×1080, file size <30MB, opens in QuickTime, plays without dropped frames.
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none — verification is the rendered MP4 itself.
|
||||
|
||||
**Verification:**
|
||||
- `ffprobe out/last30days-v3.1-launch.mp4` reports 1920×1080, 30fps, ~30.0s, h264, faststart-friendly.
|
||||
- Manual play-through end-to-end in QuickTime feels coherent and on-pace.
|
||||
- File <30MB so X upload is instant.
|
||||
|
||||
- [ ] **Unit 6: Polish pass + ship**
|
||||
|
||||
**Goal:** Watch the full render, fix obvious jank, do a second render, and stage for X posting.
|
||||
|
||||
**Requirements:** R1, R2, R3
|
||||
|
||||
**Dependencies:** Unit 5
|
||||
|
||||
**Files:**
|
||||
- Possibly modify: any scene file based on watch-through findings.
|
||||
|
||||
**Approach:**
|
||||
- Watch the rendered MP4 at full size. Note: timing felt off, transitions too fast, captions overflow, color clash, anything visibly broken.
|
||||
- Iterate: edit scene component → re-preview in Studio → re-render full MP4.
|
||||
- Cap at 2 polish passes; ship the better of the two renders.
|
||||
- Final MP4 sits at `marketing/v3.1-launch/out/last30days-v3.1-launch.mp4` ready for X upload.
|
||||
|
||||
**Test scenarios:**
|
||||
- Test expectation: none — pure subjective polish.
|
||||
|
||||
**Verification:**
|
||||
- User watches the final render and approves.
|
||||
- No glaring visual bugs (overflowing text, frozen frames, color clashes).
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
- **Interaction graph:** None — this is a standalone marketing artifact. Doesn't touch the Python engine, doesn't change any user-facing behavior.
|
||||
- **State lifecycle risks:** None.
|
||||
- **API surface parity:** N/A.
|
||||
- **Unchanged invariants:** The shipped 3.0.14 engine is untouched.
|
||||
|
||||
## Risks & Dependencies
|
||||
|
||||
| Risk | Mitigation |
|
||||
|------|------------|
|
||||
| Remotion install pulls 200MB+ of node_modules. | `marketing/v3.1-launch/node_modules/` in `.gitignore`; checked-in source stays small. |
|
||||
| Render time blows past patience (>5 min for 30s @ 1080p30). | Bun-based render or `--concurrency` flag. Default Remotion is fast enough on M-series Macs. If slow, lower preview to 720p, render final at 1080p. |
|
||||
| Captions overflow the 1920px width on certain fonts. | Use a known mono font with a measured per-character width; cap caption lines at 36 chars. |
|
||||
| The "fan-out" visual in Scene 3 looks confusing instead of magical. | Polish pass (Unit 6) is the safety net; if still bad, fall back to a simpler "1 → 3 panes wipe" instead of typed split. |
|
||||
| File size >30MB hits X upload friction. | Use `--crf=18` (high quality, reasonable size); fall back to `--crf=23` if over. 30s @ 1080p30 H.264 is normally 5-15MB. |
|
||||
|
||||
## Documentation / Operational Notes
|
||||
|
||||
- README at `marketing/v3.1-launch/README.md` documents preview / render commands.
|
||||
- After render, the MP4 is uploaded directly to X. Tweet copy is the user's call (this plan stops at the rendered file).
|
||||
|
||||
## Sources & References
|
||||
|
||||
- Related code: `scripts/lib/render.py` (`_render_comparison_scaffold` is the visual model for Scene 4); `SKILL.md` Competitor mode section (the narrative source).
|
||||
- Related PRs: #308, #311, #312 (the 3.0.11 → 3.0.14 release bundle this video markets as "v3.1").
|
||||
- External docs: https://www.remotion.dev/docs/ (Remotion 4.x API).
|
||||
- X video specs: https://help.x.com/en/using-x/twitter-videos.
|
||||
@@ -1,112 +0,0 @@
|
||||
# v3.0.9 - The Self-Debug Release
|
||||
|
||||
## Highlights
|
||||
|
||||
**v3.0.9 is live.** New user-facing capabilities, broader cross-platform support, and a skill that now runs reliably on Claude Code, Codex, Hermes, Gemini, claude.ai, and OpenClaw. The headline fix: the engine refuses "birthday gift for 40 year old" style queries with a clarifying question instead of 5 minutes of junk output. The headline feature: TikTok and YouTube top comments now render alongside Reddit's, so the most-engaged voice from every source makes it into the synthesis.
|
||||
|
||||
**The label - "The Self-Debug Release":** I handed 5 separate Opus 4.7 instances their own failed outputs and asked them to debug themselves. Three converged on "SKILL.md is too big and the LAWs are too deep." Two converged on "the engine should refuse demographic-shopping queries outright" and "the WebSearch Sources reminder is overriding LAW 1." I copy-pasted their diagnoses into code. Validation: 5/5 canonical compliance on the topics that had failed.
|
||||
|
||||
## New capabilities
|
||||
|
||||
- **TikTok and YouTube top comments render alongside Reddit's.** PR [#260](https://github.com/mvanhorn/last30days-skill/pull/260) made the top-engagement comment from each TikTok video and YouTube video first-class in the output - same prominent `💬 Top comment` treatment Reddit's top comment already got. This is the biggest user-facing output change since 3.0.0 and it was never announced. The community inspiration trace: @uppinote20's original push for richer Reddit comments ([PR #143](https://github.com/mvanhorn/last30days-skill/pull/143)) seeded the pattern; this PR generalized it across TikTok and YouTube. PR [#265](https://github.com/mvanhorn/last30days-skill/pull/265) followed up by fixing the ScrapeCreators `url=` param + new response shape for YouTube comments/transcripts so the enrichment actually works.
|
||||
|
||||
- **last30days runs on Hermes AI Agent now.** @stephenmcconnachie's PR ([#228](https://github.com/mvanhorn/last30days-skill/pull/228)) added Hermes as a first-class deploy target. `scripts/sync.sh` detects `~/.hermes/skills/research` and deploys the full skill (SKILL.md, scripts, lib modules, fixtures) to Hermes's skills directory alongside Claude Code and Codex. This is one of the biggest surface-area expansions in v3 - last30days is now usable inside the Hermes agent's research workflows without any manual wiring.
|
||||
|
||||
- **Multi-key SCRAPECREATORS_API_KEY rotation.** @zaydiscold's PR ([#268](https://github.com/mvanhorn/last30days-skill/pull/268)) added automatic key rotation. Set `SCRAPECREATORS_API_KEY_1`, `SCRAPECREATORS_API_KEY_2`, etc. and the engine rotates when a key hits rate limits instead of failing the whole run. For power users running daily queries, this is the difference between rate-limit 429s and zero-touch reliability.
|
||||
|
||||
- **The skill works on Windows now.** @Chelebii's PR ([#227](https://github.com/mvanhorn/last30days-skill/pull/227)) stabilized the vendored Bird X search client on Windows. Previously the bundled X backend had subtle runtime issues on Windows terminals; now it runs clean. Pair this with @Gujiassh's UTF-8 encoding fix ([#225](https://github.com/mvanhorn/last30days-skill/pull/225)) for saved output and Windows users get the full v3 experience without workarounds.
|
||||
|
||||
- **Linux permission checks stopped false-warning.** @george231224's PR ([#216](https://github.com/mvanhorn/last30days-skill/pull/216)) fixed `check_perms` on Linux by preferring GNU stat's syntax over the BSD stat that the skill was calling. Linux users were getting spurious permission warnings on `.env` files that were already correctly 600-chmod'd. Now the check matches reality.
|
||||
|
||||
- **Gemini CLI got a first-class install path.** @hnshah's docs PR ([#224](https://github.com/mvanhorn/last30days-skill/pull/224)) added the Gemini CLI install note and workaround for a rough edge in the Gemini skill loader. Gemini users now have a one-paragraph install flow in the README instead of having to reverse-engineer the plugin layout.
|
||||
|
||||
- **Offline quality evaluation.** @j-sperling's PR ([#233](https://github.com/mvanhorn/last30days-skill/pull/233)) added `eval_topics.json` as a fixture. Contributors and I can now run quality-regression checks on synthesis output without burning live API credits. This is the scaffolding that made the plan 015 validation gate affordable - without eval fixtures, testing 5/5 canonical compliance on every release would cost real money every time. Ships as contributor infrastructure but shows up as stability for end users.
|
||||
|
||||
- **Reddit client got a cleaner HTTP layer.** @iliaal shipped three architecture PRs back-to-back ([#207](https://github.com/mvanhorn/last30days-skill/pull/207), [#208](https://github.com/mvanhorn/last30days-skill/pull/208), [#209](https://github.com/mvanhorn/last30days-skill/pull/209)) that consolidated Reddit's HTTP handling into `http.get(params=...)`, rejected garbage input in `_parse_date`, and unified `_sc_headers` into `http.scrapecreators_headers`. End-user benefit: fewer flaky timeouts, fewer "weird parse error" crashes, a codebase that's easier for future contributors to touch without breaking Reddit. These aren't sexy PRs; they're the kind of refactor that prevents six future bug reports.
|
||||
|
||||
- **The `--days=N` flag keeps working.** @BryanTegomoh's PR ([#230](https://github.com/mvanhorn/last30days-skill/pull/230)) restored backcompat for the legacy `--days` alias so anyone who'd scripted against it in 2.x doesn't break on v3. Small PR, meaningful reliability gain for existing users.
|
||||
|
||||
- **INCLUDE_SOURCES has a sane default.** @hnshah's PR ([#223](https://github.com/mvanhorn/last30days-skill/pull/223)) defaulted the env var to empty string instead of unset. Missing env no longer breaks source inclusion on fresh installs.
|
||||
|
||||
- **Version metadata stays in sync.** @Gujiassh's PR ([#217](https://github.com/mvanhorn/last30days-skill/pull/217)) aligned the SKILL.md version header with the sync target version, and @shalomma's PR ([#229](https://github.com/mvanhorn/last30days-skill/pull/229)) closed the remaining drift between the SKILL.md header and plugin.json. "Which version am I actually on" is no longer an adventure.
|
||||
|
||||
- **Bird X engagement handling got hardened.** @j-sperling's PR ([#234](https://github.com/mvanhorn/last30days-skill/pull/234)) made `bird_x` skip all-None engagement dicts instead of crashing on them. Rare condition, but the kind of thing that silently kills a run on a specific topic.
|
||||
|
||||
- **Dev workflow hygiene.** @j-sperling's gitignore PR ([#232](https://github.com/mvanhorn/last30days-skill/pull/232)) dropped `.venv`, `.coverage`, `htmlcov`, and `.memsearch` from the tracked tree. Contributor quality-of-life; keeps PR diffs clean.
|
||||
|
||||
- **The skill installs to claude.ai.** PRs [#242](https://github.com/mvanhorn/last30days-skill/pull/242) and [#244](https://github.com/mvanhorn/last30days-skill/pull/244) shipped `scripts/build-skill.sh` plus the `.gitattributes` + `export-ignore` plumbing that packages last30days into a claude.ai-upload-ready `.skill` file under the 200-file cap. The skill is no longer Claude-Code-only - it installs directly on claude.ai, too. README has the upload workflow.
|
||||
|
||||
- **OpenAI Codex CLI discovers the skill natively.** PR [#219](https://github.com/mvanhorn/last30days-skill/pull/219) added `.agents/skills/last30days/SKILL.md` as a real file (not symlinked - Codex's loader skips symlinks) plus `.codex-plugin/plugin.json` as the namespace marker. The skill now shows up as `last30days:last30days` when Codex runs in a checkout. Inspired by @Jah-yee ([#153](https://github.com/mvanhorn/last30days-skill/pull/153)) and @dannyshmueli on X.
|
||||
|
||||
- **`/last30days` as a slash command.** PR [#267](https://github.com/mvanhorn/last30days-skill/pull/267) added `commands/last30days.md` so plugin users can type `/last30days <topic>` and Claude Code autocomplete prefix-matches it to the canonical `/last30days:last30days` form. No more typing the double-namespace.
|
||||
|
||||
## The self-debug technique, for anyone rebuilding this elsewhere
|
||||
|
||||
The breakthrough wasn't the individual fixes. It was the realization that instead of guessing why the model was ignoring the rules, I should ask the model. Five separate Opus 4.7 sessions debugged their own outputs:
|
||||
|
||||
- "Did you read SKILL.md?" → "I tried Read, hit the 25K token cap, and bailed instead of chunked-reading."
|
||||
- "Why the trailing Sources block?" → "The WebSearch tool's own reminder said MANDATORY. Precedence was unclear."
|
||||
- "Why the section headers?" → "I had strong priors on Peter Steinberger and wrote my thesis instead of passing through."
|
||||
- "Why the wrong file?" → "I read `.agents/skills/last30days/SKILL.md` first because it appeared in the path glob."
|
||||
|
||||
Three of the five said "move the LAWs to the top." Two said "make the engine enforce it so the model can't not comply." I shipped both. That's the whole technique: when the LLM-under-orchestration keeps breaking the contract, don't argue with it - ask it to debug itself, and build structural enforcement around whatever it names as the root cause.
|
||||
|
||||
## Thank you
|
||||
|
||||
**Community PR authors since v3.0.0:**
|
||||
- @j-sperling - v3 engine architecture, eval fixtures, gitignore hygiene, Bird X hardening ([#232](https://github.com/mvanhorn/last30days-skill/pull/232), [#233](https://github.com/mvanhorn/last30days-skill/pull/233), [#234](https://github.com/mvanhorn/last30days-skill/pull/234))
|
||||
- @stephenmcconnachie - Hermes AI Agent support ([#228](https://github.com/mvanhorn/last30days-skill/pull/228))
|
||||
- @zaydiscold - Multi-key SCRAPECREATORS rotation ([#268](https://github.com/mvanhorn/last30days-skill/pull/268))
|
||||
- @iliaal - Reddit HTTP helper + GitHub date parsing + ScrapeCreators header consolidation ([#207](https://github.com/mvanhorn/last30days-skill/pull/207), [#208](https://github.com/mvanhorn/last30days-skill/pull/208), [#209](https://github.com/mvanhorn/last30days-skill/pull/209))
|
||||
- @Chelebii - Windows Bird X stability ([#227](https://github.com/mvanhorn/last30days-skill/pull/227))
|
||||
- @george231224 - Linux check_perms stat ([#216](https://github.com/mvanhorn/last30days-skill/pull/216))
|
||||
- @Gujiassh - UTF-8 saved output + version metadata alignment ([#217](https://github.com/mvanhorn/last30days-skill/pull/217), [#225](https://github.com/mvanhorn/last30days-skill/pull/225))
|
||||
- @hnshah - INCLUDE_SOURCES default + Gemini install docs ([#223](https://github.com/mvanhorn/last30days-skill/pull/223), [#224](https://github.com/mvanhorn/last30days-skill/pull/224))
|
||||
- @shalomma - SKILL.md v3.0.0 version header ([#229](https://github.com/mvanhorn/last30days-skill/pull/229))
|
||||
- @BryanTegomoh - --days alias backcompat ([#230](https://github.com/mvanhorn/last30days-skill/pull/230))
|
||||
|
||||
**v3 roadmap contributors (issues and PRs that shaped the v3 feature set):**
|
||||
- @uppinote20 - rich Reddit comments ([#143](https://github.com/mvanhorn/last30days-skill/pull/143))
|
||||
- @zerone0x - GitHub as a first-class source ([#134](https://github.com/mvanhorn/last30days-skill/issues/134), [#136](https://github.com/mvanhorn/last30days-skill/pull/136))
|
||||
- @thinkun - Reddit enrichment timeout handling ([#116](https://github.com/mvanhorn/last30days-skill/pull/116))
|
||||
- @thomasmktong - pure-Python Reddit fallback ([#124](https://github.com/mvanhorn/last30days-skill/pull/124))
|
||||
- @fanispoulinakisai-boop - Reddit timeout report ([#100](https://github.com/mvanhorn/last30days-skill/issues/100))
|
||||
- @pejmanjohn - plugin directory naming ([#99](https://github.com/mvanhorn/last30days-skill/issues/99), [#78](https://github.com/mvanhorn/last30days-skill/issues/78))
|
||||
- @zl190 - HN trending merge ([#115](https://github.com/mvanhorn/last30days-skill/pull/115))
|
||||
- @hnshah - Watchlist features ([#84](https://github.com/mvanhorn/last30days-skill/pull/84), [#85](https://github.com/mvanhorn/last30days-skill/pull/85), [#86](https://github.com/mvanhorn/last30days-skill/pull/86))
|
||||
- @Jah-yee, @dannyshmueli - Codex CLI discovery
|
||||
- @Cody-Coyote - marketplace validation bug report ([#204](https://github.com/mvanhorn/last30days-skill/issues/204))
|
||||
|
||||
**The five Opus 4.7 instances that debugged their own failures on v3.0.7 and v3.0.8 and converged on the fixes.** The convergence was the breakthrough; this release is their diagnosis in code.
|
||||
|
||||
## Install / Update
|
||||
|
||||
```
|
||||
/plugin marketplace add mvanhorn/last30days-skill
|
||||
/plugin install last30days@last30days-skill
|
||||
```
|
||||
|
||||
Or if already installed:
|
||||
|
||||
```
|
||||
/plugin update last30days
|
||||
/reload-plugins
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
```
|
||||
cat ~/.claude/plugins/cache/last30days-skill/last30days/*/.claude-plugin/plugin.json | grep version
|
||||
```
|
||||
|
||||
Should print `"version": "3.0.9"`.
|
||||
|
||||
## Smoke test
|
||||
|
||||
```
|
||||
/last30days birthday gift for 40 year old
|
||||
```
|
||||
|
||||
Should ask a clarifying question before running. If it runs the engine anyway, the cache is stale - repeat the plugin update.
|
||||
|
||||
**Full Changelog:** https://github.com/mvanhorn/last30days-skill/compare/v3.0.5...v3.0.9
|
||||
@@ -1,42 +0,0 @@
|
||||
[
|
||||
{
|
||||
"topic": "OpenClaw vs NanoClaw vs ZeroClaw",
|
||||
"query_type": "comparison",
|
||||
"rationale": "Multi-entity extraction, 3-way split across AI agent frameworks."
|
||||
},
|
||||
{
|
||||
"topic": "how to set up a GLP-1 supplement routine",
|
||||
"query_type": "how_to",
|
||||
"rationale": "Trending health topic. Tests non-tech how_to."
|
||||
},
|
||||
{
|
||||
"topic": "2026 March Madness",
|
||||
"query_type": "breaking_news",
|
||||
"rationale": "Live sporting event. Tests broad breaking news recall."
|
||||
},
|
||||
{
|
||||
"topic": "best budget noise cancelling headphones 2026",
|
||||
"query_type": "product",
|
||||
"rationale": "Evergreen consumer query. Tests product review aggregation."
|
||||
},
|
||||
{
|
||||
"topic": "thoughts on OpenAI Codex pricing",
|
||||
"query_type": "opinion",
|
||||
"rationale": "Active developer debate. Tests opinion mining."
|
||||
},
|
||||
{
|
||||
"topic": "odds of US recession 2026",
|
||||
"query_type": "prediction",
|
||||
"rationale": "Major macro topic. Tests prediction market + news synthesis."
|
||||
},
|
||||
{
|
||||
"topic": "what is retrieval augmented generation",
|
||||
"query_type": "concept",
|
||||
"rationale": "Widely discussed AI concept. Tests explanation quality."
|
||||
},
|
||||
{
|
||||
"topic": "Google Wiz acquisition price and timeline",
|
||||
"query_type": "factual",
|
||||
"rationale": "Completed event ($32B). Tests factual precision."
|
||||
}
|
||||
]
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "last30days-skill",
|
||||
"version": "3.0.5",
|
||||
"version": "3.0.0",
|
||||
"description": "Research a topic from the last 30 days across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web.",
|
||||
"settings": [
|
||||
{
|
||||
|
||||
@@ -12,11 +12,7 @@ check_perms() {
|
||||
local file="$1"
|
||||
if [[ ! -f "$file" ]]; then return; fi
|
||||
local perms
|
||||
# Try GNU stat first (Linux), fall back to BSD stat (macOS).
|
||||
# On Linux, `stat -f` prints filesystem info (not permissions) and exits 0,
|
||||
# so the previous BSD-first ordering left $perms as multi-line garbage on
|
||||
# every Linux session start and printed a false WARNING.
|
||||
perms=$(stat -c '%a' "$file" 2>/dev/null || stat -f '%Lp' "$file" 2>/dev/null || echo "")
|
||||
perms=$(stat -f '%Lp' "$file" 2>/dev/null || stat -c '%a' "$file" 2>/dev/null || echo "")
|
||||
if [[ -n "$perms" && "$perms" != "600" && "$perms" != "400" ]]; then
|
||||
echo "/last30days: WARNING — $file has permissions $perms (should be 600)."
|
||||
echo " Fix: chmod 600 $file"
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# /last30days v3.1 Launch Video
|
||||
|
||||
30-second Remotion-rendered MP4 announcing **v3.1: Competitors mode** for posting on X.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd marketing/v3.1-launch
|
||||
npm install # one-time, ~200MB of node_modules
|
||||
npm run preview # opens Remotion Studio at localhost:3000 to scrub frames
|
||||
npm run render # writes out/last30days-v3.1-launch.mp4 (high quality, CRF 18)
|
||||
npm run render:fast # writes a CRF 23 preview for fast iteration
|
||||
```
|
||||
|
||||
## Specs
|
||||
|
||||
- 1920×1080, 30fps, 30 seconds (900 frames)
|
||||
- H.264 / MP4
|
||||
- Silent (autoplay-muted-friendly; captions baked in)
|
||||
- Marketing label `v3.1` (engine code version stays 3.0.14)
|
||||
|
||||
## Scene timing (single source of truth: `src/lib/timing.ts`)
|
||||
|
||||
| Scene | Frames | Time | What |
|
||||
|-------|--------|------|------|
|
||||
| 1. Hook | 0-89 | 0.0-3.0s | Badge animates in + "What if one search ran 3 at once?" |
|
||||
| 2. Old way | 90-239 | 3.0-8.0s | Single terminal: `/last30days OpenAI` |
|
||||
| 3. Fan-out | 240-419 | 8.0-14.0s | `--competitors` types in → splits into 3 panes |
|
||||
| 4. Comparison | 420-629 | 14.0-21.0s | 3 panes collapse into Head-to-Head table |
|
||||
| 5. How | 630-779 | 21.0-26.0s | 3-line text card |
|
||||
| 6. CTA | 780-899 | 26.0-30.0s | Install command + repo URL |
|
||||
|
||||
Edit `src/lib/timing.ts` to retime scenes; the `LaunchVideo` composition reads from there.
|
||||
|
||||
## Output
|
||||
|
||||
Rendered MP4 lives at `out/last30days-v3.1-launch.mp4` (gitignored). Upload directly to X.
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "last30days-v3-1-launch-video",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "30s Remotion launch video for /last30days v3.1 (competitors mode).",
|
||||
"scripts": {
|
||||
"preview": "remotion studio src/index.ts",
|
||||
"render": "remotion render src/index.ts LaunchVideo out/last30days-v3.1-launch.mp4 --codec=h264 --crf=18",
|
||||
"render:fast": "remotion render src/index.ts LaunchVideo out/last30days-v3.1-launch-preview.mp4 --codec=h264 --crf=23"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"remotion": "4.0.250",
|
||||
"@remotion/cli": "4.0.250",
|
||||
"@remotion/google-fonts": "4.0.250"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "19.0.0",
|
||||
"@types/node": "22.10.0",
|
||||
"typescript": "5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { Config } from "@remotion/cli/config";
|
||||
|
||||
Config.setVideoImageFormat("jpeg");
|
||||
Config.setOverwriteOutput(true);
|
||||
Config.setConcurrency(null);
|
||||
Config.setCodec("h264");
|
||||
@@ -1,35 +0,0 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, Sequence } from "remotion";
|
||||
import { SCENES } from "./lib/timing";
|
||||
import { Scene1Hook } from "./scenes/Scene1Hook";
|
||||
import { Scene2OldWay } from "./scenes/Scene2OldWay";
|
||||
import { Scene3FanOut } from "./scenes/Scene3FanOut";
|
||||
import { Scene4Comparison } from "./scenes/Scene4Comparison";
|
||||
import { Scene5HowItWorks } from "./scenes/Scene5HowItWorks";
|
||||
import { Scene6CTA } from "./scenes/Scene6CTA";
|
||||
import { COLORS } from "./lib/colors";
|
||||
|
||||
export const LaunchVideo: React.FC = () => {
|
||||
return (
|
||||
<AbsoluteFill style={{ background: COLORS.bgDeep }}>
|
||||
<Sequence from={SCENES.hook.from} durationInFrames={SCENES.hook.durationInFrames}>
|
||||
<Scene1Hook />
|
||||
</Sequence>
|
||||
<Sequence from={SCENES.oldWay.from} durationInFrames={SCENES.oldWay.durationInFrames}>
|
||||
<Scene2OldWay />
|
||||
</Sequence>
|
||||
<Sequence from={SCENES.fanOut.from} durationInFrames={SCENES.fanOut.durationInFrames}>
|
||||
<Scene3FanOut />
|
||||
</Sequence>
|
||||
<Sequence from={SCENES.comparison.from} durationInFrames={SCENES.comparison.durationInFrames}>
|
||||
<Scene4Comparison />
|
||||
</Sequence>
|
||||
<Sequence from={SCENES.howItWorks.from} durationInFrames={SCENES.howItWorks.durationInFrames}>
|
||||
<Scene5HowItWorks />
|
||||
</Sequence>
|
||||
<Sequence from={SCENES.cta.from} durationInFrames={SCENES.cta.durationInFrames}>
|
||||
<Scene6CTA />
|
||||
</Sequence>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import React from "react";
|
||||
import { Composition } from "remotion";
|
||||
import { LaunchVideo } from "./LaunchVideo";
|
||||
import { FPS, TOTAL_FRAMES } from "./lib/timing";
|
||||
|
||||
export const RemotionRoot: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<Composition
|
||||
id="LaunchVideo"
|
||||
component={LaunchVideo}
|
||||
durationInFrames={TOTAL_FRAMES}
|
||||
fps={FPS}
|
||||
width={1920}
|
||||
height={1080}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
import React from "react";
|
||||
import { spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { COLORS, FONT_MONO } from "../lib/colors";
|
||||
|
||||
type Props = {
|
||||
startFrame?: number;
|
||||
size?: "small" | "large";
|
||||
};
|
||||
|
||||
export const BadgeBar: React.FC<Props> = ({ startFrame = 0, size = "large" }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const elapsed = Math.max(0, frame - startFrame);
|
||||
|
||||
const scale = spring({
|
||||
frame: elapsed,
|
||||
fps,
|
||||
config: { damping: 12, stiffness: 90 },
|
||||
from: 0.85,
|
||||
to: 1,
|
||||
});
|
||||
const opacity = spring({
|
||||
frame: elapsed,
|
||||
fps,
|
||||
config: { damping: 20 },
|
||||
from: 0,
|
||||
to: 1,
|
||||
});
|
||||
|
||||
const fontSize = size === "large" ? 56 : 28;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
transform: `scale(${scale})`,
|
||||
opacity,
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize,
|
||||
color: COLORS.fgPrimary,
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: fontSize * 1.1, marginRight: 16 }}>🌐</span>
|
||||
<span>last30days</span>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 14,
|
||||
color: COLORS.accentCyan,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
v3.1
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 16,
|
||||
color: COLORS.fgDim,
|
||||
fontSize: fontSize * 0.55,
|
||||
}}
|
||||
>
|
||||
· synced 2026-04-22
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,135 +0,0 @@
|
||||
import React from "react";
|
||||
import { interpolate, useCurrentFrame } from "remotion";
|
||||
import { COLORS, FONT_MONO } from "../lib/colors";
|
||||
|
||||
type Row = {
|
||||
dimension: string;
|
||||
cells: [string, string, string];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
startFrame: number;
|
||||
entities: [string, string, string];
|
||||
rows: Row[];
|
||||
rowStaggerFrames?: number;
|
||||
};
|
||||
|
||||
export const ComparisonTable: React.FC<Props> = ({
|
||||
startFrame,
|
||||
entities,
|
||||
rows,
|
||||
rowStaggerFrames = 18,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
const headerOpacity = interpolate(
|
||||
frame - startFrame,
|
||||
[0, 12],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||
);
|
||||
|
||||
const colTemplate = "1.4fr 1fr 1fr 1fr";
|
||||
const cellPad = "16px 22px";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
background: COLORS.bgPanel,
|
||||
borderRadius: 16,
|
||||
border: `1px solid ${COLORS.border}`,
|
||||
overflow: "hidden",
|
||||
fontFamily: FONT_MONO,
|
||||
boxShadow: "0 24px 60px rgba(0,0,0,0.6)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: colTemplate,
|
||||
background: COLORS.bgPanelSoft,
|
||||
borderBottom: `1px solid ${COLORS.border}`,
|
||||
opacity: headerOpacity,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: cellPad,
|
||||
color: COLORS.fgMuted,
|
||||
fontSize: 22,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
Dimension
|
||||
</div>
|
||||
{entities.map((entity, idx) => (
|
||||
<div
|
||||
key={entity}
|
||||
style={{
|
||||
padding: cellPad,
|
||||
color: idx === 0 ? COLORS.accentCyan : COLORS.fgPrimary,
|
||||
fontSize: 26,
|
||||
fontWeight: 600,
|
||||
borderLeft: `1px solid ${COLORS.border}`,
|
||||
}}
|
||||
>
|
||||
{entity}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{rows.map((row, idx) => {
|
||||
const rowStart = startFrame + 12 + idx * rowStaggerFrames;
|
||||
const rowOpacity = interpolate(
|
||||
frame - rowStart,
|
||||
[0, 14],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||
);
|
||||
const rowSlide = interpolate(
|
||||
frame - rowStart,
|
||||
[0, 14],
|
||||
[12, 0],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={row.dimension}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: colTemplate,
|
||||
borderBottom:
|
||||
idx === rows.length - 1 ? "none" : `1px solid ${COLORS.border}`,
|
||||
opacity: rowOpacity,
|
||||
transform: `translateY(${rowSlide}px)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: cellPad,
|
||||
color: COLORS.fgMuted,
|
||||
fontSize: 22,
|
||||
}}
|
||||
>
|
||||
{row.dimension}
|
||||
</div>
|
||||
{row.cells.map((cell, cellIdx) => (
|
||||
<div
|
||||
key={cellIdx}
|
||||
style={{
|
||||
padding: cellPad,
|
||||
color: COLORS.fgPrimary,
|
||||
fontSize: 22,
|
||||
borderLeft: `1px solid ${COLORS.border}`,
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
{cell}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
import React from "react";
|
||||
import { COLORS, FONT_MONO } from "../lib/colors";
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
children?: React.ReactNode;
|
||||
glow?: boolean;
|
||||
};
|
||||
|
||||
export const TerminalWindow: React.FC<Props> = ({
|
||||
title = "/last30days",
|
||||
width = "100%",
|
||||
height = "100%",
|
||||
children,
|
||||
glow = false,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
background: COLORS.bgPanel,
|
||||
borderRadius: 16,
|
||||
border: `1px solid ${COLORS.border}`,
|
||||
boxShadow: glow
|
||||
? `0 0 60px ${COLORS.accentCyan}33, 0 24px 60px rgba(0,0,0,0.6)`
|
||||
: "0 24px 60px rgba(0,0,0,0.6)",
|
||||
overflow: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
fontFamily: FONT_MONO,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 36,
|
||||
background: COLORS.bgPanelSoft,
|
||||
borderBottom: `1px solid ${COLORS.border}`,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
padding: "0 16px",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 12,
|
||||
background: COLORS.trafficRed,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 12,
|
||||
background: COLORS.trafficYellow,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 12,
|
||||
background: COLORS.trafficGreen,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 16,
|
||||
color: COLORS.fgMuted,
|
||||
fontSize: 14,
|
||||
fontFamily: FONT_MONO,
|
||||
letterSpacing: 0.5,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "20px 28px",
|
||||
color: COLORS.fgPrimary,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
import React from "react";
|
||||
import { interpolate, useCurrentFrame } from "remotion";
|
||||
import { COLORS, FONT_MONO } from "../lib/colors";
|
||||
|
||||
type Props = {
|
||||
text: string;
|
||||
startFrame: number;
|
||||
charsPerSec?: number;
|
||||
fontSize?: number;
|
||||
color?: string;
|
||||
prefix?: string;
|
||||
prefixColor?: string;
|
||||
showCursor?: boolean;
|
||||
fps?: number;
|
||||
};
|
||||
|
||||
export const TypedLine: React.FC<Props> = ({
|
||||
text,
|
||||
startFrame,
|
||||
charsPerSec = 28,
|
||||
fontSize = 32,
|
||||
color = COLORS.fgPrimary,
|
||||
prefix,
|
||||
prefixColor = COLORS.accentGreen,
|
||||
showCursor = true,
|
||||
fps = 30,
|
||||
}) => {
|
||||
const frame = useCurrentFrame();
|
||||
const elapsed = Math.max(0, frame - startFrame);
|
||||
const totalChars = text.length;
|
||||
const lengthFrames = Math.ceil((totalChars / charsPerSec) * fps);
|
||||
const visibleChars = Math.round(
|
||||
interpolate(elapsed, [0, lengthFrames], [0, totalChars], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
}),
|
||||
);
|
||||
const visible = text.slice(0, visibleChars);
|
||||
const done = visibleChars >= totalChars;
|
||||
const cursorOn = showCursor && Math.floor(frame / 15) % 2 === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize,
|
||||
color,
|
||||
whiteSpace: "pre",
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{prefix ? (
|
||||
<span style={{ color: prefixColor, marginRight: 12 }}>{prefix}</span>
|
||||
) : null}
|
||||
<span>{visible}</span>
|
||||
{(!done || cursorOn) && (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: fontSize * 0.55,
|
||||
height: fontSize * 0.95,
|
||||
background: color,
|
||||
verticalAlign: "text-bottom",
|
||||
marginLeft: 2,
|
||||
opacity: cursorOn ? 0.85 : 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
import { registerRoot } from "remotion";
|
||||
import { RemotionRoot } from "./Root";
|
||||
|
||||
registerRoot(RemotionRoot);
|
||||
@@ -1,20 +0,0 @@
|
||||
export const COLORS = {
|
||||
bgDeep: "#0a0e14",
|
||||
bgPanel: "#11161d",
|
||||
bgPanelSoft: "#161c25",
|
||||
fgPrimary: "#e6e6e6",
|
||||
fgMuted: "#8a93a3",
|
||||
fgDim: "#5b6573",
|
||||
border: "#2a3340",
|
||||
accentCyan: "#36d6f7",
|
||||
accentMagenta: "#ff55a3",
|
||||
accentGreen: "#5fff9f",
|
||||
accentAmber: "#ffc857",
|
||||
trafficRed: "#ff5f57",
|
||||
trafficYellow: "#febc2e",
|
||||
trafficGreen: "#28c840",
|
||||
} as const;
|
||||
|
||||
export const FONT_MONO = '"JetBrains Mono", "SF Mono", "Menlo", monospace';
|
||||
export const FONT_SANS =
|
||||
'"Inter", "SF Pro Display", -apple-system, BlinkMacSystemFont, sans-serif';
|
||||
@@ -1,13 +0,0 @@
|
||||
// Single source of truth for scene frame ranges.
|
||||
// 30fps × 30s = 900 frames total.
|
||||
export const FPS = 30;
|
||||
export const TOTAL_FRAMES = 900;
|
||||
|
||||
export const SCENES = {
|
||||
hook: { from: 0, durationInFrames: 90 },
|
||||
oldWay: { from: 90, durationInFrames: 150 },
|
||||
fanOut: { from: 240, durationInFrames: 180 },
|
||||
comparison: { from: 420, durationInFrames: 210 },
|
||||
howItWorks: { from: 630, durationInFrames: 150 },
|
||||
cta: { from: 780, durationInFrames: 120 },
|
||||
} as const;
|
||||
@@ -1,61 +0,0 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { BadgeBar } from "../components/BadgeBar";
|
||||
import { COLORS, FONT_SANS } from "../lib/colors";
|
||||
|
||||
export const Scene1Hook: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const captionOpacity = interpolate(frame, [20, 35, 75, 90], [0, 1, 1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const captionLift = spring({
|
||||
frame: frame - 20,
|
||||
fps,
|
||||
config: { damping: 15, stiffness: 70 },
|
||||
from: 16,
|
||||
to: 0,
|
||||
});
|
||||
|
||||
const badgeFadeOut = interpolate(frame, [70, 90], [1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(circle at 50% 40%, ${COLORS.bgPanelSoft} 0%, ${COLORS.bgDeep} 60%)`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 64,
|
||||
}}
|
||||
>
|
||||
<div style={{ opacity: badgeFadeOut }}>
|
||||
<BadgeBar />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
opacity: captionOpacity,
|
||||
transform: `translateY(${captionLift}px)`,
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: 96,
|
||||
fontWeight: 600,
|
||||
color: COLORS.fgPrimary,
|
||||
textAlign: "center",
|
||||
letterSpacing: -1.5,
|
||||
lineHeight: 1.1,
|
||||
maxWidth: 1400,
|
||||
}}
|
||||
>
|
||||
What if one search
|
||||
<br />
|
||||
ran <span style={{ color: COLORS.accentCyan }}>3 at once?</span>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
|
||||
import { TerminalWindow } from "../components/TerminalWindow";
|
||||
import { TypedLine } from "../components/TypedLine";
|
||||
import { COLORS, FONT_MONO, FONT_SANS } from "../lib/colors";
|
||||
|
||||
export const Scene2OldWay: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
const enter = interpolate(frame, [0, 20], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const slide = interpolate(frame, [0, 20], [40, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Result card fades in after type completes (~70 frames)
|
||||
const resultFade = interpolate(frame, [70, 95], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Caption appears late
|
||||
const captionFade = interpolate(frame, [110, 130, 150], [0, 1, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: COLORS.bgDeep,
|
||||
padding: 80,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 1400,
|
||||
height: 520,
|
||||
opacity: enter,
|
||||
transform: `translateY(${slide}px)`,
|
||||
}}
|
||||
>
|
||||
<TerminalWindow title="bash">
|
||||
<TypedLine
|
||||
text="/last30days OpenAI"
|
||||
startFrame={20}
|
||||
prefix="$"
|
||||
fontSize={42}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
opacity: resultFade,
|
||||
marginTop: 36,
|
||||
padding: "20px 24px",
|
||||
background: COLORS.bgPanelSoft,
|
||||
borderRadius: 12,
|
||||
border: `1px solid ${COLORS.border}`,
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 26,
|
||||
color: COLORS.fgPrimary,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
<div style={{ color: COLORS.accentGreen }}>
|
||||
✅ All agents reported back!
|
||||
</div>
|
||||
<div style={{ color: COLORS.fgMuted, marginTop: 6 }}>
|
||||
├─ 🟠 Reddit: 14 threads
|
||||
</div>
|
||||
<div style={{ color: COLORS.fgMuted }}>
|
||||
├─ 🔵 X: 22 posts
|
||||
</div>
|
||||
<div style={{ color: COLORS.fgMuted }}>
|
||||
└─ 🟡 HN: 1 story
|
||||
</div>
|
||||
</div>
|
||||
</TerminalWindow>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
opacity: captionFade,
|
||||
marginTop: 60,
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: 38,
|
||||
color: COLORS.fgMuted,
|
||||
}}
|
||||
>
|
||||
The old way: <span style={{ color: COLORS.fgPrimary }}>one topic.</span>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -1,221 +0,0 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { TerminalWindow } from "../components/TerminalWindow";
|
||||
import { TypedLine } from "../components/TypedLine";
|
||||
import { COLORS, FONT_MONO, FONT_SANS } from "../lib/colors";
|
||||
|
||||
const PROGRESS_LINES = [
|
||||
{ source: "Reddit", color: "#ff6a3d" },
|
||||
{ source: "X", color: "#36d6f7" },
|
||||
{ source: "YouTube", color: "#ff5757" },
|
||||
{ source: "TikTok", color: "#5fff9f" },
|
||||
{ source: "Instagram", color: "#ff55a3" },
|
||||
];
|
||||
|
||||
const ENTITIES: { label: string; tag: string; accent: string }[] = [
|
||||
{ label: "OpenAI", tag: "$ /last30days OpenAI", accent: COLORS.accentCyan },
|
||||
{ label: "Anthropic", tag: "$ /last30days Anthropic", accent: COLORS.accentMagenta },
|
||||
{ label: "xAI", tag: "$ /last30days xAI", accent: COLORS.accentAmber },
|
||||
];
|
||||
|
||||
const FanPane: React.FC<{
|
||||
label: string;
|
||||
tag: string;
|
||||
accent: string;
|
||||
panelStart: number;
|
||||
}> = ({ label, tag, accent, panelStart }) => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
const localFrame = Math.max(0, frame - panelStart);
|
||||
|
||||
const enter = spring({
|
||||
frame: localFrame,
|
||||
fps,
|
||||
config: { damping: 18, stiffness: 80 },
|
||||
from: 0,
|
||||
to: 1,
|
||||
});
|
||||
const slide = interpolate(localFrame, [0, 20], [40, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
opacity: enter,
|
||||
transform: `translateY(${slide}px)`,
|
||||
height: 480,
|
||||
}}
|
||||
>
|
||||
<TerminalWindow title={label} glow>
|
||||
<div
|
||||
style={{
|
||||
color: accent,
|
||||
fontSize: 18,
|
||||
fontFamily: FONT_MONO,
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 22,
|
||||
color: COLORS.accentGreen,
|
||||
fontFamily: FONT_MONO,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
[Competitors] running...
|
||||
</div>
|
||||
{PROGRESS_LINES.map((line, idx) => {
|
||||
const lineStart = panelStart + 16 + idx * 6;
|
||||
const lineFade = interpolate(
|
||||
frame - lineStart,
|
||||
[0, 8],
|
||||
[0, 1],
|
||||
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||
);
|
||||
// pulse the in-progress dot
|
||||
const dotOn = Math.floor((frame - lineStart) / 6) % 2 === 0;
|
||||
return (
|
||||
<div
|
||||
key={line.source}
|
||||
style={{
|
||||
opacity: lineFade,
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 20,
|
||||
color: COLORS.fgMuted,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 10,
|
||||
background: dotOn ? line.color : COLORS.bgPanelSoft,
|
||||
marginRight: 12,
|
||||
boxShadow: dotOn ? `0 0 10px ${line.color}` : "none",
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: line.color, marginRight: 8 }}>
|
||||
►
|
||||
</span>
|
||||
<span>{line.source}</span>
|
||||
<span style={{ marginLeft: "auto", color: COLORS.fgDim }}>
|
||||
{dotOn ? "..." : "·"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</TerminalWindow>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Scene3FanOut: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
// Phase 1 (0-30 frames): single terminal types --competitors flag
|
||||
// Phase 2 (30+): split into 3 panes
|
||||
const splitProgress = interpolate(frame, [30, 50], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
const singleOpacity = interpolate(frame, [0, 8, 30, 45], [0, 1, 1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
// Caption fades in shortly after panes settle so it has time to read.
|
||||
const captionFade = interpolate(frame, [60, 80], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: COLORS.bgDeep,
|
||||
padding: 60,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
opacity: captionFade,
|
||||
textAlign: "center",
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: 42,
|
||||
color: COLORS.fgPrimary,
|
||||
marginBottom: 32,
|
||||
}}
|
||||
>
|
||||
Now it discovers competitors
|
||||
<br />
|
||||
<span style={{ color: COLORS.accentCyan }}>and runs all 3.</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Single terminal during phase 1, fades out as panes appear */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
opacity: singleOpacity,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 1200, height: 380 }}>
|
||||
<TerminalWindow title="bash">
|
||||
<TypedLine
|
||||
text="/last30days OpenAI --competitors"
|
||||
startFrame={0}
|
||||
prefix="$"
|
||||
fontSize={42}
|
||||
/>
|
||||
</TerminalWindow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Three panes fade in starting frame ~30 */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 24,
|
||||
opacity: splitProgress,
|
||||
}}
|
||||
>
|
||||
{ENTITIES.map((entity, idx) => (
|
||||
<FanPane
|
||||
key={entity.label}
|
||||
label={entity.label}
|
||||
tag={entity.tag}
|
||||
accent={entity.accent}
|
||||
panelStart={45 + idx * 8}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
|
||||
import { ComparisonTable } from "../components/ComparisonTable";
|
||||
import { COLORS, FONT_SANS } from "../lib/colors";
|
||||
|
||||
const ROWS = [
|
||||
{
|
||||
dimension: "What it is",
|
||||
cells: [
|
||||
"GPT-5 leader, Plus + API",
|
||||
"Claude 4, safety-first",
|
||||
"Grok, X-native, fast",
|
||||
] as [string, string, string],
|
||||
},
|
||||
{
|
||||
dimension: "30-day momentum",
|
||||
cells: [
|
||||
"GPT-5 launch wave",
|
||||
"Claude 4.7 1M context",
|
||||
"Grok 5 reveal",
|
||||
] as [string, string, string],
|
||||
},
|
||||
{
|
||||
dimension: "Community vibe",
|
||||
cells: [
|
||||
"Defensive but deep",
|
||||
"Quiet, devs-only",
|
||||
"Loud, meme-rich",
|
||||
] as [string, string, string],
|
||||
},
|
||||
{
|
||||
dimension: "Best for",
|
||||
cells: [
|
||||
"Mainstream + tools",
|
||||
"Long-context coding",
|
||||
"Live X intel",
|
||||
] as [string, string, string],
|
||||
},
|
||||
];
|
||||
|
||||
export const Scene4Comparison: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
const captionFade = interpolate(frame, [0, 12, 180, 210], [0, 1, 1, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const captionLift = interpolate(frame, [0, 14], [16, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
const tableFade = interpolate(frame, [16, 32], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: COLORS.bgDeep,
|
||||
padding: "48px 80px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
opacity: captionFade,
|
||||
transform: `translateY(${captionLift}px)`,
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: 38,
|
||||
color: COLORS.fgMuted,
|
||||
textAlign: "center",
|
||||
marginBottom: 36,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: COLORS.accentCyan, fontWeight: 600 }}>
|
||||
3 full passes.
|
||||
</span>
|
||||
<span style={{ marginLeft: 18, color: COLORS.accentMagenta, fontWeight: 600 }}>
|
||||
3 save files.
|
||||
</span>
|
||||
<span style={{ marginLeft: 18, color: COLORS.fgPrimary, fontWeight: 600 }}>
|
||||
1 comparison.
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 1640,
|
||||
opacity: tableFade,
|
||||
}}
|
||||
>
|
||||
<ComparisonTable
|
||||
startFrame={20}
|
||||
entities={["OpenAI", "Anthropic", "xAI"]}
|
||||
rows={ROWS}
|
||||
rowStaggerFrames={22}
|
||||
/>
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, interpolate, useCurrentFrame } from "remotion";
|
||||
import { COLORS, FONT_SANS } from "../lib/colors";
|
||||
|
||||
const LINES = [
|
||||
{ text: "You pick the topic.", color: COLORS.fgPrimary },
|
||||
{ text: "The agent picks the peers.", color: COLORS.accentCyan },
|
||||
{ text: "The engine fans out.", color: COLORS.accentMagenta },
|
||||
];
|
||||
|
||||
export const Scene5HowItWorks: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(circle at 50% 60%, ${COLORS.bgPanelSoft} 0%, ${COLORS.bgDeep} 70%)`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 48,
|
||||
}}
|
||||
>
|
||||
{LINES.map((line, idx) => {
|
||||
const start = 10 + idx * 28;
|
||||
const fade = interpolate(frame, [start, start + 14], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
const slide = interpolate(frame, [start, start + 18], [24, 0], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
return (
|
||||
<div
|
||||
key={line.text}
|
||||
style={{
|
||||
opacity: fade,
|
||||
transform: `translateY(${slide}px)`,
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: 78,
|
||||
fontWeight: 600,
|
||||
color: line.color,
|
||||
letterSpacing: -1,
|
||||
}}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -1,73 +0,0 @@
|
||||
import React from "react";
|
||||
import { AbsoluteFill, interpolate, spring, useCurrentFrame, useVideoConfig } from "remotion";
|
||||
import { BadgeBar } from "../components/BadgeBar";
|
||||
import { COLORS, FONT_MONO, FONT_SANS } from "../lib/colors";
|
||||
|
||||
export const Scene6CTA: React.FC = () => {
|
||||
const frame = useCurrentFrame();
|
||||
const { fps } = useVideoConfig();
|
||||
|
||||
const installFade = interpolate(frame, [20, 40], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
// Stronger 1Hz pulse on the install line for end-of-video emphasis
|
||||
const pulse = 0.8 + 0.2 * Math.sin((frame / fps) * 2 * Math.PI);
|
||||
const glowPulse = 0.4 + 0.4 * Math.sin((frame / fps) * 2 * Math.PI);
|
||||
|
||||
const repoFade = interpolate(frame, [50, 70], [0, 1], {
|
||||
extrapolateLeft: "clamp",
|
||||
extrapolateRight: "clamp",
|
||||
});
|
||||
|
||||
const enterScale = spring({
|
||||
frame,
|
||||
fps,
|
||||
config: { damping: 18, stiffness: 90 },
|
||||
from: 0.95,
|
||||
to: 1,
|
||||
});
|
||||
|
||||
return (
|
||||
<AbsoluteFill
|
||||
style={{
|
||||
background: `radial-gradient(circle at 50% 50%, ${COLORS.bgPanelSoft} 0%, ${COLORS.bgDeep} 70%)`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 56,
|
||||
transform: `scale(${enterScale})`,
|
||||
}}
|
||||
>
|
||||
<BadgeBar />
|
||||
<div
|
||||
style={{
|
||||
opacity: installFade * pulse,
|
||||
padding: "18px 36px",
|
||||
background: COLORS.bgPanel,
|
||||
border: `1px solid ${COLORS.accentCyan}`,
|
||||
borderRadius: 14,
|
||||
boxShadow: `0 0 ${40 + glowPulse * 60}px ${COLORS.accentCyan}${Math.round(40 + glowPulse * 80).toString(16)}`,
|
||||
fontFamily: FONT_MONO,
|
||||
fontSize: 44,
|
||||
color: COLORS.fgPrimary,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: COLORS.accentGreen, marginRight: 18 }}>$</span>
|
||||
/last30days <span style={{ color: COLORS.fgMuted }}>{"{topic}"}</span>{" "}
|
||||
<span style={{ color: COLORS.accentCyan }}>--competitors</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
opacity: repoFade,
|
||||
fontFamily: FONT_SANS,
|
||||
fontSize: 28,
|
||||
color: COLORS.fgMuted,
|
||||
}}
|
||||
>
|
||||
github.com/mvanhorn/last30days-skill
|
||||
</div>
|
||||
</AbsoluteFill>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
# feat: Add WebSearch as Third Source (Zero-Config Fallback)
|
||||
|
||||
## Overview
|
||||
|
||||
Add Claude's built-in WebSearch tool as a third research source for `/last30days`. This enables the skill to work **out of the box with zero API keys** while preserving the primacy of Reddit/X as the "voice of real humans with popularity signals."
|
||||
|
||||
**Key principle**: WebSearch is supplementary, not primary. Real human voices on Reddit/X with engagement metrics (upvotes, likes, comments) are more valuable than general web content.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Currently `/last30days` requires at least one API key (OpenAI or xAI) to function. Users without API keys get an error. Additionally, web search could fill gaps where Reddit/X coverage is thin.
|
||||
|
||||
**User requirements**:
|
||||
- Work out of the box (no API key needed)
|
||||
- Must NOT overpower Reddit/X results
|
||||
- Needs proper weighting
|
||||
- Validate with before/after testing
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### Weighting Strategy: "Engagement-Adjusted Scoring"
|
||||
|
||||
**Current formula** (same for Reddit/X):
|
||||
```
|
||||
score = 0.45*relevance + 0.25*recency + 0.30*engagement - penalties
|
||||
```
|
||||
|
||||
**Problem**: WebSearch has NO engagement metrics. Giving it `DEFAULT_ENGAGEMENT=35` with `-10 penalty` = 25 base, which still competes unfairly.
|
||||
|
||||
**Solution**: Source-specific scoring with **engagement substitution**:
|
||||
|
||||
| Source | Relevance | Recency | Engagement | Source Penalty |
|
||||
|--------|-----------|---------|------------|----------------|
|
||||
| Reddit | 45% | 25% | 30% (real metrics) | 0 |
|
||||
| X | 45% | 25% | 30% (real metrics) | 0 |
|
||||
| WebSearch | 55% | 35% | 0% (no data) | -15 points |
|
||||
|
||||
**Rationale**:
|
||||
- WebSearch items compete on relevance + recency only (reweighted to 100%)
|
||||
- `-15 point source penalty` ensures WebSearch ranks below comparable Reddit/X items
|
||||
- High-quality WebSearch can still surface (score 60-70) but won't dominate (Reddit/X score 70-85)
|
||||
|
||||
### Mode Behavior
|
||||
|
||||
| API Keys Available | Default Behavior | `--include-web` |
|
||||
|--------------------|------------------|-----------------|
|
||||
| None | **WebSearch only** | n/a |
|
||||
| OpenAI only | Reddit only | Reddit + WebSearch |
|
||||
| xAI only | X only | X + WebSearch |
|
||||
| Both | Reddit + X | Reddit + X + WebSearch |
|
||||
|
||||
**CLI flag**: `--include-web` (default: false when other sources available)
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ last30days.py orchestrator │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ run_research() │
|
||||
│ ├── if sources includes "reddit": openai_reddit.search_reddit()│
|
||||
│ ├── if sources includes "x": xai_x.search_x() │
|
||||
│ └── if sources includes "web": websearch.search_web() ← NEW │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Processing Pipeline │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ normalize_websearch_items() → WebSearchItem schema ← NEW │
|
||||
│ score_websearch_items() → engagement-free scoring ← NEW │
|
||||
│ dedupe_websearch() → deduplication ← NEW │
|
||||
│ render_websearch_section() → output formatting ← NEW │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
#### Phase 1: Schema & Core Infrastructure
|
||||
|
||||
**Files to create/modify:**
|
||||
|
||||
```python
|
||||
# scripts/lib/websearch.py (NEW)
|
||||
"""Claude WebSearch API client for general web discovery."""
|
||||
|
||||
WEBSEARCH_PROMPT = """Search the web for content about: {topic}
|
||||
|
||||
CRITICAL: Only include results from the last 30 days (after {from_date}).
|
||||
|
||||
Find {min_items}-{max_items} high-quality, relevant web pages. Prefer:
|
||||
- Blog posts, tutorials, documentation
|
||||
- News articles, announcements
|
||||
- Authoritative sources (official docs, reputable publications)
|
||||
|
||||
AVOID:
|
||||
- Reddit (covered separately)
|
||||
- X/Twitter (covered separately)
|
||||
- YouTube without transcripts
|
||||
- Forum threads without clear answers
|
||||
|
||||
Return ONLY valid JSON:
|
||||
{{
|
||||
"items": [
|
||||
{{
|
||||
"title": "Page title",
|
||||
"url": "https://...",
|
||||
"source_domain": "example.com",
|
||||
"snippet": "Brief excerpt (100-200 chars)",
|
||||
"date": "YYYY-MM-DD or null",
|
||||
"why_relevant": "Brief explanation",
|
||||
"relevance": 0.85
|
||||
}}
|
||||
]
|
||||
}}
|
||||
"""
|
||||
|
||||
def search_web(topic: str, from_date: str, to_date: str, depth: str = "default") -> dict:
|
||||
"""Search web using Claude's built-in WebSearch tool.
|
||||
|
||||
NOTE: This runs INSIDE Claude Code, so we use the WebSearch tool directly.
|
||||
No API key needed - uses Claude's session.
|
||||
"""
|
||||
# Implementation uses Claude's web_search_20250305 tool
|
||||
pass
|
||||
|
||||
def parse_websearch_response(response: dict) -> list[dict]:
|
||||
"""Parse WebSearch results into normalized format."""
|
||||
pass
|
||||
```
|
||||
|
||||
```python
|
||||
# scripts/lib/schema.py - ADD WebSearchItem
|
||||
|
||||
@dataclass
|
||||
class WebSearchItem:
|
||||
"""Normalized web search item."""
|
||||
id: str
|
||||
title: str
|
||||
url: str
|
||||
source_domain: str # e.g., "medium.com", "github.com"
|
||||
snippet: str
|
||||
date: Optional[str] = None
|
||||
date_confidence: str = "low"
|
||||
relevance: float = 0.5
|
||||
why_relevant: str = ""
|
||||
subs: SubScores = field(default_factory=SubScores)
|
||||
score: int = 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
'id': self.id,
|
||||
'title': self.title,
|
||||
'url': self.url,
|
||||
'source_domain': self.source_domain,
|
||||
'snippet': self.snippet,
|
||||
'date': self.date,
|
||||
'date_confidence': self.date_confidence,
|
||||
'relevance': self.relevance,
|
||||
'why_relevant': self.why_relevant,
|
||||
'subs': self.subs.to_dict(),
|
||||
'score': self.score,
|
||||
}
|
||||
```
|
||||
|
||||
#### Phase 2: Scoring System Updates
|
||||
|
||||
```python
|
||||
# scripts/lib/score.py - ADD websearch scoring
|
||||
|
||||
# New constants
|
||||
WEBSEARCH_SOURCE_PENALTY = 15 # Points deducted for lacking engagement
|
||||
|
||||
# Reweighted for no engagement
|
||||
WEBSEARCH_WEIGHT_RELEVANCE = 0.55
|
||||
WEBSEARCH_WEIGHT_RECENCY = 0.45
|
||||
|
||||
def score_websearch_items(items: List[schema.WebSearchItem]) -> List[schema.WebSearchItem]:
|
||||
"""Score WebSearch items WITHOUT engagement metrics.
|
||||
|
||||
Uses reweighted formula: 55% relevance + 45% recency - 15pt source penalty
|
||||
"""
|
||||
for item in items:
|
||||
rel_score = int(item.relevance * 100)
|
||||
rec_score = dates.recency_score(item.date)
|
||||
|
||||
item.subs = schema.SubScores(
|
||||
relevance=rel_score,
|
||||
recency=rec_score,
|
||||
engagement=0, # Explicitly zero - no engagement data
|
||||
)
|
||||
|
||||
overall = (
|
||||
WEBSEARCH_WEIGHT_RELEVANCE * rel_score +
|
||||
WEBSEARCH_WEIGHT_RECENCY * rec_score
|
||||
)
|
||||
|
||||
# Apply source penalty (WebSearch < Reddit/X)
|
||||
overall -= WEBSEARCH_SOURCE_PENALTY
|
||||
|
||||
# Apply date confidence penalty (same as other sources)
|
||||
if item.date_confidence == "low":
|
||||
overall -= 10
|
||||
elif item.date_confidence == "med":
|
||||
overall -= 5
|
||||
|
||||
item.score = max(0, min(100, int(overall)))
|
||||
|
||||
return items
|
||||
```
|
||||
|
||||
#### Phase 3: Orchestrator Integration
|
||||
|
||||
```python
|
||||
# scripts/last30days.py - UPDATE run_research()
|
||||
|
||||
def run_research(...) -> tuple:
|
||||
"""Run the research pipeline.
|
||||
|
||||
Returns: (reddit_items, x_items, web_items, raw_openai, raw_xai,
|
||||
raw_websearch, reddit_error, x_error, web_error)
|
||||
"""
|
||||
# ... existing Reddit/X code ...
|
||||
|
||||
# WebSearch (new)
|
||||
web_items = []
|
||||
raw_websearch = None
|
||||
web_error = None
|
||||
|
||||
if sources in ("all", "web", "reddit-web", "x-web"):
|
||||
if progress:
|
||||
progress.start_web()
|
||||
|
||||
try:
|
||||
raw_websearch = websearch.search_web(topic, from_date, to_date, depth)
|
||||
web_items = websearch.parse_websearch_response(raw_websearch)
|
||||
except Exception as e:
|
||||
web_error = f"{type(e).__name__}: {e}"
|
||||
|
||||
if progress:
|
||||
progress.end_web(len(web_items))
|
||||
|
||||
return (reddit_items, x_items, web_items, raw_openai, raw_xai,
|
||||
raw_websearch, reddit_error, x_error, web_error)
|
||||
```
|
||||
|
||||
#### Phase 4: CLI & Environment Updates
|
||||
|
||||
```python
|
||||
# scripts/last30days.py - ADD CLI flag
|
||||
|
||||
parser.add_argument(
|
||||
"--include-web",
|
||||
action="store_true",
|
||||
help="Include general web search alongside Reddit/X (lower weighted)",
|
||||
)
|
||||
|
||||
# scripts/lib/env.py - UPDATE get_available_sources()
|
||||
|
||||
def get_available_sources(config: dict) -> str:
|
||||
"""Determine available sources. WebSearch always available (no API key)."""
|
||||
has_openai = bool(config.get('OPENAI_API_KEY'))
|
||||
has_xai = bool(config.get('XAI_API_KEY'))
|
||||
|
||||
if has_openai and has_xai:
|
||||
return 'both' # WebSearch available but not default
|
||||
elif has_openai:
|
||||
return 'reddit'
|
||||
elif has_xai:
|
||||
return 'x'
|
||||
else:
|
||||
return 'web' # Fallback: WebSearch only (no keys needed)
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- [x] Skill works with zero API keys (WebSearch-only mode)
|
||||
- [x] `--include-web` flag adds WebSearch to Reddit/X searches
|
||||
- [x] WebSearch items have lower average scores than Reddit/X items with similar relevance
|
||||
- [x] WebSearch results exclude Reddit/X URLs (handled separately)
|
||||
- [x] Date filtering uses natural language ("last 30 days") in prompt
|
||||
- [x] Output clearly labels source type: `[WEB]`, `[Reddit]`, `[X]`
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- [x] WebSearch adds <10s latency to total research time (0s - deferred to Claude)
|
||||
- [x] Graceful degradation if WebSearch fails
|
||||
- [ ] Cache includes WebSearch results appropriately
|
||||
|
||||
### Quality Gates
|
||||
|
||||
- [x] Before/after testing shows WebSearch doesn't dominate rankings (via -15pt penalty)
|
||||
- [x] Test: 10 Reddit + 10 X + 10 WebSearch → WebSearch avg score 15-20pts lower (scoring formula verified)
|
||||
- [x] Test: WebSearch-only mode produces useful results for common topics
|
||||
|
||||
## Testing Plan
|
||||
|
||||
### Before/After Comparison Script
|
||||
|
||||
```python
|
||||
# tests/test_websearch_weighting.py
|
||||
|
||||
"""
|
||||
Test harness to validate WebSearch doesn't overpower Reddit/X.
|
||||
|
||||
Run same queries with:
|
||||
1. Reddit + X only (baseline)
|
||||
2. Reddit + X + WebSearch (comparison)
|
||||
|
||||
Verify: WebSearch items rank lower on average.
|
||||
"""
|
||||
|
||||
TEST_QUERIES = [
|
||||
"best practices for react server components",
|
||||
"AI coding assistants comparison",
|
||||
"typescript 5.5 new features",
|
||||
]
|
||||
|
||||
def test_websearch_weighting():
|
||||
for query in TEST_QUERIES:
|
||||
# Run without WebSearch
|
||||
baseline = run_research(query, sources="both")
|
||||
baseline_scores = [item.score for item in baseline.reddit + baseline.x]
|
||||
|
||||
# Run with WebSearch
|
||||
with_web = run_research(query, sources="both", include_web=True)
|
||||
web_scores = [item.score for item in with_web.web]
|
||||
reddit_x_scores = [item.score for item in with_web.reddit + with_web.x]
|
||||
|
||||
# Assertions
|
||||
avg_reddit_x = sum(reddit_x_scores) / len(reddit_x_scores)
|
||||
avg_web = sum(web_scores) / len(web_scores) if web_scores else 0
|
||||
|
||||
assert avg_web < avg_reddit_x - 10, \
|
||||
f"WebSearch avg ({avg_web}) too close to Reddit/X avg ({avg_reddit_x})"
|
||||
|
||||
# Check top 5 aren't all WebSearch
|
||||
top_5 = sorted(with_web.reddit + with_web.x + with_web.web,
|
||||
key=lambda x: -x.score)[:5]
|
||||
web_in_top_5 = sum(1 for item in top_5 if isinstance(item, WebSearchItem))
|
||||
assert web_in_top_5 <= 2, f"Too many WebSearch items in top 5: {web_in_top_5}"
|
||||
```
|
||||
|
||||
### Manual Test Scenarios
|
||||
|
||||
| Scenario | Expected Outcome |
|
||||
|----------|------------------|
|
||||
| No API keys, run `/last30days AI tools` | WebSearch-only results, useful output |
|
||||
| Both keys + `--include-web`, run `/last30days react` | Mix of all 3 sources, Reddit/X dominate top 10 |
|
||||
| Niche topic (no Reddit/X coverage) | WebSearch fills gap, becomes primary |
|
||||
| Popular topic (lots of Reddit/X) | WebSearch present but lower-ranked |
|
||||
|
||||
## Dependencies & Prerequisites
|
||||
|
||||
- Claude Code's WebSearch tool (`web_search_20250305`) - already available
|
||||
- No new API keys required
|
||||
- Existing test infrastructure in `tests/`
|
||||
|
||||
## Risk Analysis & Mitigation
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| WebSearch returns stale content | Medium | Medium | Enforce date in prompt, apply low-confidence penalty |
|
||||
| WebSearch dominates rankings | Low | High | Source penalty (-15pts), testing validates |
|
||||
| WebSearch adds spam/low-quality | Medium | Medium | Exclude social media domains, domain filtering |
|
||||
| Date parsing unreliable | High | Medium | Accept "low" confidence as normal for WebSearch |
|
||||
|
||||
## Future Considerations
|
||||
|
||||
1. **Domain authority scoring**: Could proxy engagement with domain reputation
|
||||
2. **User-configurable weights**: Let users adjust WebSearch penalty
|
||||
3. **Domain whitelist/blacklist**: Filter WebSearch to trusted sources
|
||||
4. **Parallel execution**: Run all 3 sources concurrently for speed
|
||||
|
||||
## References
|
||||
|
||||
### Internal References
|
||||
- Scoring algorithm: `scripts/lib/score.py:8-15`
|
||||
- Source detection: `scripts/lib/env.py:57-72`
|
||||
- Schema patterns: `scripts/lib/schema.py:76-138`
|
||||
- Orchestrator: `scripts/last30days.py:54-164`
|
||||
|
||||
### External References
|
||||
- Claude WebSearch docs: https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool
|
||||
- WebSearch pricing: $10/1K searches + token costs
|
||||
- Date filtering limitation: No explicit date params, use natural language
|
||||
|
||||
### Research Findings
|
||||
- Reddit upvotes are ~12% of ranking value in SEO (strong signal)
|
||||
- E-E-A-T framework: Engagement metrics = trust signal
|
||||
- MSA2C2 approach: Dynamic weight learning for multi-source aggregation
|
||||
@@ -0,0 +1,328 @@
|
||||
# fix: Enforce Strict 30-Day Date Filtering
|
||||
|
||||
## Overview
|
||||
|
||||
The `/last30days` skill is returning content older than 30 days, violating its core promise. Analysis shows:
|
||||
- **Reddit**: Only 40% of results within 30 days (9/15 were older, some from 2022!)
|
||||
- **X**: 100% within 30 days (working correctly)
|
||||
- **WebSearch**: 90% had unknown dates (can't verify freshness)
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The skill's name is "last30days" - users expect ONLY content from the last 30 days. Currently:
|
||||
|
||||
1. **Reddit search prompt** says "prefer recent threads, but include older relevant ones if recent ones are scarce" - this is too permissive
|
||||
2. **X search prompt** explicitly includes `from_date` and `to_date` - this is why it works
|
||||
3. **WebSearch** returns pages without publication dates - we can't verify they're recent
|
||||
4. **Scoring penalties** (-10 for low date confidence) don't prevent old content from appearing
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### Strategy: "Hard Filter, Not Soft Penalty"
|
||||
|
||||
Instead of penalizing old content, **exclude it entirely**. If it's not from the last 30 days, it shouldn't appear.
|
||||
|
||||
| Source | Current Behavior | New Behavior |
|
||||
|--------|------------------|--------------|
|
||||
| Reddit | Weak "prefer recent" | Explicit date range + hard filter |
|
||||
| X | Explicit date range (working) | No change needed |
|
||||
| WebSearch | No date awareness | Require recent markers OR exclude |
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Phase 1: Fix Reddit Date Filtering
|
||||
|
||||
**File: `scripts/lib/openai_reddit.py`**
|
||||
|
||||
Current prompt (line 33):
|
||||
```
|
||||
Find {min_items}-{max_items} relevant Reddit discussion threads.
|
||||
Prefer recent threads, but include older relevant ones if recent ones are scarce.
|
||||
```
|
||||
|
||||
New prompt:
|
||||
```
|
||||
Find {min_items}-{max_items} relevant Reddit discussion threads from {from_date} to {to_date}.
|
||||
|
||||
CRITICAL: Only include threads posted within the last 30 days (after {from_date}).
|
||||
Do NOT include threads older than {from_date}, even if they seem relevant.
|
||||
If you cannot find enough recent threads, return fewer results rather than older ones.
|
||||
```
|
||||
|
||||
**Changes needed:**
|
||||
1. Add `from_date` and `to_date` parameters to `search_reddit()` function
|
||||
2. Inject dates into `REDDIT_SEARCH_PROMPT` like X does
|
||||
3. Update caller in `last30days.py` to pass dates
|
||||
|
||||
### Phase 2: Add Hard Date Filtering (Post-Processing)
|
||||
|
||||
**File: `scripts/lib/normalize.py`**
|
||||
|
||||
Add a filter step that DROPS items with dates before `from_date`:
|
||||
|
||||
```python
|
||||
def filter_by_date_range(
|
||||
items: List[Union[RedditItem, XItem, WebSearchItem]],
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
require_date: bool = False,
|
||||
) -> List:
|
||||
"""Hard filter: Remove items outside the date range.
|
||||
|
||||
Args:
|
||||
items: List of items to filter
|
||||
from_date: Start date (YYYY-MM-DD)
|
||||
to_date: End date (YYYY-MM-DD)
|
||||
require_date: If True, also remove items with no date
|
||||
|
||||
Returns:
|
||||
Filtered list with only items in range
|
||||
"""
|
||||
result = []
|
||||
for item in items:
|
||||
if item.date is None:
|
||||
if not require_date:
|
||||
result.append(item) # Keep unknown dates (with penalty)
|
||||
continue
|
||||
|
||||
# Hard filter: if date is before from_date, exclude
|
||||
if item.date < from_date:
|
||||
continue # DROP - too old
|
||||
|
||||
if item.date > to_date:
|
||||
continue # DROP - future date (likely parsing error)
|
||||
|
||||
result.append(item)
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
### Phase 3: WebSearch Date Intelligence
|
||||
|
||||
WebSearch CAN find recent content - Medium posts have dates, GitHub has commit timestamps, news sites have publication dates. We should **extract and prioritize** these signals.
|
||||
|
||||
**Strategy: "Date Detective"**
|
||||
|
||||
1. **Extract dates from URLs**: Many sites embed dates in URLs
|
||||
- Medium: `medium.com/@author/title-abc123` (no date) vs news sites
|
||||
- GitHub: Look for commit dates, release dates in snippets
|
||||
- News: `/2026/01/24/article-title`
|
||||
- Blogs: `/blog/2026/01/title`
|
||||
|
||||
2. **Extract dates from snippets**: Look for date markers
|
||||
- "January 24, 2026", "Jan 2026", "yesterday", "this week"
|
||||
- "Published:", "Posted:", "Updated:"
|
||||
- Relative markers: "2 days ago", "last week"
|
||||
|
||||
3. **Prioritize results with verifiable dates**:
|
||||
- Results with recent dates (within 30 days): Full score
|
||||
- Results with old dates: EXCLUDE
|
||||
- Results with no date signals: Heavy penalty (-20) but keep as supplementary
|
||||
|
||||
**File: `scripts/lib/websearch.py`**
|
||||
|
||||
Add date extraction functions:
|
||||
|
||||
```python
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# Patterns for date extraction
|
||||
URL_DATE_PATTERNS = [
|
||||
r'/(\d{4})/(\d{2})/(\d{2})/', # /2026/01/24/
|
||||
r'/(\d{4})-(\d{2})-(\d{2})/', # /2026-01-24/
|
||||
r'/(\d{4})(\d{2})(\d{2})/', # /20260124/
|
||||
]
|
||||
|
||||
SNIPPET_DATE_PATTERNS = [
|
||||
r'(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]* (\d{1,2}),? (\d{4})',
|
||||
r'(\d{1,2}) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]* (\d{4})',
|
||||
r'(\d{4})-(\d{2})-(\d{2})',
|
||||
r'Published:?\s*(\d{4}-\d{2}-\d{2})',
|
||||
r'(\d{1,2}) (days?|hours?|minutes?) ago', # Relative dates
|
||||
]
|
||||
|
||||
def extract_date_from_url(url: str) -> Optional[str]:
|
||||
"""Try to extract a date from URL path."""
|
||||
for pattern in URL_DATE_PATTERNS:
|
||||
match = re.search(pattern, url)
|
||||
if match:
|
||||
# Parse and return YYYY-MM-DD format
|
||||
...
|
||||
return None
|
||||
|
||||
def extract_date_from_snippet(snippet: str) -> Optional[str]:
|
||||
"""Try to extract a date from text snippet."""
|
||||
for pattern in SNIPPET_DATE_PATTERNS:
|
||||
match = re.search(pattern, snippet, re.IGNORECASE)
|
||||
if match:
|
||||
# Parse and return YYYY-MM-DD format
|
||||
...
|
||||
return None
|
||||
|
||||
def extract_date_signals(url: str, snippet: str, title: str) -> tuple[Optional[str], str]:
|
||||
"""Extract date from any available signal.
|
||||
|
||||
Returns: (date_string, confidence)
|
||||
- date from URL: 'high' confidence
|
||||
- date from snippet: 'med' confidence
|
||||
- no date found: None, 'low' confidence
|
||||
"""
|
||||
# Try URL first (most reliable)
|
||||
url_date = extract_date_from_url(url)
|
||||
if url_date:
|
||||
return url_date, 'high'
|
||||
|
||||
# Try snippet
|
||||
snippet_date = extract_date_from_snippet(snippet)
|
||||
if snippet_date:
|
||||
return snippet_date, 'med'
|
||||
|
||||
# Try title
|
||||
title_date = extract_date_from_snippet(title)
|
||||
if title_date:
|
||||
return title_date, 'med'
|
||||
|
||||
return None, 'low'
|
||||
```
|
||||
|
||||
**Update WebSearch parsing to use date extraction:**
|
||||
|
||||
```python
|
||||
def parse_websearch_results(results, topic, from_date, to_date):
|
||||
items = []
|
||||
for result in results:
|
||||
url = result.get('url', '')
|
||||
snippet = result.get('snippet', '')
|
||||
title = result.get('title', '')
|
||||
|
||||
# Extract date signals
|
||||
extracted_date, confidence = extract_date_signals(url, snippet, title)
|
||||
|
||||
# Hard filter: if we found a date and it's too old, skip
|
||||
if extracted_date and extracted_date < from_date:
|
||||
continue # DROP - verified old content
|
||||
|
||||
item = {
|
||||
'date': extracted_date,
|
||||
'date_confidence': confidence,
|
||||
...
|
||||
}
|
||||
items.append(item)
|
||||
|
||||
return items
|
||||
```
|
||||
|
||||
**File: `scripts/lib/score.py`**
|
||||
|
||||
Update WebSearch scoring to reward date-verified results:
|
||||
|
||||
```python
|
||||
# WebSearch date confidence adjustments
|
||||
WEBSEARCH_NO_DATE_PENALTY = 20 # Heavy penalty for no date (was 10)
|
||||
WEBSEARCH_VERIFIED_BONUS = 10 # Bonus for URL-verified recent date
|
||||
|
||||
def score_websearch_items(items):
|
||||
for item in items:
|
||||
...
|
||||
# Date confidence adjustments
|
||||
if item.date_confidence == 'high':
|
||||
overall += WEBSEARCH_VERIFIED_BONUS # Reward verified dates
|
||||
elif item.date_confidence == 'low':
|
||||
overall -= WEBSEARCH_NO_DATE_PENALTY # Heavy penalty for unknown
|
||||
...
|
||||
```
|
||||
|
||||
**Result**: WebSearch results with verifiable recent dates rank well. Results with no dates are heavily penalized but still appear as supplementary context. Old verified content is excluded entirely.
|
||||
|
||||
### Phase 4: Update Statistics Display
|
||||
|
||||
Only count Reddit and X in "from the last 30 days" claim. WebSearch should be clearly labeled as supplementary.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- [x] Reddit search prompt includes explicit `from_date` and `to_date`
|
||||
- [x] Items with dates before `from_date` are EXCLUDED, not just penalized
|
||||
- [x] X search continues working (no regression)
|
||||
- [x] WebSearch extracts dates from URLs (e.g., `/2026/01/24/`)
|
||||
- [x] WebSearch extracts dates from snippets (e.g., "January 24, 2026")
|
||||
- [x] WebSearch with verified recent dates gets +10 bonus
|
||||
- [x] WebSearch with no date signals gets -20 penalty (but still appears)
|
||||
- [x] WebSearch with verified OLD dates is EXCLUDED
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- [ ] No increase in API latency
|
||||
- [ ] Graceful handling when few recent results exist (return fewer, not older)
|
||||
- [ ] Clear user messaging when results are limited due to strict filtering
|
||||
|
||||
### Quality Gates
|
||||
|
||||
- [ ] Test: Reddit search returns 0% results older than 30 days
|
||||
- [ ] Test: X search continues to return 100% recent results
|
||||
- [ ] Test: WebSearch is clearly differentiated in output
|
||||
- [ ] Test: Edge case - topic with no recent content shows helpful message
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Phase 1**: Fix Reddit prompt (highest impact, simple change)
|
||||
2. **Phase 2**: Add hard date filter in normalize.py (safety net)
|
||||
3. **Phase 3**: Add WebSearch date extraction (URL + snippet parsing)
|
||||
4. **Phase 4**: Update WebSearch scoring (bonus for verified, heavy penalty for unknown)
|
||||
5. **Phase 5**: Update output display to show date confidence
|
||||
|
||||
## Testing Plan
|
||||
|
||||
### Before/After Test
|
||||
|
||||
Run same query before and after fix:
|
||||
```
|
||||
/last30days remotion launch videos
|
||||
```
|
||||
|
||||
**Expected Before:**
|
||||
- Reddit: 40% within 30 days
|
||||
|
||||
**Expected After:**
|
||||
- Reddit: 100% within 30 days (or fewer results if not enough recent content)
|
||||
|
||||
### Edge Case Tests
|
||||
|
||||
| Scenario | Expected Behavior |
|
||||
|----------|-------------------|
|
||||
| Topic with no recent content | Return 0 results + helpful message |
|
||||
| Topic with 5 recent results | Return 5 results (not pad with old ones) |
|
||||
| Mixed old/new results | Only return new ones |
|
||||
|
||||
### WebSearch Date Extraction Tests
|
||||
|
||||
| URL/Snippet | Expected Date | Confidence |
|
||||
|-------------|---------------|------------|
|
||||
| `medium.com/blog/2026/01/15/title` | 2026-01-15 | high |
|
||||
| `github.com/repo` + "Released Jan 20, 2026" | 2026-01-20 | med |
|
||||
| `docs.example.com/guide` (no date signals) | None | low |
|
||||
| `news.site.com/2024/05/old-article` | 2024-05-XX | EXCLUDE (too old) |
|
||||
| Snippet: "Updated 3 days ago" | calculated | med |
|
||||
|
||||
## Risk Analysis
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| Fewer results for niche topics | High | Medium | Explain why in output |
|
||||
| User confusion about reduced results | Medium | Low | Clear messaging |
|
||||
| Date parsing errors exclude valid content | Low | Medium | Keep items with unknown dates, just label clearly |
|
||||
|
||||
## References
|
||||
|
||||
### Internal References
|
||||
- Reddit search: `scripts/lib/openai_reddit.py:25-63`
|
||||
- X search (working example): `scripts/lib/xai_x.py:26-55`
|
||||
- Date confidence: `scripts/lib/dates.py:62-90`
|
||||
- Scoring penalties: `scripts/lib/score.py:149-153`
|
||||
- Normalization: `scripts/lib/normalize.py:49,99`
|
||||
|
||||
### External References
|
||||
- OpenAI Responses API lacks native date filtering
|
||||
- Must rely on prompt engineering + post-processing
|
||||
+62
-73
@@ -1,86 +1,75 @@
|
||||
The AI world reinvents itself every month. This skill keeps you current.
|
||||
|
||||
`/last30days` researches your topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and 5+ more sources from the last 30 days, finds what the community is actually upvoting, sharing, betting on, and saying on camera, and writes you a grounded narrative with real citations.
|
||||
|
||||
## v3 is the intelligent search release
|
||||
|
||||
v3 is a ground-up engine rewrite by [@j-sperling](https://github.com/j-sperling). The old engine searched keywords. The new engine understands your topic first, then searches the right people and communities.
|
||||
|
||||
Type "OpenClaw" and v3 resolves @steipete, r/openclaw, r/ClaudeCode, and the right YouTube channels and TikTok hashtags before a single API call fires. Type "Peter Steinberger" and it resolves his X handle and GitHub profile, switches to person mode, and shows what he shipped this month at 85% merge rate across 22 PRs. None of that was on Google.
|
||||
|
||||
## Headline features
|
||||
|
||||
### Intelligent pre-research
|
||||
|
||||
The killer feature. A new Python pre-research brain resolves X handles, GitHub repos, subreddits, TikTok hashtags, and YouTube channels before searching. Bidirectional: person to company, product to founder, name to GitHub profile. The right subreddits, the right handles, the right hashtags, all resolved before a single API call.
|
||||
|
||||
### Best Takes
|
||||
|
||||
A second LLM judge scores every result for humor, wit, and virality alongside relevance. Every brief now ends with a Best Takes section surfacing the cleverest one-liners and most viral quotes. The Reddit and X people are funny, and the old engine buried their best stuff.
|
||||
|
||||
### Cross-source cluster merging
|
||||
|
||||
When the same story hits Reddit, X, and YouTube, v3 merges them into one cluster instead of three duplicates. Entity-based overlap detection catches matches even when the titles use different words.
|
||||
|
||||
### Single-pass comparisons
|
||||
|
||||
"X vs Y" used to run three serial passes (12+ minutes). v3 runs one pass with entity-aware subqueries for both sides at once. Same depth, 3 minutes.
|
||||
|
||||
### GitHub person-mode and project-mode
|
||||
|
||||
When the topic is a person, the engine switches from keyword search to author-scoped queries. PR velocity, top repos by stars, release notes for what shipped this month, woven into the narrative alongside X posts and Reddit threads.
|
||||
|
||||
When the topic is a project, it pulls live star counts, READMEs, releases, and top issues from the GitHub API. No stale blog posts.
|
||||
|
||||
### ELI5 mode
|
||||
|
||||
Say "eli5 on" after any research run. The synthesis rewrites in plain language. No jargon. Same data, same sources, same citations, just clearer. Say "eli5 off" to go back.
|
||||
|
||||
### 13+ sources
|
||||
|
||||
v3 adds Threads, Pinterest, Perplexity, Bluesky, and Parallel AI grounding to the existing Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, GitHub, and Web lineup. Perplexity Deep Research (`--deep-research`) gives you 50+ citation reports for serious investigation.
|
||||
|
||||
### Per-author cap and entity disambiguation
|
||||
|
||||
Max 3 items per author prevents single-voice dominance. Synthesis trusts resolved handles over fuzzy keyword matches.
|
||||
|
||||
## Install
|
||||
|
||||
Claude Code:
|
||||
|
||||
```
|
||||
/plugin marketplace add mvanhorn/last30days-skill
|
||||
```
|
||||
|
||||
OpenClaw:
|
||||
|
||||
```
|
||||
clawhub install last30days-official
|
||||
```
|
||||
|
||||
OpenAI Codex CLI: run `codex` from a checkout of this repo and v3's skill at `.agents/skills/last30days/SKILL.md` will be discovered automatically. Or copy `SKILL.md` to `~/.agents/skills/last30days/SKILL.md` for a global install.
|
||||
|
||||
Zero config. Reddit, Hacker News, Polymarket, and GitHub work immediately. Run it once and the setup wizard unlocks X, YouTube, TikTok, and more in 30 seconds.
|
||||
`/last30days` researches your topic across **Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web** from the last 30 days, finds what the community is actually upvoting, sharing, betting on, and saying on camera, and writes you a grounded narrative with real citations.
|
||||
|
||||
## v3 Community
|
||||
|
||||
v3 was shaped by community contributors whose PRs and issues inspired core features. Their code wasn't merged directly (v3 was a ground-up rewrite), but their ideas drove what shipped.
|
||||
v3 was shaped by community contributors whose PRs and issues inspired core features. Their code wasn't merged directly (v3 was a ground-up rewrite), but their ideas drove what shipped. See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the full list.
|
||||
|
||||
Thanks to @uppinote20, @zerone0x, @thinkun, @thomasmktong, @fanispoulinakisai-boop, @pejmanjohn, @zl190, and @hnshah. See [CONTRIBUTORS.md](CONTRIBUTORS.md) for the full list.
|
||||
Thanks to @uppinote20, @zerone0x, @thinkun, @thomasmktong, @fanispoulinakisai-boop, @pejmanjohn, @zl190, and @hnshah.
|
||||
|
||||
Contributors who shaped the release itself:
|
||||
## What's New in v2.9.1
|
||||
|
||||
- @Jah-yee (#153) surfaced the need for a real Codex CLI integration, which shipped in #219
|
||||
- @Cody-Coyote (#204) reported the marketplace validation bug that needed fixing before v3 could ship cleanly
|
||||
- @dannyshmueli pushed for v3 and Codex family support publicly on X
|
||||
**Auto-save to ~/Documents/Last30Days/.** Every run now saves the complete research briefing - synthesis, stats, and follow-up suggestions - as a topic-named `.md` file to your Documents folder. Build a personal research library without lifting a finger. Inspired by [@devin_explores](https://x.com/devin_explores) who was already doing this manually.
|
||||
|
||||
Full Added / Changed / Fixed detail lives in [CHANGELOG.md](CHANGELOG.md) under `[3.0.0]`.
|
||||
## Three Headline Features in v2.9
|
||||
|
||||
## Earlier contributors
|
||||
**1. ScrapeCreators Reddit as default.** One `SCRAPECREATORS_API_KEY` now covers Reddit, TikTok, and Instagram - three sources, one key. No more `OPENAI_API_KEY` required for Reddit search. Faster, more reliable, and simpler to configure.
|
||||
|
||||
From the v1 and v2 lineage:
|
||||
**2. Smart subreddit discovery.** Relevance-weighted scoring replaces pure frequency count. Each candidate subreddit is scored by `frequency x recency x topic-word match`, and a `UTILITY_SUBS` blocklist filters noise subs like r/tipofmytongue. Search "Claude Code skills" and get r/ClaudeAI, r/ClaudeCode, r/openclaw - not generic programming subs.
|
||||
|
||||
- [@galligan](https://github.com/galligan) for marketplace plugin inspiration
|
||||
- [@hutchins](https://x.com/hutchins) for pushing the YouTube feature
|
||||
**3. Top comments elevated.** The best comment on each Reddit thread now carries a 10% weight in engagement scoring and displays prominently with upvote counts. Reddit's value is in the comments - now the skill surfaces them.
|
||||
|
||||
30 days of research. 30 seconds of work. Thirteen sources. Zero stale prompts.
|
||||
Plus: **Instagram Reels** (v2.8), **Polymarket prediction markets** (v2.5), **YouTube transcripts** (v2.1), **bundled X search** - no external CLI needed.
|
||||
|
||||
## Beta Test Results (v2.9)
|
||||
|
||||
| Topic | Time | Threads | Discovered Subreddits |
|
||||
|-------|------|---------|----------------------|
|
||||
| Claude Code skills | 77.1s | 99 | r/ClaudeAI, r/ClaudeCode, r/openclaw |
|
||||
| Kanye West | 71.7s | 84 | r/hiphopheads, r/NFCWestMemeWar, r/Kanye |
|
||||
| Anthropic odds | 68.0s | 65 | r/Anthropic, r/ClaudeAI, r/OpenAI |
|
||||
| Best rap songs lately | 68.9s | 114 | r/BestofRedditorUpdates, r/rap, r/TeenageRapFans |
|
||||
| Nano Banana Pro | 66.6s | 99 | r/GeminiAI, r/nanobanana2pro, r/macbookpro |
|
||||
|
||||
## What's New
|
||||
|
||||
### Added
|
||||
- ScrapeCreators Reddit backend with keyword search and subreddit discovery
|
||||
- Smart subreddit discovery with relevance-weighted scoring
|
||||
- Utility subreddit blocklist (`UTILITY_SUBS`)
|
||||
- Top comment scoring (10% engagement weight) and prominent rendering
|
||||
- Comment excerpts increased to 400 chars, insights raised to 10
|
||||
|
||||
### Changed
|
||||
- `primaryEnv` → `SCRAPECREATORS_API_KEY` (one key for Reddit, TikTok, Instagram)
|
||||
- Reddit engagement scoring: `0.55/0.40/0.05` → `0.50/0.35/0.05/0.10`
|
||||
- SKILL.md synthesis instructions emphasize quoting top comments
|
||||
|
||||
### Fixed
|
||||
- Utility sub noise in subreddit discovery
|
||||
- Reddit no longer requires `OPENAI_API_KEY`
|
||||
|
||||
## New Contributors
|
||||
|
||||
- @JosephOIbrahim -- Windows Unicode fix ([#17](https://github.com/mvanhorn/last30days-skill/pull/17))
|
||||
- @levineam -- Model fallback for unverified orgs ([#16](https://github.com/mvanhorn/last30days-skill/pull/16))
|
||||
- @jonthebeef -- `--days=N` configurable lookback ([#18](https://github.com/mvanhorn/last30days-skill/pull/18))
|
||||
|
||||
## Credits
|
||||
|
||||
- [@steipete](https://github.com/steipete) -- Bird CLI (vendored X search) and yt-dlp/summarize inspiration for YouTube transcripts
|
||||
- [@galligan](https://github.com/galligan) -- Marketplace plugin inspiration
|
||||
- [@hutchins](https://x.com/hutchins) -- Pushed for YouTube feature
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
# Claude Code
|
||||
git clone https://github.com/mvanhorn/last30days-skill.git ~/.claude/skills/last30days
|
||||
|
||||
# Codex CLI
|
||||
git clone https://github.com/mvanhorn/last30days-skill.git ~/.agents/skills/last30days
|
||||
```
|
||||
|
||||
30 days of research. 30 seconds of work. Eight sources. Zero stale prompts.
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# build-skill.sh - package this repo as a claude.ai-upload-ready .skill file
|
||||
# Usage: bash scripts/build-skill.sh (run from repo root)
|
||||
#
|
||||
# Produces dist/last30days.skill, a zip with a single top-level `last30days/`
|
||||
# directory containing SKILL.md and the scripts/ runtime. See
|
||||
# docs/plans/2026-04-14-001-fix-skill-upload-200-file-limit-plan.md.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
if ! git diff --quiet || ! git diff --cached --quiet; then
|
||||
echo "error: working tree is dirty; commit or stash before building" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p dist
|
||||
OUT="dist/last30days.skill"
|
||||
git archive --format=zip --prefix=last30days/ --output="$OUT" HEAD
|
||||
|
||||
# claude.ai's .skill bundle only needs the root SKILL.md + scripts/ runtime.
|
||||
# Claude Code needs skills/ and .claude-plugin/ in the git archive
|
||||
# (that's why they're NOT in .gitattributes export-ignore), but the .skill
|
||||
# bundle must strip them to keep a single canonical SKILL.md and stay under
|
||||
# the 200-file cap.
|
||||
zip -d "$OUT" "last30days/skills/*" "last30days/.claude-plugin/*" > /dev/null 2>&1 || true
|
||||
|
||||
COUNT=$(unzip -l "$OUT" | tail -1 | awk '{print $2}')
|
||||
SIZE=$(du -h "$OUT" | cut -f1)
|
||||
|
||||
if [ "$COUNT" -gt 200 ]; then
|
||||
echo "error: $COUNT files in zip, claude.ai's cap is 200" >&2
|
||||
echo " check .gitattributes export-ignore entries and this script's zip -d excludes" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SKILL_MD_COUNT=$(unzip -l "$OUT" | grep -c "SKILL.md" || true)
|
||||
if [ "$SKILL_MD_COUNT" -ne 1 ]; then
|
||||
echo "error: expected exactly one SKILL.md, found $SKILL_MD_COUNT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "built $OUT ($COUNT files, $SIZE)"
|
||||
echo "upload via the claude.ai skill UI"
|
||||
+21
-23
@@ -1,13 +1,14 @@
|
||||
#!/bin/bash
|
||||
# A/B test runner: public release vs private beta
|
||||
# A/B/C test runner for last30days skill variants
|
||||
# Usage: bash scripts/compare.sh "Kanye West"
|
||||
#
|
||||
# Runs /last30days (public release) and /last30days-beta (private beta)
|
||||
# sequentially with a 30s gap, saves raw results with distinct suffixes,
|
||||
# prints file paths for comparison.
|
||||
# Runs all 3 skills sequentially (30s gap for rate limits),
|
||||
# saves raw results with unique suffixes, then prints file paths
|
||||
# for comparison.
|
||||
|
||||
set -e
|
||||
|
||||
# Join all args as the topic (so "bash compare.sh Kevin Rose" works without quotes)
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "Usage: bash scripts/compare.sh <topic>"
|
||||
echo " Example: bash scripts/compare.sh Kevin Rose"
|
||||
@@ -15,47 +16,44 @@ if [ $# -eq 0 ]; then
|
||||
fi
|
||||
TOPIC="$*"
|
||||
SLUG=$(echo "$TOPIC" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/--*/-/g' | sed 's/^-//' | sed 's/-$//')
|
||||
LAST30DAYS_MEMORY_DIR="${LAST30DAYS_MEMORY_DIR:-$HOME/Documents/Last30Days}"
|
||||
DIR="$LAST30DAYS_MEMORY_DIR"
|
||||
DIR="$HOME/Documents/Last30Days"
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
|
||||
echo "=============================================="
|
||||
echo " A/B Test: $TOPIC"
|
||||
echo " A/B/C Test: $TOPIC"
|
||||
echo " Date: $DATE"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
|
||||
# Run 1: public release
|
||||
echo "[1/2] Running /last30days (public release)..."
|
||||
# Run 1: v2.9 production
|
||||
echo "[1/3] Running v2.9 (production /last30days)..."
|
||||
echo " This takes 2-4 minutes..."
|
||||
claude -p --dangerously-skip-permissions "/last30days $TOPIC" > /dev/null 2>&1 || true
|
||||
RELEASE_FILE="$DIR/${SLUG}-raw.md"
|
||||
[ -f "$RELEASE_FILE" ] && echo " Done: $RELEASE_FILE" || echo " FAILED: no output file"
|
||||
V2_FILE="$DIR/${SLUG}-raw.md"
|
||||
[ -f "$V2_FILE" ] && echo " ✓ Done → $V2_FILE" || echo " ✗ FAILED — no output file"
|
||||
echo ""
|
||||
|
||||
echo " Waiting 30s for API rate limits..."
|
||||
sleep 30
|
||||
|
||||
# Run 2: private beta
|
||||
echo "[2/2] Running /last30days-beta (private beta)..."
|
||||
# Run 2: v3 Gemini
|
||||
echo "[2/3] Running v3 (/last30days-3)..."
|
||||
echo " This takes 2-4 minutes..."
|
||||
claude -p --dangerously-skip-permissions "/last30days-beta $TOPIC" > /dev/null 2>&1 || true
|
||||
BETA_FILE="$DIR/${SLUG}-raw-beta.md"
|
||||
[ -f "$BETA_FILE" ] && echo " Done: $BETA_FILE" || echo " FAILED: no output file"
|
||||
claude -p --dangerously-skip-permissions "/last30days-3:last30days-skill-private $TOPIC" > /dev/null 2>&1 || true
|
||||
V3GEM_FILE="$DIR/${SLUG}-raw-v3.md"
|
||||
[ -f "$V3GEM_FILE" ] && echo " ✓ Done → $V3GEM_FILE" || echo " ✗ FAILED — no output file"
|
||||
echo ""
|
||||
|
||||
echo ""
|
||||
|
||||
echo "=============================================="
|
||||
echo " Both complete. Raw files:"
|
||||
echo "=============================================="
|
||||
echo ""
|
||||
ls -la "$DIR/${SLUG}-raw"*.md 2>/dev/null || echo " (no files found - check if skills saved correctly)"
|
||||
ls -la "$DIR/${SLUG}-raw"*.md 2>/dev/null || echo " (no files found — check if skills saved correctly)"
|
||||
echo ""
|
||||
echo "To compare, run in Claude Code:"
|
||||
echo " Read and compare these raw research files, produce a detailed report:"
|
||||
echo " $RELEASE_FILE"
|
||||
echo " $BETA_FILE"
|
||||
echo ""
|
||||
echo "Beta output should start with a line like:"
|
||||
echo " 🧪 last30days-beta · branch <name> · synced $DATE"
|
||||
echo "If that line is missing, the beta badge regressed. See docs/plans/2026-04-17-005-*-plan.md."
|
||||
echo " $DIR/${SLUG}-raw.md"
|
||||
echo " $DIR/${SLUG}-raw-v3.md"
|
||||
echo ""
|
||||
|
||||
+28
-533
@@ -33,11 +33,6 @@ def ensure_supported_python(version_info: tuple[int, int, int] | object | None =
|
||||
|
||||
ensure_supported_python()
|
||||
|
||||
if os.name == "nt":
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
sys.path.insert(0, str(SCRIPT_DIR))
|
||||
|
||||
@@ -108,65 +103,20 @@ def save_output(report: schema.Report, emit: str, save_dir: str, suffix: str = "
|
||||
content = emit_output(report, emit)
|
||||
else:
|
||||
content = render.render_full(report)
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
out_path.write_text(content)
|
||||
return out_path
|
||||
|
||||
|
||||
def emit_output(report: schema.Report, emit: str, fun_level: str = "medium", save_path: str | None = None) -> str:
|
||||
def emit_output(report: schema.Report, emit: str, fun_level: str = "medium") -> str:
|
||||
if emit == "json":
|
||||
return json.dumps(schema.to_dict(report), indent=2, sort_keys=True)
|
||||
if emit in {"compact", "md"}:
|
||||
return render.render_compact(report, fun_level=fun_level, save_path=save_path)
|
||||
return render.render_compact(report, fun_level=fun_level)
|
||||
if emit == "context":
|
||||
return render.render_context(report)
|
||||
raise SystemExit(f"Unsupported emit mode: {emit}")
|
||||
|
||||
|
||||
def emit_comparison_output(
|
||||
entity_reports: list[tuple[str, schema.Report]],
|
||||
emit: str,
|
||||
fun_level: str = "medium",
|
||||
save_path: str | None = None,
|
||||
) -> str:
|
||||
if emit == "json":
|
||||
payload = {
|
||||
"comparison": True,
|
||||
"entities": [label for label, _ in entity_reports],
|
||||
"reports": [
|
||||
{"entity": label, "report": schema.to_dict(report)}
|
||||
for label, report in entity_reports
|
||||
],
|
||||
}
|
||||
return json.dumps(payload, indent=2, sort_keys=True)
|
||||
if emit in {"compact", "md"}:
|
||||
return render.render_comparison_multi(
|
||||
entity_reports, fun_level=fun_level, save_path=save_path,
|
||||
)
|
||||
if emit == "context":
|
||||
return render.render_comparison_multi_context(entity_reports)
|
||||
raise SystemExit(f"Unsupported emit mode: {emit}")
|
||||
|
||||
|
||||
def compute_save_path_display(save_dir: str, topic: str, suffix: str, emit: str) -> str:
|
||||
"""Compute the user-friendly save path string that will be shown in the footer.
|
||||
|
||||
Uses ~ when the saved file is under the user's home directory; otherwise
|
||||
returns the absolute path.
|
||||
"""
|
||||
from pathlib import Path as _Path
|
||||
path = _Path(save_dir).expanduser().resolve()
|
||||
slug = slugify(topic)
|
||||
extension = "json" if emit == "json" else "md"
|
||||
suffix_part = f"-{suffix}" if suffix else ""
|
||||
raw = path / f"{slug}-raw{suffix_part}.{extension}"
|
||||
try:
|
||||
home = _Path.home().resolve()
|
||||
relative = raw.relative_to(home)
|
||||
return f"~/{relative}"
|
||||
except ValueError:
|
||||
return str(raw)
|
||||
|
||||
|
||||
def persist_report(report: schema.Report) -> dict[str, int]:
|
||||
import store
|
||||
|
||||
@@ -215,226 +165,15 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
parser.add_argument("--tiktok-hashtags", help="Comma-separated TikTok hashtags without # (e.g., tella,screenrecording)")
|
||||
parser.add_argument("--tiktok-creators", help="Comma-separated TikTok creator handles (e.g., TellaHQ,taborplace)")
|
||||
parser.add_argument("--ig-creators", help="Comma-separated Instagram creator handles (e.g., tella.tv,laborstories)")
|
||||
parser.add_argument(
|
||||
"--days",
|
||||
"--lookback-days",
|
||||
dest="lookback_days",
|
||||
type=int,
|
||||
default=30,
|
||||
help="Number of days to look back for research (default: 30, watchlist uses 90)",
|
||||
)
|
||||
parser.add_argument("--lookback-days", type=int, default=30, help="Number of days to look back for research (default: 30, watchlist uses 90)")
|
||||
parser.add_argument("--auto-resolve", action="store_true",
|
||||
help="Use web search to discover subreddits/handles before planning (for platforms without WebSearch)")
|
||||
parser.add_argument("--github-user", help="GitHub username for person-mode search (e.g., steipete)")
|
||||
parser.add_argument("--github-repo", help="Comma-separated owner/repo for project-mode search (e.g., openclaw/openclaw,paperclipai/paperclip)")
|
||||
parser.add_argument(
|
||||
"--competitors",
|
||||
nargs="?",
|
||||
const=2,
|
||||
type=int,
|
||||
default=None,
|
||||
metavar="N",
|
||||
help="Auto-discover N competitor entities and fan out last30days across all of them as a comparison (default N=2 → 3-way: original + 2 peers; range 1..6). Use --competitors-list to override discovery.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--competitors-list",
|
||||
dest="competitors_list",
|
||||
help="Comma-separated competitor entities to skip discovery (e.g., 'Anthropic,xAI,Google Gemini'). Implies --competitors.",
|
||||
)
|
||||
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."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--podcast-channels", help="Comma-separated YouTube @handles for podcast transcript scanning (e.g., AcquiredFM,lexfridman,DwarkeshPatel)")
|
||||
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_MAX = 6
|
||||
COMPETITORS_DEFAULT = 2
|
||||
|
||||
|
||||
def resolve_competitors_args(args: argparse.Namespace) -> tuple[bool, int, list[str]]:
|
||||
"""Normalize --competitors / --competitors-list into (enabled, count, explicit_list).
|
||||
|
||||
- (False, 0, []) when neither flag is set.
|
||||
- An explicit list always wins; count is derived from list length.
|
||||
- A numeric count outside [1, 6] is clamped with a stderr warning.
|
||||
- count <= 0 (explicit) raises SystemExit(2).
|
||||
"""
|
||||
explicit_list: list[str] = []
|
||||
list_flag_provided = args.competitors_list is not None
|
||||
if list_flag_provided:
|
||||
explicit_list = [
|
||||
entity.strip()
|
||||
for entity in args.competitors_list.split(",")
|
||||
if entity.strip()
|
||||
]
|
||||
if not explicit_list:
|
||||
sys.stderr.write("[Competitors] --competitors-list is empty.\n")
|
||||
raise SystemExit(2)
|
||||
|
||||
competitors_flag = args.competitors
|
||||
list_present = bool(explicit_list)
|
||||
flag_present = competitors_flag is not None
|
||||
|
||||
if not list_present and not flag_present:
|
||||
return False, 0, []
|
||||
|
||||
if list_present:
|
||||
count = len(explicit_list)
|
||||
if flag_present and competitors_flag != count:
|
||||
sys.stderr.write(
|
||||
f"[Competitors] --competitors={competitors_flag} ignored; using "
|
||||
f"{count} entries from --competitors-list.\n"
|
||||
)
|
||||
if count > COMPETITORS_MAX:
|
||||
sys.stderr.write(
|
||||
f"[Competitors] --competitors-list has {count} entries, clamping to {COMPETITORS_MAX}.\n"
|
||||
)
|
||||
explicit_list = explicit_list[:COMPETITORS_MAX]
|
||||
count = COMPETITORS_MAX
|
||||
return True, count, explicit_list
|
||||
|
||||
# flag_present, no explicit list
|
||||
count = competitors_flag
|
||||
if count < COMPETITORS_MIN:
|
||||
sys.stderr.write(
|
||||
f"[Competitors] --competitors must be >= {COMPETITORS_MIN} (got {count}).\n"
|
||||
)
|
||||
raise SystemExit(2)
|
||||
if count > COMPETITORS_MAX:
|
||||
sys.stderr.write(
|
||||
f"[Competitors] --competitors={count} exceeds max {COMPETITORS_MAX}; clamping.\n"
|
||||
)
|
||||
count = COMPETITORS_MAX
|
||||
return True, count, []
|
||||
|
||||
|
||||
def _missing_sources_for_promo(diag: dict[str, object]) -> str | None:
|
||||
available = set(diag.get("available_sources") or [])
|
||||
missing = []
|
||||
@@ -451,12 +190,7 @@ def _missing_sources_for_promo(diag: dict[str, object]) -> str | None:
|
||||
return missing[0]
|
||||
|
||||
|
||||
def _show_runtime_ui(
|
||||
report: schema.Report,
|
||||
progress: ui.ProgressDisplay,
|
||||
diag: dict[str, object],
|
||||
suppress_web_promo: bool = False,
|
||||
) -> None:
|
||||
def _show_runtime_ui(report: schema.Report, progress: ui.ProgressDisplay, diag: dict[str, object]) -> None:
|
||||
counts = {source: len(items) for source, items in report.items_by_source.items()}
|
||||
display_sources = list(
|
||||
dict.fromkeys(
|
||||
@@ -473,19 +207,7 @@ def _show_runtime_ui(
|
||||
display_sources=display_sources,
|
||||
)
|
||||
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 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)
|
||||
|
||||
|
||||
@@ -537,13 +259,6 @@ def main() -> int:
|
||||
parser.print_usage(sys.stderr)
|
||||
return 2
|
||||
|
||||
if not os.environ.get("LAST30DAYS_SKIP_PREFLIGHT"):
|
||||
from lib import preflight
|
||||
refuse_msg = preflight.check_class_1_trap(topic)
|
||||
if refuse_msg:
|
||||
sys.stderr.write(refuse_msg)
|
||||
return 2
|
||||
|
||||
progress = ui.ProgressDisplay(topic, show_banner=True)
|
||||
progress.start_processing()
|
||||
|
||||
@@ -594,6 +309,7 @@ def main() -> int:
|
||||
|
||||
github_user = args.github_user.lstrip("@").lower() if args.github_user else None
|
||||
github_repos = [r.strip() for r in args.github_repo.split(",") if r.strip() and "/" in r.strip()] if args.github_repo else None
|
||||
podcast_channels = [c.strip().lstrip("@") for c in args.podcast_channels.split(",") if c.strip()] if args.podcast_channels else None
|
||||
|
||||
# --deep-research: auto-enable perplexity source and set deep flag
|
||||
if args.deep_research:
|
||||
@@ -606,214 +322,30 @@ def main() -> int:
|
||||
if "perplexity" not in include.lower():
|
||||
config["INCLUDE_SOURCES"] = f"{include},perplexity" if include else "perplexity"
|
||||
|
||||
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:
|
||||
r = pipeline.run(
|
||||
topic=topic,
|
||||
config=config,
|
||||
depth=depth,
|
||||
requested_sources=requested_sources,
|
||||
mock=args.mock,
|
||||
x_handle=args.x_handle,
|
||||
x_related=x_related,
|
||||
web_backend=args.web_backend,
|
||||
external_plan=external_plan,
|
||||
subreddits=subreddits,
|
||||
tiktok_hashtags=tiktok_hashtags,
|
||||
tiktok_creators=tiktok_creators,
|
||||
ig_creators=ig_creators,
|
||||
lookback_days=args.lookback_days,
|
||||
github_user=github_user,
|
||||
github_repos=github_repos,
|
||||
)
|
||||
r.artifacts["resolved"] = {
|
||||
"entity": topic,
|
||||
"x_handle": (args.x_handle or "").lstrip("@"),
|
||||
"subreddits": list(subreddits or []),
|
||||
"github_user": (github_user or ""),
|
||||
"github_repos": list(github_repos or []),
|
||||
"context": config.get("_auto_resolve_context", "") or "",
|
||||
}
|
||||
return r
|
||||
|
||||
if comp_enabled:
|
||||
from lib import competitors as competitors_mod
|
||||
from lib import fanout, resolve as resolve_mod
|
||||
|
||||
if comp_explicit:
|
||||
discovered = comp_explicit
|
||||
else:
|
||||
if not resolve_mod._has_backend(config) and not args.mock:
|
||||
sys.stderr.write(
|
||||
"[Competitors] Cannot auto-discover peers without help.\n"
|
||||
"\n"
|
||||
"RECOMMENDED PATH (hosting reasoning models — Claude Code, Codex, "
|
||||
"Hermes, Gemini, any agent with a WebSearch tool): YOU have "
|
||||
"WebSearch. Use it to run full Step 0.55 per entity, then invoke "
|
||||
"the engine with a vs-topic plus --competitors-plan:\n"
|
||||
" 1. WebSearch for '{topic} competitors' or '{topic} alternatives'.\n"
|
||||
" 2. For each peer, WebSearch for handles/subs/github (Step 0.55).\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
|
||||
discovered = competitors_mod.discover_competitors(
|
||||
topic, comp_count, config, lookback_days=args.lookback_days,
|
||||
)
|
||||
if not discovered:
|
||||
sys.stderr.write(
|
||||
f"[Competitors] No peers discovered for {topic!r}; aborting "
|
||||
"comparison run. Pass --competitors-list to override.\n"
|
||||
)
|
||||
return 2
|
||||
|
||||
sys.stderr.write(
|
||||
f"[Competitors] Comparing: {topic} vs " + " vs ".join(discovered) + "\n"
|
||||
)
|
||||
|
||||
def _competitor_runner(entity: str) -> schema.Report:
|
||||
# Deep-copy config so per-entity auto_resolve context does not
|
||||
# leak across sub-runs. Each sub-run writes its own
|
||||
# `_auto_resolve_context` into its local config copy.
|
||||
entity_config = dict(config)
|
||||
plan_entry = comp_plan.get(entity.strip().lower(), {})
|
||||
resolved = {
|
||||
"entity": entity,
|
||||
"x_handle": "",
|
||||
"subreddits": [],
|
||||
"github_user": "",
|
||||
"github_repos": [],
|
||||
"context": "",
|
||||
}
|
||||
# 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:
|
||||
r = resolve_mod.auto_resolve(entity, entity_config)
|
||||
except Exception as exc:
|
||||
sys.stderr.write(
|
||||
f"[Competitors] auto_resolve failed for {entity!r}: "
|
||||
f"{type(exc).__name__}: {exc}\n"
|
||||
)
|
||||
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 ""
|
||||
kwargs = subrun_kwargs_for(entity, plan_entry, resolved=resolved)
|
||||
# Record effective per-entity targeting for the Resolved block.
|
||||
resolved_effective = {
|
||||
"entity": entity,
|
||||
"x_handle": kwargs["x_handle"] or "",
|
||||
"subreddits": kwargs["subreddits"] or [],
|
||||
"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(
|
||||
topic=entity,
|
||||
config=entity_config,
|
||||
depth=depth,
|
||||
requested_sources=requested_sources,
|
||||
mock=args.mock,
|
||||
x_handle=kwargs["x_handle"],
|
||||
x_related=kwargs["x_related"],
|
||||
subreddits=kwargs["subreddits"],
|
||||
github_user=kwargs["github_user"],
|
||||
github_repos=kwargs["github_repos"],
|
||||
web_backend=args.web_backend,
|
||||
lookback_days=args.lookback_days,
|
||||
internal_subrun=True,
|
||||
)
|
||||
report.artifacts["resolved"] = resolved_effective
|
||||
return report
|
||||
|
||||
entity_reports = fanout.run_competitor_fanout(
|
||||
main_topic=topic,
|
||||
main_runner=_main_runner,
|
||||
competitors=discovered,
|
||||
competitor_runner=_competitor_runner,
|
||||
)
|
||||
if len(entity_reports) < 2:
|
||||
progress.end_processing()
|
||||
sys.stderr.write(
|
||||
f"[Competitors] Fewer than 2 sub-runs survived ({len(entity_reports)}); "
|
||||
"cannot render a comparison. Re-run without --competitors or check the "
|
||||
"warnings above.\n"
|
||||
)
|
||||
return 1
|
||||
report = entity_reports[0][1]
|
||||
else:
|
||||
entity_reports = None
|
||||
report = _main_runner()
|
||||
report = pipeline.run(
|
||||
topic=topic,
|
||||
config=config,
|
||||
depth=depth,
|
||||
requested_sources=requested_sources,
|
||||
mock=args.mock,
|
||||
x_handle=args.x_handle,
|
||||
x_related=x_related,
|
||||
web_backend=args.web_backend,
|
||||
external_plan=external_plan,
|
||||
subreddits=subreddits,
|
||||
tiktok_hashtags=tiktok_hashtags,
|
||||
tiktok_creators=tiktok_creators,
|
||||
ig_creators=ig_creators,
|
||||
lookback_days=args.lookback_days,
|
||||
github_user=github_user,
|
||||
github_repos=github_repos,
|
||||
podcast_channels=podcast_channels,
|
||||
)
|
||||
except Exception as exc:
|
||||
progress.end_processing()
|
||||
progress.show_error(str(exc))
|
||||
raise
|
||||
_show_runtime_ui(
|
||||
report, progress, diag,
|
||||
suppress_web_promo=bool(external_plan or comp_plan),
|
||||
)
|
||||
_show_runtime_ui(report, progress, diag)
|
||||
if args.store:
|
||||
counts = persist_report(report)
|
||||
sys.stderr.write(
|
||||
@@ -832,47 +364,10 @@ def main() -> int:
|
||||
pass
|
||||
|
||||
fun_level = config.get("FUN_LEVEL", "medium").lower()
|
||||
footer_save_path = None
|
||||
rendered = emit_output(report, args.emit, fun_level=fun_level)
|
||||
if args.save_dir:
|
||||
footer_save_path = compute_save_path_display(
|
||||
args.save_dir, report.topic, args.save_suffix or "", args.emit
|
||||
)
|
||||
|
||||
# Signal to render_compact whether pre-research flags were supplied.
|
||||
# Used to emit a Pre-Research Status warning when the model skipped
|
||||
# Step 0.5 / 0.55 and invoked the engine bare on an eligible topic.
|
||||
pre_research_flags_present = bool(
|
||||
args.x_handle
|
||||
or args.github_user
|
||||
or args.subreddits
|
||||
or args.plan
|
||||
or args.auto_resolve
|
||||
or args.tiktok_creators
|
||||
or args.ig_creators
|
||||
)
|
||||
report.artifacts["pre_research_flags_present"] = pre_research_flags_present
|
||||
|
||||
if entity_reports:
|
||||
rendered = emit_comparison_output(
|
||||
entity_reports, args.emit, fun_level=fun_level, save_path=footer_save_path,
|
||||
)
|
||||
else:
|
||||
rendered = emit_output(
|
||||
report, args.emit, fun_level=fun_level, save_path=footer_save_path,
|
||||
)
|
||||
if args.save_dir:
|
||||
# Save 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 "")
|
||||
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()
|
||||
print(rendered)
|
||||
return 0
|
||||
|
||||
@@ -177,8 +177,6 @@ def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]:
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
preexec_fn=preexec,
|
||||
env=_subprocess_env(),
|
||||
)
|
||||
@@ -338,8 +336,6 @@ def search_handles(
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
preexec_fn=preexec,
|
||||
env=_subprocess_env(),
|
||||
)
|
||||
@@ -464,7 +460,7 @@ def parse_bird_response(response: Dict[str, Any], query: str = "") -> List[Dict[
|
||||
"url": url,
|
||||
"author_handle": author_handle.lstrip("@"),
|
||||
"date": date,
|
||||
"engagement": engagement if any(v is not None for v in engagement.values()) else None,
|
||||
"engagement": engagement,
|
||||
"why_relevant": "", # Bird doesn't provide relevance explanations
|
||||
"relevance": _compute_relevance(query, str(tweet.get("text", ""))) if query else 0.7,
|
||||
}
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
"""Category-peer subreddit map for Step 0.55 community resolution.
|
||||
|
||||
When a topic is a product in a known category (AI image generation, AI coding
|
||||
agents, SaaS screen recording, etc.), brand-specific subreddits returned by
|
||||
WebSearch are insufficient: cross-product technique discussion lives in
|
||||
category-peer subs. This module classifies a topic into a category by matching
|
||||
compound-term patterns against the lowercased topic string, then returns the
|
||||
priority-ordered peer subreddit list for that category.
|
||||
|
||||
The map is intentionally small, curated, and code-reviewed. Adding a new
|
||||
category is a code change; there is no user-editable override surface.
|
||||
|
||||
False-positive guard: every pattern is either a multi-word compound (e.g.
|
||||
"image generation", "text to image") or a domain-specific single word
|
||||
(e.g. "midjourney", "stablediffusion"). Bare common nouns like "image",
|
||||
"ai", or "model" are never used as patterns.
|
||||
|
||||
First-match-wins: categories are evaluated in declared order. Entries are
|
||||
sorted from most-specific to least-specific so narrower categories claim a
|
||||
topic before broader ones. For example, `ai_image_generation` appears
|
||||
before `ai_chat_model` so "gpt image 2" matches the image-gen category.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional, TypedDict
|
||||
|
||||
|
||||
class _CategoryEntry(TypedDict):
|
||||
patterns: List[str]
|
||||
peer_subs: List[str]
|
||||
|
||||
|
||||
CATEGORY_PEERS: dict[str, _CategoryEntry] = {
|
||||
"ai_image_generation": {
|
||||
"patterns": [
|
||||
"image generation",
|
||||
"image gen",
|
||||
"text to image",
|
||||
"text-to-image",
|
||||
"gpt image",
|
||||
"gpt-image",
|
||||
"nano banana",
|
||||
"midjourney",
|
||||
"stable diffusion",
|
||||
"stablediffusion",
|
||||
"dall-e",
|
||||
"dalle",
|
||||
"flux.1",
|
||||
"flux schnell",
|
||||
"imagen",
|
||||
"seedance",
|
||||
"ideogram",
|
||||
"recraft",
|
||||
],
|
||||
"peer_subs": [
|
||||
"StableDiffusion",
|
||||
"midjourney",
|
||||
"dalle2",
|
||||
"aiArt",
|
||||
"PromptEngineering",
|
||||
"MediaSynthesis",
|
||||
],
|
||||
},
|
||||
"ai_video_generation": {
|
||||
"patterns": [
|
||||
"video generation",
|
||||
"text to video",
|
||||
"text-to-video",
|
||||
"sora",
|
||||
"veo 3",
|
||||
"veo3",
|
||||
"runway gen",
|
||||
"kling",
|
||||
"pika labs",
|
||||
"luma dream machine",
|
||||
"hailuo",
|
||||
],
|
||||
"peer_subs": [
|
||||
"aivideo",
|
||||
"StableDiffusion",
|
||||
"runwayml",
|
||||
"singularity",
|
||||
"MediaSynthesis",
|
||||
],
|
||||
},
|
||||
"ai_music_generation": {
|
||||
"patterns": [
|
||||
"music generation",
|
||||
"ai music",
|
||||
"suno",
|
||||
"udio",
|
||||
"riffusion",
|
||||
"stable audio",
|
||||
],
|
||||
"peer_subs": [
|
||||
"SunoAI",
|
||||
"udiomusic",
|
||||
"aimusic",
|
||||
"artificial",
|
||||
],
|
||||
},
|
||||
"ai_coding_agent": {
|
||||
"patterns": [
|
||||
"claude code",
|
||||
"cursor ide",
|
||||
"github copilot",
|
||||
"windsurf",
|
||||
"aider",
|
||||
"cline",
|
||||
"openclaw",
|
||||
"hermes agent",
|
||||
"continue.dev",
|
||||
"codeium",
|
||||
"sweep ai",
|
||||
"devin ai",
|
||||
"coding agent",
|
||||
"coding assistant",
|
||||
],
|
||||
"peer_subs": [
|
||||
"ChatGPTCoding",
|
||||
"LocalLLaMA",
|
||||
"singularity",
|
||||
"PromptEngineering",
|
||||
],
|
||||
},
|
||||
"ai_agent_framework": {
|
||||
"patterns": [
|
||||
"agent framework",
|
||||
"agentic framework",
|
||||
"langchain",
|
||||
"langgraph",
|
||||
"crewai",
|
||||
"autogen",
|
||||
"llamaindex",
|
||||
"dspy",
|
||||
"smolagents",
|
||||
],
|
||||
"peer_subs": [
|
||||
"LangChain",
|
||||
"LocalLLaMA",
|
||||
"AI_Agents",
|
||||
"MachineLearning",
|
||||
],
|
||||
},
|
||||
"ai_chat_model": {
|
||||
"patterns": [
|
||||
"gpt-5",
|
||||
"gpt-4",
|
||||
"claude opus",
|
||||
"claude sonnet",
|
||||
"claude haiku",
|
||||
"gemini pro",
|
||||
"gemini flash",
|
||||
"llama 3",
|
||||
"llama 4",
|
||||
"deepseek",
|
||||
"qwen",
|
||||
"mistral large",
|
||||
"grok",
|
||||
],
|
||||
"peer_subs": [
|
||||
"LocalLLaMA",
|
||||
"ChatGPT",
|
||||
"ClaudeAI",
|
||||
"singularity",
|
||||
"artificial",
|
||||
],
|
||||
},
|
||||
"saas_screen_recording": {
|
||||
"patterns": [
|
||||
"screen recording",
|
||||
"screen recorder",
|
||||
"loom video",
|
||||
"tella screen",
|
||||
"vidyard",
|
||||
"screen capture tool",
|
||||
],
|
||||
"peer_subs": [
|
||||
"SaaS",
|
||||
"screenrecording",
|
||||
"productivity",
|
||||
"Entrepreneur",
|
||||
],
|
||||
},
|
||||
"saas_productivity": {
|
||||
"patterns": [
|
||||
"notion app",
|
||||
"obsidian plugin",
|
||||
"obsidian app",
|
||||
"linear app",
|
||||
"asana",
|
||||
"clickup",
|
||||
"productivity app",
|
||||
],
|
||||
"peer_subs": [
|
||||
"productivity",
|
||||
"SaaS",
|
||||
"ObsidianMD",
|
||||
"Notion",
|
||||
],
|
||||
},
|
||||
"prediction_markets": {
|
||||
"patterns": [
|
||||
"polymarket",
|
||||
"kalshi",
|
||||
"prediction market",
|
||||
"event contracts",
|
||||
"manifold markets",
|
||||
],
|
||||
"peer_subs": [
|
||||
"Polymarket",
|
||||
"Kalshi",
|
||||
"predictionmarkets",
|
||||
],
|
||||
},
|
||||
"crypto_defi": {
|
||||
"patterns": [
|
||||
"defi protocol",
|
||||
"yield farming",
|
||||
"liquidity pool",
|
||||
"stablecoin",
|
||||
"ethereum layer",
|
||||
"layer 2",
|
||||
"l2 rollup",
|
||||
],
|
||||
"peer_subs": [
|
||||
"defi",
|
||||
"ethfinance",
|
||||
"CryptoCurrency",
|
||||
"ethereum",
|
||||
],
|
||||
},
|
||||
"dev_tool_cli": {
|
||||
"patterns": [
|
||||
"cli tool",
|
||||
"command line tool",
|
||||
"terminal app",
|
||||
"dev tool",
|
||||
],
|
||||
"peer_subs": [
|
||||
"commandline",
|
||||
"programming",
|
||||
"webdev",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def detect_category(topic: Optional[str]) -> Optional[str]:
|
||||
"""Classify a topic into a known category by compound-term match.
|
||||
|
||||
Returns the category id (e.g. "ai_image_generation") or None if no
|
||||
category's patterns match. Matching is case-insensitive substring over
|
||||
the lowercased topic. Declaration order wins (first-match-wins), so the
|
||||
map is ordered from most-specific to least-specific.
|
||||
|
||||
A None or empty topic returns None. Classification never raises on
|
||||
normal string inputs; callers do not need to wrap in try/except for
|
||||
typical paths, though defensive callers may.
|
||||
"""
|
||||
if not topic:
|
||||
return None
|
||||
lowered = topic.lower()
|
||||
for category_id, entry in CATEGORY_PEERS.items():
|
||||
for pattern in entry["patterns"]:
|
||||
if pattern in lowered:
|
||||
return category_id
|
||||
return None
|
||||
|
||||
|
||||
def peer_subs_for(category_id: Optional[str]) -> List[str]:
|
||||
"""Return the priority-ordered peer subreddit list for a category.
|
||||
|
||||
Returns an empty list for None or unknown category ids. The returned
|
||||
list is a fresh copy; callers may safely mutate it.
|
||||
"""
|
||||
if not category_id:
|
||||
return []
|
||||
entry = CATEGORY_PEERS.get(category_id)
|
||||
if not entry:
|
||||
return []
|
||||
return list(entry["peer_subs"])
|
||||
@@ -1,199 +0,0 @@
|
||||
"""Discover peer entities ("competitors") for a topic via web search.
|
||||
|
||||
Mirrors the `resolve.auto_resolve()` pattern: fan out 2-3 web searches via
|
||||
`grounding.web_search()`, then extract capitalized entity candidates from
|
||||
titles and snippets with deterministic text mining. No LLM call — the
|
||||
hosting reasoning model can always override discovery via
|
||||
`--competitors-list`.
|
||||
|
||||
Returned list is ordered by score (frequency across queries) and capped to
|
||||
the caller's requested count.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from . import dates, grounding
|
||||
from .resolve import _has_backend
|
||||
|
||||
# A "brand-shaped" token starts with uppercase OR is camelCase with an
|
||||
# uppercase letter later. Catches "Anthropic", "OpenAI", "xAI", "iPhone",
|
||||
# "eBay", "Hugging", "Face".
|
||||
_BRAND_TOKEN = (
|
||||
r"(?:[A-Z][A-Za-z0-9&.\-]*"
|
||||
r"|[a-z][A-Za-z0-9&.\-]*[A-Z][A-Za-z0-9&.\-]*)"
|
||||
)
|
||||
|
||||
# A capitalized phrase of 1-4 brand tokens separated by whitespace.
|
||||
_CAPITALIZED_PHRASE = re.compile(
|
||||
rf"\b{_BRAND_TOKEN}(?:\s+{_BRAND_TOKEN}){{0,3}}\b"
|
||||
)
|
||||
|
||||
# Title-case fillers common in listicle SERPs. Kept flat — extraction
|
||||
# rejects a candidate whose entire tokens are stopwords, not candidates
|
||||
# that merely contain one.
|
||||
_STOPWORD_TOKENS: frozenset[str] = frozenset(
|
||||
token.lower()
|
||||
for token in (
|
||||
# Listicle fillers
|
||||
"Top", "Best", "Worst", "Popular", "Leading", "Similar",
|
||||
"Alternatives", "Alternative", "Competitor", "Competitors",
|
||||
"vs", "Vs", "Versus", "Review", "Reviews", "Comparison",
|
||||
"Guide", "List", "Lists", "Full", "Complete", "Free", "Paid",
|
||||
"Tools", "Tool", "Options", "Rivals", "Rival", "Similar",
|
||||
"Pick", "Picks", "Ranking", "Ranked", "Recommended",
|
||||
# Grammar / time
|
||||
"The", "A", "An", "Of", "In", "For", "To", "With", "On", "At",
|
||||
"By", "From", "Is", "Are", "And", "Or", "But", "Than", "As",
|
||||
"This", "That", "These", "Those", "Our", "Your", "Their",
|
||||
"January", "February", "March", "April", "May", "June", "July",
|
||||
"August", "September", "October", "November", "December",
|
||||
# Years likely to appear as standalone tokens
|
||||
*(str(year) for year in range(2018, 2031)),
|
||||
# Miscellaneous SERP noise
|
||||
"AI", "Apps", "App", "Software", "Platform", "Service", "Startups",
|
||||
"Companies", "Company", "Products", "Product", "Brands", "Brand",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(f"[Competitors] {msg}", file=sys.stderr)
|
||||
|
||||
|
||||
def _topic_tokens(topic: str) -> set[str]:
|
||||
"""Return lowercase alphanumeric tokens of the topic for filtering."""
|
||||
return {tok for tok in re.findall(r"[A-Za-z0-9]+", topic.lower()) if tok}
|
||||
|
||||
|
||||
def _candidate_ok(candidate: str, topic_tokens: set[str]) -> bool:
|
||||
"""Filter a candidate phrase against stopwords and topic overlap."""
|
||||
tokens = [t for t in re.findall(r"[A-Za-z0-9&.\-]+", candidate) if t]
|
||||
if not tokens:
|
||||
return False
|
||||
# Reject candidates made entirely of stopwords (e.g., "Top Alternatives").
|
||||
if all(tok.lower() in _STOPWORD_TOKENS for tok in tokens):
|
||||
return False
|
||||
# Reject candidates that overlap with the topic (e.g., topic="OpenAI"
|
||||
# should not return "OpenAI Alternatives" or "OpenAI").
|
||||
lower_tokens = {tok.lower() for tok in tokens}
|
||||
if lower_tokens & topic_tokens:
|
||||
return False
|
||||
# Reject too-short one-letter tokens like "I" or single digits.
|
||||
if len(tokens) == 1 and len(tokens[0]) < 2:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _normalize_candidate(candidate: str) -> str:
|
||||
"""Collapse whitespace and strip trailing punctuation."""
|
||||
return re.sub(r"\s+", " ", candidate).strip(".,;:!?'\"()[] ")
|
||||
|
||||
|
||||
def _extract_peer_entities(
|
||||
items: list[dict], topic: str, limit: int,
|
||||
) -> list[str]:
|
||||
"""Score capitalized candidates across SERP items and return top `limit`.
|
||||
|
||||
Scoring is bag-of-phrases frequency across all items in the input. Ties
|
||||
are broken by first-seen order so the output is deterministic.
|
||||
"""
|
||||
topic_tokens = _topic_tokens(topic)
|
||||
counts: Counter[str] = Counter()
|
||||
first_seen: dict[str, int] = {}
|
||||
order = 0
|
||||
# Group candidates into a frequency map keyed by lowercased normalized
|
||||
# form so "xAI" and "xAI" count together regardless of case.
|
||||
canonical: dict[str, str] = {}
|
||||
for item in items:
|
||||
text = f"{item.get('title', '')} {item.get('snippet', '')}"
|
||||
for raw in _CAPITALIZED_PHRASE.findall(text):
|
||||
candidate = _normalize_candidate(raw)
|
||||
if not _candidate_ok(candidate, topic_tokens):
|
||||
continue
|
||||
key = candidate.lower()
|
||||
if key not in canonical:
|
||||
canonical[key] = candidate
|
||||
first_seen[key] = order
|
||||
order += 1
|
||||
counts[key] += 1
|
||||
|
||||
ranked_keys = sorted(
|
||||
counts.keys(),
|
||||
key=lambda k: (-counts[k], first_seen[k]),
|
||||
)
|
||||
return [canonical[k] for k in ranked_keys[:limit]]
|
||||
|
||||
|
||||
def _queries_for(topic: str) -> dict[str, str]:
|
||||
return {
|
||||
"competitors": f"{topic} competitors",
|
||||
"alternatives": f"{topic} alternatives",
|
||||
"vs": f"{topic} vs",
|
||||
}
|
||||
|
||||
|
||||
def discover_competitors(
|
||||
topic: str,
|
||||
count: int,
|
||||
config: dict,
|
||||
*,
|
||||
lookback_days: int = 30,
|
||||
) -> list[str]:
|
||||
"""Discover `count` peer entities for `topic` via web search.
|
||||
|
||||
Args:
|
||||
topic: The primary research topic.
|
||||
count: Desired number of competitor entities (1..N).
|
||||
config: Runtime config dict — expects the same shape as the engine
|
||||
config (BRAVE_API_KEY / EXA_API_KEY / SERPER_API_KEY / etc.).
|
||||
lookback_days: Date range for freshness. Defaults to 30.
|
||||
|
||||
Returns:
|
||||
A list of up to `count` entity names, deduped and ordered by score.
|
||||
Empty list when no web backend is configured or every search fails
|
||||
or returns zero usable candidates.
|
||||
"""
|
||||
if count < 1:
|
||||
return []
|
||||
if not _has_backend(config):
|
||||
_log("No web search backend available, skipping competitor discovery")
|
||||
return []
|
||||
|
||||
date_range = dates.get_date_range(lookback_days)
|
||||
queries = _queries_for(topic)
|
||||
collected: list[dict] = []
|
||||
searches_run = 0
|
||||
|
||||
def _search(label: str, query: str) -> tuple[str, list[dict]]:
|
||||
items, _artifact = grounding.web_search(query, date_range, config)
|
||||
return label, items
|
||||
|
||||
with ThreadPoolExecutor(max_workers=len(queries)) as executor:
|
||||
futures = {
|
||||
executor.submit(_search, label, q): label
|
||||
for label, q in queries.items()
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
label = futures[future]
|
||||
try:
|
||||
_label, items = future.result()
|
||||
collected.extend(items)
|
||||
searches_run += 1
|
||||
except Exception as exc:
|
||||
_log(f"Search failed for {label}: {exc}")
|
||||
|
||||
if not collected:
|
||||
_log(f"No SERP results for {topic!r} across {searches_run}/{len(queries)} queries")
|
||||
return []
|
||||
|
||||
entities = _extract_peer_entities(collected, topic, limit=count)
|
||||
_log(
|
||||
f"Discovered {len(entities)} competitor(s) for {topic!r} "
|
||||
f"from {searches_run}/{len(queries)} queries: {entities}"
|
||||
)
|
||||
return entities
|
||||
+2
-30
@@ -264,7 +264,7 @@ def get_config() -> dict[str, Any]:
|
||||
('XQUIK_API_KEY', None),
|
||||
('FROM_BROWSER', None),
|
||||
('SETUP_COMPLETE', None),
|
||||
('INCLUDE_SOURCES', ''),
|
||||
('INCLUDE_SOURCES', None),
|
||||
]
|
||||
|
||||
for key, default in keys:
|
||||
@@ -356,10 +356,6 @@ def get_x_source_with_method(config: dict[str, Any]) -> tuple[str | None, str]:
|
||||
if config.get("AUTH_TOKEN") and config.get("CT0"):
|
||||
method = config.get("_AUTH_TOKEN_SOURCE", "env")
|
||||
return "bird", method
|
||||
# Fall back to xurl CLI (official X API v2, OAuth2, free developer app)
|
||||
from . import xurl_x
|
||||
if xurl_x.is_available():
|
||||
return "xurl", "oauth2"
|
||||
return None, "none"
|
||||
|
||||
|
||||
@@ -405,7 +401,6 @@ def get_x_source(config: dict[str, Any]) -> str | None:
|
||||
Returns:
|
||||
'bird' if Bird is installed and explicit cookies are configured,
|
||||
'xai' if XAI_API_KEY is configured,
|
||||
'xurl' if xurl CLI is installed and authenticated,
|
||||
None if no X source available.
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
@@ -426,11 +421,6 @@ def get_x_source(config: dict[str, Any]) -> str | None:
|
||||
if has_bird_creds and bird_x.is_bird_installed():
|
||||
return 'bird'
|
||||
|
||||
# Fall back to xurl CLI (official X API v2, OAuth2, free developer app)
|
||||
from . import xurl_x
|
||||
if xurl_x.is_available():
|
||||
return 'xurl'
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -451,18 +441,6 @@ def is_youtube_comments_available(config: dict[str, Any]) -> bool:
|
||||
return 'youtube_comments' in include
|
||||
|
||||
|
||||
def is_tiktok_comments_available(config: dict[str, Any]) -> bool:
|
||||
"""Check if TikTok comment enrichment is available.
|
||||
|
||||
Requires SCRAPECREATORS_API_KEY AND tiktok_comments in INCLUDE_SOURCES.
|
||||
Mirrors the youtube_comments opt-in pattern.
|
||||
"""
|
||||
if not config.get('SCRAPECREATORS_API_KEY'):
|
||||
return False
|
||||
include = _parse_include_sources(config)
|
||||
return 'tiktok_comments' in include
|
||||
|
||||
|
||||
def is_youtube_sc_available(config: dict[str, Any]) -> bool:
|
||||
"""Check if ScrapeCreators YouTube search fallback is available.
|
||||
|
||||
@@ -601,8 +579,6 @@ def get_x_source_status(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
from . import bird_x
|
||||
|
||||
if config.get('AUTH_TOKEN') and config.get('CT0'):
|
||||
bird_x.set_credentials(config.get('AUTH_TOKEN'), config.get('CT0'))
|
||||
bird_status = bird_x.get_bird_status()
|
||||
xai_available = bool(config.get('XAI_API_KEY'))
|
||||
|
||||
@@ -612,18 +588,14 @@ def get_x_source_status(config: dict[str, Any]) -> dict[str, Any]:
|
||||
elif xai_available:
|
||||
source = 'xai'
|
||||
else:
|
||||
# Fall back to xurl CLI
|
||||
from . import xurl_x as _xurl_check
|
||||
source = 'xurl' if _xurl_check.is_available() else None
|
||||
source = None
|
||||
|
||||
from . import xurl_x as _xurl_x
|
||||
return {
|
||||
"source": source,
|
||||
"bird_installed": bird_status["installed"],
|
||||
"bird_authenticated": bird_status["authenticated"],
|
||||
"bird_username": bird_status["username"],
|
||||
"xai_available": xai_available,
|
||||
"xurl_available": _xurl_x.is_available(),
|
||||
"can_install_bird": bird_status["can_install"],
|
||||
}
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
"""Parallel multi-entity fan-out for the --competitors flag.
|
||||
|
||||
The orchestrator accepts a `main_runner()` for the topic and a
|
||||
`competitor_runner(entity)` for each peer. It parallelizes their execution
|
||||
via a `ThreadPoolExecutor` and collects per-entity Reports. Per-entity
|
||||
failures are logged and dropped; the run survives as long as the main topic
|
||||
plus at least one competitor succeed.
|
||||
|
||||
This module owns no business logic about pipeline arguments — the caller
|
||||
(scripts/last30days.py main) builds the closures with the appropriate
|
||||
config, depth, and overrides for each entity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Callable
|
||||
|
||||
from . import schema
|
||||
|
||||
# Sub-runs hit the same upstream APIs as the main topic. Cap parallelism so a
|
||||
# 6-way fan-out does not stampede a single backend's rate limit.
|
||||
MAX_PARALLEL_SUBRUNS = 6
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(f"[Fanout] {msg}", file=sys.stderr)
|
||||
|
||||
|
||||
def run_competitor_fanout(
|
||||
*,
|
||||
main_topic: str,
|
||||
main_runner: Callable[[], schema.Report],
|
||||
competitors: list[str],
|
||||
competitor_runner: Callable[[str], schema.Report],
|
||||
) -> list[tuple[str, schema.Report]]:
|
||||
"""Run main + competitor pipelines in parallel; return surviving reports.
|
||||
|
||||
Args:
|
||||
main_topic: Display label for the user's primary topic.
|
||||
main_runner: Zero-arg callable returning the main topic's Report.
|
||||
competitors: Ordered list of competitor entity names.
|
||||
competitor_runner: Callable(entity_name) -> Report for each peer.
|
||||
|
||||
Returns:
|
||||
Ordered list of (entity_name, Report) tuples for runs that succeeded.
|
||||
Empty list if every run raised; the caller decides how to surface
|
||||
partial-failure modes.
|
||||
"""
|
||||
if not competitors:
|
||||
report = main_runner()
|
||||
return [(main_topic, report)]
|
||||
|
||||
workers = min(len(competitors) + 1, MAX_PARALLEL_SUBRUNS)
|
||||
|
||||
def _run_one(label: str, fn: Callable[[], schema.Report]) -> tuple[str, schema.Report | None, Exception | None]:
|
||||
try:
|
||||
return label, fn(), None
|
||||
except Exception as exc:
|
||||
return label, None, exc
|
||||
|
||||
submissions: list[tuple[str, Callable[[], schema.Report]]] = [
|
||||
(main_topic, main_runner),
|
||||
]
|
||||
for entity in competitors:
|
||||
submissions.append((entity, lambda e=entity: competitor_runner(e)))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
futures = {
|
||||
executor.submit(_run_one, label, fn): label
|
||||
for label, fn in submissions
|
||||
}
|
||||
results: dict[str, schema.Report] = {}
|
||||
for future in as_completed(futures):
|
||||
label, report, exc = future.result()
|
||||
if exc is not None:
|
||||
_log(f"Sub-run failed for {label!r}: {type(exc).__name__}: {exc}")
|
||||
continue
|
||||
assert report is not None
|
||||
results[label] = report
|
||||
|
||||
# Preserve the original submission order rather than completion order so
|
||||
# the comparison render is deterministic across runs.
|
||||
return [(label, results[label]) for label, _ in submissions if label in results]
|
||||
@@ -17,7 +17,7 @@ import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from . import dates, log
|
||||
from . import log
|
||||
from .query import extract_core_subject
|
||||
from .relevance import token_overlap_relevance
|
||||
|
||||
@@ -106,14 +106,13 @@ def _parse_repo_from_url(html_url: str) -> str:
|
||||
|
||||
|
||||
def _parse_date(iso_str: Optional[str]) -> Optional[str]:
|
||||
"""Parse a GitHub ISO 8601 datetime string and return YYYY-MM-DD.
|
||||
|
||||
Returns None for non-date input. GitHub's API always emits ISO 8601
|
||||
(e.g. "2026-02-26T16:00:00Z"), but we defer to dates.parse_date() so
|
||||
garbage input gets rejected instead of silently sliced.
|
||||
"""
|
||||
dt = dates.parse_date(iso_str)
|
||||
return dt.strftime("%Y-%m-%d") if dt else None
|
||||
"""Extract YYYY-MM-DD from ISO 8601 datetime string."""
|
||||
if not iso_str:
|
||||
return None
|
||||
try:
|
||||
return iso_str[:10]
|
||||
except (IndexError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _compute_relevance(
|
||||
|
||||
@@ -38,7 +38,6 @@ def request(
|
||||
url: str,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
json_data: Optional[Dict[str, Any]] = None,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
timeout: int = DEFAULT_TIMEOUT,
|
||||
retries: int = MAX_RETRIES,
|
||||
max_429_retries: int = MAX_429_RETRIES,
|
||||
@@ -51,8 +50,6 @@ def request(
|
||||
url: Request URL
|
||||
headers: Optional headers dict
|
||||
json_data: Optional JSON body (for POST)
|
||||
params: Optional query-string params. Values are stringified. None values
|
||||
are dropped. If ``url`` already has a query string, ``params`` is appended.
|
||||
timeout: Request timeout in seconds
|
||||
retries: Number of retries on failure
|
||||
max_429_retries: Maximum 429 retries before giving up (separate cap)
|
||||
@@ -67,12 +64,6 @@ def request(
|
||||
headers = headers or {}
|
||||
headers.setdefault("User-Agent", USER_AGENT)
|
||||
|
||||
if params:
|
||||
filtered = {k: str(v) for k, v in params.items() if v is not None}
|
||||
if filtered:
|
||||
separator = "&" if ("?" in url) else "?"
|
||||
url = f"{url}{separator}{urlencode(filtered)}"
|
||||
|
||||
data = None
|
||||
if json_data is not None:
|
||||
data = json.dumps(json_data).encode('utf-8')
|
||||
@@ -166,14 +157,6 @@ def post_raw(url: str, json_data: Dict[str, Any], headers: Optional[Dict[str, st
|
||||
return request("POST", url, headers=headers, json_data=json_data, raw=True, **kwargs)
|
||||
|
||||
|
||||
def scrapecreators_headers(token: str) -> Dict[str, str]:
|
||||
"""Build ScrapeCreators request headers (x-api-key + JSON content type)."""
|
||||
return {
|
||||
"x-api-key": token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def get_reddit_json(path: str, timeout: int = DEFAULT_TIMEOUT, retries: int = MAX_RETRIES) -> Dict[str, Any]:
|
||||
"""Fetch Reddit thread JSON.
|
||||
|
||||
|
||||
@@ -112,6 +112,14 @@ def _log(msg: str):
|
||||
log.source_log("Instagram", msg)
|
||||
|
||||
|
||||
def _sc_headers(token: str) -> Dict[str, str]:
|
||||
"""Build ScrapeCreators request headers."""
|
||||
return {
|
||||
"x-api-key": token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _parse_date(item: Dict[str, Any]) -> Optional[str]:
|
||||
"""Parse date from ScrapeCreators Instagram item to YYYY-MM-DD.
|
||||
|
||||
@@ -241,7 +249,7 @@ def _user_reels(
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"handle": handle})
|
||||
url = f"{reels_url}?{params}"
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers = _sc_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
except Exception as e:
|
||||
@@ -252,7 +260,7 @@ def _user_reels(
|
||||
resp = _requests.get(
|
||||
reels_url,
|
||||
params={"handle": handle},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
headers=_sc_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -299,7 +307,7 @@ def search_instagram(
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"query": core_topic})
|
||||
url = f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search?{params}"
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers = _sc_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
except Exception as e:
|
||||
@@ -310,7 +318,7 @@ def search_instagram(
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/v2/instagram/reels/search",
|
||||
params={"query": core_topic},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
headers=_sc_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -395,7 +403,7 @@ def fetch_captions(
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/v2/instagram/media/transcript",
|
||||
params={"url": url},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
headers=_sc_headers(token),
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
|
||||
@@ -53,6 +53,7 @@ def normalize_source_items(
|
||||
"xiaohongshu": _normalize_grounding,
|
||||
"github": _normalize_github,
|
||||
"perplexity": _normalize_grounding,
|
||||
"podcasts": lambda s, i, idx, fd, td: _normalize_youtube(s, i, idx, fd, td),
|
||||
}
|
||||
normalizer = normalizers.get(source)
|
||||
if normalizer is None:
|
||||
@@ -69,47 +70,6 @@ def normalize_source_items(
|
||||
return filtered
|
||||
|
||||
|
||||
def _remap_comments(
|
||||
raw: list[Any],
|
||||
score_keys: tuple[str, ...],
|
||||
excerpt_keys: tuple[str, ...],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Normalize comments from any source into the shared Reddit-compatible shape.
|
||||
|
||||
Downstream code (signals._top_comment_score, render._top_comments_list,
|
||||
entity_extract, rerank) all expect `score` and `excerpt`. This helper maps
|
||||
per-source field names (YT: likes/text, TikTok: digg_count/text) onto that
|
||||
shape while preserving author/date/url passthrough.
|
||||
"""
|
||||
out: list[dict[str, Any]] = []
|
||||
for raw_c in raw:
|
||||
if not isinstance(raw_c, dict):
|
||||
continue
|
||||
score = _first_present(raw_c, score_keys, default=0)
|
||||
excerpt = _first_present(raw_c, excerpt_keys, default="")
|
||||
try:
|
||||
score_int = int(score or 0)
|
||||
except (TypeError, ValueError):
|
||||
score_int = 0
|
||||
entry: dict[str, Any] = {
|
||||
"score": score_int,
|
||||
"excerpt": str(excerpt or "")[:400],
|
||||
"author": str(raw_c.get("author") or ""),
|
||||
"date": str(raw_c.get("date") or ""),
|
||||
}
|
||||
if raw_c.get("url"):
|
||||
entry["url"] = str(raw_c["url"])
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def _first_present(d: dict[str, Any], keys: tuple[str, ...], default: Any) -> Any:
|
||||
for key in keys:
|
||||
if key in d and d[key] not in (None, ""):
|
||||
return d[key]
|
||||
return default
|
||||
|
||||
|
||||
def _domain_from_url(url: str) -> str | None:
|
||||
if not url:
|
||||
return None
|
||||
@@ -241,11 +201,6 @@ def _normalize_youtube(
|
||||
metadata: dict[str, Any] = {}
|
||||
if highlights:
|
||||
metadata["transcript_highlights"] = highlights
|
||||
metadata["top_comments"] = _remap_comments(
|
||||
item.get("top_comments") or [],
|
||||
score_keys=("score", "likes"),
|
||||
excerpt_keys=("excerpt", "text"),
|
||||
)
|
||||
return _source_item(
|
||||
item_id=str(item.get("video_id") or item.get("id") or f"YT{index + 1}"),
|
||||
source=source,
|
||||
@@ -288,16 +243,7 @@ def _normalize_shortform_video(
|
||||
relevance_hint=item.get("relevance", 0.5),
|
||||
why_relevant=str(item.get("why_relevant") or ""),
|
||||
snippet=caption,
|
||||
metadata={
|
||||
"hashtags": item.get("hashtags") or [],
|
||||
"top_comments": _remap_comments(
|
||||
item.get("top_comments") or [],
|
||||
# TikTok uses digg_count as the vote field; Instagram has no
|
||||
# comment fetcher today so the key is harmlessly absent.
|
||||
score_keys=("score", "digg_count", "likes"),
|
||||
excerpt_keys=("excerpt", "text"),
|
||||
),
|
||||
},
|
||||
metadata={"hashtags": item.get("hashtags") or []},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -49,6 +49,14 @@ def _log(msg: str):
|
||||
log.source_log("Pinterest", msg)
|
||||
|
||||
|
||||
def _sc_headers(token: str) -> Dict[str, str]:
|
||||
"""Build ScrapeCreators request headers."""
|
||||
return {
|
||||
"x-api-key": token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _parse_items(raw_items: List[Dict[str, Any]], core_topic: str) -> List[Dict[str, Any]]:
|
||||
"""Parse raw Pinterest items into normalized dicts.
|
||||
|
||||
@@ -146,7 +154,7 @@ def search_pinterest(
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"keyword": core_topic})
|
||||
url = f"{SCRAPECREATORS_BASE}/search?{params}"
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers = _sc_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
except Exception as e:
|
||||
@@ -157,7 +165,7 @@ def search_pinterest(
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/search",
|
||||
params={"keyword": core_topic},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
headers=_sc_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
+23
-65
@@ -40,7 +40,7 @@ from . import (
|
||||
xai_x,
|
||||
xiaohongshu_api,
|
||||
xquik,
|
||||
xurl_x,
|
||||
podcast_yt,
|
||||
youtube_yt,
|
||||
)
|
||||
from .cluster import cluster_candidates
|
||||
@@ -78,6 +78,7 @@ MOCK_AVAILABLE_SOURCES = [
|
||||
"github",
|
||||
"perplexity",
|
||||
"xquik",
|
||||
"podcasts",
|
||||
]
|
||||
|
||||
|
||||
@@ -123,6 +124,11 @@ def available_sources(config: dict[str, Any], requested_sources: list[str] | Non
|
||||
available.append("pinterest")
|
||||
if env.is_xquik_available(config):
|
||||
available.append("xquik")
|
||||
# Podcasts: available whenever yt-dlp is installed (same as YouTube).
|
||||
# Opt-out only. The source returns empty when no channels are resolved,
|
||||
# so there's no cost to having it available.
|
||||
if podcast_yt.is_available():
|
||||
available.append("podcasts")
|
||||
return available
|
||||
|
||||
|
||||
@@ -178,7 +184,7 @@ def run(
|
||||
lookback_days: int = 30,
|
||||
github_user: str | None = None,
|
||||
github_repos: list[str] | None = None,
|
||||
internal_subrun: bool = False,
|
||||
podcast_channels: list[str] | None = None,
|
||||
) -> schema.Report:
|
||||
settings = DEPTH_SETTINGS[depth]
|
||||
requested_sources = normalize_requested_sources(requested_sources)
|
||||
@@ -206,7 +212,7 @@ def run(
|
||||
plan = planner._sanitize_plan(
|
||||
external_plan, topic, available, requested_sources, depth,
|
||||
)
|
||||
plan_source = "external"
|
||||
print(f"[Planner] Using external plan ({len(plan.subqueries)} subqueries)", file=sys.stderr)
|
||||
else:
|
||||
plan = planner.plan_query(
|
||||
topic=topic,
|
||||
@@ -216,16 +222,7 @@ def run(
|
||||
provider=None if mock else reasoning_provider,
|
||||
model=None if mock else runtime.planner_model,
|
||||
context=config.get("_auto_resolve_context", ""),
|
||||
internal_subrun=internal_subrun,
|
||||
)
|
||||
# Source labelling: the fallback path annotates notes with "fallback-plan"
|
||||
# or "deterministic-comparison-plan"; anything else came from the LLM.
|
||||
if any("fallback" in note or "deterministic" in note for note in (plan.notes or [])):
|
||||
plan_source = "deterministic"
|
||||
elif not mock and reasoning_provider and runtime.planner_model:
|
||||
plan_source = "llm"
|
||||
else:
|
||||
plan_source = "deterministic"
|
||||
|
||||
# Safety net: ensure grounding appears in all subqueries even if the planner
|
||||
# omits it. This is redundant when the planner includes grounding via
|
||||
@@ -235,32 +232,7 @@ def run(
|
||||
if "grounding" not in sq.sources:
|
||||
sq.sources.append("grounding")
|
||||
|
||||
# Always-on planner trace. Emits one summary line plus one per subquery
|
||||
# so retrieval-breadth failures like the 2026-04-19 Hermes Agent Use Cases
|
||||
# disaster are visible without --debug. Stderr only; does not leak into
|
||||
# the user-facing stdout synthesis.
|
||||
print(
|
||||
f"[Planner] Plan: intent={plan.intent}, freshness={plan.freshness_mode}, "
|
||||
f"cluster_mode={plan.cluster_mode}, subqueries={len(plan.subqueries)}, "
|
||||
f"source={plan_source}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if plan.subqueries:
|
||||
for index, sq in enumerate(plan.subqueries, start=1):
|
||||
sources_str = ",".join(sq.sources) if sq.sources else "(none)"
|
||||
print(
|
||||
f"[Planner] sq{index} label={sq.label} "
|
||||
f'search="{sq.search_query}" sources=[{sources_str}]',
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print("[Planner] (no subqueries in plan)", file=sys.stderr)
|
||||
|
||||
bundle = schema.RetrievalBundle(artifacts={"grounding": []})
|
||||
# Expose plan_source to the renderer so render_compact can emit the
|
||||
# DEGRADED RUN banner when a named-entity topic was invoked bare
|
||||
# (source=deterministic AND no pre-research flags). LAW 7 backstop.
|
||||
bundle.artifacts["plan_source"] = plan_source
|
||||
|
||||
# Project-mode or person-mode GitHub: run once before the main subquery loop
|
||||
_github_custom_done = False
|
||||
@@ -354,6 +326,7 @@ def run(
|
||||
tiktok_hashtags=tiktok_hashtags,
|
||||
tiktok_creators=tiktok_creators,
|
||||
ig_creators=ig_creators,
|
||||
podcast_channels=podcast_channels,
|
||||
)
|
||||
] = (subquery, source)
|
||||
|
||||
@@ -384,6 +357,7 @@ def run(
|
||||
tiktok_hashtags=tiktok_hashtags,
|
||||
tiktok_creators=tiktok_creators,
|
||||
ig_creators=ig_creators,
|
||||
podcast_channels=podcast_channels,
|
||||
)
|
||||
except Exception as retry_exc:
|
||||
bundle.errors_by_source[source] = f"{exc} (retried once, still failed: {retry_exc})"
|
||||
@@ -443,7 +417,7 @@ def run(
|
||||
if bundle.items_by_source.get(source):
|
||||
del bundle.errors_by_source[source]
|
||||
|
||||
items_by_source = _finalize_items_by_source(bundle.items_by_source, topic=topic, config=config)
|
||||
items_by_source = _finalize_items_by_source(bundle.items_by_source)
|
||||
candidates = weighted_rrf(bundle.items_by_source_and_query, plan, pool_limit=settings["pool_limit"])
|
||||
ranked_candidates = rerank.rerank_candidates(
|
||||
topic=topic,
|
||||
@@ -508,28 +482,11 @@ def _normalize_score_dedupe(
|
||||
return normalized
|
||||
|
||||
|
||||
def _finalize_items_by_source(
|
||||
items_by_source_raw: dict[str, list[schema.SourceItem]],
|
||||
topic: str = "",
|
||||
config: dict | None = None,
|
||||
) -> dict[str, list[schema.SourceItem]]:
|
||||
def _finalize_items_by_source(items_by_source_raw: dict[str, list[schema.SourceItem]]) -> dict[str, list[schema.SourceItem]]:
|
||||
finalized = {}
|
||||
for source, items in items_by_source_raw.items():
|
||||
items = sorted(items, key=lambda item: item.local_rank_score or 0.0, reverse=True)
|
||||
items = dedupe.dedupe_items(items)
|
||||
# Post-merge topic-relevance filter for Polymarket: comparison queries
|
||||
# fan out into per-entity subqueries ("Hermes", "OpenClaw") whose topic
|
||||
# is too narrow for Gamma API to filter meaningfully. Re-validating the
|
||||
# merged list against the full original topic drops off-topic markets
|
||||
# (e.g., WTI crude oil, Elon tweet counts) before footer emission.
|
||||
if source == "polymarket" and topic:
|
||||
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] = dedupe.dedupe_items(items)
|
||||
return finalized
|
||||
|
||||
|
||||
@@ -840,6 +797,7 @@ def _retrieve_stream(
|
||||
tiktok_hashtags: list[str] | None = None,
|
||||
tiktok_creators: list[str] | None = None,
|
||||
ig_creators: list[str] | None = None,
|
||||
podcast_channels: list[str] | None = None,
|
||||
) -> tuple[list[dict], dict]:
|
||||
# Early exit if source was rate-limited by a sibling future
|
||||
if rate_limited_sources is not None and source in rate_limited_sources:
|
||||
@@ -904,9 +862,6 @@ def _retrieve_stream(
|
||||
depth=depth,
|
||||
)
|
||||
return xai_x.parse_x_response(result), {}
|
||||
if backend == "xurl":
|
||||
result = xurl_x.search_x(subquery.search_query, depth=depth)
|
||||
return xurl_x.parse_x_response(result, topic=subquery.search_query), {}
|
||||
raise RuntimeError("No X backend is available.")
|
||||
if source == "youtube":
|
||||
# Use raw_topic so expand_youtube_queries() generates diverse variants
|
||||
@@ -930,6 +885,13 @@ def _retrieve_stream(
|
||||
sc_token = config.get("SCRAPECREATORS_API_KEY", "")
|
||||
youtube_yt.enrich_with_comments(items, token=sc_token)
|
||||
return items, {}
|
||||
if source == "podcasts":
|
||||
podcast_query = raw_topic or subquery.search_query
|
||||
result = podcast_yt.search_podcast_youtube(
|
||||
podcast_query, from_date, to_date,
|
||||
depth=depth, channels=podcast_channels,
|
||||
)
|
||||
return result.get("items", []), {}
|
||||
if source == "tiktok":
|
||||
# Use raw_topic so expand_tiktok_queries() generates diverse variants
|
||||
# from the original user topic, not the planner's narrowed search_query.
|
||||
@@ -943,11 +905,7 @@ def _retrieve_stream(
|
||||
hashtags=tiktok_hashtags,
|
||||
creators=tiktok_creators,
|
||||
)
|
||||
items = tiktok.parse_tiktok_response(result)
|
||||
if items and env.is_tiktok_comments_available(config):
|
||||
sc_token = config.get("SCRAPECREATORS_API_KEY", "")
|
||||
tiktok.enrich_with_comments(items, token=sc_token)
|
||||
return items, {}
|
||||
return tiktok.parse_tiktok_response(result), {}
|
||||
if source == "instagram":
|
||||
# Use raw_topic so expand_instagram_queries() generates diverse variants
|
||||
# from the original user topic, not the planner's narrowed search_query.
|
||||
|
||||
+9
-144
@@ -71,6 +71,7 @@ SOURCE_CAPABILITIES = {
|
||||
"github": {"discussion", "link"},
|
||||
"grounding": {"web", "reference", "link"},
|
||||
"perplexity": {"web", "reference", "analysis"},
|
||||
"podcasts": {"discussion", "video_longform", "expert"},
|
||||
}
|
||||
DEFAULT_INTENT_CAPABILITIES = {
|
||||
"comparison": {"discussion", "video", "web", "reference", "social", "link", "market"},
|
||||
@@ -86,16 +87,9 @@ def plan_query(
|
||||
provider: providers.ReasoningClient | None,
|
||||
model: str | None,
|
||||
context: str = "",
|
||||
internal_subrun: bool = False,
|
||||
) -> schema.QueryPlan:
|
||||
"""Create a query plan. Comparison queries with extractable entities use a
|
||||
deterministic plan; other intents prefer the configured reasoning provider.
|
||||
|
||||
internal_subrun: when True, suppress the LAW 7 "No --plan passed" stderr
|
||||
warning. LAW 7 targets the hosting-reasoning-model path; competitor
|
||||
fan-out sub-runs are engine-internal and the warning is a false positive
|
||||
there. Default False preserves the warning on every user-facing invocation.
|
||||
"""
|
||||
deterministic plan; other intents prefer the configured reasoning provider."""
|
||||
if _should_force_deterministic_plan(topic):
|
||||
return _fallback_plan(
|
||||
topic,
|
||||
@@ -120,26 +114,6 @@ def plan_query(
|
||||
topic, available_sources, requested_sources, depth,
|
||||
note=f"fallback-plan (LLM error: {type(exc).__name__})",
|
||||
)
|
||||
# No --plan was passed and no engine-internal provider is configured.
|
||||
# The deterministic fallback path produces weaker plans than either the
|
||||
# --plan path or the engine-internal LLM planner path. Surface this so
|
||||
# the HOSTING reasoning model (Claude Code, Codex, Hermes, Gemini, etc.)
|
||||
# realizes it is supposed to generate the plan itself and pass --plan.
|
||||
# The word "provider" in engine messages refers to the engine's INTERNAL
|
||||
# planner credentials - NOT a prerequisite the caller needs. If you are
|
||||
# the hosting reasoning model, YOU are the provider. LAW 7 / 2026-04-19
|
||||
# Hermes Agent Use Cases failure mode.
|
||||
if not internal_subrun:
|
||||
import sys
|
||||
print(
|
||||
"[Planner] No --plan passed. If you are the reasoning model hosting "
|
||||
"this skill (Claude Code, Codex, Hermes, Gemini, or any agent runtime), "
|
||||
"YOU ARE the planner: generate a JSON query plan yourself and pass it "
|
||||
"via --plan. You do not need an API key or credentials; you ARE the "
|
||||
"LLM. The deterministic fallback below is the headless/cron path only. "
|
||||
"See LAW 7 in SKILL.md and Step 0.75 for the plan schema.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return _fallback_plan(topic, available_sources, requested_sources, depth)
|
||||
|
||||
|
||||
@@ -178,7 +152,7 @@ Return JSON only with this shape:
|
||||
}}
|
||||
|
||||
Rules:
|
||||
- emit 1 to 5 subqueries (how_to/opinion/product/breaking_news intents benefit from 4-5; factual/concept from 2)
|
||||
- emit 1 to 4 subqueries
|
||||
- every subquery must include both search_query and ranking_query
|
||||
- sources must be drawn from Available sources only
|
||||
- use cluster_mode=none for factual or many how-to queries
|
||||
@@ -189,8 +163,6 @@ Rules:
|
||||
- preserve exact proper nouns and entity strings from the topic
|
||||
- NEVER include temporal phrases in search_query: no 'last 30 days', 'recent', month names, year numbers
|
||||
- NEVER include meta-research phrases: no 'news', 'updates', 'public appearances', 'latest developments'
|
||||
- INTENT-MODIFIER HANDLING: when the topic contains one of {{use cases, use case, workflows, workflow, examples, tutorial, tutorials, review, reviews, comparison, applications, in practice, production, production use, how i use}}, STRIP that phrase from every search_query (keep its meaning in ranking_query). Emit 4-5 paraphrased subqueries that each express the intent differently (e.g., 'production', 'workflow OR pipeline', 'review OR experience', 'vs COMPETITOR', 'community discussion'). Broad retrieval, narrow ranking. This was the 2026-04-19 Hermes Agent Use Cases failure mode: the planner echoed "hermes agent use cases" as a literal search string and returned near-zero results because nobody posts that exact phrase.
|
||||
- DO NOT quote the user's full topic verbatim in search_query. Quote only multi-word proper nouns like "Hermes Agent", "Claude Code", "Nous Research". Bare keywords OR'd together retrieve more than exact-phrase searches.
|
||||
- search_query should match how content is TITLED on platforms
|
||||
- GitHub (Issues/PRs) is best for engineering, developer tools, and open source topics: 'kanye west bully' not 'kanye west album news March 2026'
|
||||
""".strip()
|
||||
@@ -233,7 +205,7 @@ def _sanitize_plan(
|
||||
source_weights = _normalize_weights(source_weights)
|
||||
|
||||
subqueries: list[schema.SubQuery] = []
|
||||
for index, subquery in enumerate((raw.get("subqueries") or [])[:_max_subqueries(intent_hint, topic)], start=1):
|
||||
for index, subquery in enumerate((raw.get("subqueries") or [])[:_max_subqueries(intent_hint)], start=1):
|
||||
if not isinstance(subquery, dict):
|
||||
continue
|
||||
sources = [source for source in subquery.get("sources") or [] if source in source_weights]
|
||||
@@ -411,22 +383,13 @@ def _fallback_plan(
|
||||
)
|
||||
)
|
||||
|
||||
# Intent-modifier fanout: when topic contains a phrase like "use cases",
|
||||
# "workflows", "examples", "review" (see _INTENT_MODIFIER_PATTERNS),
|
||||
# paraphrase the intent across 3 extra subqueries rather than echoing
|
||||
# the literal phrase. Fixes 2026-04-19 Hermes Agent Use Cases failure.
|
||||
# Excluded for comparison/prediction since those already have dedicated
|
||||
# fanout (entity-per-subquery / odds).
|
||||
if depth != "quick" and intent not in {"comparison", "prediction"} and _has_intent_modifier(topic):
|
||||
subqueries.extend(_intent_modifier_subqueries(topic, core, base_search, source_weights))
|
||||
|
||||
return schema.QueryPlan(
|
||||
intent=intent,
|
||||
freshness_mode=_default_freshness(intent),
|
||||
cluster_mode=_default_cluster_mode(intent),
|
||||
raw_topic=topic,
|
||||
subqueries=_normalize_subquery_weights(
|
||||
_trim_subqueries_for_depth(subqueries[:_max_subqueries(intent, topic)], intent, depth, list(source_weights))
|
||||
_trim_subqueries_for_depth(subqueries[:_max_subqueries(intent)], intent, depth, list(source_weights))
|
||||
),
|
||||
source_weights=_normalize_weights(source_weights),
|
||||
notes=[note],
|
||||
@@ -456,15 +419,7 @@ def _infer_intent(topic: str) -> str:
|
||||
return "concept"
|
||||
if re.search(r"\b(tournament|championship|playoffs|march madness|world cup|olympics|super bowl|final four|ceremony|awards|keynote)\b", text):
|
||||
return "breaking_news"
|
||||
# Recency signals take priority when nothing more specific matched.
|
||||
if re.search(r"\b(trending|this week|right now|today|this month)\b", text):
|
||||
return "breaking_news"
|
||||
# Default changed from "breaking_news" to "concept" on 2026-04-19 after
|
||||
# the Hermes Agent Use Cases failure: unclassified topics were getting
|
||||
# strict_recent freshness, which over-weighted the last 7 days and
|
||||
# under-weighted older relevant material. "concept" defaults to
|
||||
# evergreen_ok freshness, a safer posture for unknown topics.
|
||||
return "concept"
|
||||
return "breaking_news"
|
||||
|
||||
|
||||
def _default_freshness(intent: str) -> str:
|
||||
@@ -510,26 +465,8 @@ def _default_source_weights(intent: str, sources: list[str]) -> dict[str, float]
|
||||
|
||||
|
||||
def _keyword_query(topic: str, core: str) -> str:
|
||||
"""Build a search_query string for the deterministic fallback.
|
||||
|
||||
Quote ONLY title-cased multi-word proper nouns ("Hermes Agent",
|
||||
"Claude Code", "Nous Research") so platform search engines preserve the
|
||||
name as a phrase. Hyphenated compounds and lowercase terms are left as
|
||||
bare keywords, which broadens retrieval instead of narrowing it.
|
||||
|
||||
Prior behavior quoted the entire compound including the user's typed
|
||||
topic, producing searches like `"Hermes Agent Actual Use Cases" hermes agent actual`
|
||||
that returned near-zero matches on X and Reddit because nobody posts
|
||||
that exact phrase. See 2026-04-19 Hermes Agent Use Cases failure.
|
||||
"""
|
||||
compounds = query.extract_compound_terms(topic)
|
||||
# Only quote title-cased proper nouns (multi-word names). Hyphenated
|
||||
# compounds go unquoted so platform tokenizers can split and match.
|
||||
title_cased = [
|
||||
term for term in compounds
|
||||
if re.match(r"^(?:[A-Z][a-z]+\s+){1,}[A-Z][a-z]+$", term)
|
||||
]
|
||||
quoted = " ".join(f'"{term}"' for term in title_cased[:2])
|
||||
quoted = " ".join(f"\"{term}\"" for term in compounds[:2])
|
||||
keywords = [quoted.strip(), core.strip() or topic.strip()]
|
||||
return " ".join(part for part in keywords if part).strip()
|
||||
|
||||
@@ -577,84 +514,12 @@ def _should_force_deterministic_plan(topic: str) -> bool:
|
||||
return _infer_intent(topic) == "comparison" and len(_comparison_entities(topic)) >= 2
|
||||
|
||||
|
||||
_INTENT_MODIFIER_PATTERNS = (
|
||||
"use cases", "use case", "workflows", "workflow",
|
||||
"examples", "example", "tutorial", "tutorials",
|
||||
"review", "reviews", "comparison", "applications",
|
||||
"in practice", "production use", "production",
|
||||
"how i use",
|
||||
)
|
||||
|
||||
|
||||
def _has_intent_modifier(topic: str) -> bool:
|
||||
"""Return True if the topic contains an intent modifier phrase.
|
||||
|
||||
See 2026-04-19 Hermes Agent Use Cases failure: a literal "Hermes Agent
|
||||
use cases" search returns near-zero matches because nobody posts that
|
||||
exact phrase. Intent modifiers should be stripped from search_query
|
||||
and paraphrased across multiple subqueries.
|
||||
"""
|
||||
text = topic.lower()
|
||||
return any(pattern in text for pattern in _INTENT_MODIFIER_PATTERNS)
|
||||
|
||||
|
||||
def _intent_modifier_subqueries(
|
||||
topic: str,
|
||||
core: str,
|
||||
base_search: str,
|
||||
source_weights: dict[str, float],
|
||||
) -> list[schema.SubQuery]:
|
||||
"""Produce paraphrased subqueries for intent-modifier topics.
|
||||
|
||||
The deterministic fallback used to echo the user's literal phrase
|
||||
(e.g., "hermes agent use cases") into every search_query. This helper
|
||||
fans out 3 extra subqueries that each express the intent differently
|
||||
so retrieval pulls a broader corpus for reranking.
|
||||
"""
|
||||
entity = core or topic.strip()
|
||||
sources = list(source_weights)
|
||||
return [
|
||||
schema.SubQuery(
|
||||
label="workflows",
|
||||
search_query=f"{entity} workflow pipeline",
|
||||
ranking_query=f"What real-world workflows or pipelines are people running with {entity}?",
|
||||
sources=sources,
|
||||
weight=0.6,
|
||||
),
|
||||
schema.SubQuery(
|
||||
label="production",
|
||||
search_query=f"{entity} production real-world",
|
||||
ranking_query=f"What production deployments or real-world use cases of {entity} are people describing?",
|
||||
sources=sources,
|
||||
weight=0.55,
|
||||
),
|
||||
schema.SubQuery(
|
||||
label="experience",
|
||||
search_query=f"{entity} experience review",
|
||||
ranking_query=f"What hands-on experience reports or reviews of {entity} exist in the last 30 days?",
|
||||
sources=sources,
|
||||
weight=0.5,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _max_subqueries(intent: str, topic: str | None = None) -> int:
|
||||
# how_to/opinion/product/breaking_news/prediction benefit from 4-5
|
||||
# paraphrased subqueries when the topic carries an intent modifier
|
||||
# (use cases, workflows, examples, review, etc.). See 2026-04-19
|
||||
# Hermes Agent Use Cases failure: prior cap of 3 produced near-literal
|
||||
# echoes of the topic instead of a paraphrase fanout.
|
||||
def _max_subqueries(intent: str) -> int:
|
||||
if intent == "comparison":
|
||||
return 4
|
||||
# Intent-modifier topics get headroom for paraphrase fanout even when
|
||||
# the intent itself is factual/concept. Without this, a "Hermes Agent
|
||||
# use cases" query (classified "concept" after the 2026-04-19 default
|
||||
# change) would be capped at 2 and drop the fanout.
|
||||
if topic and _has_intent_modifier(topic):
|
||||
return 5
|
||||
if intent in {"factual", "concept"}:
|
||||
return 2
|
||||
return 5
|
||||
return 3
|
||||
|
||||
|
||||
def _default_sources_for_intent(intent: str, available_sources: list[str]) -> list[str]:
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
"""YouTube podcast discovery via transcript scanning.
|
||||
|
||||
Discovers podcast content by fetching auto-captions from LLM-resolved
|
||||
YouTube podcast channels and grepping for the search topic. Finds content
|
||||
invisible to title-based search — e.g., Acquired's "The NFL" episode
|
||||
mentions Taylor Swift 18 times, ESPN 117 times, Netflix 102 times.
|
||||
|
||||
Uses yt-dlp for channel playlist fetch + caption download. No API keys.
|
||||
Reuses transcript highlight extraction from youtube_yt.
|
||||
"""
|
||||
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from . import log
|
||||
|
||||
# How many recent episodes to scan per channel, by depth
|
||||
EPISODES_PER_CHANNEL = {
|
||||
"quick": 2,
|
||||
"default": 3,
|
||||
"deep": 4,
|
||||
}
|
||||
|
||||
# Minimum topic mentions in captions to count as a hit
|
||||
MENTION_THRESHOLD = 5
|
||||
|
||||
# Max total results to return
|
||||
RESULTS_CAP = {
|
||||
"quick": 4,
|
||||
"default": 8,
|
||||
"deep": 20,
|
||||
}
|
||||
|
||||
# Min duration in seconds to qualify as a podcast episode
|
||||
MIN_DURATION = 1200 # 20 minutes
|
||||
|
||||
|
||||
def _log(msg: str):
|
||||
log.source_log("Podcasts", msg, tty_only=False)
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""Podcast source is available when yt-dlp is installed."""
|
||||
return shutil.which("yt-dlp") is not None
|
||||
|
||||
|
||||
def resolve_channel(handle: str) -> Optional[str]:
|
||||
"""Resolve a YouTube @handle to a channel URL.
|
||||
|
||||
Tries the @handle directly first (fast, ~92% success rate).
|
||||
Falls back to ytsearch1 if the handle doesn't resolve.
|
||||
|
||||
Returns the channel URL (https://www.youtube.com/channel/...) or None.
|
||||
"""
|
||||
# Try @handle directly - use the channel/videos URL format
|
||||
# yt-dlp can fetch from @handle URLs directly for playlist operations
|
||||
direct_url = f"https://www.youtube.com/@{handle}/videos"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["yt-dlp", "--playlist-end", "1",
|
||||
"--print", "%(channel_url)s",
|
||||
"--no-download", "--no-warnings", "--ignore-config", "--no-cookies-from-browser",
|
||||
direct_url],
|
||||
capture_output=True, text=True, timeout=20,
|
||||
)
|
||||
channel_url = result.stdout.strip().split("\n")[0].strip()
|
||||
if channel_url and channel_url.startswith("http"):
|
||||
_log(f"Resolved @{handle} -> {channel_url}")
|
||||
return channel_url
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
# Fallback: search for the podcast
|
||||
_log(f"@{handle} not found, trying search fallback")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["yt-dlp", "--flat-playlist", "--playlist-end", "1",
|
||||
"--print", "%(channel_url)s",
|
||||
f'ytsearch1:"{handle}" podcast full episode'],
|
||||
capture_output=True, text=True, timeout=20,
|
||||
)
|
||||
channel_url = result.stdout.strip()
|
||||
if channel_url and channel_url.startswith("http"):
|
||||
_log(f"Search fallback resolved {handle} -> {channel_url}")
|
||||
return channel_url
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
pass
|
||||
|
||||
_log(f"Could not resolve channel: {handle}")
|
||||
return None
|
||||
|
||||
|
||||
def _fetch_recent_episodes(
|
||||
channel_url: str,
|
||||
limit: int,
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Fetch recent long-form episodes from a channel.
|
||||
|
||||
Returns list of dicts with video_id, title, channel, duration, date, views, likes.
|
||||
Filters to episodes with duration >= MIN_DURATION.
|
||||
"""
|
||||
import json as _json
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["yt-dlp", f"--playlist-end={limit + 2}",
|
||||
"--dump-json", "--no-download", "--no-warnings", "--ignore-config", "--no-cookies-from-browser",
|
||||
f"{channel_url}/videos"],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
return []
|
||||
|
||||
episodes = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
video = _json.loads(line)
|
||||
except _json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
video_id = video.get("id", "")
|
||||
title = video.get("title", "")
|
||||
channel = video.get("channel", video.get("uploader", ""))
|
||||
duration = video.get("duration") or 0
|
||||
upload_date_raw = video.get("upload_date", "")
|
||||
views = video.get("view_count") or 0
|
||||
likes = video.get("like_count") or 0
|
||||
|
||||
# Convert YYYYMMDD to YYYY-MM-DD
|
||||
date_str = None
|
||||
if upload_date_raw and len(upload_date_raw) >= 8:
|
||||
date_str = f"{upload_date_raw[:4]}-{upload_date_raw[4:6]}-{upload_date_raw[6:8]}"
|
||||
|
||||
# Filter: duration >= MIN_DURATION
|
||||
if duration < MIN_DURATION:
|
||||
continue
|
||||
|
||||
# Filter: within date range (soft - keep if no date available)
|
||||
if date_str and (date_str < from_date or date_str > to_date):
|
||||
continue
|
||||
|
||||
episodes.append({
|
||||
"video_id": video_id,
|
||||
"title": title,
|
||||
"channel_name": channel,
|
||||
"duration": duration,
|
||||
"date": date_str,
|
||||
"views": views,
|
||||
"likes": likes,
|
||||
"url": f"https://www.youtube.com/watch?v={video_id}",
|
||||
})
|
||||
|
||||
return episodes[:limit]
|
||||
|
||||
|
||||
def _fetch_captions(video_id: str, temp_dir: str) -> Optional[str]:
|
||||
"""Fetch auto-captions for a video. Returns caption text or None."""
|
||||
out_template = os.path.join(temp_dir, f"cap_{video_id}")
|
||||
try:
|
||||
subprocess.run(
|
||||
["yt-dlp", "--write-auto-sub", "--sub-lang", "en",
|
||||
"--skip-download", "--sub-format", "vtt",
|
||||
"-o", out_template,
|
||||
f"https://www.youtube.com/watch?v={video_id}"],
|
||||
capture_output=True, text=True, timeout=30,
|
||||
)
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
return None
|
||||
|
||||
vtt_path = f"{out_template}.en.vtt"
|
||||
if not os.path.exists(vtt_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(vtt_path, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
os.remove(vtt_path)
|
||||
# Strip VTT formatting: timestamps, alignment, tags, duplicate lines
|
||||
# VTT auto-captions repeat lines as they scroll, so deduplicate
|
||||
lines = []
|
||||
prev_line = ""
|
||||
for line in text.split("\n"):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("WEBVTT") or line.startswith("Kind:") or line.startswith("Language:"):
|
||||
continue
|
||||
if re.match(r"^\d{2}:\d{2}:", line):
|
||||
continue
|
||||
if re.match(r"^NOTE\b", line):
|
||||
continue
|
||||
if "align:" in line or "position:" in line:
|
||||
continue
|
||||
# Strip inline VTT tags like <c>, </c>, timestamps
|
||||
cleaned = re.sub(r"<[^>]+>", "", line)
|
||||
cleaned = cleaned.strip()
|
||||
if cleaned and not re.match(r"^\d+$", cleaned) and cleaned != prev_line:
|
||||
lines.append(cleaned)
|
||||
prev_line = cleaned
|
||||
return " ".join(lines)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
_NOISE_WORDS = frozenset({
|
||||
"the", "a", "an", "of", "and", "or", "for", "to", "in", "on", "at",
|
||||
"best", "top", "new", "latest", "review", "news", "vs", "versus",
|
||||
"album", "song", "episode", "podcast", "interview", "this", "that",
|
||||
"what", "how", "why", "where", "when", "who",
|
||||
})
|
||||
|
||||
|
||||
def _extract_key_terms(topic: str) -> List[str]:
|
||||
"""Extract meaningful terms from topic for matching.
|
||||
|
||||
For "Kanye West Bully album" -> ["Kanye West", "Bully"] or similar.
|
||||
For single words, just returns the word.
|
||||
"""
|
||||
words = [w.strip() for w in topic.split() if w.strip()]
|
||||
# Remove noise words
|
||||
meaningful = [w for w in words if w.lower() not in _NOISE_WORDS and len(w) > 2]
|
||||
if not meaningful:
|
||||
return [topic.strip()]
|
||||
|
||||
# If the topic has 2+ meaningful words, also include the full phrase
|
||||
# and the first 2 words as a potential entity name
|
||||
terms = []
|
||||
if len(meaningful) >= 2:
|
||||
# Full phrase first (for exact entity matches like "Taylor Swift")
|
||||
terms.append(" ".join(meaningful[:2]))
|
||||
terms.extend(meaningful)
|
||||
return terms
|
||||
|
||||
|
||||
def _count_mentions(text: str, topic: str) -> int:
|
||||
"""Count case-insensitive topic mentions in text.
|
||||
|
||||
Uses the maximum mention count across key terms extracted from the topic.
|
||||
"Kanye West Bully album" -> max mentions of ["Kanye West", "Kanye", "West", "Bully"].
|
||||
This way, an episode mentioning "Kanye" 85 times counts as 85, not 0.
|
||||
"""
|
||||
text_lower = text.lower()
|
||||
terms = _extract_key_terms(topic)
|
||||
max_count = 0
|
||||
for term in terms:
|
||||
pattern = re.escape(term.lower())
|
||||
count = len(re.findall(pattern, text_lower))
|
||||
if count > max_count:
|
||||
max_count = count
|
||||
return max_count
|
||||
|
||||
|
||||
def _extract_mention_context(text: str, topic: str, max_excerpts: int = 3) -> List[str]:
|
||||
"""Extract text snippets around topic mentions for highlights."""
|
||||
words = text.split()
|
||||
topic_lower = topic.lower()
|
||||
excerpts = []
|
||||
|
||||
for i, word in enumerate(words):
|
||||
# Check if we're near a mention
|
||||
window = " ".join(words[max(0, i - 5):i + 15]).lower()
|
||||
if topic_lower in window and len(excerpts) < max_excerpts:
|
||||
start = max(0, i - 10)
|
||||
end = min(len(words), i + 30)
|
||||
excerpt = " ".join(words[start:end])
|
||||
# Avoid duplicate excerpts
|
||||
if not any(excerpt[:50] in e for e in excerpts):
|
||||
excerpts.append(excerpt)
|
||||
|
||||
return excerpts
|
||||
|
||||
|
||||
def _scan_channel(
|
||||
handle: str,
|
||||
topic: str,
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
episodes_limit: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Scan a single channel's recent episodes for topic mentions.
|
||||
|
||||
Returns list of hit items with mention_count and transcript data.
|
||||
"""
|
||||
# Step 1: Resolve channel handle to URL
|
||||
channel_url = resolve_channel(handle)
|
||||
if not channel_url:
|
||||
return []
|
||||
|
||||
# Step 2: Fetch recent long-form episodes
|
||||
episodes = _fetch_recent_episodes(channel_url, episodes_limit, from_date, to_date)
|
||||
if not episodes:
|
||||
_log(f"No recent long-form episodes from {handle}")
|
||||
return []
|
||||
|
||||
_log(f"Scanning {len(episodes)} episodes from {handle}")
|
||||
|
||||
# Step 3: Fetch captions and grep for topic
|
||||
hits = []
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
for ep in episodes:
|
||||
caption_text = _fetch_captions(ep["video_id"], temp_dir)
|
||||
if not caption_text:
|
||||
continue
|
||||
|
||||
mention_count = _count_mentions(caption_text, topic)
|
||||
if mention_count < MENTION_THRESHOLD:
|
||||
continue
|
||||
|
||||
# Extract highlights around the mentions
|
||||
from .youtube_yt import extract_transcript_highlights
|
||||
highlights = extract_transcript_highlights(caption_text, topic, limit=5)
|
||||
mention_excerpts = _extract_mention_context(caption_text, topic)
|
||||
|
||||
# Cap transcript for storage
|
||||
words = caption_text.split()
|
||||
transcript_snippet = " ".join(words[:5000]) if len(words) > 5000 else caption_text
|
||||
|
||||
hits.append({
|
||||
"video_id": ep["video_id"],
|
||||
"title": ep["title"],
|
||||
"channel_name": ep["channel_name"],
|
||||
"url": ep["url"],
|
||||
"date": ep["date"],
|
||||
"duration": ep["duration"],
|
||||
"engagement": {
|
||||
"views": ep["views"],
|
||||
"likes": ep["likes"],
|
||||
},
|
||||
"mention_count": mention_count,
|
||||
"transcript_snippet": transcript_snippet,
|
||||
"transcript_highlights": highlights,
|
||||
"mention_excerpts": mention_excerpts,
|
||||
"relevance": min(1.0, mention_count / 50),
|
||||
"why_relevant": f"Podcast: {ep['channel_name']} - {ep['title'][:60]} ({mention_count} mentions)",
|
||||
})
|
||||
|
||||
_log(f" HIT: {ep['title'][:60]} ({mention_count} mentions)")
|
||||
|
||||
return hits
|
||||
|
||||
|
||||
def search_podcast_youtube(
|
||||
topic: str,
|
||||
from_date: str,
|
||||
to_date: str,
|
||||
depth: str = "default",
|
||||
channels: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Discover podcast content by scanning transcripts of resolved channels.
|
||||
|
||||
Args:
|
||||
topic: Search topic
|
||||
from_date: Start date (YYYY-MM-DD)
|
||||
to_date: End date (YYYY-MM-DD)
|
||||
depth: 'quick', 'default', or 'deep'
|
||||
channels: List of YouTube @handles to scan
|
||||
|
||||
Returns:
|
||||
Dict with 'items' list. Each item has transcript and mention data.
|
||||
"""
|
||||
if not is_available():
|
||||
_log("yt-dlp not installed")
|
||||
return {"items": [], "error": "yt-dlp not installed"}
|
||||
|
||||
if not channels:
|
||||
_log("No podcast channels provided")
|
||||
return {"items": []}
|
||||
|
||||
episodes_limit = EPISODES_PER_CHANNEL.get(depth, EPISODES_PER_CHANNEL["default"])
|
||||
results_cap = RESULTS_CAP.get(depth, RESULTS_CAP["default"])
|
||||
|
||||
_log(f"Scanning {len(channels)} podcast channels for '{topic}' (depth={depth}, {episodes_limit} eps/channel)")
|
||||
|
||||
# Scan channels in parallel
|
||||
all_hits: List[Dict[str, Any]] = []
|
||||
max_workers = min(4, len(channels))
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||
futures = {
|
||||
executor.submit(
|
||||
_scan_channel, handle, topic, from_date, to_date, episodes_limit,
|
||||
): handle
|
||||
for handle in channels
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
handle = futures[future]
|
||||
try:
|
||||
hits = future.result()
|
||||
all_hits.extend(hits)
|
||||
except Exception as exc:
|
||||
_log(f"Error scanning {handle}: {type(exc).__name__}: {exc}")
|
||||
|
||||
# Deduplicate by video_id
|
||||
seen = set()
|
||||
unique_hits = []
|
||||
for hit in all_hits:
|
||||
vid = hit["video_id"]
|
||||
if vid not in seen:
|
||||
seen.add(vid)
|
||||
unique_hits.append(hit)
|
||||
|
||||
# Score: mention_count * log(views + 1)
|
||||
for hit in unique_hits:
|
||||
views = hit["engagement"].get("views", 0)
|
||||
hit["_score"] = hit["mention_count"] * math.log(views + 1)
|
||||
|
||||
# Sort by score descending
|
||||
unique_hits.sort(key=lambda x: x["_score"], reverse=True)
|
||||
|
||||
# Cap results
|
||||
results = unique_hits[:results_cap]
|
||||
|
||||
# Clean up internal scoring field
|
||||
for hit in results:
|
||||
hit.pop("_score", None)
|
||||
|
||||
_log(f"Found {len(results)} podcast hits across {len(channels)} channels")
|
||||
return {"items": results}
|
||||
@@ -117,9 +117,6 @@ _NOISE_WORDS = frozenset({
|
||||
"software", "plugin", "skill", "agent", "bot", "search", "research",
|
||||
# Generic prediction market terms
|
||||
"market", "odds", "prediction", "forecast", "chance", "probability",
|
||||
# Comparison-query conjunctions — should not count as informative filter tokens
|
||||
# when the topic is "X vs Y vs Z"
|
||||
"vs", "versus",
|
||||
})
|
||||
|
||||
|
||||
@@ -168,103 +165,6 @@ def _passes_topic_filter(topic: str, event_title: str) -> bool:
|
||||
return match_count >= min_matches
|
||||
|
||||
|
||||
def _passes_any_informative_word(topic: str, event_title: str) -> bool:
|
||||
"""Looser variant of _passes_topic_filter that keeps an item if ANY
|
||||
informative word from the topic appears in the title.
|
||||
|
||||
Designed for post-merge validation of comparison topics (e.g., "OpenClaw vs
|
||||
Hermes vs Paperclip"), where a market mentioning just one of the entities
|
||||
is still on-topic. The stricter _passes_topic_filter (min_matches=2 for
|
||||
3+ informative words) is correct for single-entity topics like "Mill.com
|
||||
food recycler" but drops legitimate single-entity comparison results.
|
||||
"""
|
||||
core = _extract_core_subject(topic).lower()
|
||||
core_words = [w for w in re.sub(r"[^\w\s]", " ", core).split() if len(w) > 1]
|
||||
if not core_words:
|
||||
return True
|
||||
informative = [w for w in core_words if w not in _NOISE_WORDS]
|
||||
if not informative:
|
||||
return True
|
||||
|
||||
title_lower = " ".join(re.sub(r"[^\w\s]", " ", event_title.lower()).split())
|
||||
title_words = set(title_lower.split())
|
||||
|
||||
for word in informative:
|
||||
if word in title_words:
|
||||
return True
|
||||
if len(word) >= 4 and word in title_lower:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def filter_items_against_topic(topic: str, items: List[Any]) -> List[Any]:
|
||||
"""Drop items whose title shares no informative word with the original topic.
|
||||
|
||||
Called post-merge from pipeline.py so per-entity subquery results for
|
||||
comparison topics get re-validated against the ORIGINAL full topic before
|
||||
landing in the footer. Prevents noise like WTI crude oil or Elon tweet
|
||||
markets from surviving a loose "Hermes" single-entity subquery match.
|
||||
|
||||
Uses the looser _passes_any_informative_word rule (ANY entity name match
|
||||
is sufficient) so a market mentioning just one of several compared entities
|
||||
still counts as on-topic.
|
||||
|
||||
Accepts a list of either raw dicts (with 'title') or SourceItem-like objects
|
||||
(with .title attribute). Returns the filtered list in the same order.
|
||||
"""
|
||||
if not topic:
|
||||
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 ""
|
||||
|
||||
if _passes_any_informative_word(topic, title):
|
||||
filtered.append(item)
|
||||
|
||||
dropped = len(items) - len(filtered)
|
||||
if dropped:
|
||||
_log(f"Post-merge topic filter dropped {dropped} Polymarket items against full topic '{topic}'")
|
||||
|
||||
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]:
|
||||
"""Extract domain-indicator search terms from first-pass event tags.
|
||||
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
"""Engine-side query-quality pre-flight.
|
||||
|
||||
Detects Class 1 (demographic shopping) keyword-trap queries and returns a
|
||||
structured REFUSE message. The caller (scripts/last30days.py main()) writes
|
||||
the message to stderr and exits code 2. No pipeline work runs on a doomed
|
||||
query; the model sees the REFUSE on stderr and asks the user for the
|
||||
hobbies/relationship/budget context it needs.
|
||||
|
||||
Patterns ported from SKILL.md Step 0.45 prose. Only Class 1 is implemented
|
||||
here because it has a verified failure mode on v3.0.8 (2026-04-18 'birthday
|
||||
gift for 40 year old' run returned r/todayilearned and unrelated drama
|
||||
posts).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_CLASS_1_PATTERNS = [
|
||||
re.compile(
|
||||
r"^\s*(birthday\s+)?(gift|gifts|present|presents)\s+"
|
||||
r"(for|ideas\s+for)\s+(a\s+|my\s+)?\d+[\s-]?year[\s-]?old\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
re.compile(
|
||||
r"^\s*(best|top)\s+[\w\s-]+?\s+for\s+"
|
||||
r"(men|women|kids|guys|girls|teens|dads|moms|husbands|wives|brothers|sisters|friends)\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
re.compile(
|
||||
r"^\s*what\s+to\s+(buy|get|gift)\s+(for\s+)?(a\s+|my\s+)?"
|
||||
r"(\d+[\s-]?year[\s-]?old|husband|wife|dad|mom|brother|sister|friend|boss|coworker)\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
re.compile(
|
||||
r"^\s*(present|presents|gift|gifts)\s+for\s+(a\s+|my\s+)?"
|
||||
r"(husband|wife|dad|mom|brother|sister|friend|boss|coworker)\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
]
|
||||
|
||||
_QUALIFIER_PATTERNS = [
|
||||
re.compile(r"\$\d+"),
|
||||
re.compile(r"\bbudget\b", re.IGNORECASE),
|
||||
re.compile(r"\bwho\s+(loves|likes|is\s+into|enjoys)\b", re.IGNORECASE),
|
||||
re.compile(r"\bhobbies?\b", re.IGNORECASE),
|
||||
re.compile(r"\b(cooking|running|reading|gaming|golf|woodworking|coding|hiking|cycling|fishing|music)[\s-]?(obsessed|enthusiast|fan|lover)\b", re.IGNORECASE),
|
||||
]
|
||||
|
||||
_RELATIONSHIP_WORDS = {
|
||||
"husband", "wife", "dad", "mom", "father", "mother", "brother", "sister",
|
||||
"friend", "boss", "coworker", "son", "daughter", "grandma", "grandpa",
|
||||
"aunt", "uncle", "nephew", "niece", "partner", "boyfriend", "girlfriend",
|
||||
}
|
||||
|
||||
_YEAR_OLD_NOUN = re.compile(r"\byear[\s-]?old\s+(\w+)", re.IGNORECASE)
|
||||
|
||||
|
||||
def _has_qualifier(topic: str) -> bool:
|
||||
"""Return True if the topic contains hobbies/relationship/budget context.
|
||||
|
||||
A Class 1 base pattern plus a qualifier means the user already filled in
|
||||
the specificity Step 0.45 would ask for. Skip the refuse-gate and let
|
||||
the engine run.
|
||||
|
||||
Also skips when `{n} year old <activity-noun>` is present, but only when
|
||||
the noun is NOT a relationship word. 'year old runner' qualifies as an
|
||||
interest and skips; 'year old husband' is just another relationship
|
||||
reframing of the demographic query and does not skip.
|
||||
"""
|
||||
if any(pattern.search(topic) for pattern in _QUALIFIER_PATTERNS):
|
||||
return True
|
||||
|
||||
match = _YEAR_OLD_NOUN.search(topic)
|
||||
if match and match.group(1).lower() not in _RELATIONSHIP_WORDS:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def check_class_1_trap(topic: str) -> str | None:
|
||||
"""Return a REFUSE message string if the topic matches Class 1, else None.
|
||||
|
||||
Class 1 is the demographic-shopping keyword trap. The literal phrase
|
||||
'birthday gift for 40 year old' is not the vocabulary of actual gift
|
||||
discussions on Reddit, X, or TikTok, so running the engine returns
|
||||
low-signal generic posts. Refuse up-front and ask for context.
|
||||
"""
|
||||
if not topic:
|
||||
return None
|
||||
|
||||
matched = any(pattern.search(topic) for pattern in _CLASS_1_PATTERNS)
|
||||
if not matched:
|
||||
return None
|
||||
|
||||
if _has_qualifier(topic):
|
||||
return None
|
||||
|
||||
return _refuse_message(topic.strip())
|
||||
|
||||
|
||||
def _refuse_message(topic: str) -> str:
|
||||
return (
|
||||
f'[last30days] REFUSE: topic "{topic}" matches Class 1 keyword-trap '
|
||||
"pattern (demographic shopping).\n"
|
||||
"\n"
|
||||
"The literal phrase is not the vocabulary of actual gift discussions "
|
||||
"on Reddit, X, or TikTok. Running the engine will return low-signal "
|
||||
"generic posts (the 2026-04-18 validation run returned "
|
||||
"r/todayilearned and unrelated drama).\n"
|
||||
"\n"
|
||||
"Ask the user for at least one of:\n"
|
||||
" - hobbies (cooks / runs / reads / gaming / outdoors / golf / music)\n"
|
||||
" - relationship (husband / dad / friend / boss / brother)\n"
|
||||
" - budget range\n"
|
||||
"\n"
|
||||
"Then re-run with the enriched query. If the user insists 'just run it',\n"
|
||||
"re-invoke with LAST30DAYS_SKIP_PREFLIGHT=1 to bypass this gate.\n"
|
||||
)
|
||||
+98
-20
@@ -12,8 +12,15 @@ import sys
|
||||
import time
|
||||
from collections import Counter
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed, wait as futures_wait
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
try:
|
||||
import requests as _requests
|
||||
except ImportError:
|
||||
_requests = None
|
||||
|
||||
|
||||
def _first_of(*values, default=None):
|
||||
"""Return first value that is not None."""
|
||||
for v in values:
|
||||
@@ -21,7 +28,7 @@ def _first_of(*values, default=None):
|
||||
return v
|
||||
return default
|
||||
|
||||
from . import dates, http, log
|
||||
from . import http, log
|
||||
|
||||
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/reddit"
|
||||
|
||||
@@ -69,6 +76,14 @@ def _log(msg: str):
|
||||
log.source_log("Reddit", msg, tty_only=False)
|
||||
|
||||
|
||||
def _sc_headers(token: str) -> Dict[str, str]:
|
||||
"""Build ScrapeCreators request headers."""
|
||||
return {
|
||||
"x-api-key": token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _extract_core_subject(topic: str) -> str:
|
||||
"""Extract core subject from verbose query.
|
||||
|
||||
@@ -197,16 +212,27 @@ def _parse_date(value) -> Optional[str]:
|
||||
|
||||
Global search returns ``created_at`` as an ISO string
|
||||
(e.g. "2018-05-03T01:09:17.620000+0000"); subreddit search returns
|
||||
``created_utc`` as a Unix timestamp. dates.parse_date() handles both,
|
||||
plus edge cases like Z suffix and +0000 (no colon) offset.
|
||||
|
||||
Falsy inputs (None, "", 0) return None, matching the original behavior
|
||||
where a Unix timestamp of 0 meant "no date" rather than epoch 0.
|
||||
``created_utc`` as a Unix timestamp. Handle both.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
dt = dates.parse_date(str(value))
|
||||
return dt.strftime("%Y-%m-%d") if dt else None
|
||||
# ISO-8601 string (contains 'T' or '-')
|
||||
if isinstance(value, str) and ("T" in value or "-" in value):
|
||||
try:
|
||||
# Strip trailing offset variations (+0000, Z) for fromisoformat
|
||||
clean = value.replace("Z", "+00:00")
|
||||
if clean.endswith("+0000"):
|
||||
clean = clean[:-5] + "+00:00"
|
||||
dt = datetime.fromisoformat(clean)
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# Unix timestamp (int or float or numeric string)
|
||||
try:
|
||||
dt = datetime.fromtimestamp(float(value), tz=timezone.utc)
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
except (ValueError, TypeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _extract_subreddit_name(value: Any) -> str:
|
||||
@@ -324,18 +350,39 @@ def _global_search(
|
||||
Returns:
|
||||
List of post dicts
|
||||
"""
|
||||
if not _requests:
|
||||
_log("requests library not installed, falling back to urllib")
|
||||
# Use stdlib http module as fallback
|
||||
try:
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"query": query, "sort": sort, "timeframe": timeframe})
|
||||
url = f"{SCRAPECREATORS_BASE}/search?{params}"
|
||||
headers = _sc_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
return data.get("posts", data.get("data", []))
|
||||
except http.HTTPError as e:
|
||||
if e.status_code and e.status_code in (401, 403):
|
||||
raise
|
||||
_log(f"Global search error (urllib): {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
_log(f"Global search error (urllib): {e}")
|
||||
return []
|
||||
|
||||
try:
|
||||
data = http.get(
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/search",
|
||||
headers=http.scrapecreators_headers(token),
|
||||
params={"query": query, "sort": sort, "timeframe": timeframe},
|
||||
headers=_sc_headers(token),
|
||||
timeout=30,
|
||||
retries=2,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("posts", data.get("data", []))
|
||||
except http.HTTPError as e:
|
||||
if e.status_code in (401, 403):
|
||||
raise
|
||||
except _requests.exceptions.HTTPError as e:
|
||||
if e.response is not None and e.response.status_code in (401, 403):
|
||||
raise http.HTTPError(f"Auth error: {e}", e.response.status_code)
|
||||
_log(f"Global search error: {e}")
|
||||
return []
|
||||
except Exception as e:
|
||||
@@ -362,19 +409,36 @@ def _subreddit_search(
|
||||
Returns:
|
||||
List of post dicts
|
||||
"""
|
||||
if not _requests:
|
||||
try:
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({
|
||||
"subreddit": subreddit, "query": query,
|
||||
"sort": sort, "timeframe": timeframe,
|
||||
})
|
||||
url = f"{SCRAPECREATORS_BASE}/subreddit/search?{params}"
|
||||
headers = _sc_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
return data.get("posts", data.get("data", []))
|
||||
except Exception as e:
|
||||
_log(f"Subreddit search error (urllib) for r/{subreddit}: {e}")
|
||||
return []
|
||||
|
||||
try:
|
||||
data = http.get(
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/subreddit/search",
|
||||
headers=http.scrapecreators_headers(token),
|
||||
params={
|
||||
"subreddit": subreddit,
|
||||
"query": query,
|
||||
"sort": sort,
|
||||
"timeframe": timeframe,
|
||||
},
|
||||
headers=_sc_headers(token),
|
||||
timeout=30,
|
||||
retries=2,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("posts", data.get("data", []))
|
||||
except Exception as e:
|
||||
_log(f"Subreddit search error for r/{subreddit}: {e}")
|
||||
@@ -394,14 +458,28 @@ def fetch_post_comments(
|
||||
Returns:
|
||||
List of comment dicts with score, author, body, etc.
|
||||
"""
|
||||
if not _requests:
|
||||
try:
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"url": url})
|
||||
api_url = f"{SCRAPECREATORS_BASE}/post/comments?{params}"
|
||||
headers = _sc_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(api_url, headers=headers, timeout=30, retries=2)
|
||||
return data.get("comments", data.get("data", []))
|
||||
except Exception as e:
|
||||
_log(f"Comment fetch error (urllib): {e}")
|
||||
return []
|
||||
|
||||
try:
|
||||
data = http.get(
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/post/comments",
|
||||
headers=http.scrapecreators_headers(token),
|
||||
params={"url": url},
|
||||
headers=_sc_headers(token),
|
||||
timeout=30,
|
||||
retries=2,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data.get("comments", data.get("data", []))
|
||||
except Exception as e:
|
||||
_log(f"Comment fetch error: {e}")
|
||||
|
||||
+22
-933
File diff suppressed because it is too large
Load Diff
+11
-125
@@ -3,34 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from . import http, providers, query, schema
|
||||
|
||||
|
||||
# Penalty applied when a candidate does not mention the primary entity
|
||||
# from the topic in its title or snippet. Picked empirically: a typical
|
||||
# score spread in the shortlist is 30-70, so 25 points reliably pushes
|
||||
# an off-topic candidate below on-topic ones without fully zeroing out
|
||||
# marginal matches. See 2026-04-19 Hermes Agent Use Cases failure: a
|
||||
# Nate Herk "Managed Agents" video scored 51 / ranked #2 with zero
|
||||
# Hermes content.
|
||||
ENTITY_MISS_PENALTY = 25.0
|
||||
|
||||
# Intent modifiers to strip before extracting the primary entity so that,
|
||||
# for example, "Hermes Agent use cases" yields primary_entity="hermes agent"
|
||||
# rather than "hermes agent use cases". Kept in sync with
|
||||
# planner._INTENT_MODIFIER_PATTERNS.
|
||||
_INTENT_MODIFIER_RE = re.compile(
|
||||
r"\b("
|
||||
r"use cases|use case|workflows|workflow|"
|
||||
r"examples|example|tutorial|tutorials|"
|
||||
r"review|reviews|comparison|applications|"
|
||||
r"in practice|production use|production|"
|
||||
r"how i use"
|
||||
r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
from . import http, providers, schema
|
||||
|
||||
INTENT_SCORING_HINTS: dict[str, str] = {
|
||||
"comparison": (
|
||||
@@ -86,21 +60,20 @@ def rerank_candidates(
|
||||
) -> list[schema.Candidate]:
|
||||
"""Rerank the fused shortlist, demoting candidates the reranker scored as irrelevant."""
|
||||
shortlisted = candidates[:shortlist_size]
|
||||
primary_entity = _primary_entity(topic)
|
||||
if provider and model and shortlisted:
|
||||
try:
|
||||
response = provider.generate_json(model, _build_prompt(topic, plan, shortlisted, primary_entity))
|
||||
response = provider.generate_json(model, _build_prompt(topic, plan, shortlisted))
|
||||
_apply_llm_scores(shortlisted, response)
|
||||
except (ValueError, KeyError, json.JSONDecodeError, OSError, http.HTTPError) as exc:
|
||||
import sys
|
||||
print(f"[Rerank] LLM reranking failed, using local fallback: {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||
_apply_fallback_scores(shortlisted, primary_entity=primary_entity)
|
||||
_apply_fallback_scores(shortlisted)
|
||||
else:
|
||||
_apply_fallback_scores(shortlisted, primary_entity=primary_entity)
|
||||
_apply_fallback_scores(shortlisted)
|
||||
|
||||
if len(candidates) > shortlist_size:
|
||||
tail = candidates[shortlist_size:]
|
||||
_apply_fallback_scores(tail, primary_entity=primary_entity)
|
||||
_apply_fallback_scores(tail)
|
||||
|
||||
return sorted(
|
||||
candidates,
|
||||
@@ -130,7 +103,7 @@ def _fenced_untrusted_content(candidate_block: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _build_prompt(topic: str, plan: schema.QueryPlan, candidates: list[schema.Candidate], primary_entity: str = "") -> str:
|
||||
def _build_prompt(topic: str, plan: schema.QueryPlan, candidates: list[schema.Candidate]) -> str:
|
||||
ranking_queries = "\n".join(
|
||||
f"- {subquery.label}: {subquery.ranking_query}"
|
||||
for subquery in plan.subqueries
|
||||
@@ -148,16 +121,6 @@ def _build_prompt(topic: str, plan: schema.QueryPlan, candidates: list[schema.Ca
|
||||
)
|
||||
for candidate in candidates
|
||||
)
|
||||
grounding_hint = ""
|
||||
if primary_entity:
|
||||
grounding_hint = (
|
||||
f"\nPrimary entity grounding: the user's primary entity is \"{primary_entity}\". "
|
||||
"A candidate that does NOT mention this entity (or a clear synonym/abbreviation) "
|
||||
"in its title or snippet should score no higher than 30, regardless of other "
|
||||
"signals. Do not let a candidate match the topic vicinity without matching the "
|
||||
"entity itself. 2026-04-19 Hermes Agent Use Cases failure: a Nate Herk video "
|
||||
"about Claude's Managed Agents scored 51 with zero Hermes content.\n"
|
||||
)
|
||||
return f"""
|
||||
Judge search-result relevance for a last-30-days research pipeline.
|
||||
|
||||
@@ -182,7 +145,7 @@ Scoring guidance:
|
||||
- 70 to 89: clearly relevant and useful
|
||||
- 40 to 69: somewhat relevant but weaker
|
||||
- 0 to 39: weak, redundant, or off-target
|
||||
{grounding_hint}{_intent_hint_block(plan)}
|
||||
{_intent_hint_block(plan)}
|
||||
{_fenced_untrusted_content(candidate_block)}
|
||||
""".strip()
|
||||
|
||||
@@ -206,93 +169,21 @@ def _apply_llm_scores(candidates: list[schema.Candidate], payload: dict) -> None
|
||||
candidate.final_score = _final_score(candidate)
|
||||
|
||||
|
||||
def _apply_fallback_scores(candidates: list[schema.Candidate], *, primary_entity: str = "") -> None:
|
||||
def _apply_fallback_scores(candidates: list[schema.Candidate]) -> None:
|
||||
for candidate in candidates:
|
||||
rerank_score, reason = _fallback_tuple(candidate, primary_entity=primary_entity)
|
||||
rerank_score, reason = _fallback_tuple(candidate)
|
||||
candidate.rerank_score = rerank_score
|
||||
candidate.explanation = reason
|
||||
candidate.final_score = _final_score(candidate)
|
||||
|
||||
|
||||
def _candidate_haystack(candidate: schema.Candidate) -> str:
|
||||
"""Build the lowercase text blob against which entity-grounding is checked.
|
||||
|
||||
Expanded 2026-04-19 to include transcript snippets, transcript highlights,
|
||||
and top-comment text. The prior `title + snippet` check missed YouTube
|
||||
videos whose entity mentions live in transcript content and Reddit posts
|
||||
whose mentions are in top comments. Now checks all text surfaces a human
|
||||
would see.
|
||||
"""
|
||||
parts: list[str] = [candidate.title or "", candidate.snippet or ""]
|
||||
metadata = candidate.metadata or {}
|
||||
|
||||
transcript_snippet = metadata.get("transcript_snippet") or ""
|
||||
if isinstance(transcript_snippet, str):
|
||||
parts.append(transcript_snippet)
|
||||
|
||||
for hl in metadata.get("transcript_highlights") or []:
|
||||
if isinstance(hl, str):
|
||||
parts.append(hl)
|
||||
|
||||
for tc in metadata.get("top_comments") or []:
|
||||
if isinstance(tc, dict):
|
||||
parts.append(str(tc.get("excerpt", "") or tc.get("text", "") or ""))
|
||||
elif isinstance(tc, str):
|
||||
parts.append(tc)
|
||||
|
||||
for insight in metadata.get("comment_insights") or []:
|
||||
if isinstance(insight, str):
|
||||
parts.append(insight)
|
||||
|
||||
return " ".join(parts).lower()
|
||||
|
||||
|
||||
def _fallback_tuple(candidate: schema.Candidate, *, primary_entity: str = "") -> tuple[float, str]:
|
||||
def _fallback_tuple(candidate: schema.Candidate) -> tuple[float, str]:
|
||||
score = (
|
||||
(candidate.local_relevance * 100.0 * 0.7)
|
||||
+ (candidate.freshness * 0.2)
|
||||
+ (candidate.source_quality * 100.0 * 0.1)
|
||||
)
|
||||
reason = "fallback-local-score"
|
||||
# Entity-grounding demotion: if the primary entity (topic minus intent
|
||||
# modifier) is not present anywhere in the candidate's text surfaces
|
||||
# (title, snippet, transcript, transcript highlights, top comments,
|
||||
# insights), subtract ENTITY_MISS_PENALTY. Skip for candidates with
|
||||
# NO text anywhere (e.g., image-only TikToks) to avoid penalizing
|
||||
# thin-text sources unfairly. 2026-04-19 Nate Herk "Managed Agents"
|
||||
# video ranked #2 on a Hermes query despite zero Hermes mentions
|
||||
# because the old haystack only checked title + snippet.
|
||||
if primary_entity:
|
||||
haystack = _candidate_haystack(candidate)
|
||||
if haystack.strip() and primary_entity.lower() not in haystack:
|
||||
score -= ENTITY_MISS_PENALTY
|
||||
reason = "fallback-local-score (entity-miss demotion)"
|
||||
return max(0.0, min(100.0, score)), reason
|
||||
|
||||
|
||||
def _primary_entity(topic: str) -> str:
|
||||
"""Extract the primary entity from the topic for grounding checks.
|
||||
|
||||
Strips intent-modifier suffixes (see planner._INTENT_MODIFIER_PATTERNS),
|
||||
trims trailing punctuation, collapses whitespace. Returns the empty
|
||||
string for topics that are all intent modifier with no entity, so
|
||||
callers can skip the grounding check.
|
||||
"""
|
||||
stripped = _INTENT_MODIFIER_RE.sub(" ", topic)
|
||||
# Also collapse multiple spaces and strip punctuation.
|
||||
stripped = re.sub(r"\s+", " ", stripped).strip(" \t\r\n?.,:;!")
|
||||
return stripped
|
||||
|
||||
|
||||
#: Secondary entity-miss penalty applied directly to final_score (not just
|
||||
#: rerank_score). The -25 on rerank_score composes to only -15 on final_score
|
||||
#: via the 0.60 weight, which engagement bonus partially offsets on
|
||||
#: high-view YouTube items. This secondary penalty lands the full weight on
|
||||
#: the composite signal the cluster-scoring layer consumes. 2026-04-19
|
||||
#: Nate Herk "Managed Agents" video ranked at cluster #2 with score 51
|
||||
#: despite the rerank_score demotion because engagement + freshness drowned
|
||||
#: the dilute penalty. This backstop makes the demotion actually decisive.
|
||||
ENTITY_MISS_FINAL_PENALTY = 20.0
|
||||
return max(0.0, min(100.0, score)), "fallback-local-score"
|
||||
|
||||
|
||||
def _final_score(candidate: schema.Candidate) -> float:
|
||||
@@ -313,11 +204,6 @@ def _final_score(candidate: schema.Candidate) -> float:
|
||||
)
|
||||
if candidate.rerank_score is not None and candidate.rerank_score < 20.0:
|
||||
base *= 0.3
|
||||
# Secondary entity-grounding penalty: when the fallback path flagged
|
||||
# entity-miss via candidate.explanation, apply an additional penalty
|
||||
# at final_score level so engagement signal can't mask the demotion.
|
||||
if candidate.explanation and "entity-miss" in candidate.explanation:
|
||||
base = max(0.0, base - ENTITY_MISS_FINAL_PENALTY)
|
||||
return base
|
||||
|
||||
|
||||
|
||||
+5
-67
@@ -11,64 +11,14 @@ import re
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from . import categories, dates, grounding
|
||||
|
||||
MAX_SUBS = 10
|
||||
from . import dates, grounding
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(f"[Resolve] {msg}", file=sys.stderr)
|
||||
|
||||
|
||||
def _merge_category_peers(topic: str, subreddits: list[str]) -> tuple[list[str], Optional[str]]:
|
||||
"""Extend the WebSearch-extracted subreddit list with category peers.
|
||||
|
||||
Classifies the topic, fetches the category's peer subs, dedupes
|
||||
case-insensitively against the existing list, and appends missing
|
||||
peers in priority order. Caps the final list at MAX_SUBS, preserving
|
||||
every WebSearch-returned sub (they are the freshest signal) and
|
||||
trimming from the peer-additions end.
|
||||
|
||||
Returns a tuple of (merged_subs, matched_category_id_or_None).
|
||||
Emits a [Resolve] Matched category log line only when peers were
|
||||
actually added (not when every peer was already in the WebSearch set).
|
||||
|
||||
Classification failures degrade to "no match" — the unwidened list
|
||||
is returned and a warning is logged.
|
||||
"""
|
||||
try:
|
||||
category = categories.detect_category(topic)
|
||||
except Exception as exc:
|
||||
_log(f"Category classification failed: {exc}")
|
||||
return list(subreddits)[:MAX_SUBS], None
|
||||
|
||||
if category is None:
|
||||
return list(subreddits)[:MAX_SUBS], None
|
||||
|
||||
peers = categories.peer_subs_for(category)
|
||||
if not peers:
|
||||
return list(subreddits)[:MAX_SUBS], category
|
||||
|
||||
existing_lower = {s.lower() for s in subreddits}
|
||||
merged = list(subreddits)
|
||||
added: list[str] = []
|
||||
for peer in peers:
|
||||
if len(merged) >= MAX_SUBS:
|
||||
break
|
||||
if peer.lower() in existing_lower:
|
||||
continue
|
||||
merged.append(peer)
|
||||
existing_lower.add(peer.lower())
|
||||
added.append(peer)
|
||||
|
||||
if added:
|
||||
_log(f"Matched category={category}, adding peers: {', '.join(added)}")
|
||||
|
||||
return merged, category
|
||||
|
||||
|
||||
def _has_backend(config: dict) -> bool:
|
||||
"""Check if any web search backend is available."""
|
||||
return bool(
|
||||
@@ -184,19 +134,10 @@ def auto_resolve(topic: str, config: dict) -> dict:
|
||||
config: Dict with API keys (BRAVE_API_KEY, EXA_API_KEY, SERPER_API_KEY).
|
||||
|
||||
Returns:
|
||||
Dict with keys: subreddits, x_handle, github_user, github_repos,
|
||||
context, category, searches_run. Returns empty result if no web
|
||||
search backend is available.
|
||||
Dict with keys: subreddits, x_handle, context, searches_run.
|
||||
Returns empty result if no web search backend is available.
|
||||
"""
|
||||
empty = {
|
||||
"subreddits": [],
|
||||
"x_handle": "",
|
||||
"github_user": "",
|
||||
"github_repos": [],
|
||||
"context": "",
|
||||
"category": None,
|
||||
"searches_run": 0,
|
||||
}
|
||||
empty = {"subreddits": [], "x_handle": "", "context": "", "searches_run": 0}
|
||||
|
||||
if not _has_backend(config):
|
||||
_log("No web search backend available, skipping resolve")
|
||||
@@ -243,9 +184,7 @@ def auto_resolve(topic: str, config: dict) -> dict:
|
||||
github_repos = _extract_github_repos(results.get("github", []))
|
||||
context = _build_context_summary(results.get("news", []))
|
||||
|
||||
subreddits, category = _merge_category_peers(topic, subreddits)
|
||||
|
||||
_log(f"Resolved {len(subreddits)} subreddits, x_handle={x_handle!r}, github_user={github_user!r}, github_repos={github_repos!r}, context_len={len(context)}, category={category!r}")
|
||||
_log(f"Resolved {len(subreddits)} subreddits, x_handle={x_handle!r}, github_user={github_user!r}, github_repos={github_repos!r}, context_len={len(context)}")
|
||||
|
||||
return {
|
||||
"subreddits": subreddits,
|
||||
@@ -253,6 +192,5 @@ def auto_resolve(topic: str, config: dict) -> dict:
|
||||
"github_user": github_user,
|
||||
"github_repos": github_repos,
|
||||
"context": context,
|
||||
"category": category,
|
||||
"searches_run": searches_run,
|
||||
}
|
||||
|
||||
+5
-30
@@ -19,6 +19,7 @@ SOURCE_QUALITY = {
|
||||
"polymarket": 0.5,
|
||||
"instagram": 0.58,
|
||||
"tiktok": 0.58,
|
||||
"podcasts": 0.88,
|
||||
}
|
||||
|
||||
|
||||
@@ -82,11 +83,12 @@ def _top_comment_score(item: schema.SourceItem) -> float:
|
||||
|
||||
|
||||
# Per-source engagement weights: list of (field_name, weight) tuples.
|
||||
# Reddit, YouTube, and TikTok use custom functions because they include
|
||||
# a dedicated 10% top-comment-score slot (see _reddit_engagement,
|
||||
# _youtube_engagement, _tiktok_engagement).
|
||||
# Reddit uses a custom function because upvote_ratio and top_comment_score
|
||||
# are not simple log1p fields.
|
||||
ENGAGEMENT_WEIGHTS: dict[str, list[tuple[str, float]]] = {
|
||||
"x": [("likes", 0.55), ("reposts", 0.25), ("replies", 0.15), ("quotes", 0.05)],
|
||||
"youtube": [("views", 0.50), ("likes", 0.35), ("comments", 0.15)],
|
||||
"tiktok": [("views", 0.50), ("likes", 0.30), ("comments", 0.20)],
|
||||
"instagram": [("views", 0.50), ("likes", 0.30), ("comments", 0.20)],
|
||||
"hackernews": [("points", 0.55), ("comments", 0.45)],
|
||||
"bluesky": [("likes", 0.40), ("reposts", 0.30), ("replies", 0.20), ("quotes", 0.10)],
|
||||
@@ -112,29 +114,6 @@ def _reddit_engagement(item: schema.SourceItem) -> float | None:
|
||||
return (0.50 * score) + (0.35 * comments) + (0.05 * (ratio * 10.0)) + (0.10 * top_comment)
|
||||
|
||||
|
||||
def _youtube_engagement(item: schema.SourceItem) -> float | None:
|
||||
views = log1p_safe(item.engagement.get("views"))
|
||||
likes = log1p_safe(item.engagement.get("likes"))
|
||||
comments = log1p_safe(item.engagement.get("comments"))
|
||||
top_comment = _top_comment_score(item)
|
||||
if not any([views, likes, comments, top_comment]):
|
||||
return None
|
||||
# Mirrors Reddit: carve out 10% for top-comment signal, keep view-weight
|
||||
# dominant. Without comments, the pre-change weights (0.50/0.35/0.15)
|
||||
# still govern relative ordering.
|
||||
return (0.45 * views) + (0.32 * likes) + (0.13 * comments) + (0.10 * top_comment)
|
||||
|
||||
|
||||
def _tiktok_engagement(item: schema.SourceItem) -> float | None:
|
||||
views = log1p_safe(item.engagement.get("views"))
|
||||
likes = log1p_safe(item.engagement.get("likes"))
|
||||
comments = log1p_safe(item.engagement.get("comments"))
|
||||
top_comment = _top_comment_score(item)
|
||||
if not any([views, likes, comments, top_comment]):
|
||||
return None
|
||||
return (0.45 * views) + (0.27 * likes) + (0.18 * comments) + (0.10 * top_comment)
|
||||
|
||||
|
||||
def _generic_engagement(item: schema.SourceItem) -> float | None:
|
||||
if not item.engagement:
|
||||
return None
|
||||
@@ -147,10 +126,6 @@ def _generic_engagement(item: schema.SourceItem) -> float | None:
|
||||
def engagement_raw(item: schema.SourceItem) -> float | None:
|
||||
if item.source == "reddit":
|
||||
return _reddit_engagement(item)
|
||||
if item.source == "youtube":
|
||||
return _youtube_engagement(item)
|
||||
if item.source == "tiktok":
|
||||
return _tiktok_engagement(item)
|
||||
weights = ENGAGEMENT_WEIGHTS.get(item.source)
|
||||
if weights:
|
||||
return _weighted_engagement(item, weights)
|
||||
|
||||
+33
-12
@@ -9,9 +9,10 @@ API docs: https://scrapecreators.com/docs
|
||||
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from . import dates, http, log
|
||||
from . import http, log
|
||||
from .relevance import token_overlap_relevance as _compute_relevance
|
||||
|
||||
SCRAPECREATORS_BASE = "https://api.scrapecreators.com/v1/threads"
|
||||
@@ -28,6 +29,14 @@ def _log(msg: str):
|
||||
log.source_log("Threads", msg)
|
||||
|
||||
|
||||
def _sc_headers(token: str) -> Dict[str, str]:
|
||||
"""Build ScrapeCreators request headers."""
|
||||
return {
|
||||
"x-api-key": token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _extract_core_subject(topic: str) -> str:
|
||||
"""Extract core subject from verbose query for Threads search."""
|
||||
from .query import extract_core_subject
|
||||
@@ -43,17 +52,29 @@ def _extract_core_subject(topic: str) -> str:
|
||||
def _parse_date(item: Dict[str, Any]) -> Optional[str]:
|
||||
"""Parse date from Threads item to YYYY-MM-DD.
|
||||
|
||||
Tries common timestamp fields in order: taken_at and create_time
|
||||
(unix timestamps in Meta APIs), then created_at, published_at, and
|
||||
date (ISO 8601 strings). dates.parse_date() handles both.
|
||||
Tries common timestamp fields: taken_at (unix), created_at (ISO),
|
||||
and falls back to any date-like string field.
|
||||
"""
|
||||
for key in ("taken_at", "create_time", "created_at", "published_at", "date"):
|
||||
# Unix timestamp (taken_at is common in Meta APIs)
|
||||
for key in ("taken_at", "create_time"):
|
||||
ts = item.get(key)
|
||||
if ts:
|
||||
try:
|
||||
from . import dates
|
||||
return dates.timestamp_to_date(int(ts))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# ISO 8601 string
|
||||
for key in ("created_at", "published_at", "date"):
|
||||
val = item.get(key)
|
||||
if val is None:
|
||||
continue
|
||||
dt = dates.parse_date(str(val))
|
||||
if dt:
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
if val and isinstance(val, str):
|
||||
try:
|
||||
dt = datetime.fromisoformat(val.replace("Z", "+00:00"))
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -162,7 +183,7 @@ def search_threads(
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"keyword": core_topic})
|
||||
url = f"{SCRAPECREATORS_BASE}/search?{params}"
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers = _sc_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
except Exception as e:
|
||||
@@ -173,7 +194,7 @@ def search_threads(
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/search",
|
||||
params={"keyword": core_topic},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
headers=_sc_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
+15
-143
@@ -109,6 +109,14 @@ def _log(msg: str):
|
||||
log.source_log("TikTok", msg)
|
||||
|
||||
|
||||
def _sc_headers(token: str) -> Dict[str, str]:
|
||||
"""Build ScrapeCreators request headers."""
|
||||
return {
|
||||
"x-api-key": token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _parse_date(item: Dict[str, Any]) -> Optional[str]:
|
||||
"""Parse date from ScrapeCreators TikTok item to YYYY-MM-DD."""
|
||||
ts = item.get("create_time")
|
||||
@@ -219,7 +227,7 @@ def _hashtag_search(
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"hashtag": hashtag})
|
||||
url = f"{SCRAPECREATORS_BASE}/search/hashtag?{params}"
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers = _sc_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
except Exception as e:
|
||||
@@ -230,7 +238,7 @@ def _hashtag_search(
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/search/hashtag",
|
||||
params={"hashtag": hashtag},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
headers=_sc_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -266,7 +274,7 @@ def _profile_videos(
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"handle": handle, "sort_by": "latest"})
|
||||
url = f"{profile_url}?{params}"
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers = _sc_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
except Exception as e:
|
||||
@@ -277,7 +285,7 @@ def _profile_videos(
|
||||
resp = _requests.get(
|
||||
profile_url,
|
||||
params={"handle": handle, "sort_by": "latest"},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
headers=_sc_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -324,7 +332,7 @@ def search_tiktok(
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"query": core_topic, "sort_by": "relevance"})
|
||||
url = f"{SCRAPECREATORS_BASE}/search/keyword?{params}"
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers = _sc_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
except Exception as e:
|
||||
@@ -335,7 +343,7 @@ def search_tiktok(
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/search/keyword",
|
||||
params={"query": core_topic, "sort_by": "relevance"},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
headers=_sc_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -425,7 +433,7 @@ def fetch_captions(
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/video/transcript",
|
||||
params={"url": url},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
headers=_sc_headers(token),
|
||||
timeout=15,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
@@ -539,139 +547,3 @@ def parse_tiktok_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
List of item dicts ready for normalization.
|
||||
"""
|
||||
return response.get("items", [])
|
||||
|
||||
|
||||
def _tiktok_total_engagement(item: Dict[str, Any]) -> int:
|
||||
"""Total engagement for ranking which posts deserve comment enrichment."""
|
||||
eng = item.get("engagement", {})
|
||||
return (eng.get("views", 0) or 0) + (eng.get("likes", 0) or 0) + (eng.get("comments", 0) or 0)
|
||||
|
||||
|
||||
def enrich_with_comments(
|
||||
items: List[Dict[str, Any]],
|
||||
token: str,
|
||||
max_posts: int = 3,
|
||||
max_comments: int = 5,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Enrich top TikTok posts with comment data from ScrapeCreators.
|
||||
|
||||
For the top N posts by engagement, fetches comments via the SC API
|
||||
and attaches them as a ``top_comments`` field on each item. Mirrors
|
||||
youtube_yt.enrich_with_comments.
|
||||
|
||||
Args:
|
||||
items: TikTok items from search_tiktok()
|
||||
token: ScrapeCreators API key
|
||||
max_posts: How many posts to enrich with comments
|
||||
max_comments: Max comments to keep per post
|
||||
|
||||
Returns:
|
||||
Items list (mutated in place) with top_comments added to enriched items.
|
||||
"""
|
||||
if not items or not token or max_posts <= 0:
|
||||
return items
|
||||
|
||||
ranked = sorted(items, key=_tiktok_total_engagement, reverse=True)
|
||||
top_items = ranked[:max_posts]
|
||||
_log(f"Enriching comments for {len(top_items)} TikTok posts")
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
def _enrich_one(item: dict) -> bool:
|
||||
post_url = item.get("url", "")
|
||||
if not post_url:
|
||||
return False
|
||||
try:
|
||||
comments = _fetch_post_comments(post_url, token, max_comments)
|
||||
if comments:
|
||||
item["top_comments"] = comments
|
||||
return True
|
||||
except Exception as exc:
|
||||
_log(f"Comment enrichment failed for {post_url}: {exc}")
|
||||
return False
|
||||
|
||||
enriched_count = 0
|
||||
with ThreadPoolExecutor(max_workers=min(4, len(top_items))) as executor:
|
||||
futures = {executor.submit(_enrich_one, item): item for item in top_items}
|
||||
for future in as_completed(futures):
|
||||
if future.result():
|
||||
enriched_count += 1
|
||||
|
||||
_log(f"Enriched {enriched_count}/{len(top_items)} posts with comments")
|
||||
return items
|
||||
|
||||
|
||||
def _fetch_post_comments(
|
||||
post_url: str,
|
||||
token: str,
|
||||
max_comments: int = 5,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Fetch comments for a single TikTok post via ScrapeCreators.
|
||||
|
||||
SC endpoint: GET /v1/tiktok/video/comments?url=<video_url>
|
||||
Response shape: { comments: [{text, user.nickname, digg_count, create_time, ...}], cursor, total }
|
||||
|
||||
Args:
|
||||
post_url: Canonical TikTok post URL (share_url form works)
|
||||
token: ScrapeCreators API key
|
||||
max_comments: Maximum comments to return
|
||||
|
||||
Returns:
|
||||
List of comment dicts with author, text, digg_count (likes), date.
|
||||
Empty list on any error — comment failures never crash the pipeline.
|
||||
"""
|
||||
if not _requests:
|
||||
try:
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"url": post_url, "trim": "true"})
|
||||
url = f"{SCRAPECREATORS_BASE}/video/comments?{params}"
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
except Exception as exc:
|
||||
_log(f"Comment fetch error (urllib) for {post_url}: {exc}")
|
||||
return []
|
||||
else:
|
||||
try:
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_BASE}/video/comments",
|
||||
params={"url": post_url, "trim": "true"},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as exc:
|
||||
_log(f"Comment fetch error for {post_url}: {exc}")
|
||||
return []
|
||||
|
||||
raw_comments = data.get("comments") or data.get("data") or []
|
||||
# Sort by digg_count desc so normalize sees the highest-signal first.
|
||||
raw_comments = sorted(
|
||||
raw_comments,
|
||||
key=lambda c: c.get("digg_count", 0) or 0,
|
||||
reverse=True,
|
||||
)
|
||||
out: List[Dict[str, Any]] = []
|
||||
for c in raw_comments[:max_comments]:
|
||||
text = c.get("text") or ""
|
||||
if not text:
|
||||
continue
|
||||
user = c.get("user") if isinstance(c.get("user"), dict) else {}
|
||||
# Prefer unique_id (the @handle) over nickname (display name) so
|
||||
# downstream render can cite @handle consistently across platforms.
|
||||
author = user.get("unique_id") or user.get("nickname") or ""
|
||||
create_time = c.get("create_time")
|
||||
date_str = ""
|
||||
if create_time:
|
||||
try:
|
||||
date_str = dates.timestamp_to_date(int(create_time)) or ""
|
||||
except (ValueError, TypeError):
|
||||
date_str = ""
|
||||
out.append({
|
||||
"author": author,
|
||||
"text": text[:400],
|
||||
"digg_count": c.get("digg_count", 0) or 0,
|
||||
"date": date_str,
|
||||
})
|
||||
return out
|
||||
|
||||
+103
-116
@@ -18,130 +18,117 @@ const SearchClient = withSearch(TwitterClientBase);
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
function writeStdout(text) {
|
||||
if (text) process.stdout.write(text);
|
||||
}
|
||||
|
||||
function writeStderr(text) {
|
||||
if (text) process.stderr.write(text);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// --check: verify that credentials can be resolved
|
||||
if (args.includes('--check')) {
|
||||
try {
|
||||
const { cookies, warnings } = await resolveCredentials({});
|
||||
if (cookies.authToken && cookies.ct0) {
|
||||
writeStdout(JSON.stringify({ authenticated: true, source: cookies.source }));
|
||||
return 0;
|
||||
}
|
||||
writeStdout(JSON.stringify({ authenticated: false, warnings }));
|
||||
return 1;
|
||||
} catch (err) {
|
||||
writeStdout(JSON.stringify({ authenticated: false, error: err.message }));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// --whoami: check auth and output source
|
||||
if (args.includes('--whoami')) {
|
||||
try {
|
||||
const { cookies } = await resolveCredentials({});
|
||||
if (cookies.authToken && cookies.ct0) {
|
||||
writeStdout(cookies.source || 'authenticated');
|
||||
return 0;
|
||||
}
|
||||
writeStderr('Not authenticated\n');
|
||||
return 1;
|
||||
} catch (err) {
|
||||
writeStderr(`Auth check failed: ${err.message}\n`);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse search args
|
||||
let query = null;
|
||||
let count = 20;
|
||||
let jsonOutput = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--count' && args[i + 1]) {
|
||||
count = parseInt(args[i + 1], 10);
|
||||
i++;
|
||||
} else if (args[i] === '-n' && args[i + 1]) {
|
||||
count = parseInt(args[i + 1], 10);
|
||||
i++;
|
||||
} else if (args[i] === '--json') {
|
||||
jsonOutput = true;
|
||||
} else if (!args[i].startsWith('-')) {
|
||||
query = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
writeStderr('Usage: node bird-search.mjs <query> [--count N] [--json]\n');
|
||||
return 1;
|
||||
}
|
||||
|
||||
// --check: verify that credentials can be resolved
|
||||
if (args.includes('--check')) {
|
||||
try {
|
||||
// Resolve credentials (env vars, then browser cookies)
|
||||
const { cookies, warnings } = await resolveCredentials({});
|
||||
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
const msg = warnings.length > 0 ? warnings.join('; ') : 'No Twitter credentials found';
|
||||
if (jsonOutput) {
|
||||
writeStdout(JSON.stringify({ error: msg, items: [] }));
|
||||
} else {
|
||||
writeStderr(`Error: ${msg}\n`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
const client = new SearchClient({
|
||||
cookies: {
|
||||
authToken: cookies.authToken,
|
||||
ct0: cookies.ct0,
|
||||
cookieHeader: cookies.cookieHeader,
|
||||
},
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
const result = await client.search(query, count);
|
||||
|
||||
if (!result.success) {
|
||||
if (jsonOutput) {
|
||||
writeStdout(JSON.stringify({ error: result.error, items: [] }));
|
||||
} else {
|
||||
writeStderr(`Search failed: ${result.error}\n`);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
const tweets = result.tweets || [];
|
||||
if (jsonOutput) {
|
||||
writeStdout(JSON.stringify(tweets));
|
||||
if (cookies.authToken && cookies.ct0) {
|
||||
process.stdout.write(JSON.stringify({ authenticated: true, source: cookies.source }));
|
||||
process.exit(0);
|
||||
} else {
|
||||
for (const tweet of tweets) {
|
||||
const author = tweet.author?.username || 'unknown';
|
||||
writeStdout(`@${author}: ${tweet.text?.slice(0, 200)}\n\n`);
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ authenticated: false, warnings }));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return 0;
|
||||
} catch (err) {
|
||||
if (jsonOutput) {
|
||||
writeStdout(JSON.stringify({ error: err.message, items: [] }));
|
||||
} else {
|
||||
writeStderr(`Error: ${err.message}\n`);
|
||||
}
|
||||
return 1;
|
||||
process.stdout.write(JSON.stringify({ authenticated: false, error: err.message }));
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// --whoami: check auth and output source
|
||||
if (args.includes('--whoami')) {
|
||||
try {
|
||||
const { cookies } = await resolveCredentials({});
|
||||
if (cookies.authToken && cookies.ct0) {
|
||||
process.stdout.write(cookies.source || 'authenticated');
|
||||
process.exit(0);
|
||||
} else {
|
||||
process.stderr.write('Not authenticated\n');
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
process.stderr.write(`Auth check failed: ${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse search args
|
||||
let query = null;
|
||||
let count = 20;
|
||||
let jsonOutput = false;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--count' && args[i + 1]) {
|
||||
count = parseInt(args[i + 1], 10);
|
||||
i++;
|
||||
} else if (args[i] === '-n' && args[i + 1]) {
|
||||
count = parseInt(args[i + 1], 10);
|
||||
i++;
|
||||
} else if (args[i] === '--json') {
|
||||
jsonOutput = true;
|
||||
} else if (!args[i].startsWith('-')) {
|
||||
query = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
process.stderr.write('Usage: node bird-search.mjs <query> [--count N] [--json]\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const code = await main();
|
||||
process.exitCode = Number.isInteger(code) ? code : 1;
|
||||
// Resolve credentials (env vars, then browser cookies)
|
||||
const { cookies, warnings } = await resolveCredentials({});
|
||||
|
||||
if (!cookies.authToken || !cookies.ct0) {
|
||||
const msg = warnings.length > 0 ? warnings.join('; ') : 'No Twitter credentials found';
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(JSON.stringify({ error: msg, items: [] }));
|
||||
} else {
|
||||
process.stderr.write(`Error: ${msg}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create search client
|
||||
const client = new SearchClient({
|
||||
cookies: {
|
||||
authToken: cookies.authToken,
|
||||
ct0: cookies.ct0,
|
||||
cookieHeader: cookies.cookieHeader,
|
||||
},
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
|
||||
// Run search
|
||||
const result = await client.search(query, count);
|
||||
|
||||
if (!result.success) {
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(JSON.stringify({ error: result.error, items: [] }));
|
||||
} else {
|
||||
process.stderr.write(`Search failed: ${result.error}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Output results
|
||||
const tweets = result.tweets || [];
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(JSON.stringify(tweets));
|
||||
} else {
|
||||
for (const tweet of tweets) {
|
||||
const author = tweet.author?.username || 'unknown';
|
||||
process.stdout.write(`@${author}: ${tweet.text?.slice(0, 200)}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
writeStderr(`Fatal error: ${err?.message || err}\n`);
|
||||
process.exitCode = 1;
|
||||
if (jsonOutput) {
|
||||
process.stdout.write(JSON.stringify({ error: err.message, items: [] }));
|
||||
} else {
|
||||
process.stderr.write(`Error: ${err.message}\n`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
"""X (Twitter) search via xurl CLI — official X API v2 with OAuth2.
|
||||
|
||||
xurl is an open-source CLI for the X API (https://github.com/openclaw/xurl).
|
||||
It uses OAuth2 with PKCE and automatic token refresh, requiring only a free
|
||||
X Developer App. No xAI subscription or browser cookies needed.
|
||||
|
||||
Install: npm install -g xurl
|
||||
Auth: xurl auth oauth2 login
|
||||
|
||||
Priority: xAI API > Bird/GraphQL > xurl > web-only fallback
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .relevance import token_overlap_relevance as _compute_relevance
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
sys.stderr.write(f"[xurl] {msg}\n")
|
||||
sys.stderr.flush()
|
||||
|
||||
|
||||
# Depth configurations: number of results to request
|
||||
DEPTH_CONFIG = {
|
||||
"quick": 10,
|
||||
"default": 30,
|
||||
"deep": 60,
|
||||
}
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""Check if xurl is installed and has valid authentication.
|
||||
|
||||
Returns True only if xurl binary is found AND the user is authenticated
|
||||
(i.e. ``xurl whoami`` exits 0 and returns a username field).
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["xurl", "whoami"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return result.returncode == 0 and '"username"' in result.stdout
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
return False
|
||||
|
||||
|
||||
def search_x(
|
||||
query: str,
|
||||
depth: str = "default",
|
||||
) -> Dict[str, Any]:
|
||||
"""Search X via xurl CLI using X API v2 search/recent.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
depth: "quick", "default", or "deep"
|
||||
|
||||
Returns:
|
||||
Raw JSON response from X API v2 tweets/search/recent, or a dict
|
||||
with an "error" key on failure.
|
||||
"""
|
||||
max_results = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
|
||||
# X API v2 search/recent requires max_results in 10–100 range
|
||||
max_results = max(10, min(100, max_results))
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["xurl", "search", query, "-n", str(max_results)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
error_text = result.stderr.strip() or result.stdout.strip()
|
||||
return {"error": f"xurl search failed: {error_text}"}
|
||||
|
||||
return json.loads(result.stdout)
|
||||
|
||||
except FileNotFoundError:
|
||||
return {"error": "xurl not found in PATH"}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"error": "xurl search timed out (30s)"}
|
||||
except json.JSONDecodeError as exc:
|
||||
return {"error": f"Invalid JSON from xurl: {exc}"}
|
||||
except Exception as exc:
|
||||
return {"error": f"{type(exc).__name__}: {exc}"}
|
||||
|
||||
|
||||
def parse_x_response(
|
||||
response: Dict[str, Any],
|
||||
topic: str = "",
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Parse xurl search response into normalized item dicts.
|
||||
|
||||
Output format matches the existing XItem schema used by xai_x and bird_x:
|
||||
id, text, url, author_handle, date, engagement, why_relevant, relevance.
|
||||
|
||||
Args:
|
||||
response: Raw X API v2 response dict from search_x()
|
||||
topic: Original search topic (used for relevance scoring)
|
||||
|
||||
Returns:
|
||||
List of item dicts. Empty list on error or no results.
|
||||
"""
|
||||
items: List[Dict[str, Any]] = []
|
||||
|
||||
if "error" in response:
|
||||
_log(f"Error in response: {response['error']}")
|
||||
return items
|
||||
|
||||
data = response.get("data") or []
|
||||
if not data:
|
||||
return items
|
||||
|
||||
# Build author lookup from includes.users
|
||||
authors: Dict[str, Dict[str, Any]] = {}
|
||||
for user in (response.get("includes") or {}).get("users") or []:
|
||||
authors[user["id"]] = user
|
||||
|
||||
for i, tweet in enumerate(data):
|
||||
author_id = tweet.get("author_id", "")
|
||||
author = authors.get(author_id, {})
|
||||
username = author.get("username", "")
|
||||
|
||||
tweet_id = tweet.get("id", "")
|
||||
url = f"https://x.com/{username}/status/{tweet_id}" if username else ""
|
||||
|
||||
# Parse public_metrics
|
||||
engagement: Optional[Dict[str, Any]] = None
|
||||
metrics = tweet.get("public_metrics") or {}
|
||||
if metrics:
|
||||
engagement = {
|
||||
"likes": metrics.get("like_count", 0),
|
||||
"reposts": metrics.get("retweet_count", 0),
|
||||
"replies": metrics.get("reply_count", 0),
|
||||
"quotes": metrics.get("quote_count", 0),
|
||||
}
|
||||
|
||||
# Parse ISO 8601 date → YYYY-MM-DD
|
||||
date: Optional[str] = None
|
||||
created = tweet.get("created_at", "")
|
||||
if created:
|
||||
m = re.match(r"(\d{4}-\d{2}-\d{2})", created)
|
||||
if m:
|
||||
date = m.group(1)
|
||||
|
||||
text = tweet.get("text", "").strip()
|
||||
|
||||
# Relevance score via shared token-overlap function
|
||||
relevance = _compute_relevance(topic, text) if topic else 0.5
|
||||
|
||||
items.append({
|
||||
"id": f"XURL{i + 1}",
|
||||
"text": text[:500],
|
||||
"url": url,
|
||||
"author_handle": username,
|
||||
"date": date,
|
||||
"engagement": engagement,
|
||||
"why_relevant": "",
|
||||
"relevance": relevance,
|
||||
})
|
||||
|
||||
return items
|
||||
+21
-36
@@ -655,6 +655,14 @@ except ImportError:
|
||||
_requests = None
|
||||
|
||||
|
||||
def _sc_headers(token: str) -> Dict[str, str]:
|
||||
"""Build ScrapeCreators request headers."""
|
||||
return {
|
||||
"x-api-key": token,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
def _total_engagement(item: Dict[str, Any]) -> int:
|
||||
"""Combined engagement score for ranking which videos to enrich."""
|
||||
eng = item.get("engagement", {})
|
||||
@@ -732,13 +740,12 @@ def _fetch_video_comments(
|
||||
Returns:
|
||||
List of comment dicts with author, text, likes, date.
|
||||
"""
|
||||
video_url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
if not _requests:
|
||||
try:
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"url": video_url})
|
||||
params = urlencode({"id": video_id})
|
||||
url = f"{SCRAPECREATORS_YT_BASE}/video/comments?{params}"
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers = _sc_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
except Exception as exc:
|
||||
@@ -748,8 +755,8 @@ def _fetch_video_comments(
|
||||
try:
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_YT_BASE}/video/comments",
|
||||
params={"url": video_url},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
params={"id": video_id},
|
||||
headers=_sc_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -764,32 +771,11 @@ def _fetch_video_comments(
|
||||
text = c.get("text") or c.get("body") or c.get("content", "")
|
||||
if not text:
|
||||
continue
|
||||
|
||||
# SC returns author as {"name": "@handle", ...}; legacy mocks may pass a string.
|
||||
author = c.get("author") or c.get("author_name", "")
|
||||
if isinstance(author, dict):
|
||||
author = author.get("name") or author.get("handle") or ""
|
||||
|
||||
# SC nests likes under engagement.likes; legacy shapes used top-level keys.
|
||||
engagement = c.get("engagement") or {}
|
||||
likes = c.get("likes")
|
||||
if likes is None:
|
||||
likes = engagement.get("likes", 0) if isinstance(engagement, dict) else 0
|
||||
if not likes:
|
||||
likes = c.get("vote_count", 0)
|
||||
|
||||
date = (
|
||||
c.get("date")
|
||||
or c.get("published_at")
|
||||
or c.get("publishedTime")
|
||||
or c.get("publishedTimeText", "")
|
||||
)
|
||||
|
||||
comments.append({
|
||||
"author": author,
|
||||
"author": c.get("author") or c.get("author_name", ""),
|
||||
"text": text[:400],
|
||||
"likes": likes,
|
||||
"date": date,
|
||||
"likes": c.get("likes") or c.get("vote_count", 0),
|
||||
"date": c.get("date") or c.get("published_at", ""),
|
||||
})
|
||||
|
||||
return comments
|
||||
@@ -920,7 +906,7 @@ def _sc_youtube_search(keyword: str, token: str) -> List[Dict[str, Any]]:
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"keyword": keyword})
|
||||
url = f"{SCRAPECREATORS_YT_BASE}/search?{params}"
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers = _sc_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
return data.get("videos", data.get("data", data.get("items", [])))
|
||||
@@ -932,7 +918,7 @@ def _sc_youtube_search(keyword: str, token: str) -> List[Dict[str, Any]]:
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_YT_BASE}/search",
|
||||
params={"keyword": keyword},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
headers=_sc_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
@@ -953,13 +939,12 @@ def _sc_fetch_transcript(video_id: str, token: str) -> Optional[str]:
|
||||
Returns:
|
||||
Plaintext transcript string, or None if unavailable.
|
||||
"""
|
||||
video_url = f"https://www.youtube.com/watch?v={video_id}"
|
||||
if not _requests:
|
||||
try:
|
||||
from urllib.parse import urlencode
|
||||
params = urlencode({"url": video_url})
|
||||
params = urlencode({"id": video_id})
|
||||
url = f"{SCRAPECREATORS_YT_BASE}/video/transcript?{params}"
|
||||
headers = http.scrapecreators_headers(token)
|
||||
headers = _sc_headers(token)
|
||||
headers["User-Agent"] = http.USER_AGENT
|
||||
data = http.get(url, headers=headers, timeout=30, retries=2)
|
||||
except Exception as exc:
|
||||
@@ -969,8 +954,8 @@ def _sc_fetch_transcript(video_id: str, token: str) -> Optional[str]:
|
||||
try:
|
||||
resp = _requests.get(
|
||||
f"{SCRAPECREATORS_YT_BASE}/video/transcript",
|
||||
params={"url": video_url},
|
||||
headers=http.scrapecreators_headers(token),
|
||||
params={"id": video_id},
|
||||
headers=_sc_headers(token),
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
|
||||
+4
-58
@@ -11,7 +11,7 @@ COMMON_TARGETS=(
|
||||
# but local development needs the cache kept in sync with the repo.
|
||||
# Do NOT add ~/.claude/skills/last30days - it creates a duplicate
|
||||
# /last30days-3 in the slash command menu alongside the plugin version.
|
||||
"$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3/3.0.1"
|
||||
"$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3/3.0.0-alpha"
|
||||
"$HOME/.claude/plugins/cache/last30days-skill-private/last30days-3-nogem/3.0.0-nogem"
|
||||
"$HOME/.agents/skills/last30days"
|
||||
"$HOME/.codex/skills/last30days"
|
||||
@@ -24,7 +24,7 @@ sync_target() {
|
||||
|
||||
echo ""
|
||||
echo "--- Syncing to $target ---"
|
||||
mkdir -p "$target/scripts/lib"
|
||||
mkdir -p "$target/scripts/lib" "$target/variants/open/references"
|
||||
|
||||
cp "$skill_md" "$target/SKILL.md"
|
||||
|
||||
@@ -35,13 +35,7 @@ sync_target() {
|
||||
"$SRC/scripts/store.py" \
|
||||
"$target/scripts/"
|
||||
rsync -a "$SRC/scripts/lib/"*.py "$target/scripts/lib/"
|
||||
|
||||
# The OpenClaw variant lives in the private repo only. Skip cleanly when
|
||||
# running this script from the public repo where variants/open does not exist.
|
||||
if [ -d "$SRC/variants/open" ]; then
|
||||
mkdir -p "$target/variants/open/references"
|
||||
rsync -a "$SRC/variants/open/" "$target/variants/open/"
|
||||
fi
|
||||
rsync -a "$SRC/variants/open/" "$target/variants/open/"
|
||||
|
||||
if [ -d "$SRC/scripts/lib/vendor" ]; then
|
||||
rsync -a "$SRC/scripts/lib/vendor" "$target/scripts/lib/"
|
||||
@@ -69,55 +63,7 @@ for t in "${COMMON_TARGETS[@]}"; do
|
||||
sync_target "$t" "$SRC/SKILL.md"
|
||||
done
|
||||
|
||||
# Hermes sync: deploy to Hermes skills directory if it exists
|
||||
HERMES_TARGET="$HOME/.hermes/skills/research/last30days"
|
||||
if [ -d "$HOME/.hermes/skills/research" ]; then
|
||||
echo ""
|
||||
echo "--- Syncing to Hermes ---"
|
||||
mkdir -p "$HERMES_TARGET/scripts/lib"
|
||||
|
||||
cp "$SRC/SKILL.md" "$HERMES_TARGET/SKILL.md"
|
||||
|
||||
rsync -a \
|
||||
"$SRC/scripts/last30days.py" \
|
||||
"$SRC/scripts/watchlist.py" \
|
||||
"$SRC/scripts/briefing.py" \
|
||||
"$SRC/scripts/store.py" \
|
||||
"$HERMES_TARGET/scripts/"
|
||||
rsync -a "$SRC/scripts/lib/"*.py "$HERMES_TARGET/scripts/lib/"
|
||||
|
||||
if [ -d "$SRC/scripts/lib/vendor" ]; then
|
||||
rsync -a "$SRC/scripts/lib/vendor" "$HERMES_TARGET/scripts/lib/"
|
||||
fi
|
||||
|
||||
if [ -d "$SRC/fixtures" ]; then
|
||||
mkdir -p "$HERMES_TARGET/fixtures"
|
||||
rsync -a "$SRC/fixtures/" "$HERMES_TARGET/fixtures/"
|
||||
fi
|
||||
|
||||
mod_count=$(ls "$HERMES_TARGET/scripts/lib/"*.py 2>/dev/null | wc -l | tr -d ' ')
|
||||
echo " Copied $mod_count modules to Hermes"
|
||||
|
||||
if (
|
||||
cd "$HERMES_TARGET/scripts" &&
|
||||
python3 -c "import briefing, store, watchlist; from lib import youtube_yt, bird_x, render, ui; print(' Import check: OK')"
|
||||
); then
|
||||
true
|
||||
else
|
||||
echo " Import check FAILED"
|
||||
fi
|
||||
fi
|
||||
|
||||
# OpenClaw sync only runs when the private-repo OpenClaw variant is present
|
||||
# in the source tree. The public repo does not ship variants/open (the variant
|
||||
# is sanitized via strip_for_openclaw.py and published separately from
|
||||
# last30days-skill-private).
|
||||
if [ -d "$SRC/variants/open" ]; then
|
||||
sync_target "$OPENCLAW_TARGET" "$SRC/variants/open/SKILL.md"
|
||||
else
|
||||
echo ""
|
||||
echo "Skipping OpenClaw target (no variants/open in this repo)"
|
||||
fi
|
||||
sync_target "$OPENCLAW_TARGET" "$SRC/variants/open/SKILL.md"
|
||||
|
||||
echo ""
|
||||
echo "Sync complete."
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../../SKILL.md
|
||||
@@ -0,0 +1,231 @@
|
||||
---
|
||||
name: last30days
|
||||
version: "3.0.0"
|
||||
description: "Multi-query social search with intelligent planning. Agent plans queries when possible, falls back to Gemini/OpenAI when not. Research any topic across Reddit, X, YouTube, TikTok, Instagram, Hacker News, Polymarket, and the web."
|
||||
argument-hint: "last30days codex vs claude code"
|
||||
allowed-tools: Bash, Read, Write, WebSearch
|
||||
homepage: https://github.com/mvanhorn/last30days-skill
|
||||
repository: https://github.com/mvanhorn/last30days-skill
|
||||
author: mvanhorn
|
||||
license: MIT
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# last30days v3.0.0
|
||||
|
||||
Use `last30days` when the user wants recent, cross-source evidence from the last 30 days.
|
||||
|
||||
The runtime is a single v3 pipeline:
|
||||
|
||||
1. plan the query
|
||||
2. retrieve per `(subquery, source)`
|
||||
3. normalize and dedupe
|
||||
4. extract best snippets
|
||||
5. fuse with weighted RRF
|
||||
6. rerank with one relevance score
|
||||
7. cluster evidence
|
||||
8. render ranked clusters
|
||||
|
||||
## Setup: resolve the skill root
|
||||
|
||||
```bash
|
||||
for dir in \
|
||||
"." \
|
||||
"${CLAUDE_PLUGIN_ROOT:-}" \
|
||||
"${GEMINI_EXTENSION_DIR:-}" \
|
||||
"$HOME/.openclaw/workspace/skills/last30days" \
|
||||
"$HOME/.openclaw/skills/last30days" \
|
||||
"$HOME/.claude/skills/last30days" \
|
||||
"$HOME/.agents/skills/last30days" \
|
||||
"$HOME/.codex/skills/last30days"; do
|
||||
[ -n "$dir" ] && [ -f "$dir/scripts/last30days.py" ] && SKILL_ROOT="$dir" && break
|
||||
done
|
||||
|
||||
if [ -z "${SKILL_ROOT:-}" ]; then
|
||||
echo "ERROR: Could not find scripts/last30days.py" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for py in python3.14 python3.13 python3.12 python3; do
|
||||
command -v "$py" >/dev/null 2>&1 || continue
|
||||
"$py" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 12) else 1)' || continue
|
||||
LAST30DAYS_PYTHON="$py"
|
||||
break
|
||||
done
|
||||
|
||||
if [ -z "${LAST30DAYS_PYTHON:-}" ]; then
|
||||
echo "ERROR: last30days v3 requires Python 3.12+. Install python3.12 or python3.13 and rerun." >&2
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## Default command
|
||||
|
||||
```bash
|
||||
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --emit=compact
|
||||
```
|
||||
|
||||
## Useful commands
|
||||
|
||||
```bash
|
||||
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --emit=json
|
||||
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --quick
|
||||
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --deep
|
||||
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --search=reddit,x,grounding
|
||||
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" $ARGUMENTS --store
|
||||
"${LAST30DAYS_PYTHON}" "${SKILL_ROOT}/scripts/last30days.py" --diagnose
|
||||
```
|
||||
|
||||
## Runtime expectations
|
||||
|
||||
- One reasoning provider is required: `GOOGLE_API_KEY` for Gemini, `OPENAI_API_KEY` for OpenAI, or `XAI_API_KEY` for xAI.
|
||||
- `BRAVE_API_KEY` enables Brave web search (recommended). `SERPER_API_KEY` is the web fallback.
|
||||
- `SCRAPECREATORS_API_KEY` enables Reddit, TikTok, and Instagram.
|
||||
- `XAI_API_KEY` enables xAI reasoning and X search.
|
||||
- `AUTH_TOKEN` plus `CT0` enables Bird-backed X search.
|
||||
- `yt-dlp` enables YouTube.
|
||||
- Planning and reranking fall back gracefully: Gemini -> OpenAI -> xAI -> deterministic/local.
|
||||
- Web retrieval stays within Brave/Serper dated results. Undated web hits are dropped.
|
||||
- For OpenClaw-specific watchlist, briefing, and history workflows, use `variants/open/SKILL.md`.
|
||||
|
||||
## Output model
|
||||
|
||||
- `compact` and `md`: cluster-first markdown
|
||||
- `json`: full v3 report
|
||||
- `context`: short synthesis-oriented context
|
||||
|
||||
Important report fields:
|
||||
|
||||
- `provider_runtime`
|
||||
- `query_plan`
|
||||
- `ranked_candidates`
|
||||
- `clusters`
|
||||
- `items_by_source`
|
||||
- `errors_by_source`
|
||||
|
||||
## Usage guidance for agents
|
||||
|
||||
- Prefer `--quick` for fast iteration.
|
||||
- Prefer default mode when the user wants a balanced answer.
|
||||
- Prefer `--deep` only when the user explicitly wants maximum recall or the topic is complex enough to justify extra latency.
|
||||
- Prefer `--emit=json` when downstream code or evaluation will consume the result.
|
||||
- Use `--search=` only when the user explicitly wants source restrictions.
|
||||
|
||||
## X handle resolution
|
||||
|
||||
If the topic could have its own X/Twitter account (people, brands, products, companies), do a quick WebSearch for their handle:
|
||||
```
|
||||
WebSearch("{TOPIC} X twitter handle site:x.com")
|
||||
```
|
||||
If you find a verified handle, pass `--x-handle={handle}` (without @). This searches their posts directly, finding content they posted that doesn't mention their own name. Skip this for generic concepts ("best headphones 2026", "how to use Docker").
|
||||
|
||||
## Synthesis guidance
|
||||
|
||||
### First: synthesize, don't summarize
|
||||
|
||||
Extract key facts from the output first, then synthesize across sources. Lead with patterns that appear across multiple clusters. Present a unified narrative, not a source-by-source summary.
|
||||
|
||||
### Ground in actual research, not pre-existing knowledge
|
||||
|
||||
Use exact product/tool names, specific quotes, and what sources actually say. If research mentions "ClawdBot" and "@clawdbot", that is a different product than "Claude Code" -- read what the research actually says.
|
||||
|
||||
**Anti-pattern to avoid:**
|
||||
- BAD: User asks "best Claude Code skills" and you respond with generic advice: "Skills are powerful. Keep them under 500 lines."
|
||||
- GOOD: You respond with specifics from the research: "Most mentioned: /commit (5 mentions), remotion skill (4x), git-worktree (3x). The Remotion announcement got 16K likes on X per @thedorbrothers."
|
||||
|
||||
### Source weighting (highest to lowest signal)
|
||||
|
||||
1. **Cross-cluster corroboration** -- same evidence across multiple sources is the strongest signal. Lead with it.
|
||||
2. **Reddit top comments** -- often the wittiest, most insightful take. Quote directly when upvotes are high.
|
||||
3. **YouTube transcript highlights** -- pre-extracted key moments. Quote and attribute to channel name.
|
||||
4. **X/Twitter @handles** -- real-time community signal. Quote with engagement context.
|
||||
5. **Polymarket odds** -- real money on outcomes cuts through opinion. Include specific odds AND movement.
|
||||
6. **TikTok/Instagram** -- viral/creator signal. Cite @creators with views/likes.
|
||||
7. **Hacker News** -- technical community perspective. Cite as "per HN."
|
||||
8. **Web (Brave/Serper)** -- cite only when social sources don't cover a fact.
|
||||
|
||||
### Polymarket interpretation
|
||||
|
||||
When Polymarket returns relevant markets:
|
||||
1. Prefer structural/long-term markets over near-term deadlines (championship odds > regular season, IPO > incremental update)
|
||||
2. Call out the specific outcome's odds and movement, not just that a market exists
|
||||
3. Weave odds into the narrative as supporting evidence, don't isolate them
|
||||
4. When multiple relevant markets exist, highlight 3-5 ordered by importance
|
||||
|
||||
Domain importance ranking:
|
||||
- **Sports:** Championship/tournament > conference title > regular season > weekly matchup
|
||||
- **Geopolitics:** Regime change/structural > near-term strike deadlines > sanctions
|
||||
- **Tech/Business:** IPO, major product launch > incremental updates
|
||||
- **Elections:** Presidency > primary > individual state
|
||||
|
||||
### Citation rules
|
||||
|
||||
Cite the single strongest source per point in short format: "per @handle" or "per r/subreddit". Save engagement metrics for the stats section. Use the priority order from source weighting above. The tool's value is surfacing what PEOPLE are saying, not what journalists wrote.
|
||||
|
||||
### Comparison queries
|
||||
|
||||
For "X vs Y" queries, structure output as:
|
||||
|
||||
```
|
||||
## Quick Verdict
|
||||
[1-2 sentences: which one the community prefers and why, with source counts]
|
||||
|
||||
## [Entity A]
|
||||
**Community Sentiment:** [Positive/Mixed/Negative] (N mentions across sources)
|
||||
**Strengths:** [with source attribution]
|
||||
**Weaknesses:** [with source attribution]
|
||||
|
||||
## [Entity B]
|
||||
[Same structure]
|
||||
|
||||
## Head-to-Head
|
||||
| Dimension | Entity A | Entity B |
|
||||
|-----------|----------|----------|
|
||||
| [Key dim] | [position] | [position] |
|
||||
|
||||
## Bottom Line
|
||||
Choose A if... Choose B if... (based on community data)
|
||||
```
|
||||
|
||||
### Recommendation queries
|
||||
|
||||
When users ask "best X" or "top X", extract SPECIFIC NAMES:
|
||||
|
||||
```
|
||||
Most mentioned:
|
||||
[Name] -- Nx mentions
|
||||
Sources: @handle1, r/subreddit, [YouTube channel]
|
||||
|
||||
[Name] -- Nx mentions
|
||||
Sources: @handle2, r/subreddit2
|
||||
|
||||
Notable mentions: [others with 1-2 mentions]
|
||||
```
|
||||
|
||||
### Edge cases
|
||||
|
||||
- **Empty results from a source:** State what is missing. ("No Reddit discussion found for this topic.") Do not fill the gap with training data.
|
||||
- **Sources contradict each other:** Present both sides with attribution. ("Reddit r/fitness is bullish on X, while @DrExpert on X warns about Y.")
|
||||
- **All results are low-engagement or off-topic:** Acknowledge uncertainty. ("Limited recent discussion found -- these findings should be treated as preliminary.")
|
||||
|
||||
### Follow-up conversations
|
||||
|
||||
After research completes, treat yourself as an expert on this topic. Answer follow-ups from the research findings. Cite the specific threads, posts, and channels you found. Only run new research if the user asks about a DIFFERENT topic.
|
||||
|
||||
## Security and permissions
|
||||
|
||||
**What this skill does:**
|
||||
- Sends search queries to ScrapeCreators API for Reddit, TikTok, Instagram search
|
||||
- Sends search queries via xAI API or Bird client for X search
|
||||
- Sends search queries to Algolia HN Search API (free, no auth)
|
||||
- Sends search queries to Polymarket Gamma API (free, no auth)
|
||||
- Runs yt-dlp locally for YouTube search and transcript extraction (no API key)
|
||||
- Sends search queries to Brave Search API or Serper for web search (optional)
|
||||
- Uses Gemini, OpenAI, or xAI for LLM planning and reranking
|
||||
- Stores findings in local SQLite database (--store mode only)
|
||||
|
||||
**What this skill does NOT do:**
|
||||
- Does not post, like, or modify content on any platform
|
||||
- Does not access your personal accounts on any platform
|
||||
- Does not share API keys between providers
|
||||
- Does not log or cache API keys in output files
|
||||
@@ -1,60 +0,0 @@
|
||||
# Fixture: `Prompting GPT Image 2` Resolved-block regression
|
||||
|
||||
Documentation-grade fixture. Captures the pre-fix and post-fix shape of the
|
||||
Step 0.55 Resolved block for the topic `Prompting GPT Image 2`. Not parsed
|
||||
by test code — read by reviewers when evaluating regressions in
|
||||
`scripts/lib/categories.py` or the SKILL.md Step 0.55 block.
|
||||
|
||||
The live assertion lives in `tests/test_category_integration.py`. This
|
||||
markdown fixture exists so reviewers can eyeball expected behavior without
|
||||
running pytest.
|
||||
|
||||
## Failing run (2026-04-22, pre-fix)
|
||||
|
||||
User ran `/last30days Prompting GPT Image 2`. Step 0.55 WebSearch returned
|
||||
OpenAI-brand communities. The model resolved exactly those.
|
||||
|
||||
```
|
||||
Resolved:
|
||||
- X: @OpenAI (+ @sama, @openaidevs)
|
||||
- Reddit: r/OpenAI, r/ChatGPT, r/singularity, r/artificial, r/ChatGPTpromptengineering
|
||||
- TikTok: #gptimage2, #openai, #aiart
|
||||
```
|
||||
|
||||
Engine run returned thin results. User manually intervened with "make sure
|
||||
to check image generatorion reddits too" and re-ran with the image-gen
|
||||
peer subs added.
|
||||
|
||||
## Expected run (post-fix, no user intervention)
|
||||
|
||||
After Step 0.55 Section 2a (category-peer expansion) and Unit 2's engine-side
|
||||
merge in `auto_resolve`, the same topic produces:
|
||||
|
||||
```
|
||||
Resolved:
|
||||
- X: @OpenAI (+ @sama, @openaidevs)
|
||||
- Reddit: r/OpenAI, r/ChatGPT, r/singularity, r/ChatGPTpromptengineering, r/StableDiffusion, r/midjourney, r/dalle2, r/aiArt (+ ai_image_generation peers)
|
||||
- TikTok: #gptimage2, #openai, #aiart
|
||||
```
|
||||
|
||||
The peer subs (`StableDiffusion, midjourney, dalle2, aiArt`) appear alongside
|
||||
the WebSearch-returned brand subs. The `(+ ai_image_generation peers)`
|
||||
annotation is the observable contract — its absence on a product-in-a-known-
|
||||
category topic is a Step 0.55 regression.
|
||||
|
||||
## Guards
|
||||
|
||||
- `tests/test_categories.py::DetectCategoryHappyPath::test_prompting_gpt_image_2_matches_image_generation`
|
||||
- `tests/test_resolve.py::MergeCategoryPeersHappyPath::test_image_gen_topic_appends_peers`
|
||||
- `tests/test_resolve.py::AutoResolveCategoryIntegration::test_auto_resolve_returns_category_key`
|
||||
- `tests/test_category_integration.py` — end-to-end over `auto_resolve` with
|
||||
a stubbed WebSearch that mimics the original failing response.
|
||||
|
||||
## When to update this fixture
|
||||
|
||||
- Category map changed (a peer sub was reordered, added, or removed).
|
||||
- The observable Resolved-block annotation format changed.
|
||||
- A new category was added that affects this topic.
|
||||
|
||||
Do not update casually. This file is the pre/post record of the 2026-04-22
|
||||
failure.
|
||||
+1
-27
@@ -175,7 +175,7 @@ class TestVendoredBirdRuntime(unittest.TestCase):
|
||||
}
|
||||
]
|
||||
items = parse_bird_response(tweets, "test query")
|
||||
self.assertIsNone(items[0]["engagement"])
|
||||
self.assertIsNone(items[0]["engagement"]["likes"])
|
||||
|
||||
def test_fallback_to_second_key(self):
|
||||
tweets = [
|
||||
@@ -203,32 +203,6 @@ class TestVendoredBirdRuntime(unittest.TestCase):
|
||||
items = parse_bird_response(tweets, "test query")
|
||||
self.assertEqual(0, items[0]["engagement"]["likes"])
|
||||
|
||||
def test_engagement_none_when_all_fields_missing(self):
|
||||
"""All-None engagement dict should become None, not propagate."""
|
||||
tweets = [
|
||||
{
|
||||
"id": "1",
|
||||
"text": "test",
|
||||
"permanent_url": "https://x.com/u/status/1",
|
||||
}
|
||||
]
|
||||
items = parse_bird_response(tweets, "test query")
|
||||
self.assertIsNone(items[0]["engagement"])
|
||||
|
||||
def test_engagement_preserved_when_any_field_present(self):
|
||||
"""Engagement dict kept when at least one metric exists."""
|
||||
tweets = [
|
||||
{
|
||||
"id": "1",
|
||||
"text": "test",
|
||||
"permanent_url": "https://x.com/u/status/1",
|
||||
"likeCount": 5,
|
||||
}
|
||||
]
|
||||
items = parse_bird_response(tweets, "test query")
|
||||
self.assertIsNotNone(items[0]["engagement"])
|
||||
self.assertEqual(5, items[0]["engagement"]["likes"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
"""Unit tests for scripts/lib/categories.py — the Step 0.55 category-peer map.
|
||||
|
||||
Guards the 2026-04-22 `Prompting GPT Image 2` failure mode: the original bug
|
||||
was that Step 0.55 resolved only brand-adjacent subs (r/OpenAI, r/ChatGPT)
|
||||
and missed the category peers (r/StableDiffusion, r/midjourney, r/dalle2)
|
||||
where prompting techniques actually live.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
|
||||
from lib import categories
|
||||
from lib.categories import CATEGORY_PEERS, detect_category, peer_subs_for
|
||||
|
||||
|
||||
class DetectCategoryHappyPath(unittest.TestCase):
|
||||
def test_prompting_gpt_image_2_matches_image_generation(self):
|
||||
self.assertEqual(
|
||||
detect_category("Prompting GPT Image 2"),
|
||||
"ai_image_generation",
|
||||
)
|
||||
|
||||
def test_claude_code_matches_coding_agent(self):
|
||||
self.assertEqual(
|
||||
detect_category("Claude Code skills"),
|
||||
"ai_coding_agent",
|
||||
)
|
||||
|
||||
def test_suno_matches_music_generation(self):
|
||||
self.assertEqual(detect_category("Suno v4 review"), "ai_music_generation")
|
||||
|
||||
def test_polymarket_matches_prediction_markets(self):
|
||||
self.assertEqual(
|
||||
detect_category("Polymarket election odds"),
|
||||
"prediction_markets",
|
||||
)
|
||||
|
||||
def test_sora_matches_video_generation(self):
|
||||
self.assertEqual(detect_category("Sora 2 prompts"), "ai_video_generation")
|
||||
|
||||
|
||||
class PeerSubsForHappyPath(unittest.TestCase):
|
||||
def test_image_generation_peer_subs_priority_order(self):
|
||||
subs = peer_subs_for("ai_image_generation")
|
||||
self.assertIn("StableDiffusion", subs)
|
||||
self.assertIn("midjourney", subs)
|
||||
self.assertIn("dalle2", subs)
|
||||
self.assertLess(subs.index("StableDiffusion"), subs.index("midjourney"))
|
||||
self.assertLess(subs.index("midjourney"), subs.index("dalle2"))
|
||||
|
||||
def test_unknown_category_returns_empty_list(self):
|
||||
self.assertEqual(peer_subs_for("unknown_category"), [])
|
||||
|
||||
def test_none_category_returns_empty_list(self):
|
||||
self.assertEqual(peer_subs_for(None), [])
|
||||
|
||||
def test_returned_list_is_fresh_copy(self):
|
||||
first = peer_subs_for("ai_image_generation")
|
||||
first.append("MutatedSub")
|
||||
second = peer_subs_for("ai_image_generation")
|
||||
self.assertNotIn("MutatedSub", second)
|
||||
|
||||
|
||||
class DetectCategoryEdgeCases(unittest.TestCase):
|
||||
def test_case_insensitive_match(self):
|
||||
self.assertEqual(
|
||||
detect_category("STABLE DIFFUSION walkthrough"),
|
||||
"ai_image_generation",
|
||||
)
|
||||
|
||||
def test_non_category_topic_returns_none(self):
|
||||
self.assertIsNone(detect_category("Kanye West"))
|
||||
|
||||
def test_bare_image_word_does_not_trigger_image_generation(self):
|
||||
# Compound-term guard: "image" alone is not a pattern; only
|
||||
# multi-word compounds or domain-specific brand names match.
|
||||
self.assertIsNone(detect_category("image editing on my phone"))
|
||||
|
||||
def test_bare_ai_word_does_not_trigger_any_category(self):
|
||||
self.assertIsNone(detect_category("ai news today"))
|
||||
|
||||
def test_empty_topic_returns_none(self):
|
||||
self.assertIsNone(detect_category(""))
|
||||
|
||||
def test_none_topic_returns_none(self):
|
||||
self.assertIsNone(detect_category(None))
|
||||
|
||||
def test_first_match_wins_image_gen_before_chat_model(self):
|
||||
# "gpt image 2" contains "gpt image" (ai_image_generation) and the
|
||||
# substring "gpt" could resemble gpt-N chat-model patterns. The
|
||||
# narrower category wins because it is declared earlier.
|
||||
self.assertEqual(
|
||||
detect_category("gpt image 2 review"),
|
||||
"ai_image_generation",
|
||||
)
|
||||
|
||||
|
||||
class CategoryMapInvariants(unittest.TestCase):
|
||||
"""Regression guards on the map itself — catch accidental bare-word patterns."""
|
||||
|
||||
# Common nouns that would produce false positives if used as bare patterns.
|
||||
FORBIDDEN_BARE_PATTERNS = frozenset({
|
||||
"image", "video", "music", "ai", "model", "agent", "chat",
|
||||
"code", "cli", "app", "tool", "defi",
|
||||
})
|
||||
|
||||
def test_no_category_has_a_bare_common_noun_pattern(self):
|
||||
offenders = []
|
||||
for category_id, entry in CATEGORY_PEERS.items():
|
||||
for pattern in entry["patterns"]:
|
||||
if pattern.strip() in self.FORBIDDEN_BARE_PATTERNS:
|
||||
offenders.append((category_id, pattern))
|
||||
self.assertEqual(
|
||||
offenders,
|
||||
[],
|
||||
msg=(
|
||||
"Bare common-noun patterns cause false positives. "
|
||||
f"Offenders: {offenders}. Patterns must be compound "
|
||||
"(e.g. 'image generation') or domain-specific "
|
||||
"(e.g. 'midjourney')."
|
||||
),
|
||||
)
|
||||
|
||||
def test_every_category_has_at_least_one_compound_or_brand_pattern(self):
|
||||
multi_word_or_brand = re.compile(r"(\s|-|\.)|^[a-z][a-z0-9]{3,}$")
|
||||
for category_id, entry in CATEGORY_PEERS.items():
|
||||
patterns = entry["patterns"]
|
||||
self.assertTrue(patterns, f"{category_id} has no patterns")
|
||||
has_strong = any(multi_word_or_brand.search(p) for p in patterns)
|
||||
self.assertTrue(
|
||||
has_strong,
|
||||
f"{category_id} needs at least one multi-word or brand pattern",
|
||||
)
|
||||
|
||||
def test_every_category_has_at_least_two_peer_subs(self):
|
||||
for category_id, entry in CATEGORY_PEERS.items():
|
||||
self.assertGreaterEqual(
|
||||
len(entry["peer_subs"]),
|
||||
2,
|
||||
f"{category_id} should list at least 2 peer subs",
|
||||
)
|
||||
|
||||
def test_category_count_is_in_expected_range(self):
|
||||
# Sanity check: the map is intentionally small and curated.
|
||||
self.assertGreaterEqual(len(CATEGORY_PEERS), 8)
|
||||
self.assertLessEqual(len(CATEGORY_PEERS), 20)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,145 +0,0 @@
|
||||
"""End-to-end regression test for the 2026-04-22 `Prompting GPT Image 2` bug.
|
||||
|
||||
Guards the failing run's Resolved-block shape end-to-end: stubs
|
||||
`grounding.web_search` to return the OpenAI-only subs that caused the
|
||||
original failure, then asserts that `auto_resolve` now returns the widened
|
||||
list and emits the expected stderr trace.
|
||||
|
||||
If this test starts failing after a `scripts/lib/categories.py` edit, either
|
||||
the fix regressed or the map intentionally dropped the `ai_image_generation`
|
||||
category — update the test deliberately.
|
||||
|
||||
Fixture reference: `tests/fixtures/prompting-gpt-image-2-resolved-block.md`.
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
import unittest
|
||||
from contextlib import redirect_stderr
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
|
||||
from lib import resolve
|
||||
|
||||
|
||||
OPENAI_BRAND_SUBREDDIT_RESULTS = [
|
||||
{
|
||||
"title": "r/OpenAI community hub",
|
||||
"snippet": "Discussion at r/ChatGPT and r/singularity about GPT Image 2.",
|
||||
"url": "https://reddit.com/r/OpenAI/",
|
||||
},
|
||||
{
|
||||
"title": "r/ChatGPTpromptengineering prompt collection",
|
||||
"snippet": "Also see r/artificial for broader AI chatter.",
|
||||
"url": "",
|
||||
},
|
||||
]
|
||||
|
||||
EMPTY_RESULTS: list[dict] = []
|
||||
|
||||
|
||||
def _fake_websearch(label_to_items: dict[str, list[dict]]):
|
||||
def _search(query, date_range, config):
|
||||
if "subreddit" in query:
|
||||
return label_to_items.get("subreddit", EMPTY_RESULTS), {}
|
||||
if "news" in query:
|
||||
return label_to_items.get("news", EMPTY_RESULTS), {}
|
||||
if "handle" in query:
|
||||
return label_to_items.get("x_handle", EMPTY_RESULTS), {}
|
||||
if "github" in query:
|
||||
return label_to_items.get("github", EMPTY_RESULTS), {}
|
||||
return EMPTY_RESULTS, {}
|
||||
|
||||
return _search
|
||||
|
||||
|
||||
class PromptingGptImage2RegressionGuard(unittest.TestCase):
|
||||
"""The named 2026-04-22 failure mode. Resolved block must include peers."""
|
||||
|
||||
@patch("lib.resolve.grounding.web_search")
|
||||
def test_auto_resolve_widens_to_image_gen_peers(self, mock_search):
|
||||
mock_search.side_effect = _fake_websearch({
|
||||
"subreddit": OPENAI_BRAND_SUBREDDIT_RESULTS,
|
||||
})
|
||||
|
||||
result = resolve.auto_resolve(
|
||||
"Prompting GPT Image 2",
|
||||
{"BRAVE_API_KEY": "fake"},
|
||||
)
|
||||
|
||||
subs_lower = [s.lower() for s in result["subreddits"]]
|
||||
|
||||
# Original WebSearch-returned brand subs preserved
|
||||
self.assertIn("openai", subs_lower)
|
||||
self.assertIn("chatgpt", subs_lower)
|
||||
self.assertIn("singularity", subs_lower)
|
||||
|
||||
# At least three of the image-gen peers were added
|
||||
expected_peers = {"stablediffusion", "midjourney", "dalle2", "aiart", "promptengineering"}
|
||||
found_peers = expected_peers.intersection(subs_lower)
|
||||
self.assertGreaterEqual(
|
||||
len(found_peers),
|
||||
3,
|
||||
f"Expected at least 3 image-gen peer subs, found: {found_peers}. "
|
||||
f"Actual subs: {result['subreddits']}",
|
||||
)
|
||||
|
||||
self.assertEqual(result["category"], "ai_image_generation")
|
||||
|
||||
@patch("lib.resolve.grounding.web_search")
|
||||
def test_stderr_contains_category_match_log_line(self, mock_search):
|
||||
mock_search.side_effect = _fake_websearch({
|
||||
"subreddit": OPENAI_BRAND_SUBREDDIT_RESULTS,
|
||||
})
|
||||
|
||||
buf = io.StringIO()
|
||||
with redirect_stderr(buf):
|
||||
resolve.auto_resolve(
|
||||
"Prompting GPT Image 2",
|
||||
{"BRAVE_API_KEY": "fake"},
|
||||
)
|
||||
|
||||
self.assertIn("Matched category=ai_image_generation", buf.getvalue())
|
||||
|
||||
@patch("lib.resolve.grounding.web_search")
|
||||
def test_cap_enforced_end_to_end(self, mock_search):
|
||||
# Synthesize a subreddit response with 9 brand subs
|
||||
many_subs_items = [
|
||||
{"title": f"r/Brand{i}", "snippet": "", "url": ""}
|
||||
for i in range(9)
|
||||
]
|
||||
mock_search.side_effect = _fake_websearch({
|
||||
"subreddit": many_subs_items,
|
||||
})
|
||||
|
||||
result = resolve.auto_resolve(
|
||||
"Prompting GPT Image 2",
|
||||
{"BRAVE_API_KEY": "fake"},
|
||||
)
|
||||
|
||||
self.assertLessEqual(len(result["subreddits"]), resolve.MAX_SUBS)
|
||||
# The first WebSearch sub is still present (brand subs never evicted)
|
||||
self.assertIn("Brand0", result["subreddits"])
|
||||
|
||||
@patch("lib.resolve.grounding.web_search")
|
||||
def test_uncategorized_topic_does_not_inject_peers(self, mock_search):
|
||||
mock_search.side_effect = _fake_websearch({
|
||||
"subreddit": [{"title": "r/Kanye is wild", "snippet": "", "url": ""}],
|
||||
})
|
||||
|
||||
buf = io.StringIO()
|
||||
with redirect_stderr(buf):
|
||||
result = resolve.auto_resolve(
|
||||
"Kanye West latest album",
|
||||
{"BRAVE_API_KEY": "fake"},
|
||||
)
|
||||
|
||||
self.assertEqual(result["subreddits"], ["Kanye"])
|
||||
self.assertIsNone(result["category"])
|
||||
self.assertNotIn("Matched category=", buf.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,137 +0,0 @@
|
||||
# ruff: noqa: E402
|
||||
"""CLI parsing and validation for --competitors / --competitors-list."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import sys
|
||||
import unittest
|
||||
from contextlib import redirect_stderr
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import last30days as cli
|
||||
|
||||
|
||||
def _parse(*argv: str):
|
||||
parser = cli.build_parser()
|
||||
args, _extra = parser.parse_known_args(argv)
|
||||
return args
|
||||
|
||||
|
||||
class CompetitorsCliTests(unittest.TestCase):
|
||||
def test_flag_absent_returns_disabled(self):
|
||||
args = _parse("Kanye West")
|
||||
enabled, count, explicit = cli.resolve_competitors_args(args)
|
||||
self.assertFalse(enabled)
|
||||
self.assertEqual(count, 0)
|
||||
self.assertEqual(explicit, [])
|
||||
|
||||
def test_bare_flag_defaults_to_two(self):
|
||||
args = _parse("Kanye West", "--competitors")
|
||||
enabled, count, explicit = cli.resolve_competitors_args(args)
|
||||
self.assertTrue(enabled)
|
||||
self.assertEqual(count, 2)
|
||||
self.assertEqual(explicit, [])
|
||||
|
||||
def test_explicit_three_still_supported(self):
|
||||
args = _parse("OpenAI", "--competitors", "3")
|
||||
enabled, count, _explicit = cli.resolve_competitors_args(args)
|
||||
self.assertTrue(enabled)
|
||||
self.assertEqual(count, 3)
|
||||
|
||||
def test_explicit_count(self):
|
||||
args = _parse("OpenAI", "--competitors", "4")
|
||||
enabled, count, explicit = cli.resolve_competitors_args(args)
|
||||
self.assertTrue(enabled)
|
||||
self.assertEqual(count, 4)
|
||||
self.assertEqual(explicit, [])
|
||||
|
||||
def test_explicit_list_preferred_over_discovery(self):
|
||||
args = _parse(
|
||||
"OpenAI",
|
||||
"--competitors",
|
||||
"--competitors-list",
|
||||
"Anthropic,xAI,Google Gemini",
|
||||
)
|
||||
enabled, count, explicit = cli.resolve_competitors_args(args)
|
||||
self.assertTrue(enabled)
|
||||
self.assertEqual(count, 3)
|
||||
self.assertEqual(explicit, ["Anthropic", "xAI", "Google Gemini"])
|
||||
|
||||
def test_explicit_list_without_flag_implies_enabled(self):
|
||||
args = _parse("OpenAI", "--competitors-list", "Anthropic,xAI")
|
||||
enabled, count, explicit = cli.resolve_competitors_args(args)
|
||||
self.assertTrue(enabled)
|
||||
self.assertEqual(count, 2)
|
||||
self.assertEqual(explicit, ["Anthropic", "xAI"])
|
||||
|
||||
def test_list_whitespace_normalized(self):
|
||||
args = _parse("OpenAI", "--competitors-list", " Anthropic , xAI , Gemini ")
|
||||
_enabled, count, explicit = cli.resolve_competitors_args(args)
|
||||
self.assertEqual(count, 3)
|
||||
self.assertEqual(explicit, ["Anthropic", "xAI", "Gemini"])
|
||||
|
||||
def test_zero_count_rejected(self):
|
||||
args = _parse("Topic", "--competitors", "0")
|
||||
with self.assertRaises(SystemExit) as cm, redirect_stderr(io.StringIO()) as err:
|
||||
cli.resolve_competitors_args(args)
|
||||
self.assertEqual(cm.exception.code, 2)
|
||||
self.assertIn("--competitors must be >= 1", err.getvalue())
|
||||
|
||||
def test_negative_count_rejected(self):
|
||||
args = _parse("Topic", "--competitors", "-1")
|
||||
with self.assertRaises(SystemExit), redirect_stderr(io.StringIO()):
|
||||
cli.resolve_competitors_args(args)
|
||||
|
||||
def test_over_max_count_clamps_with_warning(self):
|
||||
args = _parse("Topic", "--competitors", "99")
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
enabled, count, explicit = cli.resolve_competitors_args(args)
|
||||
self.assertTrue(enabled)
|
||||
self.assertEqual(count, cli.COMPETITORS_MAX)
|
||||
self.assertEqual(explicit, [])
|
||||
self.assertIn("clamping", err.getvalue())
|
||||
|
||||
def test_overlong_list_clamps_with_warning(self):
|
||||
args = _parse(
|
||||
"Topic",
|
||||
"--competitors-list",
|
||||
"A,B,C,D,E,F,G,H",
|
||||
)
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
enabled, count, explicit = cli.resolve_competitors_args(args)
|
||||
self.assertTrue(enabled)
|
||||
self.assertEqual(count, cli.COMPETITORS_MAX)
|
||||
self.assertEqual(len(explicit), cli.COMPETITORS_MAX)
|
||||
self.assertIn("clamping to", err.getvalue())
|
||||
|
||||
def test_list_count_mismatch_warns(self):
|
||||
args = _parse(
|
||||
"Topic",
|
||||
"--competitors",
|
||||
"5",
|
||||
"--competitors-list",
|
||||
"A,B",
|
||||
)
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
enabled, count, explicit = cli.resolve_competitors_args(args)
|
||||
self.assertTrue(enabled)
|
||||
self.assertEqual(count, 2)
|
||||
self.assertEqual(explicit, ["A", "B"])
|
||||
self.assertIn("--competitors=5 ignored", err.getvalue())
|
||||
|
||||
def test_empty_list_rejected(self):
|
||||
args = _parse("Topic", "--competitors-list", ",, ,")
|
||||
with self.assertRaises(SystemExit) as cm, redirect_stderr(io.StringIO()):
|
||||
cli.resolve_competitors_args(args)
|
||||
self.assertEqual(cm.exception.code, 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -77,13 +77,6 @@ class CliV3Tests(unittest.TestCase):
|
||||
with self.assertRaises(SystemExit):
|
||||
cli.parse_search_flag(" , ")
|
||||
|
||||
def test_build_parser_accepts_days_alias_and_preserves_topic_tokens(self):
|
||||
parser = cli.build_parser()
|
||||
args, extra = parser.parse_known_args(["--days", "7", "biosecurity", "ai", "agents"])
|
||||
self.assertEqual(7, args.lookback_days)
|
||||
self.assertEqual(["biosecurity", "ai", "agents"], args.topic)
|
||||
self.assertEqual([], extra)
|
||||
|
||||
def test_ensure_supported_python_rejects_old_interpreter_with_actionable_error(self):
|
||||
stderr = io.StringIO()
|
||||
with redirect_stderr(stderr):
|
||||
@@ -135,14 +128,6 @@ class CliV3Tests(unittest.TestCase):
|
||||
payload = json.loads(path.read_text())
|
||||
self.assertEqual("OpenClaw vs NanoClaw", payload["topic"])
|
||||
|
||||
def test_save_output_writes_utf8_encoded_markdown(self):
|
||||
report = self.make_report()
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
with mock.patch("pathlib.Path.write_text", autospec=True, return_value=1) as write_text:
|
||||
cli.save_output(report, "md", tmp)
|
||||
_, kwargs = write_text.call_args
|
||||
self.assertEqual("utf-8", kwargs.get("encoding"))
|
||||
|
||||
def test_persist_report_updates_run_status_on_success_and_failure(self):
|
||||
report = self.make_report()
|
||||
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
# ruff: noqa: E402
|
||||
"""Tests for scripts/lib/fanout.run_competitor_fanout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
from contextlib import redirect_stderr
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
from lib import fanout
|
||||
|
||||
|
||||
def _fake_report(topic: str):
|
||||
"""Build a lightweight Report stand-in. Tests only check identity."""
|
||||
class _R:
|
||||
pass
|
||||
|
||||
r = _R()
|
||||
r.topic = topic
|
||||
return r
|
||||
|
||||
|
||||
class FanoutOrchestratorTests(unittest.TestCase):
|
||||
def test_main_plus_two_competitors_all_succeed(self):
|
||||
def main_runner():
|
||||
return _fake_report("OpenAI")
|
||||
|
||||
def comp_runner(entity):
|
||||
return _fake_report(entity)
|
||||
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
results = fanout.run_competitor_fanout(
|
||||
main_topic="OpenAI",
|
||||
main_runner=main_runner,
|
||||
competitors=["Anthropic", "xAI"],
|
||||
competitor_runner=comp_runner,
|
||||
)
|
||||
labels = [label for label, _ in results]
|
||||
self.assertEqual(labels, ["OpenAI", "Anthropic", "xAI"])
|
||||
self.assertEqual(results[0][1].topic, "OpenAI")
|
||||
self.assertEqual(results[1][1].topic, "Anthropic")
|
||||
|
||||
def test_one_competitor_failure_degrades_gracefully(self):
|
||||
def main_runner():
|
||||
return _fake_report("OpenAI")
|
||||
|
||||
def comp_runner(entity):
|
||||
if entity == "BrokenCo":
|
||||
raise RuntimeError("upstream offline")
|
||||
return _fake_report(entity)
|
||||
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
results = fanout.run_competitor_fanout(
|
||||
main_topic="OpenAI",
|
||||
main_runner=main_runner,
|
||||
competitors=["Anthropic", "BrokenCo", "xAI"],
|
||||
competitor_runner=comp_runner,
|
||||
)
|
||||
labels = [label for label, _ in results]
|
||||
self.assertEqual(labels, ["OpenAI", "Anthropic", "xAI"])
|
||||
self.assertIn("BrokenCo", err.getvalue())
|
||||
self.assertIn("upstream offline", err.getvalue())
|
||||
|
||||
def test_main_topic_failure_leaves_only_competitors(self):
|
||||
def main_runner():
|
||||
raise RuntimeError("main exploded")
|
||||
|
||||
def comp_runner(entity):
|
||||
return _fake_report(entity)
|
||||
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
results = fanout.run_competitor_fanout(
|
||||
main_topic="OpenAI",
|
||||
main_runner=main_runner,
|
||||
competitors=["Anthropic", "xAI"],
|
||||
competitor_runner=comp_runner,
|
||||
)
|
||||
labels = [label for label, _ in results]
|
||||
self.assertEqual(labels, ["Anthropic", "xAI"])
|
||||
self.assertIn("main exploded", err.getvalue())
|
||||
|
||||
def test_empty_competitor_list_runs_only_main(self):
|
||||
def main_runner():
|
||||
return _fake_report("OpenAI")
|
||||
|
||||
def comp_runner(_entity):
|
||||
raise AssertionError("should not be called when competitors=[]")
|
||||
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
results = fanout.run_competitor_fanout(
|
||||
main_topic="OpenAI",
|
||||
main_runner=main_runner,
|
||||
competitors=[],
|
||||
competitor_runner=comp_runner,
|
||||
)
|
||||
self.assertEqual([label for label, _ in results], ["OpenAI"])
|
||||
|
||||
def test_sub_runs_execute_in_parallel(self):
|
||||
"""Wall clock should be closer to max(latency) than sum(latency)."""
|
||||
delay = 0.2
|
||||
call_count = 3 # main + 2 competitors
|
||||
|
||||
def make_runner(_label):
|
||||
def runner():
|
||||
time.sleep(delay)
|
||||
return _fake_report(_label)
|
||||
return runner
|
||||
|
||||
def comp_runner(entity):
|
||||
return make_runner(entity)()
|
||||
|
||||
start = time.monotonic()
|
||||
with redirect_stderr(io.StringIO()):
|
||||
results = fanout.run_competitor_fanout(
|
||||
main_topic="OpenAI",
|
||||
main_runner=make_runner("OpenAI"),
|
||||
competitors=["Anthropic", "xAI"],
|
||||
competitor_runner=comp_runner,
|
||||
)
|
||||
elapsed = time.monotonic() - start
|
||||
self.assertEqual(len(results), 3)
|
||||
# Generous margin: parallel execution should finish well under
|
||||
# sum(call_count * delay) == 0.6s. We accept anything under 0.5s.
|
||||
self.assertLess(
|
||||
elapsed, delay * call_count,
|
||||
f"Expected parallel execution < {delay * call_count:.2f}s, "
|
||||
f"got {elapsed:.2f}s (sub-runs likely serialized)",
|
||||
)
|
||||
|
||||
def test_all_competitors_fail_leaves_main_only(self):
|
||||
def main_runner():
|
||||
return _fake_report("OpenAI")
|
||||
|
||||
def comp_runner(_entity):
|
||||
raise RuntimeError("all offline")
|
||||
|
||||
with redirect_stderr(io.StringIO()):
|
||||
results = fanout.run_competitor_fanout(
|
||||
main_topic="OpenAI",
|
||||
main_runner=main_runner,
|
||||
competitors=["A", "B", "C"],
|
||||
competitor_runner=comp_runner,
|
||||
)
|
||||
self.assertEqual([label for label, _ in results], ["OpenAI"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,196 +0,0 @@
|
||||
# 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()
|
||||
@@ -1,152 +0,0 @@
|
||||
# ruff: noqa: E402
|
||||
"""Tests for scripts/lib/competitors.discover_competitors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import sys
|
||||
import unittest
|
||||
from contextlib import redirect_stderr
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
from lib import competitors
|
||||
|
||||
|
||||
def _serp(items: list[tuple[str, str]]) -> list[dict]:
|
||||
"""Build a minimal SERP items list from (title, snippet) pairs."""
|
||||
return [
|
||||
{"title": title, "snippet": snippet, "url": "https://example.test/"}
|
||||
for title, snippet in items
|
||||
]
|
||||
|
||||
|
||||
OPENAI_SERP = _serp(
|
||||
[
|
||||
("OpenAI vs Anthropic vs xAI: which is better?", "xAI and Anthropic now compete directly with OpenAI."),
|
||||
("Top OpenAI alternatives in 2026", "Anthropic, Google Gemini, and xAI are the leading alternatives this year."),
|
||||
("xAI and Anthropic challenge OpenAI dominance", "xAI and Anthropic push Google Gemini hard; xAI keeps shipping."),
|
||||
("Anthropic vs xAI: head to head", "Anthropic and xAI trade punches; Google Gemini is not far behind."),
|
||||
]
|
||||
)
|
||||
|
||||
KANYE_SERP = _serp(
|
||||
[
|
||||
("Kanye West vs Drake: the feud explained", "Drake responded to Kanye with a diss track."),
|
||||
("Top rappers of the decade: Kendrick Lamar, Drake, J Cole", "Kendrick Lamar released a new album; Drake toured Europe."),
|
||||
("Drake and Kendrick Lamar trade shots", "J Cole stayed out of the Drake vs Kendrick Lamar feud."),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class CompetitorDiscoveryTests(unittest.TestCase):
|
||||
def _run(self, serp: list[dict], topic: str, count: int = 3) -> list[str]:
|
||||
config = {"BRAVE_API_KEY": "test-key"}
|
||||
with mock.patch.object(
|
||||
competitors.grounding, "web_search", return_value=(serp, {})
|
||||
):
|
||||
with redirect_stderr(io.StringIO()):
|
||||
return competitors.discover_competitors(topic, count, config)
|
||||
|
||||
def test_openai_surfaces_anthropic_and_peers(self):
|
||||
results = self._run(OPENAI_SERP, "OpenAI", count=3)
|
||||
self.assertEqual(len(results), 3)
|
||||
joined = " ".join(results)
|
||||
self.assertIn("Anthropic", joined)
|
||||
self.assertIn("xAI", joined)
|
||||
# Should not surface the topic itself
|
||||
self.assertNotIn("OpenAI", results)
|
||||
self.assertFalse(
|
||||
any("OpenAI" in entity for entity in results),
|
||||
f"Topic token leaked into results: {results}",
|
||||
)
|
||||
|
||||
def test_kanye_surfaces_rap_peers(self):
|
||||
results = self._run(KANYE_SERP, "Kanye West", count=2)
|
||||
self.assertEqual(len(results), 2)
|
||||
joined = " ".join(results)
|
||||
self.assertTrue(
|
||||
"Drake" in joined and "Kendrick Lamar" in joined,
|
||||
f"Expected Drake and Kendrick Lamar in {results}",
|
||||
)
|
||||
|
||||
def test_empty_serp_returns_empty(self):
|
||||
results = self._run([], "OpenAI", count=3)
|
||||
self.assertEqual(results, [])
|
||||
|
||||
def test_no_backend_returns_empty(self):
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
results = competitors.discover_competitors("OpenAI", 3, config={})
|
||||
self.assertEqual(results, [])
|
||||
self.assertIn("No web search backend", err.getvalue())
|
||||
|
||||
def test_backend_error_returns_empty(self):
|
||||
config = {"BRAVE_API_KEY": "test-key"}
|
||||
|
||||
def boom(*_args, **_kwargs):
|
||||
raise RuntimeError("SERP provider offline")
|
||||
|
||||
err = io.StringIO()
|
||||
with mock.patch.object(competitors.grounding, "web_search", side_effect=boom):
|
||||
with redirect_stderr(err):
|
||||
results = competitors.discover_competitors("OpenAI", 3, config)
|
||||
self.assertEqual(results, [])
|
||||
self.assertIn("Search failed", err.getvalue())
|
||||
|
||||
def test_topic_tokens_filtered(self):
|
||||
"""Candidates overlapping topic tokens are rejected."""
|
||||
serp = _serp(
|
||||
[
|
||||
("Open AI vs Anthropic", "Open AI, Anthropic, and Google lead."),
|
||||
("OpenAI Alternatives: Anthropic", "Anthropic is a competitor to Open AI."),
|
||||
]
|
||||
)
|
||||
results = self._run(serp, "OpenAI", count=3)
|
||||
# "Open AI" shares the "openai" lowercased-concatenation? Actually tokenizer
|
||||
# splits "Open AI" into ["open", "ai"]. Topic "OpenAI" tokenizes to ["openai"].
|
||||
# They do not overlap at the token level, which is fine — the filter is
|
||||
# best-effort. We only assert that bare "OpenAI" is filtered and real
|
||||
# competitors still surface.
|
||||
self.assertNotIn("OpenAI", results)
|
||||
self.assertIn("Anthropic", results)
|
||||
|
||||
def test_deduplicates_case_insensitively(self):
|
||||
serp = _serp(
|
||||
[
|
||||
("Anthropic vs Gemini", "anthropic is strong."),
|
||||
("ANTHROPIC makes Claude", "Anthropic announced Claude 4."),
|
||||
]
|
||||
)
|
||||
results = self._run(serp, "OpenAI", count=3)
|
||||
# "Anthropic" should appear exactly once (first-seen capitalization wins).
|
||||
anthropic_matches = [r for r in results if r.lower() == "anthropic"]
|
||||
self.assertEqual(len(anthropic_matches), 1)
|
||||
|
||||
def test_count_one_returns_single(self):
|
||||
results = self._run(OPENAI_SERP, "OpenAI", count=1)
|
||||
self.assertEqual(len(results), 1)
|
||||
|
||||
def test_stopword_only_candidates_rejected(self):
|
||||
serp = _serp(
|
||||
[
|
||||
("Top Alternatives", "Best Competitors and Top Tools."),
|
||||
("Free Software Reviews", "Complete Guide to The Options."),
|
||||
]
|
||||
)
|
||||
results = self._run(serp, "Widget", count=5)
|
||||
self.assertEqual(
|
||||
results, [],
|
||||
f"Stopword-only phrases should not be returned: got {results}",
|
||||
)
|
||||
|
||||
def test_count_zero_returns_empty(self):
|
||||
results = self._run(OPENAI_SERP, "OpenAI", count=0)
|
||||
self.assertEqual(results, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,180 +0,0 @@
|
||||
# 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()
|
||||
@@ -1,331 +0,0 @@
|
||||
# ruff: noqa: E402
|
||||
"""Integration tests for per-entity Step 0.55 resolution inside competitor fan-out."""
|
||||
|
||||
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):
|
||||
"""Minimal Report stand-in for runner return values."""
|
||||
class _R:
|
||||
pass
|
||||
|
||||
r = _R()
|
||||
r.topic = topic
|
||||
r.artifacts = {}
|
||||
return r
|
||||
|
||||
|
||||
def _build_main_args(*overrides):
|
||||
"""Minimal argparse.Namespace-like object for the competitor path."""
|
||||
import argparse
|
||||
ns = argparse.Namespace(
|
||||
topic=["Kanye West"],
|
||||
mock=False,
|
||||
competitors=2,
|
||||
competitors_list=None,
|
||||
quick=False,
|
||||
deep=False,
|
||||
emit="compact",
|
||||
search=None,
|
||||
debug=False,
|
||||
diagnose=False,
|
||||
save_dir=None,
|
||||
save_suffix=None,
|
||||
store=False,
|
||||
x_handle=None,
|
||||
x_related=None,
|
||||
web_backend="auto",
|
||||
deep_research=False,
|
||||
plan=None,
|
||||
subreddits=None,
|
||||
tiktok_hashtags=None,
|
||||
tiktok_creators=None,
|
||||
ig_creators=None,
|
||||
lookback_days=30,
|
||||
auto_resolve=False,
|
||||
github_user=None,
|
||||
github_repo=None,
|
||||
)
|
||||
return ns
|
||||
|
||||
|
||||
class PerEntityResolveTests(unittest.TestCase):
|
||||
"""Verify each competitor sub-run calls auto_resolve with its own topic and
|
||||
that the resolved fields are threaded into pipeline.run."""
|
||||
|
||||
def test_auto_resolve_called_per_competitor(self):
|
||||
from lib import resolve as resolve_mod
|
||||
from lib import pipeline as pipeline_mod
|
||||
|
||||
config = {"BRAVE_API_KEY": "test-key"}
|
||||
|
||||
captured_resolve_topics: list[str] = []
|
||||
captured_pipeline_kwargs: list[dict] = []
|
||||
|
||||
def fake_resolve(topic, _cfg):
|
||||
captured_resolve_topics.append(topic)
|
||||
per_topic = {
|
||||
"Drake": {
|
||||
"x_handle": "Drake",
|
||||
"subreddits": ["DrakeTheType", "hiphopheads"],
|
||||
"github_user": "",
|
||||
"github_repos": [],
|
||||
"context": "Drake ICEMAN rollout",
|
||||
"category": None,
|
||||
"searches_run": 4,
|
||||
},
|
||||
"Kendrick Lamar": {
|
||||
"x_handle": "kendricklamar",
|
||||
"subreddits": ["KendrickLamar", "hiphopheads"],
|
||||
"github_user": "",
|
||||
"github_repos": [],
|
||||
"context": "Meet The Grahams revival",
|
||||
"category": None,
|
||||
"searches_run": 4,
|
||||
},
|
||||
}
|
||||
return per_topic.get(topic, {
|
||||
"x_handle": "", "subreddits": [], "github_user": "",
|
||||
"github_repos": [], "context": "",
|
||||
"category": None, "searches_run": 0,
|
||||
})
|
||||
|
||||
def fake_pipeline_run(**kwargs):
|
||||
captured_pipeline_kwargs.append(kwargs)
|
||||
return _fake_report(kwargs["topic"])
|
||||
|
||||
with mock.patch.object(resolve_mod, "auto_resolve", side_effect=fake_resolve), \
|
||||
mock.patch.object(resolve_mod, "_has_backend", return_value=True), \
|
||||
mock.patch.object(pipeline_mod, "run", side_effect=fake_pipeline_run):
|
||||
# Exercise the competitor_runner closure pattern from main() by
|
||||
# calling it directly with two competitors.
|
||||
self._run_competitor_closure(
|
||||
config=config,
|
||||
competitors=["Drake", "Kendrick Lamar"],
|
||||
mock_flag=False,
|
||||
)
|
||||
|
||||
# auto_resolve was called once per competitor
|
||||
self.assertEqual(sorted(captured_resolve_topics), ["Drake", "Kendrick Lamar"])
|
||||
# pipeline.run received resolved fields per entity
|
||||
by_topic = {kw["topic"]: kw for kw in captured_pipeline_kwargs}
|
||||
self.assertEqual(by_topic["Drake"]["x_handle"], "Drake")
|
||||
self.assertEqual(
|
||||
by_topic["Drake"]["subreddits"], ["DrakeTheType", "hiphopheads"],
|
||||
)
|
||||
self.assertEqual(by_topic["Kendrick Lamar"]["x_handle"], "kendricklamar")
|
||||
# internal_subrun=True on all competitor sub-runs
|
||||
self.assertTrue(all(kw["internal_subrun"] for kw in captured_pipeline_kwargs))
|
||||
|
||||
def test_mock_mode_skips_auto_resolve(self):
|
||||
from lib import resolve as resolve_mod
|
||||
from lib import pipeline as pipeline_mod
|
||||
|
||||
resolve_called = []
|
||||
|
||||
def fake_resolve(*a, **k):
|
||||
resolve_called.append((a, k))
|
||||
return {}
|
||||
|
||||
with mock.patch.object(resolve_mod, "auto_resolve", side_effect=fake_resolve), \
|
||||
mock.patch.object(pipeline_mod, "run", side_effect=lambda **kw: _fake_report(kw["topic"])):
|
||||
self._run_competitor_closure(
|
||||
config={"BRAVE_API_KEY": "test-key"},
|
||||
competitors=["Anthropic"],
|
||||
mock_flag=True,
|
||||
)
|
||||
|
||||
self.assertEqual(resolve_called, [])
|
||||
|
||||
def test_no_backend_skips_auto_resolve(self):
|
||||
from lib import resolve as resolve_mod
|
||||
from lib import pipeline as pipeline_mod
|
||||
|
||||
resolve_called = []
|
||||
|
||||
def fake_resolve(*a, **k):
|
||||
resolve_called.append((a, k))
|
||||
return {}
|
||||
|
||||
with mock.patch.object(resolve_mod, "auto_resolve", side_effect=fake_resolve), \
|
||||
mock.patch.object(resolve_mod, "_has_backend", return_value=False), \
|
||||
mock.patch.object(pipeline_mod, "run", side_effect=lambda **kw: _fake_report(kw["topic"])):
|
||||
self._run_competitor_closure(
|
||||
config={},
|
||||
competitors=["Anthropic"],
|
||||
mock_flag=False,
|
||||
)
|
||||
|
||||
self.assertEqual(resolve_called, [])
|
||||
|
||||
def test_resolve_failure_degrades_gracefully(self):
|
||||
from lib import resolve as resolve_mod
|
||||
from lib import pipeline as pipeline_mod
|
||||
|
||||
captured_pipeline_kwargs: list[dict] = []
|
||||
|
||||
def fake_resolve(_topic, _cfg):
|
||||
raise RuntimeError("upstream offline")
|
||||
|
||||
def fake_pipeline_run(**kwargs):
|
||||
captured_pipeline_kwargs.append(kwargs)
|
||||
return _fake_report(kwargs["topic"])
|
||||
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err), \
|
||||
mock.patch.object(resolve_mod, "auto_resolve", side_effect=fake_resolve), \
|
||||
mock.patch.object(resolve_mod, "_has_backend", return_value=True), \
|
||||
mock.patch.object(pipeline_mod, "run", side_effect=fake_pipeline_run):
|
||||
self._run_competitor_closure(
|
||||
config={"BRAVE_API_KEY": "test-key"},
|
||||
competitors=["Anthropic"],
|
||||
mock_flag=False,
|
||||
)
|
||||
|
||||
# Warning logged but run continues with planner defaults
|
||||
self.assertIn("auto_resolve failed for 'Anthropic'", err.getvalue())
|
||||
self.assertEqual(len(captured_pipeline_kwargs), 1)
|
||||
self.assertIsNone(captured_pipeline_kwargs[0]["x_handle"])
|
||||
self.assertIsNone(captured_pipeline_kwargs[0]["subreddits"])
|
||||
|
||||
def test_resolved_artifact_stored_on_report(self):
|
||||
from lib import resolve as resolve_mod
|
||||
from lib import pipeline as pipeline_mod
|
||||
|
||||
with mock.patch.object(resolve_mod, "auto_resolve", return_value={
|
||||
"x_handle": "Drake",
|
||||
"subreddits": ["DrakeTheType"],
|
||||
"github_user": "",
|
||||
"github_repos": [],
|
||||
"context": "Drake context",
|
||||
"category": None,
|
||||
"searches_run": 4,
|
||||
}), \
|
||||
mock.patch.object(resolve_mod, "_has_backend", return_value=True), \
|
||||
mock.patch.object(pipeline_mod, "run", side_effect=lambda **kw: _fake_report(kw["topic"])):
|
||||
results = self._run_competitor_closure(
|
||||
config={"BRAVE_API_KEY": "test-key"},
|
||||
competitors=["Drake"],
|
||||
mock_flag=False,
|
||||
)
|
||||
|
||||
self.assertIn("resolved", results[0].artifacts)
|
||||
resolved = results[0].artifacts["resolved"]
|
||||
self.assertEqual(resolved["entity"], "Drake")
|
||||
self.assertEqual(resolved["x_handle"], "Drake")
|
||||
self.assertEqual(resolved["subreddits"], ["DrakeTheType"])
|
||||
self.assertEqual(resolved["context"], "Drake context")
|
||||
|
||||
def test_config_not_mutated_across_sub_runs(self):
|
||||
"""_auto_resolve_context from entity A must not leak into entity B."""
|
||||
from lib import resolve as resolve_mod
|
||||
from lib import pipeline as pipeline_mod
|
||||
|
||||
captured_contexts: list[str] = []
|
||||
|
||||
def fake_resolve(topic, _cfg):
|
||||
per_topic = {
|
||||
"Drake": {"x_handle": "Drake", "subreddits": [], "github_user": "",
|
||||
"github_repos": [], "context": "Drake unique context",
|
||||
"category": None, "searches_run": 4},
|
||||
"Kendrick Lamar": {"x_handle": "kendricklamar", "subreddits": [],
|
||||
"github_user": "", "github_repos": [],
|
||||
"context": "Kendrick unique context",
|
||||
"category": None, "searches_run": 4},
|
||||
}
|
||||
return per_topic[topic]
|
||||
|
||||
def fake_pipeline_run(**kwargs):
|
||||
captured_contexts.append(
|
||||
kwargs["config"].get("_auto_resolve_context", "")
|
||||
)
|
||||
return _fake_report(kwargs["topic"])
|
||||
|
||||
shared_config = {"BRAVE_API_KEY": "test-key"}
|
||||
with mock.patch.object(resolve_mod, "auto_resolve", side_effect=fake_resolve), \
|
||||
mock.patch.object(resolve_mod, "_has_backend", return_value=True), \
|
||||
mock.patch.object(pipeline_mod, "run", side_effect=fake_pipeline_run):
|
||||
self._run_competitor_closure(
|
||||
config=shared_config,
|
||||
competitors=["Drake", "Kendrick Lamar"],
|
||||
mock_flag=False,
|
||||
)
|
||||
|
||||
# Each sub-run received its own entity's context — no cross-leak.
|
||||
self.assertIn("Drake unique context", captured_contexts)
|
||||
self.assertIn("Kendrick unique context", captured_contexts)
|
||||
# The shared outer config was not mutated
|
||||
self.assertNotIn("_auto_resolve_context", shared_config)
|
||||
|
||||
# --- test helpers -----------------------------------------------------
|
||||
|
||||
def _run_competitor_closure(self, *, config, competitors, mock_flag):
|
||||
"""Replicate the competitor_runner closure from last30days.main() and
|
||||
call it against each competitor. Returns the list of Reports."""
|
||||
from lib import pipeline, resolve as resolve_mod
|
||||
|
||||
class _Args:
|
||||
pass
|
||||
args = _Args()
|
||||
args.mock = mock_flag
|
||||
args.web_backend = "auto"
|
||||
args.lookback_days = 30
|
||||
|
||||
def runner(entity: str):
|
||||
entity_config = dict(config)
|
||||
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 as exc:
|
||||
sys.stderr.write(
|
||||
f"[Competitors] auto_resolve failed for {entity!r}: "
|
||||
f"{type(exc).__name__}: {exc}\n"
|
||||
)
|
||||
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"]
|
||||
report = 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,
|
||||
)
|
||||
report.artifacts["resolved"] = resolved
|
||||
return report
|
||||
|
||||
return [runner(c) for c in competitors]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,14 +0,0 @@
|
||||
from scripts.lib import env
|
||||
|
||||
|
||||
def test_include_sources_defaults_to_empty_string(monkeypatch, tmp_path):
|
||||
# Ensure the env var is not set
|
||||
monkeypatch.delenv("INCLUDE_SOURCES", raising=False)
|
||||
|
||||
# Avoid reading any real user config file by patching the resolved module path directly
|
||||
monkeypatch.setattr(env, "CONFIG_FILE", tmp_path / "does-not-exist.env")
|
||||
|
||||
cfg = env.get_config()
|
||||
|
||||
assert "INCLUDE_SOURCES" in cfg
|
||||
assert cfg["INCLUDE_SOURCES"] == ""
|
||||
@@ -1,76 +0,0 @@
|
||||
# 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()
|
||||
@@ -56,22 +56,6 @@ class TestParseDate(unittest.TestCase):
|
||||
def test_empty(self):
|
||||
self.assertIsNone(github._parse_date(""))
|
||||
|
||||
def test_rejects_garbage(self):
|
||||
"""The old naive slicing returned 'hello worl' for 'hello world'. Reject it."""
|
||||
self.assertIsNone(github._parse_date("hello world"))
|
||||
self.assertIsNone(github._parse_date("not-a-date"))
|
||||
self.assertIsNone(github._parse_date("abcdefghij"))
|
||||
|
||||
def test_rejects_invalid_date_values(self):
|
||||
"""An out-of-range date like 2026-99-99 is not a real date."""
|
||||
self.assertIsNone(github._parse_date("2026-99-99"))
|
||||
|
||||
def test_iso_with_offset(self):
|
||||
self.assertEqual(github._parse_date("2026-03-15T12:00:00+00:00"), "2026-03-15")
|
||||
|
||||
def test_iso_with_no_colon_offset(self):
|
||||
self.assertEqual(github._parse_date("2026-03-15T12:00:00+0000"), "2026-03-15")
|
||||
|
||||
|
||||
class TestSearchGithub(unittest.TestCase):
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
|
||||
@@ -41,66 +41,3 @@ class Test429RetryLimit(unittest.TestCase):
|
||||
http.request("GET", "http://example.com", retries=3)
|
||||
|
||||
self.assertEqual(mock_urlopen.call_count, 3)
|
||||
|
||||
|
||||
def _mock_response(body: str = '{"ok": true}', status: int = 200):
|
||||
resp = MagicMock()
|
||||
resp.__enter__ = MagicMock(return_value=resp)
|
||||
resp.__exit__ = MagicMock(return_value=False)
|
||||
resp.read.return_value = body.encode("utf-8")
|
||||
resp.status = status
|
||||
return resp
|
||||
|
||||
|
||||
class TestParamsEncoding(unittest.TestCase):
|
||||
"""request() should urlencode the params dict into the URL."""
|
||||
|
||||
def _sent_url(self, mock_urlopen) -> str:
|
||||
request_arg = mock_urlopen.call_args[0][0]
|
||||
return request_arg.full_url
|
||||
|
||||
@patch("lib.http.urllib.request.urlopen")
|
||||
def test_params_appended_to_url(self, mock_urlopen):
|
||||
mock_urlopen.return_value = _mock_response()
|
||||
http.get("https://api.example.com/search", params={"q": "test", "limit": 10})
|
||||
sent_url = self._sent_url(mock_urlopen)
|
||||
self.assertIn("q=test", sent_url)
|
||||
self.assertIn("limit=10", sent_url)
|
||||
|
||||
@patch("lib.http.urllib.request.urlopen")
|
||||
def test_params_appended_with_existing_query_string(self, mock_urlopen):
|
||||
mock_urlopen.return_value = _mock_response()
|
||||
http.get("https://api.example.com/search?api_key=secret", params={"q": "test"})
|
||||
sent_url = self._sent_url(mock_urlopen)
|
||||
self.assertTrue(sent_url.startswith("https://api.example.com/search?api_key=secret&"))
|
||||
self.assertIn("q=test", sent_url)
|
||||
|
||||
@patch("lib.http.urllib.request.urlopen")
|
||||
def test_none_values_dropped(self, mock_urlopen):
|
||||
mock_urlopen.return_value = _mock_response()
|
||||
http.get("https://api.example.com/search", params={"q": "test", "filter": None})
|
||||
sent_url = self._sent_url(mock_urlopen)
|
||||
self.assertIn("q=test", sent_url)
|
||||
self.assertNotIn("filter", sent_url)
|
||||
|
||||
@patch("lib.http.urllib.request.urlopen")
|
||||
def test_empty_params_leaves_url_unchanged(self, mock_urlopen):
|
||||
mock_urlopen.return_value = _mock_response()
|
||||
http.get("https://api.example.com/search", params={})
|
||||
sent_url = self._sent_url(mock_urlopen)
|
||||
self.assertEqual(sent_url, "https://api.example.com/search")
|
||||
|
||||
@patch("lib.http.urllib.request.urlopen")
|
||||
def test_no_params_kwarg_leaves_url_unchanged(self, mock_urlopen):
|
||||
mock_urlopen.return_value = _mock_response()
|
||||
http.get("https://api.example.com/search")
|
||||
sent_url = self._sent_url(mock_urlopen)
|
||||
self.assertEqual(sent_url, "https://api.example.com/search")
|
||||
|
||||
@patch("lib.http.urllib.request.urlopen")
|
||||
def test_int_and_bool_params_stringified(self, mock_urlopen):
|
||||
mock_urlopen.return_value = _mock_response()
|
||||
http.get("https://api.example.com/search", params={"count": 25, "raw": True})
|
||||
sent_url = self._sent_url(mock_urlopen)
|
||||
self.assertIn("count=25", sent_url)
|
||||
self.assertIn("raw=True", sent_url)
|
||||
|
||||
@@ -49,165 +49,6 @@ class NormalizeV3Tests(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual([], normalized)
|
||||
|
||||
def test_youtube_top_comments_passthrough_with_field_mapping(self):
|
||||
"""YT comments from enrich_with_comments use likes/text; normalize must
|
||||
carry them into metadata as the Reddit-compatible {score, excerpt} shape."""
|
||||
items = [
|
||||
{
|
||||
"video_id": "vid-1",
|
||||
"title": "How to deploy",
|
||||
"url": "https://youtube.com/watch?v=vid-1",
|
||||
"channel_name": "Example",
|
||||
"date": "2026-03-01",
|
||||
"engagement": {"views": 10000, "likes": 500, "comments": 30},
|
||||
"top_comments": [
|
||||
{"author": "Alice", "text": "Best tutorial ever", "likes": 120, "date": "2026-03-02"},
|
||||
{"author": "Bob", "text": "Helped me ship", "likes": 45, "date": "2026-03-03"},
|
||||
{"author": "Carol", "text": "Solid walkthrough", "likes": 7, "date": "2026-03-04"},
|
||||
],
|
||||
}
|
||||
]
|
||||
normalized = normalize.normalize_source_items(
|
||||
"youtube", items, "2026-02-15", "2026-03-17",
|
||||
)
|
||||
self.assertEqual(1, len(normalized))
|
||||
top = normalized[0].metadata.get("top_comments")
|
||||
self.assertIsNotNone(top)
|
||||
self.assertEqual(3, len(top))
|
||||
# First comment: likes->score, text->excerpt
|
||||
self.assertEqual(120, top[0]["score"])
|
||||
self.assertEqual("Best tutorial ever", top[0]["excerpt"])
|
||||
self.assertEqual("Alice", top[0]["author"])
|
||||
self.assertEqual("2026-03-02", top[0]["date"])
|
||||
# Preserves ordering from input (already sorted desc upstream)
|
||||
self.assertEqual(45, top[1]["score"])
|
||||
self.assertEqual(7, top[2]["score"])
|
||||
|
||||
def test_youtube_top_comments_empty_list_passes_through_cleanly(self):
|
||||
items = [
|
||||
{
|
||||
"video_id": "vid-2",
|
||||
"title": "Short clip",
|
||||
"url": "https://youtube.com/watch?v=vid-2",
|
||||
"channel_name": "Example",
|
||||
"date": "2026-03-01",
|
||||
"engagement": {"views": 50, "likes": 2},
|
||||
"top_comments": [],
|
||||
}
|
||||
]
|
||||
normalized = normalize.normalize_source_items(
|
||||
"youtube", items, "2026-02-15", "2026-03-17",
|
||||
)
|
||||
self.assertEqual(1, len(normalized))
|
||||
# Empty list is fine; metadata may have empty top_comments or omit it.
|
||||
top = normalized[0].metadata.get("top_comments", [])
|
||||
self.assertEqual([], top)
|
||||
|
||||
def test_youtube_without_top_comments_key_does_not_crash(self):
|
||||
items = [
|
||||
{
|
||||
"video_id": "vid-3",
|
||||
"title": "No comments fetched",
|
||||
"url": "https://youtube.com/watch?v=vid-3",
|
||||
"channel_name": "Example",
|
||||
"date": "2026-03-01",
|
||||
"engagement": {"views": 100, "likes": 5},
|
||||
}
|
||||
]
|
||||
normalized = normalize.normalize_source_items(
|
||||
"youtube", items, "2026-02-15", "2026-03-17",
|
||||
)
|
||||
self.assertEqual(1, len(normalized))
|
||||
self.assertEqual([], normalized[0].metadata.get("top_comments", []))
|
||||
|
||||
def test_youtube_top_comments_feed_top_comment_score_signal(self):
|
||||
"""Integration: after normalize, signals._top_comment_score should
|
||||
return log1p(first comment score) for YT, proving the full chain."""
|
||||
from lib import signals
|
||||
import math
|
||||
items = [
|
||||
{
|
||||
"video_id": "vid-4",
|
||||
"title": "Viral comment thread",
|
||||
"url": "https://youtube.com/watch?v=vid-4",
|
||||
"channel_name": "Example",
|
||||
"date": "2026-03-01",
|
||||
"engagement": {"views": 1000, "likes": 50, "comments": 10},
|
||||
"top_comments": [
|
||||
{"author": "A", "text": "Legendary", "likes": 9999, "date": "2026-03-02"},
|
||||
],
|
||||
}
|
||||
]
|
||||
normalized = normalize.normalize_source_items(
|
||||
"youtube", items, "2026-02-15", "2026-03-17",
|
||||
)
|
||||
self.assertAlmostEqual(math.log1p(9999), signals._top_comment_score(normalized[0]), places=4)
|
||||
|
||||
def test_tiktok_top_comments_passthrough_with_digg_count_mapping(self):
|
||||
"""TikTok comments from enrich_with_comments use digg_count/text;
|
||||
normalize must map to the shared {score, excerpt} shape."""
|
||||
items = [
|
||||
{
|
||||
"id": "tt-1",
|
||||
"text": "POV: shipping on Friday",
|
||||
"url": "https://www.tiktok.com/@u/video/tt-1",
|
||||
"author_name": "u",
|
||||
"date": "2026-03-01",
|
||||
"engagement": {"views": 50000, "likes": 2000, "comments": 300},
|
||||
"top_comments": [
|
||||
{"author": "Alice", "text": "dead", "digg_count": 1200, "date": "2026-03-02"},
|
||||
{"author": "Bob", "text": "so real", "digg_count": 400, "date": "2026-03-03"},
|
||||
],
|
||||
}
|
||||
]
|
||||
normalized = normalize.normalize_source_items(
|
||||
"tiktok", items, "2026-02-15", "2026-03-17",
|
||||
)
|
||||
self.assertEqual(1, len(normalized))
|
||||
top = normalized[0].metadata.get("top_comments")
|
||||
self.assertEqual(2, len(top))
|
||||
self.assertEqual(1200, top[0]["score"])
|
||||
self.assertEqual("dead", top[0]["excerpt"])
|
||||
self.assertEqual("Alice", top[0]["author"])
|
||||
self.assertEqual(400, top[1]["score"])
|
||||
|
||||
def test_tiktok_without_top_comments_does_not_crash(self):
|
||||
items = [
|
||||
{
|
||||
"id": "tt-2",
|
||||
"text": "plain clip",
|
||||
"url": "https://www.tiktok.com/@u/video/tt-2",
|
||||
"author_name": "u",
|
||||
"date": "2026-03-01",
|
||||
"engagement": {"views": 1000, "likes": 20},
|
||||
}
|
||||
]
|
||||
normalized = normalize.normalize_source_items(
|
||||
"tiktok", items, "2026-02-15", "2026-03-17",
|
||||
)
|
||||
self.assertEqual([], normalized[0].metadata.get("top_comments", []))
|
||||
|
||||
def test_tiktok_top_comments_feed_top_comment_score_signal(self):
|
||||
from lib import signals
|
||||
import math
|
||||
items = [
|
||||
{
|
||||
"id": "tt-3",
|
||||
"text": "viral",
|
||||
"url": "https://www.tiktok.com/@u/video/tt-3",
|
||||
"author_name": "u",
|
||||
"date": "2026-03-01",
|
||||
"engagement": {"views": 100000, "likes": 5000, "comments": 500},
|
||||
"top_comments": [
|
||||
{"author": "A", "text": "this aged well", "digg_count": 50000, "date": "2026-03-02"},
|
||||
],
|
||||
}
|
||||
]
|
||||
normalized = normalize.normalize_source_items(
|
||||
"tiktok", items, "2026-02-15", "2026-03-17",
|
||||
)
|
||||
self.assertAlmostEqual(math.log1p(50000), signals._top_comment_score(normalized[0]), places=4)
|
||||
|
||||
def test_grounding_requires_a_usable_date(self):
|
||||
items = [
|
||||
{
|
||||
|
||||
@@ -28,30 +28,6 @@ class PipelineV3Tests(unittest.TestCase):
|
||||
self.assertIn("grounding", report.items_by_source)
|
||||
self.assertEqual("gemini", report.provider_runtime.reasoning_provider)
|
||||
|
||||
def test_planner_trace_always_fires_on_mock_run(self):
|
||||
"""Unit 5: The unified planner trace emits one summary line plus one
|
||||
line per subquery on every run, regardless of --debug. 2026-04-19
|
||||
Hermes Agent Use Cases failure: retrieval-breadth issues were invisible
|
||||
because the internal planner path logged nothing.
|
||||
"""
|
||||
import io
|
||||
import contextlib
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stderr(buf):
|
||||
pipeline.run(
|
||||
topic="test topic",
|
||||
config={"LAST30DAYS_REASONING_PROVIDER": "gemini"},
|
||||
depth="quick",
|
||||
requested_sources=["reddit", "x", "grounding"],
|
||||
mock=True,
|
||||
)
|
||||
output = buf.getvalue()
|
||||
self.assertIn("[Planner] Plan: intent=", output)
|
||||
self.assertIn("subqueries=", output)
|
||||
self.assertIn("source=", output)
|
||||
# At least one per-subquery line.
|
||||
self.assertIn("[Planner] sq1 label=", output)
|
||||
|
||||
|
||||
class TestSourceFetchCap(unittest.TestCase):
|
||||
"""X source fetch count must be capped by MAX_SOURCE_FETCHES."""
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
# ruff: noqa: E402
|
||||
"""Tests for planner.plan_query internal_subrun quiet mode."""
|
||||
|
||||
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 PlannerQuietModeTests(unittest.TestCase):
|
||||
def _call(self, *, internal_subrun: bool):
|
||||
err = io.StringIO()
|
||||
with redirect_stderr(err):
|
||||
plan = planner.plan_query(
|
||||
topic="Acme Corp",
|
||||
available_sources=["grounding", "reddit"],
|
||||
requested_sources=None,
|
||||
depth="default",
|
||||
provider=None,
|
||||
model=None,
|
||||
internal_subrun=internal_subrun,
|
||||
)
|
||||
return plan, err.getvalue()
|
||||
|
||||
def test_default_emits_law7_warning(self):
|
||||
plan, stderr = self._call(internal_subrun=False)
|
||||
self.assertIn("No --plan passed", stderr)
|
||||
self.assertIn("YOU ARE the planner", stderr)
|
||||
self.assertTrue(plan.subqueries)
|
||||
|
||||
def test_internal_subrun_suppresses_warning(self):
|
||||
plan, stderr = self._call(internal_subrun=True)
|
||||
self.assertNotIn("No --plan passed", stderr)
|
||||
self.assertNotIn("YOU ARE the planner", stderr)
|
||||
# Still returns a valid fallback plan
|
||||
self.assertTrue(plan.subqueries)
|
||||
|
||||
def test_internal_subrun_still_allows_other_warnings(self):
|
||||
"""Quiet mode only silences the LAW 7 block, not all planner output."""
|
||||
plan, _stderr = self._call(internal_subrun=True)
|
||||
# The plan itself is deterministic fallback; verify note carries
|
||||
# no planner-error indication.
|
||||
self.assertGreater(len(plan.subqueries), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -281,177 +281,5 @@ class PlannerV3Tests(unittest.TestCase):
|
||||
self.assertIn("instagram", all_sources)
|
||||
|
||||
|
||||
class IntentModifierBreadthTests(unittest.TestCase):
|
||||
"""Unit 2: Topics with intent modifiers (use cases, workflows, examples,
|
||||
review, comparison) must fan out across paraphrased subqueries rather
|
||||
than echo the literal phrase. 2026-04-19 Hermes Agent Use Cases failure.
|
||||
"""
|
||||
|
||||
def test_max_subqueries_raised_to_5_for_how_to(self):
|
||||
self.assertEqual(5, planner._max_subqueries("how_to"))
|
||||
|
||||
def test_max_subqueries_raised_to_5_for_opinion(self):
|
||||
self.assertEqual(5, planner._max_subqueries("opinion"))
|
||||
|
||||
def test_max_subqueries_raised_to_5_for_product(self):
|
||||
self.assertEqual(5, planner._max_subqueries("product"))
|
||||
|
||||
def test_max_subqueries_unchanged_for_comparison(self):
|
||||
self.assertEqual(4, planner._max_subqueries("comparison"))
|
||||
|
||||
def test_max_subqueries_unchanged_for_factual_and_concept(self):
|
||||
self.assertEqual(2, planner._max_subqueries("factual"))
|
||||
self.assertEqual(2, planner._max_subqueries("concept"))
|
||||
|
||||
def test_has_intent_modifier_detects_use_cases(self):
|
||||
self.assertTrue(planner._has_intent_modifier("Hermes Agent use cases"))
|
||||
self.assertTrue(planner._has_intent_modifier("Hermes Agent Actual Use Cases"))
|
||||
|
||||
def test_has_intent_modifier_detects_workflows(self):
|
||||
self.assertTrue(planner._has_intent_modifier("Claude Code workflows"))
|
||||
|
||||
def test_has_intent_modifier_detects_review_and_tutorial(self):
|
||||
self.assertTrue(planner._has_intent_modifier("Ollama review"))
|
||||
self.assertTrue(planner._has_intent_modifier("DSPy tutorial"))
|
||||
|
||||
def test_has_intent_modifier_false_for_bare_entity(self):
|
||||
self.assertFalse(planner._has_intent_modifier("Kanye West"))
|
||||
self.assertFalse(planner._has_intent_modifier("hermes agent"))
|
||||
|
||||
def test_fallback_fans_out_when_intent_modifier_present(self):
|
||||
plan = planner.plan_query(
|
||||
topic="Hermes Agent use cases",
|
||||
available_sources=["reddit", "x", "youtube", "hackernews"],
|
||||
requested_sources=None,
|
||||
depth="default",
|
||||
provider=None,
|
||||
model=None,
|
||||
)
|
||||
# Expect at least 3 subqueries total (primary + fanout); cap is 5 for
|
||||
# how_to/opinion/product/breaking_news. Label set should include at
|
||||
# least one of the paraphrase labels.
|
||||
labels = {sq.label for sq in plan.subqueries}
|
||||
self.assertGreaterEqual(len(plan.subqueries), 3)
|
||||
self.assertTrue(
|
||||
labels & {"workflows", "production", "experience"},
|
||||
f"Expected paraphrase labels in {labels}",
|
||||
)
|
||||
|
||||
def test_fallback_does_not_fan_out_for_bare_entity(self):
|
||||
plan = planner.plan_query(
|
||||
topic="Kanye West",
|
||||
available_sources=["reddit", "x", "grounding"],
|
||||
requested_sources=None,
|
||||
depth="default",
|
||||
provider=None,
|
||||
model=None,
|
||||
)
|
||||
# Bare entity without intent modifier should not trigger the paraphrase
|
||||
# fanout (those labels are not in the plan).
|
||||
labels = {sq.label for sq in plan.subqueries}
|
||||
self.assertFalse(labels & {"workflows", "production", "experience"})
|
||||
|
||||
def test_prompt_includes_intent_modifier_rule(self):
|
||||
prompt = planner._build_prompt(
|
||||
topic="Hermes Agent use cases",
|
||||
available_sources=["reddit", "x", "youtube"],
|
||||
requested_sources=None,
|
||||
depth="default",
|
||||
)
|
||||
self.assertIn("INTENT-MODIFIER HANDLING", prompt)
|
||||
self.assertIn("use cases", prompt)
|
||||
self.assertIn("STRIP that phrase", prompt)
|
||||
|
||||
|
||||
class FallbackDefaultsTests(unittest.TestCase):
|
||||
"""Unit 3: Deterministic fallback defaults and keyword_query quoting.
|
||||
2026-04-19 Hermes Agent Use Cases failure.
|
||||
"""
|
||||
|
||||
def test_unclassified_topic_defaults_to_concept_not_breaking_news(self):
|
||||
# Prior default was "breaking_news" with strict_recent freshness,
|
||||
# which biased against older relevant material on unfamiliar topics.
|
||||
self.assertEqual("concept", planner._infer_intent("some unfamiliar topic"))
|
||||
self.assertEqual("concept", planner._infer_intent("Hermes Agent"))
|
||||
|
||||
def test_recency_signals_still_break_out_to_breaking_news(self):
|
||||
self.assertEqual("breaking_news", planner._infer_intent("trending AI tools"))
|
||||
self.assertEqual("breaking_news", planner._infer_intent("what's happening today"))
|
||||
self.assertEqual("breaking_news", planner._infer_intent("this week in AI"))
|
||||
|
||||
def test_specific_intents_still_classify_correctly(self):
|
||||
# Regression: other regex branches still fire as before.
|
||||
self.assertEqual("how_to", planner._infer_intent("how to deploy Docker"))
|
||||
self.assertEqual("factual", planner._infer_intent("who acquired Wiz"))
|
||||
self.assertEqual("opinion", planner._infer_intent("thoughts on OpenAI Codex"))
|
||||
self.assertEqual("comparison", planner._infer_intent("Codex vs Claude Code"))
|
||||
|
||||
def test_keyword_query_quotes_only_title_cased_proper_nouns(self):
|
||||
# "Hermes Agent" is a multi-word title-cased proper noun — keep quoted.
|
||||
# "Use Cases" is also title-cased BUT we only quote the first 2
|
||||
# title-cased compounds; the first extracted is "Hermes Agent".
|
||||
search = planner._keyword_query("Hermes Agent use cases", "hermes agent")
|
||||
self.assertIn('"Hermes Agent"', search)
|
||||
# The old behavior quoted the entire typed topic; confirm it does not.
|
||||
self.assertNotIn('"Hermes Agent Actual Use Cases"', search)
|
||||
|
||||
def test_keyword_query_does_not_quote_bare_lowercase_topic(self):
|
||||
search = planner._keyword_query("kanye west bully", "kanye west bully")
|
||||
# Lowercase topics have no title-cased compound to quote.
|
||||
self.assertNotIn('"', search)
|
||||
|
||||
def test_fallback_logs_warning_when_no_provider(self):
|
||||
import io
|
||||
import contextlib
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stderr(buf):
|
||||
planner.plan_query(
|
||||
topic="Hermes Agent use cases",
|
||||
available_sources=["reddit", "x"],
|
||||
requested_sources=None,
|
||||
depth="default",
|
||||
provider=None,
|
||||
model=None,
|
||||
)
|
||||
output = buf.getvalue()
|
||||
# New language: "No --plan passed" + "YOU ARE the planner" +
|
||||
# runtime enumeration. Unit 4 (2026-04-19) rewrite to stop the
|
||||
# "no provider = no LLM = I need a key" misread.
|
||||
self.assertIn("No --plan passed", output)
|
||||
self.assertIn("YOU ARE the planner", output)
|
||||
self.assertIn("you ARE the LLM", output)
|
||||
# Runtime-agnostic: each supported runtime name should appear.
|
||||
for runtime_name in ("Claude Code", "Codex", "Hermes", "Gemini"):
|
||||
self.assertIn(runtime_name, output)
|
||||
# The old misleading phrasing must NOT appear.
|
||||
self.assertNotIn("No --plan and no LLM provider configured", output)
|
||||
|
||||
def test_fallback_does_not_log_new_warning_when_provider_present(self):
|
||||
# When a provider is configured, the provider path runs; if it
|
||||
# errors, we get the "LLM planning failed" message, NOT the
|
||||
# "No --plan passed" guidance (which is specifically for the
|
||||
# no-provider-no-plan caller path).
|
||||
import io
|
||||
import contextlib
|
||||
buf = io.StringIO()
|
||||
|
||||
class _NoopProvider:
|
||||
def generate_json(self, model, prompt):
|
||||
raise ValueError("force fallback for test")
|
||||
|
||||
with contextlib.redirect_stderr(buf):
|
||||
planner.plan_query(
|
||||
topic="Kanye West",
|
||||
available_sources=["reddit", "x"],
|
||||
requested_sources=None,
|
||||
depth="default",
|
||||
provider=_NoopProvider(),
|
||||
model="some-model",
|
||||
)
|
||||
output = buf.getvalue()
|
||||
self.assertIn("LLM planning failed", output)
|
||||
self.assertNotIn("No --plan passed", output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
# 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()
|
||||
@@ -1,128 +0,0 @@
|
||||
"""Tests for scripts/lib/preflight.py Class 1 keyword-trap refuse-gate.
|
||||
|
||||
Class 1 (demographic shopping) is the one failure class that shipped to
|
||||
public v3.0.8 and still returned junk for queries like 'birthday gift for
|
||||
40 year old'. This module is the engine's structural refusal, so the model
|
||||
cannot bypass by skipping SKILL.md.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
||||
|
||||
from lib import preflight
|
||||
|
||||
|
||||
class TestClass1Match(unittest.TestCase):
|
||||
"""Queries that MUST trigger the refuse-gate."""
|
||||
|
||||
def test_birthday_gift_for_age(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("birthday gift for 40 year old"))
|
||||
|
||||
def test_gift_for_age(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("gift for 42 year old"))
|
||||
|
||||
def test_gift_for_age_relationship(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("gift for my 42 year old husband"))
|
||||
|
||||
def test_gift_ideas_for_age(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("gift ideas for 30 year old"))
|
||||
|
||||
def test_present_for_age(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("present for a 50 year old"))
|
||||
|
||||
def test_hyphenated_year_old(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("gift for 40-year-old"))
|
||||
|
||||
def test_best_for_men(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("best running shoes for men"))
|
||||
|
||||
def test_best_for_women(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("best gifts for women"))
|
||||
|
||||
def test_best_for_kids(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("best toys for kids"))
|
||||
|
||||
def test_what_to_buy_husband(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("what to buy my husband"))
|
||||
|
||||
def test_what_to_get_boss(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("what to get my boss"))
|
||||
|
||||
def test_what_to_gift_age(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("what to gift a 35 year old"))
|
||||
|
||||
def test_gifts_for_husband(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("gifts for my husband"))
|
||||
|
||||
def test_case_insensitive(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap("Birthday Gift For 40 Year Old"))
|
||||
|
||||
def test_leading_whitespace(self):
|
||||
self.assertIsNotNone(preflight.check_class_1_trap(" gift for 40 year old "))
|
||||
|
||||
|
||||
class TestClass1Skip(unittest.TestCase):
|
||||
"""Queries that MUST NOT trigger the refuse-gate (qualifier present or not shopping)."""
|
||||
|
||||
def test_named_person(self):
|
||||
self.assertIsNone(preflight.check_class_1_trap("Peter Steinberger"))
|
||||
|
||||
def test_comparison(self):
|
||||
self.assertIsNone(preflight.check_class_1_trap("OpenClaw vs Paperclip"))
|
||||
|
||||
def test_entity_query(self):
|
||||
self.assertIsNone(preflight.check_class_1_trap("Kanye West"))
|
||||
|
||||
def test_general_concept(self):
|
||||
self.assertIsNone(preflight.check_class_1_trap("vibe coding"))
|
||||
|
||||
def test_budget_qualifier(self):
|
||||
self.assertIsNone(preflight.check_class_1_trap("gift for my husband, $200 budget"))
|
||||
|
||||
def test_hobby_qualifier(self):
|
||||
self.assertIsNone(preflight.check_class_1_trap("gift for my cooking-obsessed husband"))
|
||||
|
||||
def test_loves_qualifier(self):
|
||||
self.assertIsNone(preflight.check_class_1_trap("gift for my dad who loves golf"))
|
||||
|
||||
def test_is_into_qualifier(self):
|
||||
self.assertIsNone(preflight.check_class_1_trap("gift for my brother who is into woodworking"))
|
||||
|
||||
def test_specific_interest_in_query(self):
|
||||
self.assertIsNone(preflight.check_class_1_trap("birthday gift for 40 year old runner"))
|
||||
|
||||
|
||||
class TestRefuseMessage(unittest.TestCase):
|
||||
"""The REFUSE message must contain the diagnostic content the model needs."""
|
||||
|
||||
def test_refuse_mentions_class_1(self):
|
||||
msg = preflight.check_class_1_trap("birthday gift for 40 year old")
|
||||
assert msg is not None
|
||||
self.assertIn("Class 1", msg)
|
||||
|
||||
def test_refuse_asks_for_hobbies(self):
|
||||
msg = preflight.check_class_1_trap("gift for 40 year old")
|
||||
assert msg is not None
|
||||
self.assertIn("hobbies", msg.lower())
|
||||
|
||||
def test_refuse_asks_for_relationship(self):
|
||||
msg = preflight.check_class_1_trap("gift for 40 year old")
|
||||
assert msg is not None
|
||||
self.assertIn("relationship", msg.lower())
|
||||
|
||||
def test_refuse_asks_for_budget(self):
|
||||
msg = preflight.check_class_1_trap("gift for 40 year old")
|
||||
assert msg is not None
|
||||
self.assertIn("budget", msg.lower())
|
||||
|
||||
def test_refuse_echoes_topic(self):
|
||||
msg = preflight.check_class_1_trap("birthday gift for 40 year old")
|
||||
assert msg is not None
|
||||
self.assertIn("birthday gift for 40 year old", msg)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+18
-48
@@ -29,38 +29,19 @@ class RegressionTests(unittest.TestCase):
|
||||
self.assertIn("clusters", payload)
|
||||
self.assertIn("items_by_source", payload)
|
||||
|
||||
def assert_comparison_shape(self, payload: dict) -> None:
|
||||
"""Post-3.0.13: vs-topics produce N full passes, merged output has
|
||||
comparison=True + entities list + per-entity report wrapper."""
|
||||
self.assertTrue(payload.get("comparison"))
|
||||
self.assertIn("entities", payload)
|
||||
self.assertIn("reports", payload)
|
||||
self.assertEqual(len(payload["entities"]), len(payload["reports"]))
|
||||
# Each report entry wraps a single-topic report
|
||||
for entry in payload["reports"]:
|
||||
self.assertIn("entity", entry)
|
||||
self.assertIn("report", entry)
|
||||
# Inner report still has the single-topic shape
|
||||
inner = entry["report"]
|
||||
self.assertIn("topic", inner)
|
||||
self.assertIn("query_plan", inner)
|
||||
self.assertIn("clusters", inner)
|
||||
|
||||
def test_openclaw_three_way_comparison_preserves_entities(self):
|
||||
payload = run_mock_json("openclaw vs. nanoclaw vs. ironclaw")
|
||||
self.assert_comparison_shape(payload)
|
||||
entities = [e.lower() for e in payload["entities"]]
|
||||
self.assertIn("openclaw", entities)
|
||||
self.assertIn("nanoclaw", entities)
|
||||
self.assertIn("ironclaw", entities)
|
||||
# No cross-entity keyword pollution in any per-entity report's plan
|
||||
for entry in payload["reports"]:
|
||||
plan = entry["report"]["query_plan"]
|
||||
joined = "\n".join(
|
||||
sq["search_query"] for sq in plan["subqueries"]
|
||||
).lower()
|
||||
self.assertNotIn("corsair", joined)
|
||||
self.assertNotIn("mouse", joined)
|
||||
self.assert_common_shape(payload)
|
||||
plan = payload["query_plan"]
|
||||
self.assertEqual("comparison", plan["intent"])
|
||||
joined_queries = "\n".join(subquery["search_query"] for subquery in plan["subqueries"]).lower()
|
||||
self.assertIn("openclaw", joined_queries)
|
||||
self.assertIn("nanoclaw", joined_queries)
|
||||
self.assertIn("ironclaw", joined_queries)
|
||||
self.assertNotIn("corsair", joined_queries)
|
||||
self.assertNotIn("mouse", joined_queries)
|
||||
for subquery in plan["subqueries"]:
|
||||
self.assertGreaterEqual(len(subquery["sources"]), 4)
|
||||
|
||||
def test_how_to_keeps_web_video_and_discussion_sources(self):
|
||||
payload = run_mock_json("how to deploy on Fly.io")
|
||||
@@ -83,24 +64,13 @@ class RegressionTests(unittest.TestCase):
|
||||
|
||||
def test_two_way_comparison_preserves_exact_strings(self):
|
||||
payload = run_mock_json("DeepSeek R1 vs GPT-5")
|
||||
self.assert_comparison_shape(payload)
|
||||
entities_lower = [e.lower() for e in payload["entities"]]
|
||||
self.assertIn("deepseek r1", entities_lower)
|
||||
self.assertIn("gpt-5", entities_lower)
|
||||
# Each per-entity pass has its own entity in its plan
|
||||
topics_by_entity = {
|
||||
entry["entity"].lower(): entry["report"]["topic"].lower()
|
||||
for entry in payload["reports"]
|
||||
}
|
||||
self.assertEqual(topics_by_entity["deepseek r1"], "deepseek r1")
|
||||
self.assertEqual(topics_by_entity["gpt-5"], "gpt-5")
|
||||
# No cross-entity pollution
|
||||
for entry in payload["reports"]:
|
||||
plan = entry["report"]["query_plan"]
|
||||
joined = "\n".join(
|
||||
sq["search_query"] for sq in plan["subqueries"]
|
||||
).lower()
|
||||
self.assertNotIn("corsair", joined)
|
||||
self.assert_common_shape(payload)
|
||||
plan = payload["query_plan"]
|
||||
self.assertEqual("comparison", plan["intent"])
|
||||
joined_queries = "\n".join(subquery["search_query"] for subquery in plan["subqueries"]).lower()
|
||||
self.assertIn("deepseek r1", joined_queries)
|
||||
self.assertIn("gpt-5", joined_queries)
|
||||
self.assertNotIn("corsair", joined_queries)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,305 +0,0 @@
|
||||
# ruff: noqa: E402
|
||||
"""Tests for render.render_comparison_multi and emit_comparison_output."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
|
||||
import last30days as cli
|
||||
from lib import render, schema
|
||||
|
||||
|
||||
def _build_report(topic: str, cluster_titles: list[str]) -> schema.Report:
|
||||
query_plan = schema.QueryPlan(
|
||||
intent="comparison",
|
||||
freshness_mode="balanced_recent",
|
||||
cluster_mode="debate",
|
||||
raw_topic=topic,
|
||||
subqueries=[
|
||||
schema.SubQuery(
|
||||
label="primary",
|
||||
search_query=topic,
|
||||
ranking_query=topic,
|
||||
sources=["grounding"],
|
||||
)
|
||||
],
|
||||
source_weights={"grounding": 1.0},
|
||||
)
|
||||
clusters: list[schema.Cluster] = []
|
||||
candidates: list[schema.Candidate] = []
|
||||
for idx, title in enumerate(cluster_titles):
|
||||
candidate_id = f"{topic.lower().replace(' ', '-')}-c{idx}"
|
||||
item = schema.SourceItem(
|
||||
source="grounding",
|
||||
item_id=f"g-{candidate_id}",
|
||||
title=f"{title} evidence",
|
||||
body=f"Body for {title}",
|
||||
url=f"https://example.test/{candidate_id}",
|
||||
snippet=f"Snippet for {title}",
|
||||
published_at="2026-04-20",
|
||||
)
|
||||
candidate = schema.Candidate(
|
||||
candidate_id=candidate_id,
|
||||
item_id=item.item_id,
|
||||
source="grounding",
|
||||
title=item.title,
|
||||
url=item.url,
|
||||
snippet=item.snippet,
|
||||
subquery_labels=["primary"],
|
||||
native_ranks={"grounding": idx + 1},
|
||||
local_relevance=0.8 - idx * 0.1,
|
||||
freshness=5,
|
||||
engagement=10,
|
||||
source_quality=0.9,
|
||||
rrf_score=0.6 - idx * 0.05,
|
||||
sources=["grounding"],
|
||||
source_items=[item],
|
||||
final_score=80.0 - idx * 5,
|
||||
)
|
||||
candidates.append(candidate)
|
||||
clusters.append(
|
||||
schema.Cluster(
|
||||
cluster_id=f"cl-{idx}",
|
||||
title=title,
|
||||
candidate_ids=[candidate_id],
|
||||
representative_ids=[candidate_id],
|
||||
score=80.0 - idx * 5,
|
||||
sources=["grounding"],
|
||||
)
|
||||
)
|
||||
return schema.Report(
|
||||
topic=topic,
|
||||
range_from="2026-03-23",
|
||||
range_to="2026-04-22",
|
||||
generated_at="2026-04-22T00:00:00+00:00",
|
||||
provider_runtime=schema.ProviderRuntime(
|
||||
reasoning_provider="mock",
|
||||
planner_model="mock-planner",
|
||||
rerank_model="mock-rerank",
|
||||
),
|
||||
query_plan=query_plan,
|
||||
clusters=clusters,
|
||||
ranked_candidates=candidates,
|
||||
items_by_source={"grounding": [c.source_items[0] for c in candidates]},
|
||||
errors_by_source={},
|
||||
)
|
||||
|
||||
|
||||
class RenderComparisonMultiTests(unittest.TestCase):
|
||||
def test_three_entity_table(self):
|
||||
reports = [
|
||||
("OpenAI", _build_report("OpenAI", ["GPT-5 drop", "API pricing cut"])),
|
||||
("Anthropic", _build_report("Anthropic", ["Claude 4.7 ship", "MCP rollout"])),
|
||||
("xAI", _build_report("xAI", ["Grok 4 release", "Memphis cluster"])),
|
||||
]
|
||||
rendered = render.render_comparison_multi(reports)
|
||||
# All three entities appear in the header
|
||||
self.assertIn("OpenAI vs Anthropic vs xAI", rendered)
|
||||
# Each entity has its own evidence section
|
||||
self.assertIn("## OpenAI", rendered)
|
||||
self.assertIn("## Anthropic", rendered)
|
||||
self.assertIn("## xAI", rendered)
|
||||
# Scaffold table header has a column per entity
|
||||
self.assertIn("| Dimension | OpenAI | Anthropic | xAI |", rendered)
|
||||
# Envelope scaffolding present
|
||||
self.assertIn("EVIDENCE FOR SYNTHESIS", rendered)
|
||||
self.assertIn("END OF last30days CANONICAL OUTPUT", rendered)
|
||||
|
||||
def test_two_entity_table_has_two_columns(self):
|
||||
reports = [
|
||||
("Kanye West", _build_report("Kanye West", ["Donda 2 release"])),
|
||||
("Drake", _build_report("Drake", ["For All The Dogs"])),
|
||||
]
|
||||
rendered = render.render_comparison_multi(reports)
|
||||
self.assertIn("| Dimension | Kanye West | Drake |", rendered)
|
||||
self.assertIn("## Kanye West", rendered)
|
||||
self.assertIn("## Drake", rendered)
|
||||
|
||||
def test_empty_clusters_renders_placeholder(self):
|
||||
reports = [
|
||||
("OpenAI", _build_report("OpenAI", ["GPT-5 drop"])),
|
||||
("ObscureCompetitor", _build_report("ObscureCompetitor", [])),
|
||||
]
|
||||
rendered = render.render_comparison_multi(reports)
|
||||
self.assertIn("## ObscureCompetitor", rendered)
|
||||
self.assertIn("no significant discussion this month", rendered)
|
||||
# Main still has its cluster
|
||||
self.assertIn("GPT-5 drop", rendered)
|
||||
|
||||
def test_warnings_aggregated_and_labeled(self):
|
||||
report_a = _build_report("OpenAI", ["GPT-5 drop"])
|
||||
report_b = _build_report("Anthropic", ["Claude 4.7"])
|
||||
report_a.warnings.append("Brave quota exhausted")
|
||||
report_b.warnings.append("Exa returned 0 results")
|
||||
rendered = render.render_comparison_multi(
|
||||
[("OpenAI", report_a), ("Anthropic", report_b)]
|
||||
)
|
||||
self.assertIn("[OpenAI] Brave quota exhausted", rendered)
|
||||
self.assertIn("[Anthropic] Exa returned 0 results", rendered)
|
||||
|
||||
def test_raises_on_empty_input(self):
|
||||
with self.assertRaises(ValueError):
|
||||
render.render_comparison_multi([])
|
||||
|
||||
def test_context_emit(self):
|
||||
reports = [
|
||||
("OpenAI", _build_report("OpenAI", ["GPT-5 drop"])),
|
||||
("Anthropic", _build_report("Anthropic", ["Claude 4.7"])),
|
||||
]
|
||||
out = render.render_comparison_multi_context(reports)
|
||||
self.assertIn("Comparison: OpenAI vs Anthropic", out)
|
||||
self.assertIn("## OpenAI", out)
|
||||
self.assertIn("## Anthropic", out)
|
||||
self.assertIn("GPT-5 drop", out)
|
||||
|
||||
|
||||
class ResolvedEntitiesBlockTests(unittest.TestCase):
|
||||
def _build_with_resolved(self, label, topic, resolved):
|
||||
r = _build_report(topic, ["Cluster A"])
|
||||
if resolved is not None:
|
||||
r.artifacts["resolved"] = resolved
|
||||
return (label, r)
|
||||
|
||||
def test_block_emitted_when_any_entity_has_resolved(self):
|
||||
reports = [
|
||||
self._build_with_resolved("OpenAI", "OpenAI", {
|
||||
"entity": "OpenAI",
|
||||
"x_handle": "OpenAI",
|
||||
"subreddits": ["OpenAI", "MachineLearning"],
|
||||
"github_user": "openai",
|
||||
"github_repos": ["openai/gpt"],
|
||||
"context": "GPT-5 release signals are strong",
|
||||
}),
|
||||
self._build_with_resolved("Anthropic", "Anthropic", {
|
||||
"entity": "Anthropic",
|
||||
"x_handle": "AnthropicAI",
|
||||
"subreddits": ["ClaudeAI"],
|
||||
"github_user": "anthropics",
|
||||
"github_repos": [],
|
||||
"context": "",
|
||||
}),
|
||||
]
|
||||
rendered = render.render_comparison_multi(reports)
|
||||
self.assertIn("## Resolved Entities", rendered)
|
||||
self.assertIn("**OpenAI**: X @OpenAI", rendered)
|
||||
self.assertIn("r/OpenAI, r/MachineLearning", rendered)
|
||||
self.assertIn("@openai (openai/gpt)", rendered)
|
||||
self.assertIn("**Anthropic**: X @AnthropicAI", rendered)
|
||||
# Missing context renders as "-"
|
||||
self.assertIn("Context: -", rendered)
|
||||
|
||||
def test_block_omitted_when_no_resolved_artifacts(self):
|
||||
reports = [
|
||||
self._build_with_resolved("A", "A", None),
|
||||
self._build_with_resolved("B", "B", None),
|
||||
]
|
||||
rendered = render.render_comparison_multi(reports)
|
||||
self.assertNotIn("## Resolved Entities", rendered)
|
||||
|
||||
def test_missing_fields_render_as_dash(self):
|
||||
reports = [
|
||||
self._build_with_resolved("OpenAI", "OpenAI", {
|
||||
"entity": "OpenAI",
|
||||
"x_handle": "",
|
||||
"subreddits": [],
|
||||
"github_user": "",
|
||||
"github_repos": [],
|
||||
"context": "",
|
||||
}),
|
||||
]
|
||||
rendered = render.render_comparison_multi(reports)
|
||||
self.assertIn("**OpenAI**: X - | Subs - | GitHub - | Context: -", rendered)
|
||||
|
||||
def test_long_context_truncated(self):
|
||||
long = "a" * 200
|
||||
reports = [
|
||||
self._build_with_resolved("X", "X", {
|
||||
"entity": "X",
|
||||
"x_handle": "",
|
||||
"subreddits": [],
|
||||
"github_user": "",
|
||||
"github_repos": [],
|
||||
"context": long,
|
||||
}),
|
||||
]
|
||||
rendered = render.render_comparison_multi(reports)
|
||||
# The truncate helper adds an ellipsis; context line should not show
|
||||
# the full 200-char string.
|
||||
self.assertNotIn("a" * 200, rendered)
|
||||
|
||||
def test_context_emit_includes_resolved_block(self):
|
||||
reports = [
|
||||
self._build_with_resolved("OpenAI", "OpenAI", {
|
||||
"entity": "OpenAI",
|
||||
"x_handle": "OpenAI",
|
||||
"subreddits": ["OpenAI"],
|
||||
"github_user": "",
|
||||
"github_repos": [],
|
||||
"context": "",
|
||||
}),
|
||||
]
|
||||
out = render.render_comparison_multi_context(reports)
|
||||
self.assertIn("## Resolved Entities", out)
|
||||
self.assertIn("**OpenAI**: X @OpenAI", out)
|
||||
|
||||
def test_subreddit_overflow_truncated(self):
|
||||
reports = [
|
||||
self._build_with_resolved("X", "X", {
|
||||
"entity": "X",
|
||||
"x_handle": "",
|
||||
"subreddits": ["a", "b", "c", "d", "e", "f", "g"],
|
||||
"github_user": "",
|
||||
"github_repos": [],
|
||||
"context": "",
|
||||
}),
|
||||
]
|
||||
rendered = render.render_comparison_multi(reports)
|
||||
self.assertIn("r/a, r/b, r/c, r/d, r/e (+2)", rendered)
|
||||
|
||||
|
||||
class EmitComparisonOutputTests(unittest.TestCase):
|
||||
def test_json_emit_nests_per_entity(self):
|
||||
reports = [
|
||||
("OpenAI", _build_report("OpenAI", ["GPT-5 drop"])),
|
||||
("Anthropic", _build_report("Anthropic", ["Claude 4.7"])),
|
||||
]
|
||||
out = cli.emit_comparison_output(reports, emit="json")
|
||||
payload = json.loads(out)
|
||||
self.assertTrue(payload["comparison"])
|
||||
self.assertEqual(payload["entities"], ["OpenAI", "Anthropic"])
|
||||
self.assertEqual(len(payload["reports"]), 2)
|
||||
self.assertEqual(payload["reports"][0]["entity"], "OpenAI")
|
||||
self.assertIn("topic", payload["reports"][0]["report"])
|
||||
|
||||
def test_compact_and_md_both_route_to_multi(self):
|
||||
reports = [
|
||||
("A", _build_report("A", ["Thing A"])),
|
||||
("B", _build_report("B", ["Thing B"])),
|
||||
]
|
||||
compact = cli.emit_comparison_output(reports, emit="compact")
|
||||
md = cli.emit_comparison_output(reports, emit="md")
|
||||
self.assertIn("| Dimension | A | B |", compact)
|
||||
self.assertEqual(compact, md)
|
||||
|
||||
def test_context_emit_goes_to_context_renderer(self):
|
||||
reports = [
|
||||
("A", _build_report("A", ["Thing A"])),
|
||||
("B", _build_report("B", ["Thing B"])),
|
||||
]
|
||||
out = cli.emit_comparison_output(reports, emit="context")
|
||||
self.assertIn("Comparison: A vs B", out)
|
||||
|
||||
def test_unsupported_emit_raises(self):
|
||||
reports = [("A", _build_report("A", ["Thing A"]))]
|
||||
with self.assertRaises(SystemExit):
|
||||
cli.emit_comparison_output(reports, emit="xml")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+11
-192
@@ -117,72 +117,8 @@ class RenderV3Tests(unittest.TestCase):
|
||||
report.errors_by_source = {"x": "HTTP 400: Bad Request"}
|
||||
text = render.render_compact(report)
|
||||
self.assertIn("## Source Errors", text)
|
||||
|
||||
|
||||
class OutputEnvelopeTests(unittest.TestCase):
|
||||
"""LAW 6 envelope comments: scope "pass through verbatim" unambiguously.
|
||||
|
||||
Added 2026-04-19 after the Hermes Agent Use Cases failure where two
|
||||
consecutive runs dumped `## Ranked Evidence Clusters` as user output.
|
||||
"""
|
||||
|
||||
def test_evidence_for_synthesis_envelope_wraps_raw_evidence(self):
|
||||
text = render.render_compact(sample_report())
|
||||
self.assertIn("<!-- EVIDENCE FOR SYNTHESIS:", text)
|
||||
self.assertIn("<!-- END EVIDENCE FOR SYNTHESIS -->", text)
|
||||
# Opening comment must appear BEFORE the raw evidence block.
|
||||
self.assertLess(
|
||||
text.index("<!-- EVIDENCE FOR SYNTHESIS:"),
|
||||
text.index("## Ranked Evidence Clusters"),
|
||||
)
|
||||
# Closing comment must appear AFTER Source Coverage.
|
||||
self.assertGreater(
|
||||
text.index("<!-- END EVIDENCE FOR SYNTHESIS -->"),
|
||||
text.index("## Source Coverage"),
|
||||
)
|
||||
|
||||
def test_pass_through_footer_envelope_wraps_emoji_tree(self):
|
||||
text = render.render_compact(sample_report())
|
||||
self.assertIn("<!-- PASS-THROUGH FOOTER:", text)
|
||||
self.assertIn("<!-- END PASS-THROUGH FOOTER -->", text)
|
||||
# Emoji footer sits between the two markers.
|
||||
open_idx = text.index("<!-- PASS-THROUGH FOOTER:")
|
||||
close_idx = text.index("<!-- END PASS-THROUGH FOOTER -->")
|
||||
self.assertIn("All agents reported back!", text[open_idx:close_idx])
|
||||
|
||||
def test_canonical_boundary_scopes_pass_through_to_footer(self):
|
||||
text = render.render_compact(sample_report())
|
||||
# New boundary text scopes verbatim to the PASS-THROUGH FOOTER block,
|
||||
# not everything above.
|
||||
self.assertIn("Pass through ONLY the PASS-THROUGH FOOTER block verbatim", text)
|
||||
# Self-check string is present so the model has a concrete failure signal.
|
||||
self.assertIn("### 1.", text)
|
||||
self.assertIn("LAW 6", text)
|
||||
# The prior ambiguous phrasing is gone.
|
||||
self.assertNotIn("Pass through the lines ABOVE this boundary verbatim", text)
|
||||
|
||||
def test_envelopes_appear_in_md_emit_mode(self):
|
||||
# --emit md and --emit compact both route to render_compact, so the
|
||||
# same envelopes apply. Guard against future divergence.
|
||||
text = render.render_compact(sample_report())
|
||||
self.assertEqual(text.count("<!-- EVIDENCE FOR SYNTHESIS:"), 1)
|
||||
self.assertEqual(text.count("<!-- END EVIDENCE FOR SYNTHESIS -->"), 1)
|
||||
self.assertEqual(text.count("<!-- PASS-THROUGH FOOTER:"), 1)
|
||||
self.assertEqual(text.count("<!-- END PASS-THROUGH FOOTER -->"), 1)
|
||||
|
||||
def test_no_dangling_envelope_open_without_close(self):
|
||||
# Open/close counts must always match, even for empty clusters.
|
||||
report = sample_report()
|
||||
report.clusters = []
|
||||
text = render.render_compact(report)
|
||||
self.assertEqual(
|
||||
text.count("<!-- EVIDENCE FOR SYNTHESIS:"),
|
||||
text.count("<!-- END EVIDENCE FOR SYNTHESIS -->"),
|
||||
)
|
||||
self.assertEqual(
|
||||
text.count("<!-- PASS-THROUGH FOOTER:"),
|
||||
text.count("<!-- END PASS-THROUGH FOOTER -->"),
|
||||
)
|
||||
self.assertIn("HTTP 400: Bad Request", text)
|
||||
self.assertIn("X:", text)
|
||||
|
||||
|
||||
class RenderTopCommentsTests(unittest.TestCase):
|
||||
@@ -267,31 +203,31 @@ class RenderTopCommentsTests(unittest.TestCase):
|
||||
]
|
||||
report = self._make_report_with_comments(top_comments=comments)
|
||||
text = render.render_compact(report)
|
||||
# Reddit authors render with u/ prefix now.
|
||||
self.assertIn("u/user1 (500 upvotes):", text)
|
||||
self.assertIn("u/user2 (200 upvotes):", text)
|
||||
self.assertIn("u/user3 (50 upvotes):", text)
|
||||
self.assertNotIn("u/user4 (8 upvotes):", text)
|
||||
self.assertNotIn("u/user5 (3 upvotes):", text)
|
||||
self.assertIn("Comment (500 upvotes):", text)
|
||||
self.assertIn("Comment (200 upvotes):", text)
|
||||
self.assertIn("Comment (50 upvotes):", text)
|
||||
self.assertNotIn("Comment (8 upvotes):", text)
|
||||
self.assertNotIn("Comment (3 upvotes):", text)
|
||||
|
||||
def test_reddit_1_comment_renders_1(self):
|
||||
"""Reddit candidate with 1 comment renders 1."""
|
||||
comments = [{"score": 100, "excerpt": "Single comment", "author": "user1"}]
|
||||
report = self._make_report_with_comments(top_comments=comments)
|
||||
text = render.render_compact(report)
|
||||
self.assertIn("u/user1 (100 upvotes): Single comment", text)
|
||||
self.assertIn("Comment (100 upvotes): Single comment", text)
|
||||
|
||||
def test_reddit_0_comments_no_section(self):
|
||||
"""Reddit candidate with 0 comments renders no comment section."""
|
||||
report = self._make_report_with_comments(top_comments=[])
|
||||
text = render.render_compact(report)
|
||||
self.assertNotIn("Comment (", text)
|
||||
self.assertNotIn("upvotes)", text)
|
||||
|
||||
def test_non_reddit_no_comments(self):
|
||||
"""Non-Reddit candidate doesn't render comments when metadata has none."""
|
||||
report = self._make_report_with_comments(source="grounding", top_comments=[])
|
||||
text = render.render_compact(report)
|
||||
self.assertNotIn("upvotes)", text)
|
||||
self.assertNotIn("Comment (", text)
|
||||
self.assertIn("Test cluster", text)
|
||||
|
||||
def test_all_comments_below_score_10_no_section(self):
|
||||
@@ -303,65 +239,9 @@ class RenderTopCommentsTests(unittest.TestCase):
|
||||
]
|
||||
report = self._make_report_with_comments(top_comments=comments)
|
||||
text = render.render_compact(report)
|
||||
self.assertNotIn("Comment (", text)
|
||||
self.assertNotIn("upvotes)", text)
|
||||
|
||||
def test_youtube_comments_use_likes_label_and_50_threshold(self):
|
||||
comments = [
|
||||
{"score": 120, "excerpt": "legit fire tutorial", "author": "alice"},
|
||||
{"score": 60, "excerpt": "saved me hours", "author": "bob"},
|
||||
{"score": 10, "excerpt": "below threshold", "author": "carol"},
|
||||
]
|
||||
report = self._make_report_with_comments(source="youtube", top_comments=comments)
|
||||
text = render.render_compact(report)
|
||||
# YouTube authors render with @ prefix now.
|
||||
self.assertIn("@alice (120 likes): legit fire tutorial", text)
|
||||
self.assertIn("@bob (60 likes): saved me hours", text)
|
||||
self.assertNotIn("@carol (10 likes)", text)
|
||||
|
||||
def test_reddit_comment_without_author_falls_back_to_legacy_label(self):
|
||||
"""When author is missing or [deleted], render falls back to 'Comment (...)'."""
|
||||
comments = [
|
||||
{"score": 500, "excerpt": "No author field", "author": ""},
|
||||
{"score": 200, "excerpt": "Deleted user", "author": "[deleted]"},
|
||||
{"score": 50, "excerpt": "Removed user", "author": "[removed]"},
|
||||
]
|
||||
report = self._make_report_with_comments(top_comments=comments)
|
||||
text = render.render_compact(report)
|
||||
# Legacy format preserved - no u/ prefix leaks with empty/deleted handles.
|
||||
self.assertIn("Comment (500 upvotes): No author field", text)
|
||||
self.assertIn("Comment (200 upvotes): Deleted user", text)
|
||||
self.assertIn("Comment (50 upvotes): Removed user", text)
|
||||
self.assertNotIn("u/ (", text)
|
||||
self.assertNotIn("u/[deleted]", text)
|
||||
self.assertNotIn("u/[removed]", text)
|
||||
|
||||
def test_tiktok_comments_render_with_at_handle(self):
|
||||
"""TikTok source renders @handle attribution on comment lines."""
|
||||
comments = [
|
||||
{"score": 3986, "excerpt": "oh no. who's going to make the same phone every year now..", "author": "moosanoormahomed"},
|
||||
{"score": 925, "excerpt": "This is either going to go so well or so bad", "author": "Muna9e"},
|
||||
]
|
||||
report = self._make_report_with_comments(source="tiktok", top_comments=comments)
|
||||
text = render.render_compact(report)
|
||||
self.assertIn("@moosanoormahomed (3986 likes):", text)
|
||||
self.assertIn("@Muna9e (925 likes):", text)
|
||||
# Render must not silently label YT as upvotes.
|
||||
self.assertNotIn("Comment (120 upvotes)", text)
|
||||
|
||||
def test_tiktok_comments_use_likes_label_and_500_threshold(self):
|
||||
comments = [
|
||||
{"score": 2000, "excerpt": "this aged well", "author": "a"},
|
||||
{"score": 600, "excerpt": "so real", "author": "b"},
|
||||
{"score": 400, "excerpt": "below tt threshold", "author": "c"},
|
||||
{"score": 50, "excerpt": "way below", "author": "d"},
|
||||
]
|
||||
report = self._make_report_with_comments(source="tiktok", top_comments=comments)
|
||||
text = render.render_compact(report)
|
||||
self.assertIn("@a (2000 likes): this aged well", text)
|
||||
self.assertIn("@b (600 likes): so real", text)
|
||||
self.assertNotIn("@c (400 likes)", text)
|
||||
self.assertNotIn("@d (50 likes)", text)
|
||||
|
||||
|
||||
class RenderBestTakesCompactTests(unittest.TestCase):
|
||||
"""Tests for Best Takes section in compact output and fun tags on candidates."""
|
||||
@@ -490,66 +370,5 @@ class RenderBestTakesCompactTests(unittest.TestCase):
|
||||
self.assertNotIn("## Best Takes", text)
|
||||
|
||||
|
||||
class DegradedRunBannerTests(unittest.TestCase):
|
||||
"""Unit 1: DEGRADED RUN WARNING surfaces bare named-entity invocations
|
||||
in user-visible stdout. LAW 7 backstop. 2026-04-19 Hermes Agent Use
|
||||
Cases Run 1 failure mode.
|
||||
"""
|
||||
|
||||
def _bare_named_entity_report(self) -> schema.Report:
|
||||
report = sample_report()
|
||||
report.topic = "Hermes Agent"
|
||||
report.artifacts["plan_source"] = "deterministic"
|
||||
report.artifacts["pre_research_flags_present"] = False
|
||||
return report
|
||||
|
||||
def test_banner_appears_on_bare_named_entity_deterministic_run(self):
|
||||
text = render.render_compact(self._bare_named_entity_report())
|
||||
self.assertIn("## DEGRADED RUN WARNING", text)
|
||||
self.assertIn("<!-- USER-VISIBLE BANNER:", text)
|
||||
self.assertIn("<!-- END USER-VISIBLE BANNER -->", text)
|
||||
self.assertIn("YOU ARE", text)
|
||||
# Runtime-agnostic enumeration: all host runtimes appear.
|
||||
for runtime_name in ("Claude Code", "Codex", "Hermes", "Gemini"):
|
||||
self.assertIn(runtime_name, text)
|
||||
|
||||
def test_banner_positioned_before_evidence_envelope(self):
|
||||
text = render.render_compact(self._bare_named_entity_report())
|
||||
banner_idx = text.index("## DEGRADED RUN WARNING")
|
||||
envelope_idx = text.index("<!-- EVIDENCE FOR SYNTHESIS:")
|
||||
self.assertLess(banner_idx, envelope_idx,
|
||||
"DEGRADED RUN banner must appear BEFORE evidence envelope so pass-through catches it.")
|
||||
|
||||
def test_banner_suppressed_when_plan_source_external(self):
|
||||
report = self._bare_named_entity_report()
|
||||
report.artifacts["plan_source"] = "external"
|
||||
text = render.render_compact(report)
|
||||
self.assertNotIn("## DEGRADED RUN WARNING", text)
|
||||
|
||||
def test_banner_suppressed_when_plan_source_llm(self):
|
||||
report = self._bare_named_entity_report()
|
||||
report.artifacts["plan_source"] = "llm"
|
||||
text = render.render_compact(report)
|
||||
self.assertNotIn("## DEGRADED RUN WARNING", text)
|
||||
|
||||
def test_banner_suppressed_when_pre_research_flags_present(self):
|
||||
report = self._bare_named_entity_report()
|
||||
report.artifacts["pre_research_flags_present"] = True
|
||||
text = render.render_compact(report)
|
||||
self.assertNotIn("## DEGRADED RUN WARNING", text)
|
||||
|
||||
def test_banner_suppressed_on_non_eligible_abstract_topic(self):
|
||||
report = self._bare_named_entity_report()
|
||||
# Multi-word lowercase abstract phrase is NOT pre-research-eligible.
|
||||
report.topic = "how to deploy containers in the cloud"
|
||||
text = render.render_compact(report)
|
||||
self.assertNotIn("## DEGRADED RUN WARNING", text)
|
||||
|
||||
def test_banner_mentions_law_7_and_plan_flag(self):
|
||||
text = render.render_compact(self._bare_named_entity_report())
|
||||
self.assertIn("LAW 7", text)
|
||||
self.assertIn("--plan", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user