thoughtleaders-cli 0.7.14__tar.gz → 0.7.15__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/.claude-plugin/plugin.json +1 -1
  2. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/AGENTS.md +1 -1
  3. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/PKG-INFO +1 -1
  4. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/pyproject.toml +1 -1
  5. thoughtleaders_cli-0.7.15/skills/tl-keyword-research/SKILL.md +309 -0
  6. thoughtleaders_cli-0.7.15/skills/tl-keyword-research/references/context-classifier.md +89 -0
  7. thoughtleaders_cli-0.7.15/skills/tl-keyword-research/scripts/fetch_context.py +184 -0
  8. thoughtleaders_cli-0.7.15/skills/tl-keyword-research/scripts/search_channels.py +268 -0
  9. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/__init__.py +1 -1
  10. thoughtleaders_cli-0.7.14/skills/tl-keyword-research/SKILL.md +0 -167
  11. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/.claude-plugin/marketplace.json +0 -0
  12. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/.github/dependabot.yml +0 -0
  13. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/.github/workflows/ci.yml +0 -0
  14. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/.github/workflows/cli-integration.yml +0 -0
  15. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/.github/workflows/python-publish.yml +0 -0
  16. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/.gitignore +0 -0
  17. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/API.md +0 -0
  18. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/CLAUDE.md +0 -0
  19. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/LICENSE +0 -0
  20. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/README.md +0 -0
  21. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/agents/tl-analyst.md +0 -0
  22. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/agents/youtube-comment-classifier.md +0 -0
  23. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/hooks/hooks.json +0 -0
  24. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/hooks/scripts/load-tl-skill.mjs +0 -0
  25. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/hooks/scripts/post-usage.sh +0 -0
  26. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/hooks/scripts/pre-check.sh +0 -0
  27. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl/SKILL.md +0 -0
  28. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl/references/business-glossary.md +0 -0
  29. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl/references/elasticsearch-schema.md +0 -0
  30. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl/references/firebolt-schema.md +0 -0
  31. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl/references/postgres-schema.md +0 -0
  32. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-channel-authenticity/.gitignore +0 -0
  33. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-channel-authenticity/SKILL.md +0 -0
  34. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-channel-authenticity/references/comment-patterns.md +0 -0
  35. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-channel-authenticity/references/peer-cohort.md +0 -0
  36. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-channel-authenticity/references/red-flags.md +0 -0
  37. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-channel-authenticity/references/scoring.md +0 -0
  38. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-channel-authenticity/scripts/_io_utf8.py +0 -0
  39. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-channel-authenticity/scripts/analyze_channel.py +0 -0
  40. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-channel-authenticity/scripts/anomaly_detector.py +0 -0
  41. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-channel-authenticity/scripts/comment_analyzer.py +0 -0
  42. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-channel-authenticity/scripts/comment_scraper.py +0 -0
  43. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-channel-authenticity/scripts/engagement_ratios.py +0 -0
  44. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-channel-authenticity/scripts/peer_cohort.py +0 -0
  45. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-channel-authenticity/scripts/report.py +0 -0
  46. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-channel-authenticity/scripts/resolve_channel.py +0 -0
  47. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-channel-authenticity/scripts/score.py +0 -0
  48. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-channel-authenticity/scripts/tl_cli.py +0 -0
  49. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-channel-authenticity/scripts/video_integrity.py +0 -0
  50. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-channel-authenticity/scripts/view_curves.py +0 -0
  51. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-keyword-research/scripts/probe.py +0 -0
  52. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-save-report/SKILL.md +0 -0
  53. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-save-report/references/columns_brands.md +0 -0
  54. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-save-report/references/columns_channels.md +0 -0
  55. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-save-report/references/columns_content.md +0 -0
  56. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-save-report/references/columns_sponsorships.md +0 -0
  57. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-save-report/references/intelligence_filterset_schema.json +0 -0
  58. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-save-report/references/intelligence_widget_schema.json +0 -0
  59. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-save-report/references/report_glossary.md +0 -0
  60. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-save-report/references/sortable_columns.json +0 -0
  61. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-save-report/references/sponsorship_filterset_schema.json +0 -0
  62. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-save-report/references/sponsorship_widget_schema.json +0 -0
  63. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-save-report/references/widgets.md +0 -0
  64. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-top-partnerships/SKILL.md +0 -0
  65. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-top-partnerships/scripts/top_partnerships.py +0 -0
  66. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-views-guarantee/SKILL.md +0 -0
  67. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/skills/tl-views-guarantee/scripts/vg.py +0 -0
  68. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/_completions.py +0 -0
  69. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/_typer_utils.py +0 -0
  70. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/auth/__init__.py +0 -0
  71. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/auth/commands.py +0 -0
  72. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/auth/login.py +0 -0
  73. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/auth/pkce.py +0 -0
  74. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/auth/token_store.py +0 -0
  75. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/client/__init__.py +0 -0
  76. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/client/errors.py +0 -0
  77. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/client/http.py +0 -0
  78. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/__init__.py +0 -0
  79. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/_comments_common.py +0 -0
  80. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/balance.py +0 -0
  81. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/brands.py +0 -0
  82. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/bulk_import.py +0 -0
  83. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/changelog.py +0 -0
  84. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/channels.py +0 -0
  85. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/credits.py +0 -0
  86. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/db.py +0 -0
  87. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/deals.py +0 -0
  88. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/describe.py +0 -0
  89. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/doctor.py +0 -0
  90. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/matches.py +0 -0
  91. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/proposals.py +0 -0
  92. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/recommender.py +0 -0
  93. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/reports.py +0 -0
  94. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/schema.py +0 -0
  95. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/setup.py +0 -0
  96. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/snapshots.py +0 -0
  97. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/sponsorships.py +0 -0
  98. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/uploads.py +0 -0
  99. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/commands/whoami.py +0 -0
  100. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/config.py +0 -0
  101. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/filters.py +0 -0
  102. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/hints.py +0 -0
  103. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/main.py +0 -0
  104. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/output/__init__.py +0 -0
  105. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/output/formatter.py +0 -0
  106. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/src/tl_cli/self_update.py +0 -0
  107. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/tests/__init__.py +0 -0
  108. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/tests/test_auth.py +0 -0
  109. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/tests/test_brands_winner_channels.py +0 -0
  110. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/tests/test_channels_lookalike.py +0 -0
  111. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/tests/test_describe.py +0 -0
  112. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/tests/test_filters.py +0 -0
  113. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/tests/test_http_auth.py +0 -0
  114. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/tests/test_output.py +0 -0
  115. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/tests/test_reports.py +0 -0
  116. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/tests/test_setup.py +0 -0
  117. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/tests/test_sponsorships.py +0 -0
  118. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/tests_cli/AGENTS.md +0 -0
  119. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/tests_cli/conftest.py +0 -0
  120. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/tests_cli/test_balance.py +0 -0
  121. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/tests_cli/test_db_es.py +0 -0
  122. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/tests_cli/test_db_fb.py +0 -0
  123. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/tests_cli/test_db_pg.py +0 -0
  124. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/tests_cli/test_schema.py +0 -0
  125. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/tests_cli/test_whoami.py +0 -0
  126. {thoughtleaders_cli-0.7.14 → thoughtleaders_cli-0.7.15}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tl-cli",
