thoughtleaders-cli 0.5.2__tar.gz → 0.6.1__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 (68) hide show
  1. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/.claude-plugin/plugin.json +1 -1
  2. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/AGENTS.md +3 -1
  3. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/PKG-INFO +22 -8
  4. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/README.md +21 -7
  5. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/commands/tl.md +2 -2
  6. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/docs/architecture.md +6 -1
  7. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/pyproject.toml +1 -1
  8. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/skills/tl/SKILL.md +80 -40
  9. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/skills/tl/references/firebolt-schema.md +6 -5
  10. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/__init__.py +1 -1
  11. thoughtleaders_cli-0.6.1/src/tl_cli/commands/recommender.py +314 -0
  12. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/main.py +2 -0
  13. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/uv.lock +2 -2
  14. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/.claude-plugin/marketplace.json +0 -0
  15. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/.github/workflows/python-publish.yml +0 -0
  16. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/.gitignore +0 -0
  17. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/CLAUDE.md +0 -0
  18. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/LICENSE +0 -0
  19. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/agents/tl-analyst.md +0 -0
  20. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/commands/tl-balance.md +0 -0
  21. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/commands/tl-reports.md +0 -0
  22. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/commands/tl-sponsorships.md +0 -0
  23. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/hooks/hooks.json +0 -0
  24. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/hooks/scripts/post-usage.sh +0 -0
  25. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/hooks/scripts/pre-check.sh +0 -0
  26. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/skills/tl/references/business-glossary.md +0 -0
  27. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/skills/tl/references/elasticsearch-schema.md +0 -0
  28. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/skills/tl/references/postgres-schema.md +0 -0
  29. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/_completions.py +0 -0
  30. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/auth/__init__.py +0 -0
  31. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/auth/commands.py +0 -0
  32. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/auth/login.py +0 -0
  33. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/auth/pkce.py +0 -0
  34. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/auth/token_store.py +0 -0
  35. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/client/__init__.py +0 -0
  36. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/client/errors.py +0 -0
  37. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/client/http.py +0 -0
  38. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/commands/__init__.py +0 -0
  39. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/commands/ask.py +0 -0
  40. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/commands/balance.py +0 -0
  41. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/commands/brands.py +0 -0
  42. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/commands/changelog.py +0 -0
  43. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/commands/channels.py +0 -0
  44. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/commands/comments.py +0 -0
  45. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/commands/db.py +0 -0
  46. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/commands/deals.py +0 -0
  47. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/commands/describe.py +0 -0
  48. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/commands/doctor.py +0 -0
  49. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/commands/matches.py +0 -0
  50. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/commands/proposals.py +0 -0
  51. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/commands/reports.py +0 -0
  52. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/commands/schema.py +0 -0
  53. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/commands/setup.py +0 -0
  54. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/commands/snapshots.py +0 -0
  55. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/commands/sponsorships.py +0 -0
  56. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/commands/uploads.py +0 -0
  57. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/commands/whoami.py +0 -0
  58. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/config.py +0 -0
  59. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/filters.py +0 -0
  60. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/hints.py +0 -0
  61. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/output/__init__.py +0 -0
  62. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/output/formatter.py +0 -0
  63. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/src/tl_cli/self_update.py +0 -0
  64. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/tests/__init__.py +0 -0
  65. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/tests/test_auth.py +0 -0
  66. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/tests/test_filters.py +0 -0
  67. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/tests/test_output.py +0 -0
  68. {thoughtleaders_cli-0.5.2 → thoughtleaders_cli-0.6.1}/tests/test_sponsorships.py +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tl-cli",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
4
4
  "description": "ThoughtLeaders CLI — query sponsorship deals, channels, brands, uploads, and intelligence from the terminal",
