thoughtleaders-cli 0.9.0__tar.gz → 0.9.2__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 (147) hide show
  1. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/.claude-plugin/plugin.json +1 -1
  2. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/AGENTS.md +1 -1
  3. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/PKG-INFO +1 -1
  4. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/hooks/scripts/pre-check.sh +4 -6
  5. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/pyproject.toml +1 -1
  6. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl/SKILL.md +18 -10
  7. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl/references/business-glossary.md +4 -2
  8. thoughtleaders_cli-0.9.2/skills/tl/references/elasticsearch-schema.md +286 -0
  9. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl/references/firebolt-schema.md +2 -0
  10. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl/references/postgres-schema.md +52 -5
  11. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-keyword-research/SKILL.md +2 -2
  12. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-keyword-research/references/elasticsearch-content-search.md +27 -16
  13. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-keyword-research/references/help.md +1 -1
  14. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-keyword-research/scripts/build_report.py +6 -6
  15. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-keyword-research/scripts/probe.py +9 -1
  16. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-save-report/SKILL.md +2 -2
  17. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-save-report/references/report_glossary.md +1 -1
  18. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-save-report/references/sponsorship_filterset_schema.json +1 -1
  19. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/__init__.py +1 -1
  20. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/client/errors.py +12 -1
  21. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/db.py +56 -23
  22. thoughtleaders_cli-0.9.2/src/tl_cli/commands/profiles.py +59 -0
  23. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/main.py +2 -0
  24. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/output/formatter.py +16 -0
  25. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/query_history.py +69 -9
  26. thoughtleaders_cli-0.9.2/tests/test_db_repeat_warning.py +194 -0
  27. thoughtleaders_cli-0.9.2/tests/test_error_hints.py +45 -0
  28. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests/test_output.py +51 -0
  29. thoughtleaders_cli-0.9.2/tests/test_profiles.py +66 -0
  30. thoughtleaders_cli-0.9.0/skills/tl/references/elasticsearch-schema.md +0 -267
  31. thoughtleaders_cli-0.9.0/tests/test_db_repeat_warning.py +0 -121
  32. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/.claude-plugin/marketplace.json +0 -0
  33. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/.github/dependabot.yml +0 -0
  34. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/.github/workflows/ci.yml +0 -0
  35. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/.github/workflows/cli-integration.yml +0 -0
  36. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/.github/workflows/python-publish.yml +0 -0
  37. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/.gitignore +0 -0
  38. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/API.md +0 -0
  39. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/CLAUDE.md +0 -0
  40. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/LICENSE +0 -0
  41. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/README.md +0 -0
  42. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/agents/keyword-context-classifier.md +0 -0
  43. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/agents/keyword-entity-resolver.md +0 -0
  44. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/agents/keyword-relevance-validator.md +0 -0
  45. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/agents/tl-analyst.md +0 -0
  46. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/agents/youtube-comment-classifier.md +0 -0
  47. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/hooks/hooks.json +0 -0
  48. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/hooks/scripts/load-tl-skill.mjs +0 -0
  49. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/hooks/scripts/post-usage.sh +0 -0
  50. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-channel-authenticity/.gitignore +0 -0
  51. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-channel-authenticity/SKILL.md +0 -0
  52. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-channel-authenticity/references/comment-patterns.md +0 -0
  53. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-channel-authenticity/references/peer-cohort.md +0 -0
  54. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-channel-authenticity/references/red-flags.md +0 -0
  55. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-channel-authenticity/references/scoring.md +0 -0
  56. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-channel-authenticity/scripts/_io_utf8.py +0 -0
  57. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-channel-authenticity/scripts/analyze_channel.py +0 -0
  58. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-channel-authenticity/scripts/anomaly_detector.py +0 -0
  59. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-channel-authenticity/scripts/comment_analyzer.py +0 -0
  60. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-channel-authenticity/scripts/comment_scraper.py +0 -0
  61. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-channel-authenticity/scripts/engagement_ratios.py +0 -0
  62. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-channel-authenticity/scripts/peer_cohort.py +0 -0
  63. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-channel-authenticity/scripts/report.py +0 -0
  64. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-channel-authenticity/scripts/resolve_channel.py +0 -0
  65. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-channel-authenticity/scripts/score.py +0 -0
  66. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-channel-authenticity/scripts/tl_cli.py +0 -0
  67. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-channel-authenticity/scripts/video_integrity.py +0 -0
  68. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-channel-authenticity/scripts/view_curves.py +0 -0
  69. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-keyword-research/scripts/expand_entities.py +0 -0
  70. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-keyword-research/scripts/fetch_context.py +0 -0
  71. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-keyword-research/scripts/search_channels.py +0 -0
  72. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-keyword-research/scripts/search_videos.py +0 -0
  73. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-keyword-research/scripts/select_keywords.py +0 -0
  74. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-save-report/references/columns_brands.md +0 -0
  75. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-save-report/references/columns_channels.md +0 -0
  76. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-save-report/references/columns_content.md +0 -0
  77. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-save-report/references/columns_sponsorships.md +0 -0
  78. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-save-report/references/intelligence_filterset_schema.json +0 -0
  79. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-save-report/references/intelligence_widget_schema.json +0 -0
  80. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-save-report/references/sortable_columns.json +0 -0
  81. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-save-report/references/sponsorship_widget_schema.json +0 -0
  82. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-save-report/references/widgets.md +0 -0
  83. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-top-partnerships/SKILL.md +0 -0
  84. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-top-partnerships/scripts/top_partnerships.py +0 -0
  85. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-views-guarantee/SKILL.md +0 -0
  86. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/skills/tl-views-guarantee/scripts/vg.py +0 -0
  87. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/_completions.py +0 -0
  88. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/_typer_utils.py +0 -0
  89. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/auth/__init__.py +0 -0
  90. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/auth/commands.py +0 -0
  91. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/auth/login.py +0 -0
  92. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/auth/pkce.py +0 -0
  93. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/auth/token_store.py +0 -0
  94. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/client/__init__.py +0 -0
  95. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/client/http.py +0 -0
  96. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/__init__.py +0 -0
  97. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/_comments_common.py +0 -0
  98. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/balance.py +0 -0
  99. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/brands.py +0 -0
  100. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/bulk_import.py +0 -0
  101. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/changelog.py +0 -0
  102. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/channels.py +0 -0
  103. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/credits.py +0 -0
  104. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/deals.py +0 -0
  105. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/describe.py +0 -0
  106. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/doctor.py +0 -0
  107. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/matches.py +0 -0
  108. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/proposals.py +0 -0
  109. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/recommender.py +0 -0
  110. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/reports.py +0 -0
  111. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/schema.py +0 -0
  112. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/setup.py +0 -0
  113. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/snapshots.py +0 -0
  114. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/sponsorships.py +0 -0
  115. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/uploads.py +0 -0
  116. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/commands/whoami.py +0 -0
  117. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/config.py +0 -0
  118. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/filters.py +0 -0
  119. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/hints.py +0 -0
  120. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/output/__init__.py +0 -0
  121. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/src/tl_cli/self_update.py +0 -0
  122. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests/__init__.py +0 -0
  123. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests/test_auth.py +0 -0
  124. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests/test_brands_winner_channels.py +0 -0
  125. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests/test_build_report.py +0 -0
  126. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests/test_channels_lookalike.py +0 -0
  127. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests/test_describe.py +0 -0
  128. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests/test_expand_entities.py +0 -0
  129. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests/test_filters.py +0 -0
  130. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests/test_http_auth.py +0 -0
  131. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests/test_probe.py +0 -0
  132. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests/test_reports.py +0 -0
  133. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests/test_scripts_executable.py +0 -0
  134. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests/test_search_channels.py +0 -0
  135. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests/test_search_videos.py +0 -0
  136. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests/test_select_keywords.py +0 -0
  137. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests/test_setup.py +0 -0
  138. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests/test_sponsorships.py +0 -0
  139. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests_cli/AGENTS.md +0 -0
  140. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests_cli/conftest.py +0 -0
  141. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests_cli/test_balance.py +0 -0
  142. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests_cli/test_db_es.py +0 -0
  143. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests_cli/test_db_fb.py +0 -0
  144. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests_cli/test_db_pg.py +0 -0
  145. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests_cli/test_schema.py +0 -0
  146. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/tests_cli/test_whoami.py +0 -0
  147. {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tl-cli",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
4
  "description": "ThoughtLeaders CLI — query sponsorship deals, channels, brands, uploads, and intelligence from the terminal",
5
5
  "author": {
6
6
  "name": "ThoughtLeaders",
@@ -95,7 +95,7 @@ Warn the user if they are creating a release and the latest commit didn't bump t
95
95
  ## Coding
96
96
 
97
97
  * Do not reference internal architecture of the ThoughtLeaders app in comments or skills. Specifially: do not reference internal table names, field names, API endpoints, Python modules or functions (including the sanitizer).
98
- * Do not let server implementation details into skill files (anything under `skills/`). Skills describe *what the CLI does* from the user's seat — observable command surface, inputs, outputs, examples. Do not say "the server enforces X", "the API validates Y on its side", "the backend rejects Z" — those are mechanism notes that drift the moment the server changes. State the user-visible behaviour ("unknown keys come back as 400") without naming where it's enforced.
98
+ * Do not let server implementation details or internal Django project terminology into skill files (anything under `skills/`). Skills describe *what the CLI does* from the user's seat — observable command surface, inputs, outputs, examples. Do not say "the server enforces X", "the API validates Y on its side", "the backend rejects Z" — those are mechanism notes that drift the moment the server changes. Do not name Django enums, model/class names, app labels, or settings keys (e.g. `ContentField`, `dashboard.enums.*`) — describe the concept in user-observable terms instead. State the user-visible behaviour ("unknown keys come back as 400") without naming where it's enforced.
99
99
  * **All `import` and `from X import Y` statements live at the top of the Python module file** — after the module docstring, before any code. No inline imports inside function bodies, no lazy imports for "speed" or "optional dependency" reasons. `from __future__ import …` goes at the very top (Python requires that). The only legitimate inline-import exception is **platform-conditional imports** that cannot succeed on the other platform (e.g. `import msvcrt` on Linux, `import termios`/`tty` on Windows) — those stay inside their `if sys.platform == …:` guard. If a circular-import problem makes a top-level import impossible, fix the circular dependency rather than working around it with an inline import.
100
100
 
101
101
  # Updating
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thoughtleaders-cli
3
- Version: 0.9.0
3
+ Version: 0.9.2
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
@@ -20,11 +20,9 @@ if ! tl auth status --quiet 2>/dev/null; then
20
20
  exit 0 # Don't block, just warn
21
21
  fi
22
22
 
23
- # For list commands without explicit limit, suggest adding one
24
- if [[ "$COMMAND" =~ ^tl[[:space:]]+(deals|uploads|channels|brands)[[:space:]] ]]; then
25
- if [[ ! "$COMMAND" =~ limit: ]] && [[ ! "$COMMAND" =~ --limit ]]; then
26
- echo "HINT: Consider adding a limit to control credit usage (e.g., limit:50)" >&2
27
- fi
28
- fi
23
+ # NOTE: no "add a limit" hint here. List commands are server-limited by
24
+ # default and typically cost far below the warning-worthy range, so the
25
+ # hint was pure noise for agents (it fired on every unlimited list call
26
+ # regardless of cost).
29
27
 
30
28
  exit 0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thoughtleaders-cli"
7
- version = "0.9.0"
7
+ version = "0.9.2"
8
8
  description = "ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -229,37 +229,45 @@ tl <entity> comment-edit <comment-id> "msg" # Edit own comment (author or super
229
229
  ```bash
230
230
  tl sponsorships update <id> '<json>' # Edit a sponsorship (adlink)
231
231
  tl channels update <id> '<json>' # Edit a channel
232
+ tl profiles update <id> '<json>' # Edit a brand/publisher profile (superuser only)
232
233
  ```
233
234
 
234
235
  Examples:
235
236
  ```bash
236
237
  tl sponsorships update 98765 '{"publish_status": "sold"}'
237
238
  tl sponsorships update 98765 '{"publish_status": 3}'
239
+ tl profiles update 8871 '{"superuser_notes": "VIP account — always cc the AM lead"}'
240
+ tl profiles update 8871 '{"superuser_notes": null}'
238
241
  tl channels update 12345 '{"demographic_male_share": 62}'
239
242
  tl channels update 12345 '{"demographic_geo": {"US": 60, "UK": 12, "CA": 8}}'
240
243
  tl channels update 12345 '{"demographic_male_share": 55, "demographic_usa_share": 70}'
241
244
  tl channels update 12345 '{"outreach_email": "press@creator.com"}'
242
- tl channels update 12345 '{"all_emails": {"press@creator.com": {"timestamp": "2026-06-25T12:00:00+00:00", "source": "manual"}}}'
243
245
  tl channels update 12345 '{"media_selling_network_join_date": "2026-01-15"}'
244
246
  ```
245
247
 
246
- **Channel contact emails.** Besides demographics, `tl channels update` accepts two
247
- contact fields:
248
+ **Channel contact emails.** Besides demographics, `tl channels update` accepts one
249
+ contact field:
248
250
 
249
251
  - **`outreach_email`** — the channel's primary outreach address (a single email string, or `null`).
250
- - **`all_emails`** — the channel's full email archive. It is **not** a free-form blob: it's
251
- a JSON object keyed by email address, each value an object `{"timestamp": <ISO-8601 datetime>, "source": <string>}`.
252
- `timestamp` must parse as ISO-8601 (e.g. `"2026-06-25T12:00:00+00:00"`); `source` records
253
- where the address came from. Known `source` values: `outreach_email` (the address that was
254
- the primary outreach email, archived when the channel was blacklisted), `google_search`,
255
- `scheduled_rescrape`. Use a descriptive token such as `manual` for hand-added entries.
256
- An edit **replaces** the whole `all_emails` object, so include every entry you want to keep.
252
+
253
+ The channel's full email archive (`all_emails` — a JSON object keyed by email address,
254
+ each value recording when and where the address was found) is **not editable here**;
255
+ sending it returns a 400. It is append-only: new addresses are added with the
256
+ internal CLI's `tl-internal channels add-email` command (superuser-only), which never
257
+ modifies or removes existing entries.
257
258
 
258
259
  **MSN membership.** `tl channels update` also accepts **`media_selling_network_join_date`** —
259
260
  the date the channel joined the Media Selling Network, as a `YYYY-MM-DD` string. This date is
260
261
  itself the membership flag: set it to add the channel to MSN (it then reads as `is_msn` and
261
262
  appears in MSN-filtered queries), or send `null` to remove the channel from the network.
262
263
 
264
+ **Profile notes.** `tl profiles update` (superuser only) edits a brand/publisher profile's
265
+ **`superuser_notes`** — the internal free-text notes field on the customer record
266
+ (`thoughtleaders_profile.superuser_notes`, max 2500 chars). Send a string to set, `null` to
267
+ clear. This is currently the only editable profile field; anything else returns a 400 listing
268
+ the editable surface. Use it for account-handling notes meant for the internal team — it is
269
+ never shown to the customer.
270
+
263
271
  ### Creating and vetting sponsorships
264
272
 
265
273
  This is the end-to-end workflow for proposing a sponsorship, then moving it through the funnel as the two sides respond. Three create commands plus `tl sponsorships update` cover every state transition the CLI exposes.
@@ -69,6 +69,7 @@ Maps business terms to database concepts.
69
69
  | `owner_advertiser_id` | `profile` | **Account owner.** Who owns the brand relationship overall. Often same person as owner_sales on adlinks, but not always. |
70
70
  | `owner_publisher_id` | `profile` | Channel relationship owner on the profile level |
71
71
  | `owner_sales_id` | `profile` | Sales owner at profile level |
72
+ | `superuser_notes` | `profile` | Internal free-text account-handling notes on the customer record (max 2500 chars). Team-facing only — never shown to the customer. Superusers can edit it through the CLI (see SKILL.md → "Updating records"). |
72
73
 
73
74
  **Key insight:** Ownership exists on both `profile` (account-level) and `adlink` (deal-level). For revenue attribution, always use `adlink.owner_sales_id`.
74
75
 
@@ -96,7 +97,7 @@ Vocabulary that AMs use about channels, mapped to the actual DB encoding. Most o
96
97
  | **Brand on a deal** | `adlink → advertiser_profile_id → profile_brands.profile_id → profile_brands.brand_id → brand` | 3-table chain. There is NO direct `brand_id` on adlink. See [postgres-schema.md](postgres-schema.md). |
97
98
  | **Channel on a deal** | `adlink.ad_spot_id → adspot.channel_id → channel` | NO direct `channel_id` on adlink. |
98
99
  | **Brand-virgin / VPN-virgin (etc.)** | Channel has no `adlink` row joined to any of the target brand_ids | Used in candidate sourcing ("never sponsored by any VPN brand"). Caveat: only catches TL-brokered deals; channels that ran the brand directly (no TL involvement) appear "virgin" but aren't — cross-check ES `sponsored_brand_mentions` before final outreach. |
99
- | **Channel quality score** *(internal-only)* | `sponsorship_score` on the indexed channel doc + `thoughtleaders_channel.sponsorship_score` (PG) | TL-internal composite score combining engagement, fulfillment, and historical sponsorship performance. **Use it internally to rank/tiebreak candidates, but do NOT quote the raw decimal in AM-facing or external output** — the score isn't documented to AMs and the absolute value isn't meaningful without context. In AM-facing prose, translate to qualitative language: "top-quartile fit," "strongest quality score in the candidate set," "high sponsorship-quality signal." |
100
+ | **Channel quality score** *(internal-only)* | `sponsorship_score` on the indexed channel doc + `thoughtleaders_channel.sponsorship_score` (PG) | TL-internal sponsorship track-record score, range 0–10: blends how many distinct brands sponsored the channel over the past 2 years (40%) with how many of them booked repeatedly (60%). Scores below 5 read as little/no track record (the low range is deliberately compressed). **Use it internally to rank/tiebreak candidates, but do NOT quote the raw decimal in AM-facing or external output** — the score isn't documented to AMs and the absolute value isn't meaningful without context. In AM-facing prose, translate to qualitative language: "top-quartile fit," "strongest quality score in the candidate set," "high sponsorship-quality signal." |
100
101
 
101
102
  ## Projected Views (PV) — three related but distinct fields
102
103
 
@@ -106,6 +107,7 @@ AMs use "PV" loosely. There are three different DB fields, each meaning somethin
106
107
  |---------|----------|---------------------|
107
108
  | **PV (channel baseline)** | `thoughtleaders_channel.projected_views` | Channel-level "typical views per video" used as CPM denominator. ⚠️ Coverage and freshness vary; cross-check Firebolt longform median for hero-tier deals. |
108
109
  | **PV (deal-specific)** | `thoughtleaders_adlink.projected_views_at_purchase_date` | Snapshot of projected views at the moment the deal was sold. Use this for historical CPM analysis. |
110
+ | **PV (video, at upload)** — the web app's "video projected views" | `projected_views` on the ES **video** doc | The channel's format-specific PV **frozen when TL first indexed the video** (never updated afterwards). Compare to the channel's current PV to see growth/decline since the upload, or to the video's actual `views` to see over/under-performance vs expectation. See the ES schema reference for the calculation. |
109
111
  | **VG (View Guarantee)** | `thoughtleaders_adlink.views_guarantee` | The contractual minimum views the brand is guaranteed. 0/NULL = no guarantee. NOT the same as PV — VG is a contractual floor, PV is an estimate. |
110
112
 
111
113
  When an AM says "what's the PV on this channel?" — they almost always mean `channel.projected_views`. When they say "what was the PV on this deal?" — they mean `adlink.projected_views_at_purchase_date`. When they say "did we hit the VG?" — they mean `adlink.view_guarantee_hit_date IS NOT NULL`.
@@ -119,7 +121,7 @@ Two derived metrics on the indexed channel doc that AMs use to qualify a channel
119
121
  | **Fulfillment rate** | `fulfillment_rate` (channel doc, scaled_float) | The share of a channel's content that is sponsored — `sponsored / all` content over the measurement window, expressed as a fraction. Higher = the channel reliably delivers paid integrations. | Quality signal: a high fulfillment rate means past brands have actually run on this channel, not just been pitched. AMs use it to filter out "looks promising but never closes" channels. |
120
122
  | **Renewal rate** | `renewal_rate` (channel doc, scaled_float) | The rate at which a brand-channel sponsorship relationship repeats over time, computed from clusters of sponsorship deals between a single subject (channel or brand) and its linked entities, with date-distribution heuristics (default 365-day max interval). | Loyalty signal: a high renewal rate means brands keep coming back to this channel. AMs use it to identify "sticky" channels worth premium positioning, and to flag low-renewal channels as one-shots. |
121
123
 
122
- Both metrics live on the channel side of the indexed video docs (the `channel.*` nested object). Channel pages in TL's product surface these as quality scores; in AM-facing reports, you can quote them as percentages (`0.45 → "45% renewal rate"`).
124
+ Both metrics live on the **channel docs** in the search index — not on video docs (the embedded `channel.*` object on video docs carries no metrics). Channel pages in TL's product surface these as quality scores; in AM-facing reports, you can quote them as percentages (`0.45 → "45% renewal rate"`).
123
125
 
124
126
  ## Industry Terms vs TL Vocabulary
125
127
 
@@ -0,0 +1,286 @@
1
+ # ThoughtLeaders Elasticsearch Schema Reference
2
+
3
+ ## How to query
4
+
5
+ All ES access goes through the `tl` CLI:
6
+
7
+ ```bash
8
+ tl db es '{"size": 1, "query": {"match_all": {}}}' --json
9
+
10
+ # Read body from stdin
11
+ cat query.json | tl db es -
12
+ ```
13
+
14
+ The index is **fixed server-side**. The client cannot select an index — there is no `--index` flag.
15
+
16
+ Cost grows non-linearly with result size (raw db queries use the list curve at `mult=1.4`). Aggregation queries bill on `min(hits.total, 200)` instead of `len(hits)`. See `SKILL.md` for the curve formula and the row-count → credits table.
17
+
18
+ Output flags: `--json`, `--csv`, `--md`, `--toon`. The CLI flattens hits into rows of `{_id, _score, ...source}`; aggregations come back in the response envelope and are rendered after the rows in TTY mode.
19
+
20
+ ## Accepted query bodies
21
+
22
+ See the output of `tl db es`" for the object schema. Highlights:
23
+
24
+ - **Top-level keys** accepted: `query`, `aggs`/`aggregations`, `sort`, `_source`, `size`, `from`, `search_after`, `track_total_hits`, `highlight`, `fields`, `min_score`, `timeout`, `collapse`, `post_filter`. Anything else (incl. `scroll`, `pit`, `runtime_mappings`, `knn`) is not accepted.
25
+ - `size` ≤ 10,000. `from + size` ≤ 10,000 — to page past 10,000 hits use `search_after` (see *Deep pagination* below), not `from`.
26
+ - `search_after` must be a non-empty array of ≤ 10 scalar sort values, requires an explicit `sort`, and `from` must be 0 or omitted.
27
+ - **Accepted query types** include `term`/`terms`/`match`/`bool`/`nested`/`range`/`exists`/`match_phrase`/`simple_query_string` (the sanctioned rich-Boolean text surface — `+` required, `|` OR, `-` NOT, `"phrase"`, `"phrase"~N` slop, trailing-`*` prefix, `~N` fuzzy, `(...)` grouping, per-field `^boosts`; always set `default_operator: "and"` and an explicit `fields` list or counts balloon). `query_string`, `regexp`, `wildcard`, `fuzzy`, `more_like_this`, `has_child`, `has_parent`, `parent_id` are not accepted.
28
+ - **No scripts** — keys that start with `script` (e.g. `script_fields`, `script_score`, `scripted_metric`) or end with `_script` (e.g. `bucket_script`) are not accepted. A field whose name merely contains `script` as a substring (e.g. `transcript`, `description`) is fine.
29
+ - **Aggregations are bounded, not forbidden**: up to ~50 agg nodes per body, bucket nesting ≤ 3 levels deep (single-bucket framing aggs like `filter`/`nested`/`missing` count toward depth), terms-like bucket `size` ≤ 10,000, and a worst-case total bucket count cap — so a `filter` agg wrapping a `cardinality` (the common counts-plus-recency shape) is fine in one call. Heavy/experimental agg types (e.g. `significant_terms`, script-based pipeline aggs) are not accepted.
30
+ - **`highlight` is accepted in the body but highlight fragments are not returned** — result rows carry only `_id`, `_score`, and the `_source` fields. Request the `_source` fields you need for validation samples instead.
31
+
32
+ ### ElasticSearch document structure ("articles")
33
+
34
+ The `doc_type` join field distinguishes video uploads ("articles") from channel data — channel docs are parents, article docs are their children. Filter with `{"term": {"doc_type": "article"}}` or `{"term": {"doc_type": "channel"}}`. ⚠️ Term-querying `doc_type.name` matches nothing — even though article docs' `_source` shows `doc_type` as an object with a `name` key, that's join-field syntax, not a queryable subfield.
35
+
36
+ #### Upload/video Fields (selected — 73 total)
37
+
38
+ Filter with `{"term": {"doc_type": "article"}}`. Coverage percentages are live `exists` counts (July 2026, ~676M video docs) — they drift slowly as the index grows.
39
+
40
+ | Field | Type | Description |
41
+ |-------|------|-------------|
42
+ | `id` | keyword | Video/article ID. Compound form `<channel_id>:<youtube_id>` (matches PG `adlink.article_id` and ES `_id`). |
43
+ | `title` | text | Video title (~100%) |
44
+ | `description` | text | ⚠️ **Does not exist on video docs** — `exists` matches 0 of ~676M (verified). The video's description text lives in `summary`; `description` is a channel-doc field (the channel's "About this channel" text). |
45
+ | `content` | text | ⚠️ **Podcast episodes only** — the episode's show-notes/body text from the podcast feed (often HTML fragments). ~7% of docs overall; effectively absent on YouTube videos (~256k legacy docs holding flat transcript prose, and 0 YouTube docs since 2025). Never search it for YouTube content — use `summary` / `transcript`. |
46
+ | `transcript` | text | Raw transcript — stored as YouTube timed-text **XML**, not plain text (see note below). ~57% of docs; present on both longform and shorts. |
47
+ | `transcript_language` | keyword | Language code of the caption track the transcript came from (present when `transcript` is) |
48
+ | `summary` | text | ⚠️ **Misleading name — this is the video's creator-written description** (the text under the video: promo links, hashtags, timestamps, subscribe blocks), NOT an AI summary. Verified by sampling old and recent docs. ~86% of docs. This is *the* field for searching video-description text. |
49
+ | `evergreenness` | float | Per-video evergreen score: `(views at age 180d − views at age 30d) / views at age 30d`. ≥ 1 = evergreen (views at day 180 are at least double the day-30 views). Only computed for videos with ≥ 5,000 views published since 2022 (~16% of docs). |
50
+ | `publication_date` | date | When the video was published (~100%) |
51
+ | `discovery_time` | date | When TL first indexed the video. Only ~39% of docs — absent on older docs. |
52
+ | `url` | keyword | Watch/episode URL. **Stored-only: retrievable in `_source` but not searchable** (`exists`/`term` match 0 docs). |
53
+ | `image_url` | keyword | Thumbnail URL on podcast docs; absent on YouTube video docs. Stored-only, not searchable. |
54
+ | `views` | long | View count at the last metrics update (~92%) |
55
+ | `projected_views` | long | **The channel's projected views for this video's format, frozen at the time TL first indexed the video** (~45%) — the web app's "video projected views". TL's prediction of views at age = 30 days, computed **only from the channel's recent uploads of the same content type** (a short's value comes from the channel's shorts, a longform video's from its longform uploads — never mixed): ≥ 4 same-format videos' day-30 views, median-anchored with outliers trimmed. **Never updated after first index**, so it's a snapshot: compare it to the channel doc's current `impression*` to see whether the channel grew or declined since the upload, and to the video's `views` to see whether the video over- or under-performed expectations. Caveats: the format bucket at stamp time is duration-based (≤ 60s → shorts projection; anything longer — including live streams — gets the longform projection), and for channels added to TL after the fact "first indexed" is discovery time, not the publish date. |
56
+ | `likes` | long | Like count (~87%) |
57
+ | `comments` | integer | Comment count (~67%) |
58
+ | `duration` | integer | Video duration in seconds (~100%) |
59
+ | `content_type` | keyword | `longform` / `short` / `live` — the complete value set. ~71% of docs; older docs have none (missing ≠ longform). Podcast/RSS docs have no `content_type`. |
60
+ | `content_aspects` | keyword | Flags: `podcast`, `paid_promotion`, `unlisted` — the complete value set. Only ~2.6% of docs carry any. |
61
+ | `hashtags` | keyword | Hashtags from the video description, stored **without the leading `#`** and lowercase (e.g. `marchmadness`); non-Latin tags appear percent-encoded (`%D0%B0…`) (~32%) |
62
+ | `channel` | object | Embedded channel subset: `channel.id`, `channel.content_category`, `channel.format`, `channel.publication_id`, `channel.country`, `channel.language` — no text fields, no metrics. This is where a video's language/country/format/category live (top-level `language`, `country`, `format`, `content_category` exist only on channel docs). |
63
+
64
+ ⚠️ **Channel-doc fields that look like video fields but match 0 video docs:** `total_views`, `engagement`, `duration_live`/`duration_longform`/`duration_shorts`, `language`, `country`, `format`, `content_category`, `face_on_screen`. They live on channel docs (see below); on video docs use the embedded `channel.*` subset where available.
65
+
66
+ #### Brand Mention Fields
67
+
68
+ | Field | Type | Description |
69
+ |-------|------|-------------|
70
+ | `brand_mentions` | nested | Detected brand-mention objects (`id`, `type` organic/sponsored, `field`, `snippet`, `start_ts`/`end_ts`, `position`, `probability`, `detection_tool`) (~12%). Being `nested`, it must be queried with a `nested` query — a plain `{"exists": {"field": "brand_mentions"}}` matches 0 docs. |
71
+ | `all_brand_mentions` | keyword | Brand IDs with any mention — the union of sponsored + organic (~12%) |
72
+ | `sponsored_brand_mentions` | keyword | Brand IDs with a sponsored mention |
73
+ | `organic_brand_mentions` | keyword | Brand IDs with an organic mention |
74
+ | `not_sponsored_by` | object | Brand IDs marked as explicitly *not* sponsoring this video. **Not searchable** (`exists` matches 0 docs). |
75
+
76
+ #### Channel Fields
77
+
78
+ Filter with `{"term": {"doc_type": "channel"}}`. ~45.5M channel docs (July 2026); coverage percentages below are live `exists` counts against that total.
79
+
80
+ Contains a denormalized subset of the PostgreSQL channel data.
81
+
82
+ ⚠️ **Channel docs are duplicated** — one channel id can appear as several identical docs (a well-known channel showed 8+ copies). Doc counts ≠ channel counts; dedupe with `collapse` on `id` or aggregate with `cardinality`.
83
+
84
+ ⚠️ **Legacy field names** — PostgreSQL and Firebolt renamed these, Elasticsearch did not. Raw ES queries must use the old names; the new names match **0 docs** (verified live, they fail silently):
85
+
86
+ | ES (use this) | PG `thoughtleaders_channel` | Firebolt `channel_metrics` |
87
+ |---|---|---|
88
+ | `reach` | `subscribers` | `subscribers` |
89
+ | `impression` / `impression_live` / `impression_shorts` | `projected_views*` | `projected_views` |
90
+ | `is_tl_channel` | `is_tpp` | — |
91
+
92
+ | Field | Type | Description |
93
+ |-------|------|-------------|
94
+ | `name` | text | Channel display name (~100%) |
95
+ | `description` | text | The channel's creator-written YouTube "About this channel" text (~97%): usually first-person, links, promo. Especially worth investigating if it contains creator contact information. |
96
+ | `description.domains` | text | Same "About this channel" text, additionally indexed so **website domains are searchable as single terms** — `{"term": {"description.domains": "patreon.com"}}` matches channels whose About text *links to* patreon.com anywhere (including inside full URLs), while plain topic words match identically to `description` (verified: same counts). This is the field the platform's `channel_description` report filter actually searches. |
97
+ | `reach` | long | Subscriber count (~98%). ⚠️ NOT ad-industry "reach" (unique audience exposed) — this is the channel's subscriber count. Same data as PG `thoughtleaders_channel.subscribers` / Firebolt `channel_metrics.subscribers`. |
98
+ | `impression` | long | TL's projected views per longform video at age = 30 days — computed only from the channel's recent **longform** videos' day-30 views (≥ 4 required; median-anchored, outliers trimmed) (~27%). This is the channel's *current* projection; a video doc's `projected_views` is the same quantity frozen when that video was first indexed — diff them to see channel growth/decline since the upload. ⚠️ NOT actual views and NOT ad-industry "impressions"; for actual views see `total_views` / the video docs. |
99
+ | `impression_live` | long | Projected views per live stream at age = 30 days, from the channel's live streams only (~6%) |
100
+ | `impression_shorts` | long | Projected views per short at age = 30 days, from the channel's shorts only (~21%) |
101
+ | `total_views` | long | Lifetime actual views across the channel (~68%) |
102
+ | `engagement` | long | Views-per-comment ratio over the channel's last 30 days of uploads: `sum(views) / sum(comments)`, rounded (~25%). **Lower = more engaged audience** (fewer views per comment). Not an engagement count. |
103
+ | `duration_longform` | integer | Average longform video duration in seconds, over the channel's uploads from the trailing 365 days (~46%) |
104
+ | `duration_shorts` | integer | Average short duration, same window (~32%) |
105
+ | `duration_live` | integer | Average live-stream duration, same window (~11%) |
106
+ | `is_tl_channel` | boolean | TPP partner channel (100%) |
107
+ | `is_active` | boolean | Channel is active (100%) |
108
+ | `media_selling_network_join_date` | date | MSN join date; non-null = MSN member (~1%) |
109
+ | `has_outreach_email` | boolean | Has contact email (100%) |
110
+ | `outreach_email` | text | Contact email (~45%) |
111
+ | `social_links` | text | Flat array of the channel's profile URLs (~47%), e.g. `["https://twitter.com/…", "https://instagram.com/…", "https://discord.gg/…"]`. Source is a per-platform map plus a catch-all `_other` map for unrecognized platforms; in ES all of it is flattened into this one URL array (the `_other` URLs are folded in, the platform names are dropped). Occasional stray entries (bare emails, nested arrays) exist. |
112
+ | `male_share` | byte | Male audience % — only ~1.6% of channel docs have demographic data |
113
+ | `usa_share` | byte | US audience % — same ~1.6% coverage |
114
+ | `device` | object | Audience device demographics where known: `device.primary` (most common device) and `device.share` (per-device % map). Very sparse (~0.2%). |
115
+ | `sponsorship_price` | scaled_float | Estimated price of a sponsored video on this channel, from **TL's sponsorship calculator** (~27%). Inputs: the channel's last-30-day views and comments, fulfillment rate, renewal rate, and longform evergreenness. Recomputed on the channel's regular metrics-update cycle, so it moves as the channel's data changes. |
116
+ | `sponsorship_score` | scaled_float | TL-internal sponsorship track-record score, **range 0–10, higher = better** (~96%). Blends how many distinct brands sponsored the channel in the last 2 years (40%) with how many of them booked repeatedly (60%). The scale is deliberately skewed: raw low scores are compressed below 5, so **< 5 reads as weak/no track record and ≥ 5 as a real one**. Internal-only — don't quote the raw decimal externally (see business glossary). |
117
+ | `evergreenness` | float | ⚠️ **Dead — 0 docs.** Use the per-format fields below. |
118
+ | `evergreenness_longform` | scaled_float | Median per-video evergreenness of the channel's longform uploads from the trailing 365 days (~24%). Per-video evergreenness = `(views@180d − views@30d) / views@30d`; ≥ 1 = evergreen (day-180 views at least double day-30). Recomputed on the metrics-update cycle. |
119
+ | `evergreenness_shorts` | scaled_float | Same, for shorts (~20%) |
120
+ | `evergreenness_live` | scaled_float | Same, for live streams (~4%) |
121
+ | `trend` | float | View-trend angle for longform uploads (~13%). Positive = growing views. |
122
+ | `trend_shorts` | scaled_float | View-trend angle for shorts (~12%) |
123
+ | `trend_live` | scaled_float | View-trend angle for live streams (~3%). There is **no `trend_longform`** — the longform trend is the bare `trend`. |
124
+ | `posts_per_90_days` | integer | Longform uploads per 90 days, normalized from the trailing 365 days (~96%) |
125
+ | `posts_per_90_days_shorts` | integer | Shorts per 90 days (~67%) |
126
+ | `posts_per_90_days_live` | integer | Live streams per 90 days (~67%) |
127
+ | `fulfillment_rate` | scaled_float | Share of the channel's longform uploads (trailing 365 days) that carry a sponsored mention (~40%) |
128
+ | `renewal_rate` | scaled_float | Rate at which the channel's sponsoring brands come back (~96%) |
129
+ | `metrics_update_period` | byte | ⚠️ Vestigial — populated on only ~2,300 docs (~0.005%). |
130
+ | `offline_since` | date | ⚠️ **Dead — 0 docs.** Use `is_active`. |
131
+ | `content_category` | integer | TL's own content-category code, 1–22 (~94%). **Not YouTube's categories** — see the category map in `postgres-schema.md`. |
132
+ | `format` | integer | Platform format code (100%): 1 = Newsletter, 3 = Podcast, **4 = YouTube**, 5 = Blog, 7 = Twitch, 8 = TikTok, 9 = Instagram, 10 = LinkedIn. |
133
+ | `face_on_screen` | boolean | ThoughtLeaders-sourced flag: whether the creator shows their face on screen when doing brand sponsorships (~1% of channel docs). |
134
+
135
+ #### AI & Enrichment Fields
136
+
137
+ | Field | Type | Description |
138
+ |-------|------|-------------|
139
+ | `ai` | object | **Channel docs only** (0 video docs). Holds exactly the three AI-generated fields below (~93% of channel docs). |
140
+ | `ai.description` | text | AI-generated third-person channel profile, always in English regardless of the channel's language (~93%) |
141
+ | `ai.topic_descriptions` | text | AI-generated prose paragraph describing the channel's content topics. A **single string, not an array**. Only ~37% of channel docs — absence means "not yet generated", not "off-topic". |
142
+ | `ai.brand_safety` | keyword | Brand-safety letter grade `A`–`F` (~93%; A ≈ 91% of graded channels) |
143
+ | `applied_enrichments` | keyword | Enrichment names applied to the video (e.g. `brand_extractor`) |
144
+ | `article_category` | object | ⚠️ **Dead — 0 docs.** |
145
+
146
+ #### System Fields
147
+
148
+ | Field | Type | Description |
149
+ |-------|------|-------------|
150
+ | `@timestamp` | date | Index/update timestamp (~92% of video docs) |
151
+ | `doc_type` | join | Parent-child join (channel→video) |
152
+ | `es_index_tag` | keyword | Publication-period tag: quarterly from 2019 (`2025-q2`), yearly 2016–2018 (`2017`), `2015-and-before` for older. **Stored-only — not searchable** (`exists`/`term` match 0 docs). |
153
+
154
+ ## Common Query Patterns
155
+
156
+ ### Search videos by sponsored brand mention
157
+
158
+ ```bash
159
+ tl db es '{
160
+ "size": 50,
161
+ "query": {"term": {"sponsored_brand_mentions": "5612"}},
162
+ "_source": ["title", "channel.id", "publication_date", "views"],
163
+ "sort": [{"publication_date": "desc"}]
164
+ }'
165
+ ```
166
+
167
+ ### Search videos for a single channel
168
+
169
+ ```bash
170
+ tl db es '{
171
+ "size": 100,
172
+ "query": {"term": {"channel.id": 12345}},
173
+ "sort": [{"publication_date": "desc"}]
174
+ }'
175
+ ```
176
+
177
+ ### Count sponsored mentions for a brand (size:0 + track_total_hits)
178
+
179
+ ```bash
180
+ tl db es '{
181
+ "size": 0,
182
+ "track_total_hits": true,
183
+ "query": {"term": {"sponsored_brand_mentions": "5612"}}
184
+ }'
185
+ ```
186
+
187
+ ### Full-text search on title/summary/transcript
188
+
189
+ (`summary` = the video's creator-written description. `description` field is not pupulated for articles, and `content` is podcast-only — see the field table.)
190
+
191
+ ```bash
192
+ tl db es '{
193
+ "size": 20,
194
+ "query": {
195
+ "multi_match": {
196
+ "query": "ergonomic keyboard review",
197
+ "fields": ["title^3", "summary", "transcript"]
198
+ }
199
+ },
200
+ "_source": ["title", "channel.id", "publication_date"]
201
+ }'
202
+ ```
203
+
204
+ ### Filter by date range
205
+
206
+ ```bash
207
+ tl db es '{
208
+ "size": 100,
209
+ "query": {
210
+ "bool": {
211
+ "filter": [
212
+ {"term": {"channel.id": 12345}},
213
+ {"range": {"publication_date": {"gte": "2026-01-01", "lte": "2026-03-31"}}}
214
+ ]
215
+ }
216
+ }
217
+ }'
218
+ ```
219
+
220
+ ### Aggregation example (aggregations are bounded — see *Accepted query bodies* above)
221
+
222
+ ```bash
223
+ tl db es '{
224
+ "size": 0,
225
+ "aggs": {
226
+ "by_channel": {
227
+ "terms": {"field": "channel.id", "size": 20}
228
+ }
229
+ },
230
+ "query": {"term": {"sponsored_brand_mentions": "5612"}}
231
+ }'
232
+ ```
233
+
234
+ For more dimensions, run multiple `tl db es` calls and join client-side.
235
+
236
+ ### Deep pagination — `search_after`
237
+
238
+ `from + size` is capped at 10,000, and the stateful cursors (`scroll`, `pit`) are not accepted. To page past 10,000 hits, use the stateless `search_after` cursor: sort deterministically with a unique tiebreaker (the `id` field — not `_id`), then pass each response's `next_search_after` envelope value back as `search_after` in the next request, keeping the same `query` and `sort`:
239
+
240
+ ```bash
241
+ # First page
242
+ tl db es '{
243
+ "size": 10000,
244
+ "query": {"term": {"channel.id": 12345}},
245
+ "sort": [{"publication_date": "asc"}, {"id": "asc"}]
246
+ }'
247
+ # → envelope includes "next_search_after": ["2025-09-14", "12345:abc123"]
248
+
249
+ # Next page — identical query & sort, plus the cursor
250
+ tl db es '{
251
+ "size": 10000,
252
+ "query": {"term": {"channel.id": 12345}},
253
+ "sort": [{"publication_date": "asc"}, {"id": "asc"}],
254
+ "search_after": ["2025-09-14", "12345:abc123"]
255
+ }'
256
+ ```
257
+
258
+ Repeat until a page comes back short (`next_search_after` is absent on an empty page). Pages are not a consistent snapshot — concurrent indexing can occasionally duplicate or skip a boundary row, which is fine for analytics sweeps. Date-range windowing (filtering by `publication_date` ranges) remains a good alternative when you want resumable, idempotent slices.
259
+
260
+ ## Text analyzer behavior
261
+
262
+ `text` fields on article docs (`title`, `summary`, `transcript`) appear to use the `standard` analyzer (tokenize + lowercase, no stemmer, no English-possessive filter), so inflections, plurals, and possessives are each indexed as distinct terms. For example: `bitcoin` (4,466,300) vs `bitcoins` (489,262). For stemming-style recall, expand the query side with a `bool.should` over the variants.
263
+
264
+ One consequence: URLs in article fields tokenize on punctuation (`substack.com` → `substack`, `com`), so you can't term-match a domain there. The exception is the channel-doc `description.domains` subfield, where whole domains are single searchable terms — use it to find channels by a linked domain (see the channel field table).
265
+
266
+ ## Transcript field format
267
+
268
+ The `transcript` field's `_source` is **YouTube timed-text caption XML**, not plain prose. Each caption cue is a `<text start="…" dur="…">` element wrapped in `<transcript>`. The inner text is HTML-entity-encoded — on older docs **double-encoded** (an apostrophe is `&amp;#39;`, i.e. an escaped `&#39;`), on recent docs single-encoded (`&apos;`):
269
+
270
+ ```xml
271
+ <?xml version="1.0" encoding="utf-8" ?>
272
+ <transcript><text start="0.05" dur="4.33">I&amp;#39;m going to</text>...</transcript>
273
+ ```
274
+
275
+ - **Searching is unaffected** — the field is analyzed as `text`, so `match` / `match_phrase` queries hit the words directly regardless of the markup. The XML only matters when you retrieve and read the raw `_source`.
276
+ - **For plain prose**, strip the markup yourself, e.g. `jq -r '.results[0].transcript' | sed -E 's/<[^>]+>/ /g'`, then unescape entities (twice on older docs). Don't reach for the `content` field — it's podcast-only and absent on YouTube docs.
277
+
278
+ ## Notes & gotchas
279
+
280
+ - **Composite IDs:** `tl-platform.id` and `_id` are `<channel_id>:<youtube_id>`. The `youtube_id` portion alone is what Firebolt's `article_metrics.id` stores.
281
+ - **Add a `publication_date` range filter** whenever the question is time-bounded — the alias is fixed, so this is the only way to narrow the search.
282
+ - `sponsored_brand_mentions` and `organic_brand_mentions` are keyword arrays — use `term` queries.
283
+ - For brand mention details (position, snippet, detection_tool), the data is in the `brand_mentions` nested field.
284
+ - **Stored-only fields** — retrievable in `_source` but invisible to `exists`/`term`/`match` (queries on them silently match 0 docs): `url`, `image_url`, `es_index_tag`, `not_sponsored_by`.
285
+ - **`publication_id` is deprecated** — don't use for joins.
286
+ - No write access. The CLI only exposes `_search` against `tl-platform-*`.
@@ -33,10 +33,12 @@ Drop to `tl db fb` only when you need a shape `tl snapshots` doesn't produce (cu
33
33
  - **SELECT only.** No DDL/DML/transactions/SET/locks.
34
34
  - **Single table.** No JOIN, CTE (`WITH`), subquery, set operation, or `LATERAL`.
35
35
  - **Only known tables:** `article_metrics`, `channel_metrics`. Other names return `UNKNOWN_TABLE`.
36
+ - **Constant-only probes are allowed:** `SELECT 1` (no FROM, no column references) works as a connectivity check. Anything naming a column or `*` still needs a FROM.
36
37
  - **WHERE/HAVING may only reference indexed columns** (`channel_id`/`id` for `article_metrics`; `id` for `channel_metrics`). Filtering by `age`, `publication_date`, `view_count`, `duration`, `scrape_date`, etc. in WHERE returns `NON_INDEXED_FILTER:<col>`. Apply those constraints client-side after fetching.
37
38
  - **Leading index column must be equality-or-IN-filtered with literals** (`channel_id = 1` or `channel_id IN (1,2,3)`). Without it: `MISSING_INDEXED_FILTER`.
38
39
  - **Trivial-aggregation exception:** a SELECT whose projected expressions are all aggregates and which has no GROUP BY / HAVING may omit WHERE entirely. Use only for tiny sanity checks.
39
40
  - **No mandatory LIMIT/OFFSET** — but Firebolt will time out on bad plans, so keep the leading-index filter selective.
41
+ - **Errors carry the real diagnostic.** A query that Firebolt itself rejects (unknown column, syntax error) comes back as a 400 with Firebolt's own error description, plus a rename hint where one is known. Common trap: `channel_metrics` has `subscribers`, not the older `reach`.
40
42
 
41
43
  ## Tables
42
44
 
@@ -126,11 +126,22 @@ A deal is **committed** when it is SOLD, or OPEN with `brand_approval_status` in
126
126
 
127
127
  ### `thoughtleaders_brand`
128
128
 
129
+ > 🚨 **Columns that DO NOT exist on `thoughtleaders_brand` — common hallucinations:**
130
+ > - ❌ `domain` / `url` — the website column is `website`.
131
+ > - ❌ `brand_name` — it's plain `name` here (unlike channel, which prefixes its display fields).
132
+ > - ❌ `is_active` — brands have no active/inactive flag; every row is live.
133
+ > - ❌ `sponsored_topics` (or similar topic columns) — the closest fields are `keywords` and `ai_description`.
134
+ > - ❌ `organization_id` — org lives on profile, not brand (see Key Relationships below).
135
+
129
136
  | Column | Type | Description |
130
137
  |--------|------|-------------|
131
138
  | `id` | int | Primary key |
132
- | `name` | varchar | Brand name |
139
+ | `name` | varchar | Brand name. ⚠️ Plain `name`, not `brand_name`. |
133
140
  | `description` | text | Brand description |
141
+ | `website` | varchar | Brand website URL. ⚠️ The column is `website`, NOT `domain`. |
142
+ | `slug` | varchar | TL-platform slug |
143
+ | `keywords` | varchar | Topic/detection keywords for the brand |
144
+ | `ai_description` | JSONB | AI-generated descriptive info about the brand |
134
145
  | `creator_id` | int FK | User who created it |
135
146
 
136
147
  #### Junction Tables
@@ -169,14 +180,14 @@ A channel can have multiple adspots (different sellers: talent manager, direct,
169
180
  | `subscribers` | bigint | Subscriber count. |
170
181
  | `media_selling_network_join_date` | date/timestamptz | When the channel joined the MSN. **MSN membership = this column IS NOT NULL.** |
171
182
  | `is_tpp` | boolean | True = TPP channel — TL's closest-partner channels (~144 at 100k+ subscribers), a strict subset of MSN. Prefer when booking: fastest response, easiest to close. ⚠️ **This is not the MSN flag.** For MSN, use `media_selling_network_join_date IS NOT NULL`. |
172
- | `content_category` | int | Content category code (1–22), as assigned by YouTube. This assignment is too unreliable, do not use it for discovering channels. **For topic/category discovery, prefer `tl recommender top-channels "<tag>"` |
183
+ | `content_category` | int | TL's own content-category code (1–22) — **not YouTube's categories**: 1 Backend Development, 2 Design, 3 Entrepreneurship, 4 Frontend Development, 5 Lifestyle, 6 Marketing, 7 Mobile Development, 8 Sales, 9 Travel, 10 Business, 11 Photography, 12 General Knowledge, 13 Personal Finance, 14 News & Politics, 15 Technology, 16 Gaming, 17 Food, 18 Sports, 19 How To & Crafts, 20 Entertainment, 21 Health & Fitness, 22 Music. The assignment is too unreliable for discovery — do not use it to find channels by topic. **For topic/category discovery, prefer `tl recommender top-channels "<tag>"` |
173
184
  | `is_active` | boolean | Whether the channel is active. ⚠️ **Always include `is_active = true` in channel queries** unless explicitly looking for archived rows. |
174
185
  | `country` | varchar | Channel's primary country (ISO 3166-1 alpha-2 code, e.g. `US`, `GB`, `BR`). This is often the cleanest answer to "geography" questions on sponsorships. May be NULL or blank. |
175
186
  | `language` | varchar | Primary content language. ⚠️ **Short ISO 639 codes — NOT BCP-47.** Mostly 2-letter ISO 639-1 (`en`, `pt`, `hi`) for major languages; occasionally 3-letter ISO 639-2/3 (`arc`, `arz`, `ase`, `ceb`) for languages without a 2-letter code. Filtering with `language = 'en-US'` returns zero rows. **Don't assume `LENGTH(language) = 2`** — that silently drops the 3-letter long-tail. May be NULL. |
176
187
  | `last_published` | date | Date of the channel's most recently seen video. Use for "is the channel still active?" filters — e.g. `last_published >= CURRENT_DATE - INTERVAL '120 days'`. |
177
188
  | `sponsorship_score` | double precision | TL-internal channel quality score (range 0-10, higher is better, if below 5, the channel is low quality). Useful as a tiebreaker when ranking candidate channels. |
178
189
  | `ai_description` | JSON | Descriptive information about a channel. Contains fields such as `description`, `audience`, `topic_descriptions`, and `brand_safety`. Useful as a regex-target for thematic filtering when the recommender results are too coarse (e.g. filtering "technology" down to actual tech reviewers via keywords like `tech|gadget|review|software`). |
179
- | `evergreenness` | float | Evergreen score |
190
+ | `evergreenness` | float | Longform evergreen score: median per-video `(views@180d − views@30d) / views@30d` over the channel's trailing-year uploads; ≥ 1 = evergreen (day-180 views at least double day-30). In ES this lands on the channel doc as `evergreenness_longform`. |
180
191
  | `demographic_usa_share` | smallint (0–100) | Percentage of the channel's audience based in the US. Convenience for the common "is this a US-heavy channel?" filter — pre-computed from `demographic_geo['US']`. NULL when the channel has no demographic data. |
181
192
  | `demographic_male_share` | smallint (0–100) | Percentage of the channel's audience that's male. `female_share = 100 - demographic_male_share` (no separate column). NULL when the channel has no demographic data. |
182
193
  | `demographic_age_median_value` | varchar | The age-bucket label (e.g. `25-34`) corresponding to the median of `demographic_age`, pre-computed on save. Indexed; cheap to filter on. NULL when there's no age data. |
@@ -275,6 +286,7 @@ JOIN thoughtleaders_profile_brands pb ON p.id = pb.profile_id
275
286
  JOIN thoughtleaders_brand b ON pb.brand_id = b.id
276
287
  -- NEVER: JOIN brand b ON b.id = a.advertiser_profile_id (different ID spaces, returns wrong data)
277
288
  ```
289
+ This 3-table join is the one brand path that works in every session — use it in anything reusable.
278
290
 
279
291
  **Adlink → Organization:**
280
292
  ```sql
@@ -289,7 +301,42 @@ JOIN thoughtleaders_profile p ON p.user_id = adspot.publisher_id
289
301
  ```
290
302
  Joining `adspot.publisher_id → profile.id` directly mixes ID spaces and returns garbage.
291
303
 
292
- ## `thoughtleaders_profile` persona constants
304
+ ## Key columns for the `thoughtleaders_profile` table
305
+
306
+ The profile is the account record for a person/company on the platform. Every brand-side query hops through it (`adlink.advertiser_profile_id → profile → profile_brands → brand`), which makes it the most-joined table after the sponsorships themselves.
307
+
308
+ > 🚨 **Columns that DO NOT exist on `thoughtleaders_profile` — common hallucinations:**
309
+ > - ❌ `name` / `email` — the person's name and email live on `auth_user` (join via `user_id`: `first_name`, `last_name`, `email`). The company name is `organization_name`, denormalized onto the profile.
310
+ > - ❌ `auth_user_id` — the FK column is `user_id`.
311
+ > - ❌ `brand_id` — a profile can have several brands; join `thoughtleaders_profile_brands` on `profile_id`.
312
+ > - ❌ `is_tpp` — that's a channel attribute; a profile's channels come via `thoughtleaders_profile_channels`.
313
+
314
+ | Column | Type | Description |
315
+ |--------|------|-------------|
316
+ | `id` | int | Primary key. ⚠️ NOT the same ID space as `auth_user.id` — never join a `*_id` pointing at users to `profile.id` directly. |
317
+ | `user_id` | int FK | → `auth_user.id` (the person's name and email live there) |
318
+ | `organization_id` | int FK | → `thoughtleaders_organization.id` |
319
+ | `organization_name` | varchar | Denormalized company/organization display name |
320
+ | `persona` | int | Account persona (see constants below) |
321
+ | `is_advertiser` | boolean | Buyer (brand) side capability flag |
322
+ | `is_publisher` | boolean | Seller (channel) side capability flag |
323
+ | `owner_sales_id` | int FK | → `auth_user.id` — TL-side sales owner for this profile |
324
+ | `owner_advertiser_id` | int FK | → `auth_user.id` — TL-side brand account owner |
325
+ | `owner_publisher_id` | int FK | → `auth_user.id` — on advertiser profiles: the adops user in charge |
326
+ | `media_buying_network_join_date` | date | MBN (brand-side buying network) join date. **MBN membership = this column IS NOT NULL.** ⚠️ Don't confuse with the channel's `media_selling_network_join_date` (MSN — the channel-side network). |
327
+ | `superuser_notes` | text | Internal TL notes about the profile |
328
+ | `buying_preference_*` | various | The brand's buying preferences (audience age/gender/geo shares, face-on-screen, custom instructions) |
329
+
330
+ **Profile with the person's name and email:**
331
+ ```sql
332
+ SELECT p.id, u.first_name, u.last_name, u.email, p.organization_name
333
+ FROM thoughtleaders_profile p
334
+ JOIN auth_user u ON u.id = p.user_id
335
+ WHERE p.organization_id = 123
336
+ LIMIT 50 OFFSET 0
337
+ ```
338
+
339
+ ### `thoughtleaders_profile` persona constants
293
340
 
294
341
  | Value | Label |
295
342
  |-------|-------|
@@ -297,7 +344,7 @@ Joining `adspot.publisher_id → profile.id` directly mixes ID spaces and return
297
344
  | 2 | Creator |
298
345
  | 3 | Talent Manager |
299
346
  | 4 | Media Agency |
300
- | 5 | Creator Service |
347
+ | 5 | Other |
301
348
 
302
349
  ## `thoughtleaders_profile_channels` (Profile ↔ Channel M2M)
303
350
 
@@ -563,8 +563,8 @@ raw `|`/`+`/`-` are literal text in a link; `*`/`~` are rejected, enumerate
563
563
  variants first), and emits:
564
564
 
565
565
  - `filter_set` — platform shape (`keywords`, `keyword_operator`,
566
- `content_fields`, per-group field/exclude maps). Fields are **ContentField
567
- enum names** (`title`, `summary`, `transcript`, `channel_description`,
566
+ `content_fields`, per-group field/exclude maps). Fields are **recognized
567
+ content field names** (`title`, `summary`, `transcript`, `channel_description`,
568
568
  `channel_topic_description`, …) — unknown names fail loudly.
569
569
  - `report_link` — paste-ready URL that opens the report with the filter applied
570
570
  (no saved record, no credits). **The default thing to hand the user.**