3
- "version": "0.7.14",
3
+ "version": "0.7.15",
4
4
  "description": "ThoughtLeaders CLI — query sponsorship deals, channels, brands, uploads, and intelligence from the terminal",
5
5
  "author": {
6
6
  "name": "ThoughtLeaders",
@@ -52,7 +52,7 @@ This repo is also a Claude Code plugin, and can directly be installed as one.
52
52
  ### Bundled skills — when to invoke
53
53
 
54
54
  - **`tl`** — the main skill for querying ThoughtLeaders data. Default for any sponsorship / channel / brand / upload / report question.
55
- - **`tl-keyword-research`** — invoke whenever the user wants to find videos or channels by **content keywords** (topics, concepts, niches) that aren't covered by a curated recommender tag, OR to validate that a candidate channel's content actually touches a given topic. Returns `{operator, keywords:[{keyword,count}]}` from a ranked ES probe over `title` / `summary` / `transcript`; the caller then runs the actual content search with the surviving high-count terms. **Do not compose keyword sets by hand for `tl db es` content searches — delegate to this skill first.** See `skills/tl/SKILL.md` → *Channel & video discovery* for the four-path decision tree and when to use this vs the recommender / raw SQL.
55
+ - **`tl-keyword-research`** — invoke whenever the user wants to find videos or channels by **content keywords** (topics, concepts, niches) that aren't covered by a curated recommender tag, OR to validate that a candidate channel's content actually touches a given topic. **Default output: a ranked, context-validated set of channels** (each flagged for sponsorability) — discovered via field-weighted ES relevance (`title` > `summary` > `transcript`), then sense-checked so channels that only use the keyword in an unrelated sense are dropped. The keyword-distribution shape (`{operator, keywords:[{keyword,count}]}`) is now an **opt-in mode**, produced only when the user explicitly asks for keyword counts / distribution. **Do not compose keyword sets by hand for `tl db es` content searches — delegate to this skill first.** See `skills/tl/SKILL.md` → *Channel & video discovery* for the four-path decision tree and when to use this vs the recommender / raw SQL.
56
56
  - **`tl-save-report`**, **`adapt-tl-data`**, **`tl-views-guarantee`**, **`tl-top-partnerships`** — narrower workflows; the skill files document their own triggers. `tl-top-partnerships` is brand-user-facing: ranks a brand's sold sponsorships by live eCPM vs the sold-date projection and delivers a two-tab Google Sheet via `gws`.
57
57
 
58
58
  ### Skill content boundaries
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thoughtleaders-cli
3
- Version: 0.7.14
3
+ Version: 0.7.15
4
4
  Summary: ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence
5
5
  Project-URL: Homepage, https://thoughtleaders.io
6
6
  Project-URL: Repository, https://github.com/ThoughtLeaders-io/thoughtleaders-cli
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thoughtleaders-cli"
7
- version = "0.7.14"
7
+ version = "0.7.15"
8
8
  description = "ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -0,0 +1,309 @@
1
+ ---
2
+ name: tl-keyword-research
3
+ tl-blurb: find & validate channels by topic
4
+ description: |
5
+ Find YouTube channels that *reliably* cover a topic — discovered by content keywords (topics, concepts, niches), then validated by the CONTEXT in which those keywords appear in the channel's videos. Invoke when the user wants channels for a topic ("find investing channels", "channels that cover tiktok shops"), not by channel ID/name. Default output: a ranked, context-validated set of channels with sponsorability flags (JSON), plus an offer to save as a TL report. The keyword-distribution mode (counts per keyword) is OPT-IN — only when the user explicitly asks for "keyword counts / distribution / how common is X". By default it runs ≥3 refinement rounds and checks in with you before finalizing — say "run autonomously" (or pass `autonomous` / `--auto`) to skip the pauses and let it finish on its own.
6
+ ---
7
+
8
+ # tl-keyword-research — topic → validated channels
9
+
10
+ Given a topic (seed keyword(s) or an NL phrase), arrive at **a CNF (Conjunctive Normal
11
+ Form) keyword expression** that reliably selects channels covering it — *and* the ranked,
12
+ validated set of channels that expression selects. The flow: widen the topic into
13
+ keywords, find candidate channels by **field-weighted** relevance (`title` > `summary` >
14
+ `transcript`), spot-check the **context** of the keyword in each candidate with cheap
15
+ agents to drop channels that only use the word in an unrelated sense, flag each survivor
16
+ for sponsorability, and over **≥3 refinement rounds** compose/recompose the boolean query
17
+ — narrowing and expanding — based on what the context check reveals.
18
+
19
+ **The final result is the CNF expression** — an AND of OR-clauses with negated exclusions,
20
+ e.g. `(investing OR "stock market") AND (stocks OR etf) AND (NOT sermon) AND (NOT betting)`
21
+ — together with the validated channels it selects. The CNF is the distilled, reusable
22
+ artifact: it re-runs verbatim via `search_channels.py --any/--not` and saves with the report.
23
+
24
+ **Default output = the CNF + validated channels.** The old keyword-distribution output
25
+ (`{keyword, count}`) is now an **opt-in mode** — produce it only when the user
26
+ explicitly asks for keyword counts / distribution / "how common is X". See
27
+ [Opt-in: keyword distribution](#opt-in-keyword-distribution).
28
+
29
+ `<SKILL_DIR>` below is this skill's directory (the one holding `SKILL.md`).
30
+
31
+ ## When to invoke / skip
32
+
33
+ Invoke when the user wants **channels for a topic** (concepts, niches), not a specific
34
+ channel/brand by ID or name.
35
+
36
+ Skip when:
37
+ - The user has explicit channel / brand IDs or names → `tl channels find` / `tl brands find`.
38
+ - The intent maps cleanly to a curated recommender tag (e.g. "Cooking channels") →
39
+ `tl recommender top-channels "<tag>"`. Don't re-discover curated tags by text match.
40
+
41
+ ## The pipeline (you orchestrate; scripts + cheap agents do the work)
42
+
43
+ 1. **Expand** — you widen the seeds into keywords and pick AND/OR.
44
+ 2. **Search channels** — `search_channels.py` → field-weighted candidate channels (the default result shape).
45
+ 3. **Fetch context** — `fetch_context.py` → keyword-in-context evidence per candidate.
46
+ 4. **Validate** — spawn cheap (Haiku) agents to judge each channel's keyword sense and surface adjacent terms.
47
+ 5. **Refine** — compose AND/OR/NOT to narrow & expand, probe fitness, backtrack; **≥3 rounds**, then checkpoint with the user (and interview them if they want more).
48
+
49
+ ## Pacing & autonomy
50
+
51
+ **Default is interactive.** At the **start** of a run, tell the user in one line how this
52
+ will go — e.g. *"I'll refine over at least 3 rounds, then check in before finalizing. Say
53
+ 'run autonomously' if you'd rather I not pause."* That lets them opt out **before** the
54
+ first interview, not just after round 3.
55
+
56
+ **Opt-out triggers — any of these → run autonomously** (full behavior in Stage 5): the
57
+ user says run autonomously / without pausing / "don't stop to ask", **or** invokes the
58
+ skill with an `autonomous` / `--auto` argument. The preference holds for the rest of the
59
+ session unless the user revokes it.
60
+
61
+ ---
62
+
63
+ ## Stage 1 — Expand (you)
64
+
65
+ Broaden the seed(s) into **5–15** candidates: synonyms, sub-areas, specific multi-word
66
+ phrases, inflectional variants (ES text fields are **not stemmed** — `invest`/`investing`/`investments`
67
+ are distinct terms), and reasonable abbreviations. Hard rules:
68
+
69
+ - Generic topic/concept terms only. **No brand names** unless the seeds already contain
70
+ one (then adjacent brands in the same category are fine). **No channel names.**
71
+ - No random-letter padding.
72
+
73
+ **AND vs OR** (pass to the scripts via `--operator`):
74
+ - Default **OR** (union: "crypto channels" = crypto / bitcoin / Web3 / …).
75
+ - **AND** only for composite-noun phrases ("AI cooking", "Roman naval warfare") or
76
+ explicit "both X and Y". Under AND, keep candidates *inside the intersection* — don't
77
+ broaden each component independently.
78
+
79
+ ## Stage 2 — Search channels (default output)
80
+
81
+ ```bash
82
+ # Flat OR (broad first pass)
83
+ python3 <SKILL_DIR>/scripts/search_channels.py --operator OR --size 200 \
84
+ investing "index funds" "stock market" "personal finance"
85
+
86
+ # Composed boolean — the lever for narrowing/expanding (see Stage 5):
87
+ # (investing OR "stock market" OR "index funds") AND (stocks OR portfolio OR etf)
88
+ # AND NOT (sermon OR gospel OR "sports investing" OR betting)
89
+ python3 <SKILL_DIR>/scripts/search_channels.py --size 200 \
90
+ --any "investing,stock market,index funds" \
91
+ --any "stocks,portfolio,etf" \
92
+ --not "sermon,gospel,sports investing,betting"
93
+ # JSON array / newline list on stdin also accepted; --since/--until scope publication_date.
94
+ ```
95
+
96
+ Runs ONE collapsed ES search (`collapse` on `channel.id`, sorted by `_score`) so each
97
+ channel surfaces its single best-matching video, then enriches with name +
98
+ sponsorability. **Field weighting is the priority you asked for:** title hits outscore
99
+ summary, which outscore transcript (default boosts `title^4,summary^2,transcript^1`,
100
+ tunable via `--fields`). **Boolean composition:** each `--any "a,b"` is one required
101
+ OR-group (a dimension); repeating `--any` ANDs the groups; `--not` excludes a sense.
102
+ So adding a group narrows, adding terms to a group widens, `--not` prunes — this is the
103
+ machinery Stage 5 drives. Output: `query` (the boolean it ran), `total_matching_videos`,
104
+ and per channel `channel_id, name, score, top_video_id, top_video_title, sponsorability{…}`.
105
+
106
+ ## Stage 3 — Fetch context
107
+
108
+ ```bash
109
+ python3 <SKILL_DIR>/scripts/fetch_context.py --channels 466311,2105,199308 \
110
+ --samples 4 --window 160 investing
111
+ ```
112
+
113
+ For each candidate, pulls its top-scoring matching videos and extracts the text window
114
+ around each keyword occurrence. **`transcript` is YouTube caption XML** — the script
115
+ strips tags + unescapes entities + windows client-side (ES highlight can't be used: the
116
+ CLI API strips highlight, and transcript fragments would be full of `<text …>` tags
117
+ anyway). Returns per channel: `{channel_id, match_count, sampled, snippets:[{video_id,
118
+ title, field, keyword, text}]}`. `match_count` (how many of the channel's videos match)
119
+ is a useful breadth signal alongside the score.
120
+
121
+ ## Stage 4 — Validate context (cheap / Haiku agents)
122
+
123
+ This is the core of "too weak → validated". Keyword matches are noisy: a search for
124
+ **investing** surfaces *"sports investing"* (betting) and church-sermon channels
125
+ ("invest in your faith") right next to real finance channels. Spot-check the sense.
126
+
127
+ Spawn cheap agents (Agent tool, **`model: haiku`** or a similarly cheap one) using the prompt in
128
+ [`references/context-classifier.md`](references/context-classifier.md). Give each agent
129
+ a `TOPIC:` line (the intended sense) and usually a `NOT:` line (senses to exclude), plus
130
+ a batch of the Stage-3 evidence. Batch ≈50–100 channels per agent and run batches in
131
+ **parallel**. Each agent returns, per channel:
132
+
133
+ `{i, channel_id, verdict: on_topic|mixed|off_topic, confidence, evidence_quote, adjacent_terms[], notes}`
134
+
135
+ - **`adjacent_terms`** is the discovery signal — co-occurring topics/brands worth
136
+ considering for expansion (e.g. "Amazon"/"affiliate" under "tiktok shop").
137
+ - Surface the interesting verdicts and adjacent terms back to yourself for Stage 5.
138
+
139
+ **Steer for completeness, then verify it — cheap models silently drop the tail of a
140
+ long list.** This is an output-generation behavior, not an input-context limit (the
141
+ evidence fits easily), so two guards:
142
+
143
+ 1. **Anchor the count in the prompt.** Index the evidence (`i: 0…N-1`) and open the
144
+ user message with the exact target, e.g. *"There are exactly 50 channels (indices
145
+ 0–49). Return exactly 50 objects. The last channel_id is 778812."* Keep per-item
146
+ output terse (the classifier caps `evidence_quote` at ≤8 words) so the full list fits.
147
+ 2. **Completeness check + re-send (the guarantee).** After each agent returns, diff the
148
+ returned `channel_id`s against the batch you sent. If any are missing (or the agent
149
+ emitted prose / invalid JSON), re-send just the missing channels to a fresh agent and
150
+ merge. Never assume a batch came back whole. Smaller batches make this fire less often.
151
+
152
+ ## Stage 5 — Iterative compositional refinement (≥3 rounds, then ask the user)
153
+
154
+ This is the heart of the skill. You **research the topic by composing and recomposing a
155
+ boolean keyword query** — combining `--any` OR-groups (AND'd together) and `--not`
156
+ exclusions — to narrow and expand the channel set, examining the results each round,
157
+ probing their fitness, and **backtracking** when a move makes things worse. This is not
158
+ a single pass with a flat operator; it is a search through query-space. Each composed
159
+ query **is** a CNF expression — an AND of OR-clauses plus negated literals — so refinement
160
+ is the incremental construction of the final CNF, the skill's end-product.
161
+
162
+ **Run at least 3 rounds.** Three is the floor, not a cap — do not stop before three even
163
+ if round 1 looks good.
164
+
165
+ ### Each round
166
+
167
+ 1. **Compose / recompose** the boolean query. Round 1 is usually broad: one OR-group of
168
+ the Stage-1 expansion. Later rounds add structure (below).
169
+ 2. **Search** — `search_channels.py` with the `--any` / `--not` you composed → candidates.
170
+ 3. **Probe for fitness** — run Stages 3–4 (fetch context + Haiku validation) on the top
171
+ candidates. Read the verdicts and `adjacent_terms`.
172
+ 4. **Score the round's fitness** from what you see, e.g.: the share of `on_topic` vs
173
+ `off_topic`/`mixed`; whether off-topic verdicts cluster on one confusable sense;
174
+ how many **sponsorable, high-reach** channels are `on_topic`; and how much the set
175
+ changed. Write this down.
176
+ 5. **Decide the next move** and **record** (query, fitness, decision) so you can backtrack:
177
+ - **Narrow** when off-topic clusters on a sense, or the set is too broad/noisy: add a
178
+ required dimension (`--any "stocks,portfolio,etf"`), and/or exclude the bad sense
179
+ (`--not "sermon,sports investing"`).
180
+ - **Expand** when fitness is high but reach is thin, or `adjacent_terms` reveal a
181
+ productive direction: widen a group, or add a discovered term. Example: under
182
+ **"tiktok shop"** the context check repeatedly surfaces **"Amazon"** / **"affiliate"**
183
+ → add them (`--any "tiktok shop,amazon,affiliate"`) as common affiliate providers.
184
+ - **Backtrack** when a move *reduced* fitness (fewer good channels, more noise, lost a
185
+ strong channel): discard it, return to the previous recorded query, and try a
186
+ **different** axis. Backtracking is expected, not failure.
187
+ 6. Keep a **running validated set** across rounds (dedupe by `channel_id`); carry forward
188
+ confirmed `on_topic`/`mixed` channels even as the query shifts.
189
+
190
+ Each round, briefly report: the boolean query you ran, the fitness read, and the move
191
+ (narrow / expand / backtrack) with the reason.
192
+
193
+ ### After round 3 (and every round thereafter) — checkpoint with the user
194
+
195
+ **Default (interactive):** do **not** finalize autonomously. Present the current validated
196
+ set with a short summary (the CNF expression, fitness, what changed across rounds), then
197
+ **ask the user to validate the results and choose**: accept as-is · run more refinement
198
+ rounds · adjust direction.
199
+
200
+ **Autonomy override — honor a stated preference.** If the user has told you to run
201
+ autonomously / without pausing / "don't stop to ask" — **or** invoked the skill with an
202
+ `autonomous` / `--auto` argument — (in this message or earlier in the session),
203
+ **skip this checkpoint and the intent interview**: keep refining on your own
204
+ judgement, still run **≥3 rounds**, stop when fitness stops improving (or at a sane cap,
205
+ ~6 rounds), and return the CNF + channels directly. Honor that preference for the rest of
206
+ the session unless the user revokes it, and note in the result that you ran autonomously.
207
+
208
+ **If the user chooses more rounds, interview them about the *intent* behind the keywords
209
+ first** — so the next rounds are steered, not guessed. Ask what would sharpen the
210
+ composition, e.g.: which sense of the topic they actually mean (and which to exclude);
211
+ the audience / intent / format they care about (beginner vs advanced, reviews vs news);
212
+ must-have vs nice-to-have sub-topics; brands/products that should count or must not; and
213
+ any reach / language / recency constraints. Fold the answers into the `--any` groups,
214
+ `--not` exclusions, and the validator's `TOPIC:` / `NOT:` lines, then run the next rounds
215
+ and checkpoint again.
216
+
217
+ ## Sponsorability — rank all, flag (don't filter)
218
+
219
+ Return **all** topically-validated channels ranked by relevance; do not drop a channel
220
+ for being unbookable. Annotate each with the `sponsorability` block from Stage 2:
221
+ `is_active`, `is_tpp` (TPP partner), `is_msn` (Media Selling Network member — has a join
222
+ date), `has_outreach_email`, `sponsorship_price`, `reach` (subscribers). The user
223
+ decides what to do with non-sponsorable matches.
224
+
225
+ ## Mixed-context channels — label all, drop only off-topic
226
+
227
+ Keep `on_topic` **and** `mixed` channels in the output, each carrying its verdict +
228
+ confidence. Exclude only channels the agents judged **clearly `off_topic`** (wrong
229
+ sense). Surface the count of dropped channels so the exclusion is visible.
230
+
231
+ ## Output (default)
232
+
233
+ A single JSON object. The **CNF expression is the headline result**; the channels are the
234
+ set it selects. `cnf` comes straight from the final `search_channels.py` run.
235
+
236
+ ```json
237
+ {
238
+ "topic": "financial investing",
239
+ "cnf": {
240
+ "expression": "(investing OR \"stock market\" OR \"index funds\") AND (stocks OR portfolio OR etf) AND (NOT sermon) AND (NOT \"sports investing\")",
241
+ "clauses": [["investing", "stock market", "index funds"], ["stocks", "portfolio", "etf"], ["NOT sermon"], ["NOT sports investing"]]
242
+ },
243
+ "rounds": 3,
244
+ "channels": [
245
+ {
246
+ "channel_id": 2105, "name": "Financial Education",
247
+ "score": 55.37, "match_count": 312,
248
+ "verdict": "on_topic", "confidence": "high",
249
+ "evidence_quote": "how to build a stock portfolio",
250
+ "adjacent_terms": ["index funds", "roth ira"],
251
+ "sponsorability": {"is_active": true, "is_tpp": false, "is_msn": false,
252
+ "has_outreach_email": true, "sponsorship_price": 4710.0, "reach": 938000}
253
+ }
254
+ ],
255
+ "excluded_off_topic": [{"channel_id": 199308, "name": "Cornerstone Church Sermons", "evidence_quote": "invest in your relationship with God"}]
256
+ }
257
+ ```
258
+
259
+ Then **offer to save** the validated set: `tl-import` / `tl-save-report` (channels report);
260
+ record the CNF `expression` alongside so the report is reproducible.
261
+
262
+ ## Opt-in: keyword distribution
263
+
264
+ Only when the user explicitly asks for keyword counts / distribution, run the ranking
265
+ probe instead of (or before) the channel search:
266
+
267
+ ```bash
268
+ # Channel-topic counts — default fields (title, summary, transcript)
269
+ python3 <SKILL_DIR>/scripts/probe.py crypto bitcoin DeFi Web3 "smart contract"
270
+ # Video-level counts — pass --fields title,summary (drop transcript noise)
271
+ python3 <SKILL_DIR>/scripts/probe.py --fields title,summary "budget meal prep" "cheap meal prep"
272
+ # Composite noun — --operator AND keeps candidates inside the intersection
273
+ python3 <SKILL_DIR>/scripts/probe.py --operator AND "3d printing" "resin printing minis"
274
+ ```
275
+
276
+ Emits `{"operator", "keywords":[{"keyword","count"}, …]}` sorted descending. This is the
277
+ old default; it now serves the explicit "how common is X" question.
278
+
279
+ ## Cost
280
+
281
+ - `search_channels.py`: 2 ES calls (collapse search + enrichment), ~`size` billed rows
282
+ each, no expensive fields → cheap.
283
+ - `fetch_context.py`: 1 ES call per channel; it returns `transcript`/`summary` (priced
284
+ fields), so cost ≈ channels × `--samples` × field-rate. Keep `--samples` small (default 4)
285
+ and validate the top candidates, not the whole tail.
286
+ - Haiku validation: cheap by design; batch + parallelize.
287
+ - Use `tl db es … --pricing` to preview; `tl describe show db` for live rates.
288
+
289
+ ## Self-check before emitting
290
+
291
+ 1. Default output is the validated-channel JSON object (not keyword counts) unless the
292
+ user explicitly asked for distribution.
293
+ 2. Field weighting applied: title > summary > transcript.
294
+ 3. Every channel sent for validation has a verdict — batch returns were diffed by
295
+ `channel_id` and any missing were re-sent, not silently dropped; only clearly
296
+ `off_topic` channels were excluded, and the dropped set is surfaced.
297
+ 4. Refinement composed `--any` / `--not` (not just a flat operator) to narrow & expand,
298
+ probed fitness each round, and backtracked when a move hurt fitness.
299
+ 5. **At least 3 rounds** were run; each round reported its query, fitness, and move.
300
+ 6. The final result includes the **CNF expression** (AND of OR-clauses + negated literals)
301
+ as the headline artifact, alongside the channels it selects.
302
+ 7. Checkpoint honored: by default, after round 3 you **asked the user to validate and
303
+ choose**, and **interviewed them about keyword intent** before any further rounds —
304
+ **unless** the user asked to run autonomously, in which case you ran ≥3 rounds and
305
+ returned directly, noting the skipped checkpoint. Nothing was saved without confirmation
306
+ (or per the user's autonomy preference).
307
+ 8. Each channel carries its `sponsorability` flags (all ranked, none filtered out for being unbookable).
308
+ 9. Offered to save the result (CNF + channels) as a TL report (did not save unprompted).
309
+ 10. If the user requested a chart, render it as an SVG.
@@ -0,0 +1,89 @@
1
+ # Keyword Context Classifier (cheap / Haiku)
2
+
3
+ This is the prompt for the per-channel context check. The orchestrator spawns one
4
+ cheap agent (e.g. **model `haiku`**) per batch of channels with the System role set
5
+ to everything below, then puts the `TOPIC` line + the indexed evidence JSON in the
6
+ user message, and runs batches in parallel.
7
+
8
+ **The orchestrator MUST anchor completeness in the user message** — state the exact
9
+ count and the last id, e.g.:
10
+ `There are exactly 50 channels (indices 0–49). Return exactly 50 objects. The last channel_id is 778812.`
11
+ and **after** collecting results, verify every `channel_id` came back and re-send any
12
+ missing ones. Cheap models drop the tail of long lists; steering reduces it, the
13
+ re-send guarantees it.
14
+
15
+ On promotion to `tl-cli` this can be registered as a subagent
16
+ (`agents/keyword-context-classifier.md`, `model: haiku`, `tools: Read`).
17
+
18
+ ---
19
+
20
+ You decide whether a YouTube channel genuinely covers a topic, or only mentions the
21
+ topic's keyword(s) in an **unrelated sense**. ThoughtLeaders uses your verdict to
22
+ pick channels for paid sponsorships, so a false "on_topic" wastes real money — be
23
+ skeptical. Judge from the snippets only; do not invent context.
24
+
25
+ ## Input
26
+
27
+ The user message contains:
28
+ 1. A count line stating exactly how many channels there are and the last `channel_id`.
29
+ 2. A `TOPIC:` line describing the intended sense, and usually a `NOT:` line listing
30
+ senses to exclude. Example:
31
+ `TOPIC: financial investing — stocks, funds, assets, retirement, portfolios.`
32
+ `NOT: sports betting ("sports investing"), religious ("invest in your faith"), investing time/effort in people.`
33
+ 3. A JSON array of channels, each indexed and with sampled keyword-in-context snippets:
34
+ `[{"i": 0, "channel_id": 466311, "snippets": [{"field","keyword","text"}, ...]}, ...]`
35
+ `field` is one of `title` / `summary` / `transcript`. A title hit is a stronger
36
+ topic signal than a lone transcript mention.
37
+
38
+ ## Completeness — NON-NEGOTIABLE
39
+
40
+ You MUST return exactly one object for **every** input channel — same `i`, same
41
+ `channel_id`, same order, from index 0 through the last one.
42
+
43
+ - Do **not** stop early, summarize, abbreviate, collapse duplicates, or write `...`
44
+ / "and so on". Keep going until you have emitted the last `channel_id` named in the
45
+ count line.
46
+ - A long input is not a reason to shorten the output. Process the whole list.
47
+ - Keep each object terse (see limits below) so the full set fits — brevity per item
48
+ is how you finish the list, not dropping items.
49
+ - Before you finish: count your objects. If the count is less than the stated total,
50
+ continue from where you stopped until it matches.
51
+
52
+ ## Verdict (choose exactly one per channel)
53
+
54
+ - **on_topic** — snippets show the keyword used in the intended sense across the
55
+ channel's content. Clear, repeated, in-sense usage.
56
+ - **off_topic** — the keyword is used only in an excluded / unrelated sense (the
57
+ `NOT` cases, or anything clearly outside `TOPIC`). This is the exclusion signal.
58
+ - **mixed** — both in-sense and unrelated usage, or too thin/ambiguous to call
59
+ on_topic with confidence. (Mixed channels are KEPT downstream and labelled —
60
+ reserve **off_topic** for channels whose keyword use is clearly the wrong sense.)
61
+
62
+ When torn between on_topic and mixed, prefer **mixed**. Only use **off_topic** when
63
+ the evidence clearly shows the wrong sense.
64
+
65
+ ## Also surface (the discovery signal)
66
+
67
+ - **adjacent_terms** — notable topics, products, or brand names that co-occur in the
68
+ snippets and could sharpen the search (e.g. under "tiktok shop": "amazon",
69
+ "affiliate", "temu"). Lower-case, deduped, ≤6 items. `[]` if none.
70
+ - **evidence_quote** — one verbatim phrase from a snippet, **≤8 words**, that best
71
+ justifies the verdict.
72
+
73
+ ## Output — STRICT
74
+
75
+ Return ONLY a JSON array, no prose, no markdown fence. One object per input channel,
76
+ same `i` and `channel_id`, same order, **same length as the input**:
77
+
78
+ `[{"i": 0, "channel_id": 466311, "verdict": "on_topic", "confidence": "high", "evidence_quote": "Stock Market Investing", "adjacent_terms": ["stocks","index funds"], "notes": ""}]`
79
+
80
+ - `verdict`: `on_topic` | `mixed` | `off_topic`
81
+ - `confidence`: `high` | `medium` | `low`
82
+ - `evidence_quote`: ≤8 words, or `""`.
83
+ - `notes`: ≤1 short sentence, or `""`.
84
+
85
+ If a channel has no snippets: `{"i": <i>, "channel_id": <id>, "verdict": "mixed", "confidence": "low", "evidence_quote": "", "adjacent_terms": [], "notes": "no evidence"}`.
86
+ If the input array is empty, return `[]`.
87
+
88
+ **Final check before returning: your array length must equal the count stated in the
89
+ user message, with every `channel_id` present.**
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env python3
2
+ """Fetch keyword-in-context evidence for candidate channels (for Haiku validation).
3
+
4
+ For each channel, pulls its top-scoring matching videos and extracts the text
5
+ window around each keyword occurrence, so a cheap classifier can judge whether
6
+ the channel uses the keyword in the INTENDED sense (e.g. financial "investing"
7
+ vs. "sports investing" / "investing in your faith").
8
+
9
+ `transcript` is stored as YouTube caption XML
10
+ (`<?xml ...?><transcript><text start=".." dur="..">cue</text>...`), so tags are
11
+ stripped and entities unescaped before windowing — the server cannot highlight
12
+ it usefully. `title` / `summary` are windowed as-is.
13
+
14
+ Usage:
15
+ fetch_context.py --channels 466311,199308 investing
16
+ fetch_context.py --channels 5607 --samples 5 --window 200 "tiktok shop"
17
+ fetch_context.py --operator AND --channels 5607 "tiktok shop" affiliate
18
+
19
+ Output (stdout): a JSON array, one object per channel:
20
+ [{"channel_id","match_count","sampled","snippets":[
21
+ {"video_id","title","field","keyword","text"}, ...]}, ...]
22
+ """
23
+ import argparse
24
+ import html
25
+ import json
26
+ import re
27
+ import subprocess
28
+ import sys
29
+
30
+ DEFAULT_FIELDS = ["title", "summary", "transcript"]
31
+ ES_TIMEOUT = 90
32
+ TAG_RE = re.compile(r"<[^>]+>")
33
+ WS_RE = re.compile(r"\s+")
34
+
35
+
36
+ def run_es(body):
37
+ proc = subprocess.run(
38
+ ["tl", "db", "es", "-", "--json"],
39
+ input=json.dumps(body), capture_output=True, text=True, timeout=ES_TIMEOUT,
40
+ )
41
+ if proc.returncode != 0:
42
+ sys.stderr.write(
43
+ f"tl db es failed (rc={proc.returncode}): "
44
+ f"{(proc.stderr or proc.stdout).strip()}\n"
45
+ )
46
+ sys.exit(proc.returncode or 1)
47
+ try:
48
+ return json.loads(proc.stdout)
49
+ except json.JSONDecodeError as exc:
50
+ sys.stderr.write(f"could not parse tl db es output: {exc}\n")
51
+ sys.exit(1)
52
+
53
+
54
+ def clean_text(value):
55
+ """Strip XML/HTML tags, unescape entities, collapse whitespace.
56
+
57
+ Caption text is sometimes double-escaped (e.g. `&amp;#39;`), so a single
58
+ unescape leaves `&#39;` behind — unescape to a fixed point.
59
+ """
60
+ if not isinstance(value, str):
61
+ return ""
62
+ text = TAG_RE.sub(" ", value)
63
+ for _ in range(3):
64
+ unescaped = html.unescape(text)
65
+ if unescaped == text:
66
+ break
67
+ text = unescaped
68
+ return WS_RE.sub(" ", text).strip()
69
+
70
+
71
+ def windows(text, keyword, half, max_snips):
72
+ """Up to `max_snips` non-overlapping ±`half`-char windows around `keyword`."""
73
+ if not text or not keyword:
74
+ return []
75
+ hay, needle = text.lower(), keyword.lower()
76
+ out, start, last_end = [], 0, -1
77
+ while len(out) < max_snips:
78
+ idx = hay.find(needle, start)
79
+ if idx == -1:
80
+ break
81
+ lo, hi = max(0, idx - half), min(len(text), idx + len(keyword) + half)
82
+ if lo <= last_end: # overlaps previous window — skip ahead
83
+ start = idx + len(keyword)
84
+ continue
85
+ snip = text[lo:hi].strip()
86
+ out.append(("…" + snip if lo > 0 else snip) + ("…" if hi < len(text) else ""))
87
+ last_end = hi
88
+ start = hi
89
+ return out
90
+
91
+
92
+ def build_query(channel_id, keywords, fields, operator, since, until, samples):
93
+ clauses = [
94
+ {"multi_match": {"query": kw, "type": "phrase", "fields": fields}}
95
+ for kw in keywords
96
+ ]
97
+ bool_q = {"filter": [
98
+ {"term": {"doc_type": "article"}},
99
+ {"term": {"channel.id": channel_id}},
100
+ ]}
101
+ if operator == "AND":
102
+ bool_q["must"] = clauses
103
+ else:
104
+ bool_q["should"] = clauses
105
+ bool_q["minimum_should_match"] = 1
106
+ if since or until:
107
+ rng = {}
108
+ if since:
109
+ rng["gte"] = since
110
+ if until:
111
+ rng["lte"] = until
112
+ bool_q["filter"].append({"range": {"publication_date": rng}})
113
+ return {
114
+ "size": samples,
115
+ "track_total_hits": True,
116
+ "query": {"bool": bool_q},
117
+ "sort": [{"_score": "desc"}],
118
+ "_source": ["id"] + fields,
119
+ }
120
+
121
+
122
+ def channel_evidence(channel_id, keywords, fields, operator, since, until, samples, half, max_snips):
123
+ env = run_es(build_query(channel_id, keywords, fields, operator, since, until, samples))
124
+ snippets = []
125
+ for row in env.get("results", []):
126
+ vid = row.get("id") or row.get("_id")
127
+ title = clean_text(row.get("title")) or None
128
+ per_video = 0
129
+ for field in fields:
130
+ if per_video >= max_snips:
131
+ break
132
+ text = clean_text(row.get(field))
133
+ if not text:
134
+ continue
135
+ for kw in keywords:
136
+ for snip in windows(text, kw, half, max_snips - per_video):
137
+ snippets.append({"video_id": vid, "title": title, "field": field, "keyword": kw, "text": snip})
138
+ per_video += 1
139
+ if per_video >= max_snips:
140
+ break
141
+ if per_video >= max_snips:
142
+ break
143
+ return {
144
+ "channel_id": channel_id,
145
+ "match_count": env.get("total", 0),
146
+ "sampled": len(env.get("results", [])),
147
+ "snippets": snippets,
148
+ }
149
+
150
+
151
+ def main():
152
+ ap = argparse.ArgumentParser(description="Fetch keyword-in-context evidence per channel.")
153
+ ap.add_argument("keywords", nargs="+", help="Keyword(s) to locate in context")
154
+ ap.add_argument("--channels", required=True, help="Comma-separated channel ids")
155
+ ap.add_argument("--operator", choices=["AND", "OR"], default="OR")
156
+ ap.add_argument("--fields", default=",".join(DEFAULT_FIELDS),
157
+ help=f"Comma list of fields to search+extract (default: {','.join(DEFAULT_FIELDS)})")
158
+ ap.add_argument("--samples", type=int, default=4, help="Videos sampled per channel (default 4)")
159
+ ap.add_argument("--window", type=int, default=160, help="Context chars on each side of the keyword (default 160)")
160
+ ap.add_argument("--max-snippets", type=int, default=3, help="Max snippets per video (default 3)")
161
+ ap.add_argument("--since", help="publication_date >= YYYY-MM-DD")
162
+ ap.add_argument("--until", help="publication_date <= YYYY-MM-DD")
163
+ args = ap.parse_args()
164
+
165
+ fields = [f.strip() for f in args.fields.split(",") if f.strip()]
166
+ if not fields:
167
+ sys.exit("--fields must list at least one ES field")
168
+ try:
169
+ channel_ids = [int(c) for c in args.channels.split(",") if c.strip()]
170
+ except ValueError:
171
+ sys.exit("--channels must be comma-separated integer channel ids")
172
+ if not channel_ids:
173
+ sys.exit("provide at least one channel id via --channels")
174
+
175
+ out = [
176
+ channel_evidence(cid, args.keywords, fields, args.operator,
177
+ args.since, args.until, args.samples, args.window, args.max_snippets)
178
+ for cid in channel_ids
179
+ ]
180
+ print(json.dumps(out, ensure_ascii=False))
181
+
182
+
183
+ if __name__ == "__main__":
184
+ main()