5
5
  "author": {
6
6
  "name": "ThoughtLeaders",
@@ -1,6 +1,6 @@
1
1
  # Project Overview
2
2
 
3
- **tl-cli** is a Python CLI for querying ThoughtLeaders sponsorship data (sponsorships, channels, brands, uploads, snapshots, reports). Built with Typer + Rich + httpx. Designed as an "agent-first tool" — the CLI handles structured commands and output, while the user's AI agent (Claude) provides intelligence.
3
+ **tl-cli** is a Python CLI for querying ThoughtLeaders sponsorship data (sponsorships, channels, brands, uploads, snapshots, reports, vector recommender). Built with Typer + Rich + httpx. Designed as an "agent-first tool" — the CLI handles structured commands and output, while the user's AI agent (Claude) provides intelligence.
4
4
 
5
5
  # Architecture
6
6
 
@@ -20,6 +20,8 @@ When adding a new data command, follow this pattern. See `sponsorships.py` for t
20
20
 
21
21
  `deals`, `matches`, and `proposals` are shortcut commands that delegate to sponsorships' `do_list`/`do_show`/`do_create` with a pre-set status filter. They reject explicit `status:` filters — users should use `tl sponsorships list` for finer-grained status filtering.
22
22
 
23
+ `recommender` (`commands/recommender.py`) wraps the vector-recommender API at `/api/cli/v1/recommender/*` — `tags` (free), `top-channels` / `top-profiles` / `top-brands`, `inspect-channel`, `inspect-brand`, `similar-to-profile` (all 50 credits flat, Intelligence-gated). The three `top-*` URLs share one server resolver; `top-brands` dedupes the underlying profile rows by brand. Channel→channel and brand→brand similarity stay on `tl channels similar` / `tl brands similar`. When updating the SKILL or examples, prefer steering category/topic discovery (e.g. "Cooking channels") to `tl recommender top-channels "<tag>"` rather than `WHERE content_category = <code>` SQL — the recommender is ranked, not equality-based. The underlying recommender code uses "element"/"field_name" terminology; the CLI/API layer renames these to "tag" at the boundary.
24
+
23
25
  ## Filter Parsing (`filters.py`)
24
26
 
25
27
  `parse_filters()` handles `key:value` and `key:"quoted value"` syntax. Returns `dict[str, str]` passed as query params. Date filter keys (listed in `DATE_FILTER_KEYS` — e.g. `since`, `created-at`, `created-at-start`, `publish-date-end`) accept keywords `today`, `yesterday`, `tomorrow`. Sponsorship date fields (`created-at`, `publish-date`, `purchase-date`, `send-date`) each expose three filter shapes: bare `<field>:<date>` matches within that date/period, and `<field>-start:` / `<field>-end:` give inclusive lower/upper bounds (both sides inclusive; partial dates expand to the whole period). Empty-string values result in `IS NULL` queries on the backend.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thoughtleaders-cli
3
- Version: 0.5.2
3
+ Version: 0.6.1
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
@@ -81,13 +81,16 @@ tl uploads list q:code --csv
81
81
  # Show upload details (supports colon-containing IDs)
82
82
  tl uploads show 1174310:0BehkmVa7ak
83
83
 
84
- # Search channels. msn and tpp are tri-state filters (yes/no/both; default 'both').
85
- # msn = opted-in to receive sponsorship offers; tpp = exclusively TL-managed.
86
- # Both are also returned as boolean fields on every channel response.
87
- tl channels list category:cooking min-subs:100k
88
- tl channels list msn:yes # MSN channels only (~11k)
89
- tl channels list tpp:yes # TPP channels only (~169)
90
- tl channels list msn:no min-subs:500k # big non-MSN channels
84
+ # Search channels via raw SQL `tl db pg` against thoughtleaders_channel
85
+ # (run `tl schema pg` once to confirm the live column set).
86
+ tl db pg "SELECT id, channel_name, total_views FROM thoughtleaders_channel
87
+ WHERE content_category = <COOKING_CODE> AND total_views >= 100000
88
+ ORDER BY total_views DESC LIMIT 50 OFFSET 0"
89
+ tl db pg "SELECT id, channel_name FROM thoughtleaders_channel
90
+ WHERE is_tl_channel = TRUE LIMIT 200 OFFSET 0" # all TPP channels (~169)
91
+ # MSN status (media_selling_network_join_date) is scrubbed from the
92
+ # advertiser sandbox view — for MSN-only / non-MSN lookups, the
93
+ # structured filter is the right tool: `tl channels list msn:yes|no`.
91
94
 
92
95
  # Show channel detail — accepts numeric ID or channel name.
93
96
  # Names that match more than one active channel print a candidate list
@@ -102,6 +105,17 @@ tl channels show "Economics Explained"
102
105
  tl channels similar 12345 --limit 10
103
106
  tl channels similar "Tremending girls" min-score:0.85 --limit 5
104
107
 
108
+ # Vector recommender — discovery by category/demographic tag (Intelligence plan).
109
+ # `tags` is free; `top`, `inspect-*`, and `similar-to-profile` cost 50 credits flat.
110
+ tl recommender tags # List every tag (free)
111
+ tl recommender tags cooking # Search tag names by substring
112
+ tl recommender top-channels "Cooking" msn:yes --limit 50 # Top channels for a tag
113
+ tl recommender top-profiles "Cooking" mbn:yes --limit 30 # Top brand profiles (one brand → potentially multiple profiles)
114
+ tl recommender top-brands "Cooking" --limit 30 # Top brands (deduped from profiles)
115
+ tl recommender inspect-channel 12345 # Per-tag breakdown of a channel's vector
116
+ tl recommender inspect-brand Nike # Per-tag breakdown of a brand's ideal vector
117
+ tl recommender similar-to-profile 842 # Channels closest to a brand profile
118
+
105
119
  # Brand intelligence
106
120
  tl brands show Nike
107
121
 
@@ -54,13 +54,16 @@ tl uploads list q:code --csv
54
54
  # Show upload details (supports colon-containing IDs)
55
55
  tl uploads show 1174310:0BehkmVa7ak
56
56
 
57
- # Search channels. msn and tpp are tri-state filters (yes/no/both; default 'both').
58
- # msn = opted-in to receive sponsorship offers; tpp = exclusively TL-managed.
59
- # Both are also returned as boolean fields on every channel response.
60
- tl channels list category:cooking min-subs:100k
61
- tl channels list msn:yes # MSN channels only (~11k)
62
- tl channels list tpp:yes # TPP channels only (~169)
63
- tl channels list msn:no min-subs:500k # big non-MSN channels
57
+ # Search channels via raw SQL `tl db pg` against thoughtleaders_channel
58
+ # (run `tl schema pg` once to confirm the live column set).
59
+ tl db pg "SELECT id, channel_name, total_views FROM thoughtleaders_channel
60
+ WHERE content_category = <COOKING_CODE> AND total_views >= 100000
61
+ ORDER BY total_views DESC LIMIT 50 OFFSET 0"
62
+ tl db pg "SELECT id, channel_name FROM thoughtleaders_channel
63
+ WHERE is_tl_channel = TRUE LIMIT 200 OFFSET 0" # all TPP channels (~169)
64
+ # MSN status (media_selling_network_join_date) is scrubbed from the
65
+ # advertiser sandbox view — for MSN-only / non-MSN lookups, the
66
+ # structured filter is the right tool: `tl channels list msn:yes|no`.
64
67
 
65
68
  # Show channel detail — accepts numeric ID or channel name.
66
69
  # Names that match more than one active channel print a candidate list
@@ -75,6 +78,17 @@ tl channels show "Economics Explained"
75
78
  tl channels similar 12345 --limit 10
76
79
  tl channels similar "Tremending girls" min-score:0.85 --limit 5
77
80
 
81
+ # Vector recommender — discovery by category/demographic tag (Intelligence plan).
82
+ # `tags` is free; `top`, `inspect-*`, and `similar-to-profile` cost 50 credits flat.
83
+ tl recommender tags # List every tag (free)
84
+ tl recommender tags cooking # Search tag names by substring
85
+ tl recommender top-channels "Cooking" msn:yes --limit 50 # Top channels for a tag
86
+ tl recommender top-profiles "Cooking" mbn:yes --limit 30 # Top brand profiles (one brand → potentially multiple profiles)
87
+ tl recommender top-brands "Cooking" --limit 30 # Top brands (deduped from profiles)
88
+ tl recommender inspect-channel 12345 # Per-tag breakdown of a channel's vector
89
+ tl recommender inspect-brand Nike # Per-tag breakdown of a brand's ideal vector
90
+ tl recommender similar-to-profile 842 # Channels closest to a brand profile
91
+
78
92
  # Brand intelligence
79
93
  tl brands show Nike
80
94
 
@@ -18,8 +18,8 @@ The user wants to query ThoughtLeaders data. Translate their request into the ri
18
18
  ## Examples
19
19
 
20
20
  - "/tl sold sponsorships for Nike in Q1" → `tl sponsorships list status:sold brand:"Nike" purchase-date-start:2026-01-01 purchase-date-end:2026-03-31`
21
- - "/tl cooking channels over 100k subs" → `tl channels list category:cooking min-subs:100000`
22
- - "/tl mobile-first US cooking channels" → `tl channels list category:cooking primary-device:mobile min-us-share:50`
21
+ - "/tl cooking channels over 100k subs" → `tl db pg "SELECT id, channel_name, total_views FROM thoughtleaders_channel WHERE content_category = <COOKING_CODE> AND total_views >= 100000 ORDER BY total_views DESC LIMIT 50 OFFSET 0"`
22
+ - "/tl mobile-first US cooking channels" → `tl db pg "SELECT id, channel_name, demographic_usa_share FROM thoughtleaders_channel WHERE content_category = <COOKING_CODE> AND demographic_device_primary = 'mobile' AND demographic_usa_share >= 50 ORDER BY total_views DESC LIMIT 50 OFFSET 0"`
23
23
  - "/tl Nike's sponsorship activity" → `tl brands show Nike`
24
24
  - "/tl run my Q1 report" → `tl reports --json` then `tl reports run <id>`
25
25
  - "/tl check my balance" → `tl balance`
@@ -75,7 +75,12 @@ Filters are passed as `key:value` pairs after `list`:
75
75
  tl sponsorships list status:sold brand:"Nike" purchase-date:2026-01
76
76
  tl sponsorships list status:pending send-date:2026-03
77
77
  tl uploads list channel:12345 type:longform since:2026-03
78
- tl channels list category:cooking min-subs:100k language:en
78
+ # Channel discovery is raw SQL — the structured `tl channels list` covers
79
+ # only narrow lookups. Default to:
80
+ tl db pg "SELECT id, channel_name, total_views FROM thoughtleaders_channel
81
+ WHERE content_category = <COOKING_CODE> AND language = 'en'
82
+ AND total_views >= 1000000
83
+ ORDER BY total_views DESC LIMIT 50 OFFSET 0"
79
84
  ```
80
85
 
81
86
  Sponsorship date filtering (on `created-at`, `publish-date`, `purchase-date`, `send-date`) exposes three shapes per field:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "thoughtleaders-cli"
7
- version = "0.5.2"
7
+ version = "0.6.1"
8
8
  description = "ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -51,12 +51,12 @@ ThoughtLeaders is a sponsorship marketplace connecting **Brands** (advertisers /
51
51
 
52
52
  The centre of the data model is **Sponsorships** — business relationships between brands and channels. Sponsorships have a funnel of types, from broad to narrow:
53
53
 
54
- - **Sponsorships** — the broadest category, encompassing all stages
54
+ - **Sponsorships** — the broadest category, encompassing all stages, stored in the `thoughtleaders_adlink` table.
55
55
  - **Matches** — possible brand-channel pairings that ThoughtLeaders thinks could work
56
56
  - **Proposals** — matches that have been proposed to both sides to consider
57
57
  - **Deals** — contractually agreed-upon sponsorships (sold), either in production or published
58
58
 
59
- Sponsorships are sometimes called "Ads" or "Ad campaigns".
59
+ Sponsorships are sometimes called "Ads" or "Ad campaigns". An obsolete name for "sponsorship" is an "adlink".
60
60
 
61
61
  The CLI has shortcut commands for each type: `tl matches`, `tl proposals`, `tl deals`. These filter `tl sponsorships` by status.
62
62
 
@@ -66,15 +66,15 @@ Other key concepts:
66
66
  - **Reports** — saved report configurations that can be re-run
67
67
  - **Comments** — notes attached to sponsorships
68
68
  - **Adspots** — types of ads a channel carries (e.g. mention, dedicated video, product placement). Returned by `tl channels show`; each carries price/cost.
69
- - **MSN** (Media Selling Network) — the ~11k YouTube channels that have opted in to receive sponsorship offers. Returned as a boolean `msn` field on every channel response (list, detail, similar). Derived server-side from whether `Channel.media_selling_network_join_date` is non-null the timestamp itself isn't exposed over the CLI, just the boolean. Filterable via `msn:` tri-state: `msn:yes` (MSN only the default on `similar`; on `list` the default is `both`), `msn:no` (non-MSN only), `msn:both` (no filter).
70
- - **TPP** (ThoughtLeaders Partner Program, a.k.a. "TL channels") — the smaller, exclusive ~169 channels TL manages directly. Returned as the `tpp` boolean field on every channel response (list, detail, similar). Filterable via `tpp:` with the same tri-state vocabulary: `tpp:yes` / `tpp:no` / `tpp:both` (default `both`).
69
+ - **MSN** (Media Selling Network) — the ~11k YouTube channels that have opted in to receive sponsorship offers. A channels is in the MSN group if the `channel.media_selling_network_join_date` field is not null.
70
+ - **TPP** (ThoughtLeaders Partner Program, a.k.a. "TL channels") — the smaller, exclusive ~169 channels TL manages directly. A channel is in the TPP group if the `channel.is_tl_channel` is True.
71
71
  - **`demographics_updated_at`** (on channel detail) — ISO timestamp of when demographic screenshots were last uploaded and processed via OCR. If non-null, the channel has demographics screenshots on file. If null, no screenshots have been uploaded. Use this to check whether a channel has demographics data from screenshots.
72
72
  - **`impression`** (on channels) — projected views per video on that channel. Forward-looking estimate. May be null when not yet computed.
73
73
  - **`views`** (on sponsorships) — actual view count of the sold and published sponsored video, accessible when `article_id` is set.
74
74
  - **`impressions_guarantee`** (on sponsorships) — projected/guaranteed impressions for the sponsorship. Numeric; rounded to int in list output.
75
75
  - **Sponsorship detail fields** (returned by `tl sponsorships show <id> --json`) — in addition to the list-view columns, the detail payload includes `integration` (raw int), `publish_count`, `common_name`, `outreach_email`, nested `publisher` (`first_name`, `last_name`, `email`), nested `brand_contact` (`first_name`, `last_name`, `email`), and `brand.organization_name`. Use these when generating IOs, contracts, or outreach.
76
76
  - **CPM** has two distinct meanings depending on level — pick the one the user actually wants:
77
- - **Channel CPM** = `(adspot.price / channel.impression) × 1000` — projected price per thousand projected views. Used for pricing decisions **before** a sponsorship is sold. Available for channels with active adspots via `tl channels show <channel_id>`.
77
+ - **Channel CPM** = `(adspot.price / channel.impression) * 1000` — projected price per thousand projected views. Used for pricing decisions **before** a sponsorship is sold. Available for channels with active adspots via `tl channels show <channel_id>`.
78
78
  - **Sponsorship CPM** = calculated in either of two ways: if `views` is present, then CPM is `(sponsorship.price / sponsorship.views) × 1000`, meaning realized cost per thousand actual views, computed post-publication. If `views` is null, Compute from the sponsorship's `price` and the channel's `impression` fields.
79
79
  - **CPM does not have a range filter.** To find sponsorships in a CPM range (e.g. "around $15"), fetch the record set with other filters first, then apply the CPM range in post-processing (jq, Python, etc.) on the returned `cpm` field. Plan queries and pagination accordingly — the server cannot reduce the result count based on CPM.
80
80
  - **Sponsorship dates** — each sponsorship has four distinct dates, useful for different queries:
@@ -87,23 +87,23 @@ Other key concepts:
87
87
  Users see data scoped by their organization and plan:
88
88
  - **Media buyers** see sponsorships where their org is the brand. They see `price` but never `cost`.
89
89
  - **Media sellers** see sponsorships where their org is the publisher. They see `cost` but never `price`.
90
- - **Intelligence plan** is required for `tl brands`, full channel search, and full uploads.
90
+ - **Intelligence plan** is required for accessing information not strictly related to the user's organisation.
91
91
 
92
92
  When querying sponsorship bookings, query by `status:sold` and filter the the date range only by `purchase_date`. Otherwise, query for state:sold by `created_at`.
93
93
 
94
- An obsolete name for "sponsorship" is an "adlink".
95
-
96
94
  ## Methodology
97
95
 
98
96
  Where possible, if searching for a sponsorship match between channels and brands, first search for what do similar brands sponsor / which brands is the channel usually sponsored by. The similarity judgement should be preferably based on similar topics, similar upload frequency, similar channel sizes, and only after all that, on demographics.
99
97
 
98
+ Use the `tl channels similar` and `tl brands similar` commands to explore 1:1 similarity between known channels or brands. For category- or topic-driven discovery (e.g. "find me Cooking channels", "who scores high on USA share?"), use `tl recommender top-channels "<tag>"` (or `top-brands`/`top-profiles`) against the vector recommender — that's faster, ranked by category-strength. Run `tl recommender tags` to discover the valid tag names.
99
+
100
100
  ## Workflow
101
101
 
102
102
  At the start of session, always run a `tl help` command to find out which commands are available, and the `tl whoami` command to find out what you have access to.
103
103
 
104
104
  Unless the user specifically asks for running a specific report or showing the result of a specific report, find the data by using other, low-level commands.
105
105
 
106
- 1. **Discover first**: Run `tl describe show <resource> --json` to learn available fields, filters, and credit costs before querying
106
+ 1. **Discover first**: Run `tl describe show <resource> --json` to learn available fields, filters, and credit costs before querying. Use `tl schema pg`, `tl schema es`, and `tl schema fb` to find information about the main database (pg), the articles / uploads database (es), and the channel metrics database (fb).
107
107
  2. **Check saved reports**: Run `tl reports --json` to see if the user has a saved report that already answers their question
108
108
  3. **Check credits**: Run `tl balance --json` before expensive queries. Warn the user if a query will cost many credits.
109
109
  4. **Query with filters**: Use `key:value` filter syntax for structured queries
@@ -138,6 +138,13 @@ tl brands show <id-or-name> # Brand detail (1 credit)
138
138
  tl brands history <id-or-name> # Sponsorship history (5 credits/result, linear)
139
139
  tl brands history <query> --channel <id> # Brand mentions on specific channel
140
140
  tl brands similar <id-or-name> # Find similar brands via profile vector KNN (50 credits flat)
141
+ tl recommender tags [query] # List vector tag names — categories, demographics, formats (free)
142
+ tl recommender top-channels "<tag>" # Top channels loaded on a vector tag (50 credits; Intelligence)
143
+ tl recommender top-profiles "<tag>" # Top brand profiles loaded on a vector tag (50 credits)
144
+ tl recommender top-brands "<tag>" # Top brands (deduped from profiles) loaded on a vector tag (50 credits)
145
+ tl recommender inspect-channel <ref> # Show a channel's feature-vector breakdown (50 credits; Intelligence)
146
+ tl recommender inspect-brand <ref> # Show a brand profile's ideal-vector breakdown (50 credits; Intelligence)
147
+ tl recommender similar-to-profile <id> # Channels closest to a brand profile's ideal vector (50 credits; Intelligence)
141
148
  tl snapshots channel <id> # Channel metrics over time — list curve, mult 1.2 (Firebolt-backed)
142
149
  tl snapshots video <id> --channel <id> # Video view curve — list curve, mult 1.2 (--channel required!)
143
150
  tl reports # List saved reports — list curve, mult 1.3
@@ -278,10 +285,10 @@ See [references/business-glossary.md](references/business-glossary.md) for reven
278
285
 
279
286
  | Capability | Status | Workaround |
280
287
  |---|---|---|
281
- | Arbitrary read-only `SELECT` on Postgres | **Available** via `tl db pg`. | SELECT-only, mandatory `LIMIT ≤ 500` + `OFFSET`, function allowlist, no `::reg*` casts. See `references/postgres-schema.md`. |
282
- | Cross-reference helpers ("channels proposed to brand X", "channels sponsored by MBN brands in last N days") | **Unavailable** these were stacked PG joins. | Approximate with `tl brands history <brand>` (videos where the brand was detected extract channel IDs) and `tl sponsorships list brand:<name> status:<...>`. Won't perfectly match (e.g. `media_buying_network_join_date` isn't exposed). |
283
- | **AdLink INSERT** with custom price/cost/owner/`weighted_price`/`created_where` | **Unavailable** — `tl sponsorships create` exists but only creates a free *proposal* between a channel and a brand. It does not let you set price/cost/owner_sales_id/send_date/etc. | Done in the app or by a human with DB access. |
284
- | Pre-insert validation queries (joining `adspot ↔ channel ↔ profile ↔ org` to confirm MSN, integration=1, persona, plan) | **Unavailable** as a single query (needs PG joins). | Partial: `tl channels show <id>` exposes `msn`, `tpp`, and active adspots with `integration` codes. Persona/plan/profile-level checks aren't surfaced. |
288
+ | Arbitrary read-only `SELECT` on Postgres | **Available** via `tl db pg`. | SELECT-only, mandatory `LIMIT ≤ 500` + `OFFSET`, only certain SQL forms are allowed. See `references/postgres-schema.md`. |
289
+ | Cross-reference helpers ("channels proposed to brand X", "channels sponsored by MBN brands in last N days") | **Available** via `tl db pg`. | Write the join: `thoughtleaders_adlink` `adspot` `channel` `profile` `profile_brands` `brand`. Filter by `publish_status` for proposed/sold and by date range as needed. See `references/postgres-schema.md` for the exact column names. |
290
+ | **AdLink INSERT** with custom price/cost/owner/`weighted_price`/`created_where` | **Unavailable** — `tl sponsorships create` exists but only creates a free *proposal* between a channel and a brand. The `tl db pg` sanitizer accepts SELECT only — no INSERT/UPDATE. | Done in the app or by a human with DB access. |
291
+ | Pre-insert validation queries (joining `adspot ↔ channel ↔ profile ↔ org` to confirm MSN, integration=1, persona, plan) | **Available** via `tl db pg`. | One SELECT joining the four tables. Use `thoughtleaders_channel.media_selling_network_join_date IS NOT NULL` for MSN, `thoughtleaders_adspot.integration = 1` for mention adspots, `thoughtleaders_profile.persona` for the persona code (see persona constants in `references/postgres-schema.md`). |
285
292
  | Firebolt cross-table or join queries; filtering on non-indexed columns in WHERE | **Unavailable** — not accepted. | Fetch a wider slice keyed on `channel_id` (and optionally `id`), filter the rest in `jq`/Python. |
286
293
  | ES `query_string`, `regexp`, `wildcard`, `fuzzy`, `more_like_this`, parent/child joins; any `script_*`; multiple aggregations in one body | **Unavailable** — not accepted. | Rewrite using `term`/`terms`/`match`/`bool`/`nested`. For multi-agg dashboards, run multiple `tl db es` calls and combine client-side. For "similar"-style queries, try `tl channels similar` / `tl brands similar` (vector KNN, server-implemented). |
287
294
  | ES deep pagination beyond `from+size = 10,000` | **Unavailable** via raw — `scroll` and `pit` aren't allowlisted; `search_after` is allowed but `from` is still capped. | Use `search_after` with `sort` to walk past 10k. For huge sweeps, narrow with `publication_date` ranges. |
@@ -309,43 +316,52 @@ tl changelog --md > CHANGELOG.md # Capture for a doc
309
316
  `tl changelog` summaries are LLM-generated server-side from full commit messages and cached per version, so repeat calls are fast and don't re-bill the LLM. The release date and a 2–4 sentence prose summary come back per version.
310
317
 
311
318
  ### Filter syntax
312
- All list commands accept `key:value` filters:
319
+ Structured list commands accept `key:value` filters (use them for trivially simple lookups):
313
320
  ```bash
314
321
  tl sponsorships list status:sold brand:"Nike" purchase-date:2026-01
315
322
  tl uploads list channel:12345 type:longform
316
- tl channels list category:cooking min-subs:100k language:en
317
- tl channels list tpp:yes # list all TPP (TL-managed) channels
318
- tl channels list tpp:no primary-device:mobile # mobile-first channels that aren't in TPP
319
- tl channels list msn:yes category:tech # Media Selling Network channels in tech
320
- tl channels list msn:no min-subs:500k # big non-MSN channels (not yet opted in)
321
323
  ```
322
324
 
323
325
  Date filters accept keywords: `today`, `yesterday`, `tomorrow`.
324
326
 
325
- #### Channel demographic filters
327
+ #### Channel discovery — recommender first, raw SQL second
328
+
329
+ For category- or demographic-driven discovery, **use the vector recommender, not `content_category` SQL.** The recommender ranks channels by how strongly they load on a category/demographic tag (cosine-style scores), instead of forcing exact equality on a single integer code. It also returns the matching brand profiles alongside the channels — useful when the user actually wants to know "who buys this kind of inventory."
330
+
331
+ ```bash
332
+ # Discover the right tag name first (free)
333
+ tl recommender tags cooking
334
+ tl recommender tags "usa"
335
+
336
+ # Top channels & profiles loaded on a vector tag (50 credits; Intelligence)
337
+ tl recommender top-channels "Cooking" msn:yes --limit 50
338
+ tl recommender top-channels "Tech" --limit 30
339
+ tl recommender top-brands "USA share" mbn:yes --limit 50
340
+ ```
326
341
 
327
- These filters apply to both `tl channels list` and `tl sponsorships list` (the latter filters by the associated channel's demographics):
342
+ Use `tl db pg` only for predicates the recommender can't express — pure attribute filters (`is_tl_channel`, `language`, `demographic_device_primary`), aggregations, and joins. Run `tl schema pg` once to confirm the live column set; the columns referenced below are stable.
328
343
 
329
344
  ```bash
330
- # Primary device type
331
- tl channels list primary-device:mobile
332
- tl channels list primary-device:desktop
333
- tl channels list primary-device:tablet
334
-
335
- # Minimum device audience share (0–100)
336
- tl channels list min-mobile-share:60
337
- tl channels list min-desktop-share:30
338
- tl channels list min-tablet-share:10
339
-
340
- # Minimum geo share (0–100, ISO country codes lowercase)
341
- tl channels list min-us-share:70
342
- tl channels list min-gb-share:25
343
-
344
- # Combine with other filters
345
- tl channels list category:tech primary-device:mobile min-us-share:50 min-subs:100k
346
- tl sponsorships list status:sold primary-device:mobile min-us-share:60
345
+ # All TPP (TL-managed) channels — pure attribute filter, not a category query
346
+ tl db pg "SELECT id, channel_name, content_category, total_views
347
+ FROM thoughtleaders_channel
348
+ WHERE is_tl_channel = TRUE
349
+ ORDER BY total_views DESC
350
+ LIMIT 200 OFFSET 0"
351
+
352
+ # Mobile-first non-TPP channels device share, not topic
353
+ tl db pg "SELECT id, channel_name, demographic_device_primary, total_views
354
+ FROM thoughtleaders_channel
355
+ WHERE is_tl_channel = FALSE
356
+ AND demographic_device_primary = 'mobile'
357
+ ORDER BY total_views DESC
358
+ LIMIT 100 OFFSET 0"
347
359
  ```
348
360
 
361
+ For per-country share beyond the recommender's "USA share" tag, use the `demographic_geo` jsonb in raw SQL: `(demographic_geo->>'gb')::int >= 25`. Same pattern with `demographic_device->>'mobile'` for non-primary device shares.
362
+
363
+ **MSN status (`media_selling_network_join_date`) is scrubbed from the advertiser sandbox view.** Raw SQL can't filter on it from an advertiser context; use the structured `tl channels list msn:yes|no|both` for MSN-specific lookups, then drop down to SQL on the resulting IDs (`WHERE id IN (...)`) for further analysis.
364
+
349
365
  ### Output flags
350
366
  - `--json` — structured JSON (use this for parsing)
351
367
  - `--csv` — CSV output
@@ -417,9 +433,19 @@ tl reports --json # Find the report ID first
417
433
  tl reports run 42 --json
418
434
  ```
419
435
 
420
- "Find mobile-first US channels in cooking":
436
+ "Find Cooking channels with US-heavy mobile audiences":
421
437
  ```bash
422
- tl channels list category:cooking primary-device:mobile min-us-share:50 --json
438
+ # Use the vector recommender for the topic, then narrow with structured filters / SQL on the IDs.
439
+ tl recommender top-channels "Cooking" msn:yes --limit 100 --json \
440
+ | jq -r '.results[].channel_id' \
441
+ | paste -sd, - \
442
+ | xargs -I {} tl db pg "SELECT id, channel_name, total_views, demographic_usa_share
443
+ FROM thoughtleaders_channel
444
+ WHERE id IN ({})
445
+ AND demographic_device_primary = 'mobile'
446
+ AND demographic_usa_share >= 50
447
+ ORDER BY total_views DESC
448
+ LIMIT 50 OFFSET 0" --json
423
449
  ```
424
450
 
425
451
  "Show sold sponsorships targeting mobile US audiences":
@@ -438,3 +464,17 @@ tl channels similar 29834 tpp:yes --limit 30 # TPP (TL-managed)
438
464
  tl channels similar 29834 min-subs:1000000 exclude:477487 --limit 15 # client-side filters
439
465
  ```
440
466
  **Both `tl channels show` and `tl channels similar` accept either a numeric channel ID or a channel name.** Name arguments are case-insensitive partial matches; if more than one active channel matches, the command prints a candidates table (channel_id, subscribers, name) and exits 1 so you can retry with a specific ID. The `msn` filter on `similar` is tri-state: `yes` (only MSN channels — the default), `no` (only non-MSN channels), `both` (no MSN filter). `tl channels look-alike` is a hidden alias for `similar` that matches the internal "look-alike channels" terminology.
467
+
468
+ "Browse the vector recommender" (categories, demographics, formats — `tl recommender tags` is free):
469
+ ```bash
470
+ tl recommender tags # Full tag list (free)
471
+ tl recommender tags cooking # Search tag names by substring
472
+ tl recommender top-channels "Cooking" msn:yes --limit 50 # Top channels loaded on a tag (50 credits)
473
+ tl recommender top-profiles "Cooking" --limit 30 # Top brand profiles for the tag
474
+ tl recommender top-brands "USA share" mbn:yes --limit 30 # Top brands (deduped) — demographic tag, MBN only
475
+ tl recommender top-channels "Tech" exclude-for-profile:842 # Drop channels already proposed for profile 842
476
+ tl recommender inspect-channel 29834 # Per-tag breakdown of a channel's vector
477
+ tl recommender inspect-brand Nike # Per-tag breakdown of a brand's ideal vector
478
+ tl recommender similar-to-profile 842 --limit 30 # Channels closest to a brand profile's ideal vector
479
+ ```
480
+ Use `tl recommender top` for category/topic discovery (it's ranked) and `tl channels similar` / `tl brands similar` for 1:1 lookalike searches.
@@ -49,7 +49,7 @@ Tracks YouTube video metrics over time. Each row = one scrape of one video on on
49
49
  | Column | Type | Description |
50
50
  |--------|------|-------------|
51
51
  | `id` | TEXT | YouTube video ID (e.g., `'dQw4w9WgXcQ'`). **Bare YouTube ID** — NOT the compound `<channel_id>:<youtube_id>` form used in PG `adlink.article_id` and ES `_id`. |
52
- | `channel_id` | INT | TL channel ID (matches the channel ID returned by `tl channels list`) |
52
+ | `channel_id` | INT | TL channel ID (matches `thoughtleaders_channel.id` in Postgres) |
53
53
  | `channel_format` | INT | Platform format (4 = YouTube) |
54
54
  | `publication_date` | DATE | When the video was published |
55
55
  | `scrape_date` | DATE | When this data point was captured |
@@ -85,8 +85,8 @@ Firebolt's **only advantage** is historical metric snapshots. For everything els
85
85
  | Current view count on a video | **Elasticsearch** (`tl uploads show` or `tl db es`) |
86
86
  | Current subscriber count | **Elasticsearch** (`tl channels show`) |
87
87
  | Video metadata (title, tags, duration) | **Elasticsearch** |
88
- | Find channels by criteria | **`tl channels list`** (Postgres) |
89
- | Deal / pipeline / sponsorship data | **`tl sponsorships`** etc. (Postgres) |
88
+ | Find channels by criteria | **`tl db pg`** against `thoughtleaders_channel` |
89
+ | Deal / pipeline / sponsorship data | **`tl db pg`** against `thoughtleaders_adlink` (joins to `adspot`/`channel`/`profile`/`brand`) |
90
90
  | View curve over time (age 7→30→90→180) | **Firebolt** ✅ |
91
91
  | Views at age 30 vs age 180 (evergreenness) | **Firebolt** ✅ |
92
92
  | Channel subscriber growth trend | **Firebolt** ✅ |
@@ -116,8 +116,9 @@ Every Firebolt workflow has two steps:
116
116
  **Step 1 — get `channel_id` and (optionally) video IDs from PG/ES.**
117
117
 
118
118
  ```bash
119
- # Channels matching some criterion (PG side)
120
- tl channels list category:tech --json --limit 50 | jq '.results[].id'
119
+ # Channels matching some category (vector recommender — preferred over content_category equality)
120
+ tl recommender top-channels "Tech" msn:yes --limit 50 --json \
121
+ | jq '.results[].channel_id'
121
122
 
122
123
  # Or videos for a specific brand's deals (Postgres side, via tl sponsorships)
123
124
  tl deals list brand:"Nike" --json --limit 500 \
@@ -1,3 +1,3 @@
1
1
  """ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence."""
2
2
 
3
- __version__ = "0.5.2"
3
+ __version__ = "0.6.1"
@@ -0,0 +1,314 @@
1
+ """tl recommender — Vector-recommender introspection and discovery.
2
+
3
+ Surfaces the channel/profile feature-vector machinery that powers the
4
+ "Recommender Insights" web view: list the vector tags (categories,
5
+ demographics, formats, etc.), find the top channels and profiles loaded
6
+ on a given tag, inspect a single channel or brand vector, or fetch
7
+ channels similar to a brand profile's ideal vector.
8
+
9
+ For 1:1 similarity use `tl channels similar` and `tl brands similar`.
10
+ """
11
+
12
+ import urllib.parse
13
+
14
+ import typer
15
+ from rich.console import Console
16
+
17
+ from tl_cli.client.errors import ApiError, handle_api_error
18
+ from tl_cli.client.http import get_client
19
+ from tl_cli.filters import parse_filters
20
+ from tl_cli.output.formatter import detect_format, output, output_single
21
+
22
+ app = typer.Typer(help="Vector recommender (tags, top-channels/profiles/brands, vector inspection, profile→channel similarity)")
23
+
24
+
25
+ TOP_CHANNEL_COLUMNS = ["value", "channel_id", "channel_name", "slug"]
26
+ TOP_PROFILE_COLUMNS = ["value", "profile_id", "profile_email", "brand_name", "brand_slug"]
27
+ TOP_BRAND_COLUMNS = ["value", "brand_slug", "brand_name", "profile_id"]
28
+ TOP_COLUMN_CONFIG = {"value": {"justify": "right"}}
29
+
30
+
31
+ def _handle_recommender_error(e: ApiError) -> None:
32
+ """Show ambiguity candidates inline; otherwise default handler."""
33
+ if e.status_code == 400 and isinstance(e.raw, dict) and e.raw.get("candidates"):
34
+ err = Console(stderr=True)
35
+ err.print(f"[yellow]{e.detail}[/yellow]")
36
+ err.print()
37
+ err.print("[bold]Candidates:[/bold]")
38
+ for c in e.raw["candidates"]:
39
+ cid = c.get("channel_id") or c.get("brand_id") or "?"
40
+ name = c.get("name", "")
41
+ extra = c.get("website") or c.get("subscribers") or ""
42
+ err.print(f" {cid:>10} {name} [dim]{extra}[/dim]")
43
+ raise typer.Exit(1)
44
+ handle_api_error(e)
45
+
46
+
47
+ @app.callback(invoke_without_command=True)
48
+ def recommender(ctx: typer.Context) -> None:
49
+ """Vector recommender."""
50
+ if ctx.invoked_subcommand is None:
51
+ typer.echo(ctx.get_help())
52
+
53
+
54
+ @app.command("tags")
55
+ def tags_cmd(
56
+ args: list[str] = typer.Argument(None, help="Optional substring (matches tag or normalized name)"),
57
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
58
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
59
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
60
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
61
+ ) -> None:
62
+ """List vector tag names (free).
63
+
64
+ Use this to discover the tag names accepted by `tl recommender top`.
65
+ Each tag is one dimension of a channel/profile feature vector —
66
+ e.g. content categories like "Cooking", demographic buckets like
67
+ "Age 18-24", device shares, country shares.
68
+
69
+ Examples:
70
+ tl recommender tags
71
+ tl recommender tags cooking
72
+ tl recommender tags "age 18"
73
+ """
74
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
75
+ query = _strip_quotes(" ".join(args or []).strip())
76
+ params = {"q": query} if query else {}
77
+ client = get_client()
78
+ try:
79
+ data = client.get("/recommender/tags", params=params)
80
+ output(
81
+ data,
82
+ fmt,
83
+ columns=["group", "field_name"],
84
+ title="Recommender vector tags",
85
+ )
86
+ except ApiError as e:
87
+ handle_api_error(e)
88
+ finally:
89
+ client.close()
90
+
91
+
92
+ def _strip_quotes(value: str) -> str:
93
+ """Strip one matching pair of surrounding quotes if present.
94
+
95
+ Lets users paste an example like `tl recommender top-channels "cooking"`
96
+ where the shell already strips quotes, but also tolerates a layer of
97
+ extra quoting from agents or scripts that re-wrap the literal.
98
+ """
99
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
100
+ return value[1:-1]
101
+ return value
102
+
103
+
104
+ def _do_top(kind: str, tag: str, args: list[str], fmt: str, limit: int, columns: list[str], title: str) -> None:
105
+ tag = _strip_quotes(tag)
106
+ filters = parse_filters(args or [])
107
+ server_keys = {"msn", "mbn", "exclude-for-channel", "exclude-for-profile"}
108
+ params = {k: v for k, v in filters.items() if k in server_keys}
109
+ params["tag"] = tag
110
+ params["limit"] = str(limit)
111
+
112
+ client = get_client()
113
+ try:
114
+ data = client.get(f"/recommender/top/{kind}", params=params)
115
+ output(
116
+ data,
117
+ fmt,
118
+ columns=columns,
119
+ title=title,
120
+ column_config=TOP_COLUMN_CONFIG,
121
+ )
122
+ except ApiError as e:
123
+ handle_api_error(e)
124
+ finally:
125
+ client.close()
126
+
127
+
128
+ @app.command("top-channels")
129
+ def top_channels_cmd(
130
+ tag: str = typer.Argument(..., help='Vector tag name (e.g. "Cooking", "Age 18-24"). Run `tl recommender tags` to discover valid names.'),
131
+ args: list[str] = typer.Argument(None, help="Filters (key:value pairs)."),
132
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
133
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
134
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
135
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
136
+ limit: int = typer.Option(50, "--limit", "-l", help="Max results (1-100)"),
137
+ ) -> None:
138
+ """Top channels loaded on a single vector tag.
139
+
140
+ Costs 50 credits per call. Intelligence plan required.
141
+
142
+ Filters:
143
+ msn:<yes|no|all> MSN membership (default: all)
144
+ exclude-for-profile:<id> Drop channels already proposed for this profile
145
+
146
+ Examples:
147
+ tl recommender top-channels "Cooking"
148
+ tl recommender top-channels "Tech" msn:yes --limit 30
149
+ tl recommender top-channels "Cooking" exclude-for-profile:842
150
+ """
151
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
152
+ _do_top("channels", tag, args or [], fmt, limit, TOP_CHANNEL_COLUMNS, f"Top channels: {tag}")
153
+
154
+
155
+ @app.command("top-profiles")
156
+ def top_profiles_cmd(
157
+ tag: str = typer.Argument(..., help='Vector tag name (e.g. "Cooking", "Age 18-24"). Run `tl recommender tags` to discover valid names.'),
158
+ args: list[str] = typer.Argument(None, help="Filters (key:value pairs)."),
159
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
160
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
161
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
162
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
163
+ limit: int = typer.Option(50, "--limit", "-l", help="Max results (1-100)"),
164
+ ) -> None:
165
+ """Top brand profiles loaded on a single vector tag.
166
+
167
+ Costs 50 credits per call. Intelligence plan required. Profiles can
168
+ represent the same brand more than once (one brand → multiple
169
+ profiles); use `top-brands` for brand-deduplicated results.
170
+
171
+ Filters:
172
+ mbn:<yes|no|all> MBN membership (default: all)
173
+ exclude-for-channel:<id> Drop profiles already proposed for this channel
174
+
175
+ Examples:
176
+ tl recommender top-profiles "Cooking"
177
+ tl recommender top-profiles "USA share" mbn:yes --limit 30
178
+ """
179
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
180
+ _do_top("profiles", tag, args or [], fmt, limit, TOP_PROFILE_COLUMNS, f"Top profiles: {tag}")
181
+
182
+
183
+ @app.command("top-brands")
184
+ def top_brands_cmd(
185
+ tag: str = typer.Argument(..., help='Vector tag name (e.g. "Cooking", "Age 18-24"). Run `tl recommender tags` to discover valid names.'),
186
+ args: list[str] = typer.Argument(None, help="Filters (key:value pairs)."),
187
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
188
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
189
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
190
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
191
+ limit: int = typer.Option(50, "--limit", "-l", help="Max results (1-100)"),
192
+ ) -> None:
193
+ """Top brands loaded on a single vector tag (deduplicated from profiles).
194
+
195
+ Costs 50 credits per call. Intelligence plan required. Server-side
196
+ aggregates the underlying profile rows by brand, keeping the
197
+ highest-scoring profile per brand.
198
+
199
+ Filters:
200
+ mbn:<yes|no|all> MBN membership of the underlying profile (default: all)
201
+ exclude-for-channel:<id> Drop brands already proposed for this channel
202
+
203
+ Examples:
204
+ tl recommender top-brands "Cooking"
205
+ tl recommender top-brands "USA share" mbn:yes --limit 30
206
+ """
207
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
208
+ _do_top("brands", tag, args or [], fmt, limit, TOP_BRAND_COLUMNS, f"Top brands: {tag}")
209
+
210
+
211
+ @app.command("inspect-channel")
212
+ def inspect_channel_cmd(
213
+ channel_ref: str = typer.Argument(..., help="Channel ID (numeric) or name (partial match, must be unique)"),
214
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
215
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
216
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
217
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
218
+ ) -> None:
219
+ """Show a channel's feature vector grouped by category.
220
+
221
+ Costs 50 credits per call. Intelligence plan required. Returns the
222
+ grouped sparse vector (active dimensions only) and the magnitude.
223
+
224
+ Examples:
225
+ tl recommender inspect-channel 12345
226
+ tl recommender inspect-channel "MrBeast"
227
+ """
228
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
229
+ encoded = urllib.parse.quote(channel_ref, safe="")
230
+ client = get_client()
231
+ try:
232
+ data = client.get(f"/recommender/channels/{encoded}/inspect")
233
+ output_single(data, fmt)
234
+ except ApiError as e:
235
+ _handle_recommender_error(e)
236
+ finally:
237
+ client.close()
238
+
239
+
240
+ @app.command("inspect-brand")
241
+ def inspect_brand_cmd(
242
+ brand_ref: str = typer.Argument(..., help="Brand ID (numeric) or name (partial match, must be unique)"),
243
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
244
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
245
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
246
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
247
+ ) -> None:
248
+ """Show a brand profile's ideal feature vector grouped by category.
249
+
250
+ Costs 50 credits per call. Intelligence plan required. Resolves the
251
+ brand to its (preferred MBN) profile and inspects that profile's
252
+ aggregated vector.
253
+
254
+ Examples:
255
+ tl recommender inspect-brand 287
256
+ tl recommender inspect-brand Nike
257
+ """
258
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
259
+ encoded = urllib.parse.quote(brand_ref, safe="")
260
+ client = get_client()
261
+ try:
262
+ data = client.get(f"/recommender/brands/{encoded}/inspect")
263
+ output_single(data, fmt)
264
+ except ApiError as e:
265
+ _handle_recommender_error(e)
266
+ finally:
267
+ client.close()
268
+
269
+
270
+ @app.command("similar-to-profile")
271
+ def similar_to_profile_cmd(
272
+ profile_id: int = typer.Argument(..., help="Profile ID (numeric)"),
273
+ args: list[str] = typer.Argument(None, help="Filters (key:value pairs)."),
274
+ json_output: bool = typer.Option(False, "--json", help="JSON output"),
275
+ csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
276
+ md_output: bool = typer.Option(False, "--md", help="Markdown output"),
277
+ toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
278
+ limit: int = typer.Option(20, "--limit", "-l", help="Max results (1-100)"),
279
+ ) -> None:
280
+ """Channels closest to a brand profile's ideal vector.
281
+
282
+ Costs 50 credits per call. Intelligence plan required. Channels the
283
+ brand has already worked with or been proposed are excluded.
284
+
285
+ Filters:
286
+ language:<iso> Content language (default: en)
287
+ msn:<yes|no> Restrict to MSN channels (default: no)
288
+
289
+ Examples:
290
+ tl recommender similar-to-profile 842
291
+ tl recommender similar-to-profile 842 msn:yes --limit 30
292
+ """
293
+ fmt = detect_format(json_output, csv_output, md_output, toon_output)
294
+ filters = parse_filters(args or [])
295
+ params = {k: v for k, v in filters.items() if k in {"language", "msn"}}
296
+ params["limit"] = str(limit)
297
+ client = get_client()
298
+ try:
299
+ data = client.get(f"/recommender/profiles/{profile_id}/similar", params=params)
300
+ for r in data.get("results", []):
301
+ score = r.get("score")
302
+ if isinstance(score, (int, float)) and fmt in ("table", "md"):
303
+ r["score"] = f"{score * 100:.1f}%"
304
+ output(
305
+ data,
306
+ fmt,
307
+ columns=["score", "channel_id", "channel_name", "slug"],
308
+ title=f"Channels similar to profile {profile_id}",
309
+ column_config={"score": {"justify": "right"}},
310
+ )
311
+ except ApiError as e:
312
+ handle_api_error(e)
313
+ finally:
314
+ client.close()
@@ -27,6 +27,7 @@ from tl_cli.commands.db import app as db_app
27
27
  from tl_cli.commands.deals import app as deals_app
28
28
  from tl_cli.commands.matches import app as matches_app
29
29
  from tl_cli.commands.proposals import app as proposals_app
30
+ from tl_cli.commands.recommender import app as recommender_app
30
31
  from tl_cli.commands.sponsorships import app as sponsorships_app
31
32
  from tl_cli.commands.describe import app as describe_app
32
33
  from tl_cli.commands.schema import app as schema_app
@@ -95,6 +96,7 @@ app.add_typer(deals_app, name="deals")
95
96
  app.add_typer(uploads_app, name="uploads")
96
97
  app.add_typer(channels_app, name="channels")
97
98
  app.add_typer(brands_app, name="brands")
99
+ app.add_typer(recommender_app, name="recommender")
98
100
  app.add_typer(snapshots_app, name="snapshots")
99
101
  app.add_typer(reports_app, name="reports")
100
102
  app.add_typer(comments_app, name="comments")
@@ -387,8 +387,8 @@ wheels = [
387
387
  ]
388
388
 
389
389
  [[package]]
390
- name = "tl-cli"
391
- version = "0.4.19"
390
+ name = "thoughtleaders-cli"
391
+ version = "0.6.0"
392
392
  source = { editable = "." }
393
393
  dependencies = [
394
394
  { name = "authlib" },