thoughtleaders-cli 0.5.2__py3-none-any.whl → 0.6.2__py3-none-any.whl

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: thoughtleaders-cli
3
- Version: 0.5.2
3
+ Version: 0.6.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
@@ -81,13 +81,19 @@ 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
+ # NOTE: For topic / category discovery, prefer the vector recommender over
87
+ # `content_category` equality `tl recommender top-channels "<tag>"`
88
+ # returns channels ranked by how strongly they load on the topic, not just
89
+ # rows where the single category code matches exactly.
90
+ tl db pg "SELECT id, channel_name, total_views FROM thoughtleaders_channel
91
+ WHERE content_category = <COOKING_CODE> AND total_views >= 100000
92
+ ORDER BY total_views DESC LIMIT 50 OFFSET 0"
93
+ tl db pg "SELECT id, channel_name FROM thoughtleaders_channel
94
+ WHERE is_tl_channel = TRUE LIMIT 200 OFFSET 0" # all TPP channels (~169)
95
+ # MSN status: filter on `media_selling_network_join_date IS [NOT] NULL`
96
+ # in the same raw SQL query (column is scrubbed from advertiser sandboxes).
91
97
 
92
98
  # Show channel detail — accepts numeric ID or channel name.
93
99
  # Names that match more than one active channel print a candidate list
@@ -102,6 +108,17 @@ tl channels show "Economics Explained"
102
108
  tl channels similar 12345 --limit 10
103
109
  tl channels similar "Tremending girls" min-score:0.85 --limit 5
104
110
 
111
+ # Vector recommender — discovery by category/demographic tag (Intelligence plan).
112
+ # `tags` is free; `top`, `inspect-*`, and `similar-to-profile` cost 50 credits flat.
113
+ tl recommender tags # List every tag (free)
114
+ tl recommender tags cooking # Search tag names by substring
115
+ tl recommender top-channels "Cooking" msn:yes --limit 50 # Top channels for a tag
116
+ tl recommender top-profiles "Cooking" mbn:yes --limit 30 # Top brand profiles (one brand → potentially multiple profiles)
117
+ tl recommender top-brands "Cooking" --limit 30 # Top brands (deduped from profiles)
118
+ tl recommender inspect-channel 12345 # Per-tag breakdown of a channel's vector
119
+ tl recommender inspect-brand Nike # Per-tag breakdown of a brand's ideal vector
120
+ tl recommender similar-to-profile 842 # Channels closest to a brand profile
121
+
105
122
  # Brand intelligence
106
123
  tl brands show Nike
107
124
 
@@ -1,9 +1,9 @@
1
- tl_cli/__init__.py,sha256=RftbUNBtMIhq65nPfhykjSeeT7epzdazaNOPsp7j6ZU,112
1
+ tl_cli/__init__.py,sha256=wILNcDcV3Z8rxRRQ5R-gJwPGVOwzgAp_HOpgXWqmoMA,112
2
2
  tl_cli/_completions.py,sha256=kOyEUqC26vbYvyXWi513WX8fF73qQLR5WWuRSe_wqyk,164
3
3
  tl_cli/config.py,sha256=teQF2Smk38AFkrkaUnR0peo-CPy5CMIKiNoA0D5R9jQ,1945
4
4
  tl_cli/filters.py,sha256=jymgt21vl2d67yTXD_ceRIxTn6H6OYI5-QvQyE4Y4z0,2937
5
5
  tl_cli/hints.py,sha256=cT8kuDtkAZqwXkc2RV0Yg_abofK-g9UiXwTTBunX78U,1557
6
- tl_cli/main.py,sha256=EK3DZ0EwI2qSwXZyDOTuZh5K2rfe0jI5Wo-HWy82KK8,6924
6
+ tl_cli/main.py,sha256=3q4klTy71jQoD4Q29PU1HnZHCkLfWw_aREkDgPpv8I8,7038
7
7
  tl_cli/self_update.py,sha256=elLAH5gBOY00COWbSq5G6iwFx3e-59heKKWFrnAbQDg,5438
8
8
  tl_cli/auth/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
9
  tl_cli/auth/commands.py,sha256=uf4grUdpPnY-oUBUNHJQVW30eCFBguwVm1mGwgwPX2Q,1589
@@ -18,7 +18,7 @@ tl_cli/commands/ask.py,sha256=dRt2H80MCzKB-EYYRFJMpr6gA798NE9Qlsi5D6kODII,2062
18
18
  tl_cli/commands/balance.py,sha256=2cWQIktn_EYvGc3qDhKF-qrun857_6AYOzDxvowsjl0,2091
19
19
  tl_cli/commands/brands.py,sha256=W-J7FdSqwNudWLTRHZ8k7QMIIJxdXVVoZSuj9R9_8zM,6638
20
20
  tl_cli/commands/changelog.py,sha256=D1PtDdHpawTlWqUHjKzVmv9yXLSU915UVmI3dZzEwyA,4241
21
- tl_cli/commands/channels.py,sha256=637rJX_vjK-G8HRZ2m0oESmmLotxB7dpChxFyDn2zwI,12494
21
+ tl_cli/commands/channels.py,sha256=LVUpDqtwJEuzbpl4yKO7hDm2eaUz2xEXfC7aniGyL_4,10641
22
22
  tl_cli/commands/comments.py,sha256=AgpnLtB7RYgc1grWWHPV51kI44BE2JdTlpOmtnPwFeY,2059
23
23
  tl_cli/commands/db.py,sha256=XpP6OE43puZrGILfspoRyVXpKPdteztJE2aySD9DJOI,4030
24
24
  tl_cli/commands/deals.py,sha256=9-boF_HVO6xQ5ULPxatWRxbCIwMjzKCbCUb12tEwAe0,2093
@@ -26,6 +26,7 @@ tl_cli/commands/describe.py,sha256=QjTTdAxG7eWV5Z8jNkonikZ-5ScWOZ8NO5_xYublxUY,5
26
26
  tl_cli/commands/doctor.py,sha256=zhzrCSIMSYmS0c7pr1JAtKLWeanWffTUU4QYFW7fxXo,4701
27
27
  tl_cli/commands/matches.py,sha256=C4_kWRpTGu2lduDmrFpgi-D0Q4MuPc_3Cc76Ud2_i1g,2805
28
28
  tl_cli/commands/proposals.py,sha256=MWohD2UdEVRIVzYX-5uKlKE8U56nQu4OJMHbiaXuM_s,2837
29
+ tl_cli/commands/recommender.py,sha256=g-6oLOpkK1Mk8OhUnG5dMxLvWDt3Vy-91s4Dgo_6lrQ,13230
29
30
  tl_cli/commands/reports.py,sha256=mgpg_sSNfAkDD40NI32wJH2-HGSwU2gryUhGYLTXB0Y,12849
30
31
  tl_cli/commands/schema.py,sha256=pMCVmX_v0HuCnLiJzAG0RZqmrKEo2g2uH8YcUid7kQw,1511
31
32
  tl_cli/commands/setup.py,sha256=7u74yFj58aYyXfoWbm5SLCS_La4VmSCLTPXHJ789Kb0,15024
@@ -36,22 +37,22 @@ tl_cli/commands/whoami.py,sha256=HL_0X0hWI-NsmWfjgy0mhWO3TNdWztt_AAlWRJysyVk,734
36
37
  tl_cli/output/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
37
38
  tl_cli/output/formatter.py,sha256=3XowEGCLyEGpZy0LoN0r8WI9GfbhyPWSa9PHkBJmPiM,16590
38
39
  tl_cli/_plugin/.claude-plugin/marketplace.json,sha256=l56PMmyjfGXNGlV30wRyOAe74B6gJNCVNCxgsBbSNxc,446
39
- tl_cli/_plugin/.claude-plugin/plugin.json,sha256=_smB5pzJNZAVyuPyf_P6vZtMh9hhedtYyhA28NW99vA,466
40
+ tl_cli/_plugin/.claude-plugin/plugin.json,sha256=XlbdJwde6cpVT3Wlc2szjhlgw-De_3KRdK9g5ouTOTA,466
40
41
  tl_cli/_plugin/agents/tl-analyst.md,sha256=5ho4gyZOjqeVwQKhg4jtgye4WI0BAFem4oBzfhqUBJQ,6735
41
42
  tl_cli/_plugin/commands/tl-balance.md,sha256=0sQjVBu1xZXmzoTBqFZF8MhYAsuc9F7Vci23nbTDZ9Q,204
42
43
  tl_cli/_plugin/commands/tl-reports.md,sha256=uB1u9gvcSSgxsXEPfHvT1iZjSxm-jH63nUbs_wsS5_o,511
43
44
  tl_cli/_plugin/commands/tl-sponsorships.md,sha256=z4QUzsK608GsOLqawvyqd2w1voVQOlNod_4CoyHs3DU,2353
44
- tl_cli/_plugin/commands/tl.md,sha256=_K0seOwgz7CojXjVAI-TGdW01pbZYrUyNKC5qmvWKK8,1525
45
+ tl_cli/_plugin/commands/tl.md,sha256=9DB7P_sLeJDe6RYDjjowZvhNTEa_wbAkCa7wPccXfhU,1692
45
46
  tl_cli/_plugin/hooks/hooks.json,sha256=lU4w5iZGSnVJ1sZn18kbJGh2s-iOvG3TtAXmiVK61mE,476
46
47
  tl_cli/_plugin/hooks/scripts/post-usage.sh,sha256=WVvZLkZik6lbeZ20Kh-wgm4JkRFHFN0Uwl4C8S3Y0sY,759
47
48
  tl_cli/_plugin/hooks/scripts/pre-check.sh,sha256=E9KeuXy6yeHEBOnOFW4hDW-Et-Dbp1Oh--3WXKfOX78,898
48
- tl_cli/_plugin/skills/tl/SKILL.md,sha256=ileEo9IWpEQiZV1OtD6l-Hym0vjfe8SxaywaM1D2gkI,33131
49
+ tl_cli/_plugin/skills/tl/SKILL.md,sha256=fWNsHQdBmHLXnVuyOZhfTVPTfwedL5K9K2YcRxjyL-E,37237
49
50
  tl_cli/_plugin/skills/tl/references/business-glossary.md,sha256=PNQIxpIZ0zIxb3nHnvPeKUSFpjvnm4Q-950nQkNkfUM,7598
50
51
  tl_cli/_plugin/skills/tl/references/elasticsearch-schema.md,sha256=c7zFBj3PHweyw7AhrV_-Fy0GAxz9aralWdDSC16yd_s,10713
51
- tl_cli/_plugin/skills/tl/references/firebolt-schema.md,sha256=WocgEZSoa6phbbuWT49FsGPCWliq1QvA0hC_J0u6vcc,10043
52
+ tl_cli/_plugin/skills/tl/references/firebolt-schema.md,sha256=3Y9lV-2zMJRNgyXCQ80djzBHECEA5HikqCF-jwYa4kY,10187
52
53
  tl_cli/_plugin/skills/tl/references/postgres-schema.md,sha256=cBTclNLCh15MWVDRKM9VCLX7WVgfKk_bLxJrFEkheSQ,10677
53
- thoughtleaders_cli-0.5.2.dist-info/METADATA,sha256=3hu2QjQNI3nM02S4eQ7JyFQH0G9Yg6v-GyfqpQhNMaE,7786
54
- thoughtleaders_cli-0.5.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
55
- thoughtleaders_cli-0.5.2.dist-info/entry_points.txt,sha256=umZp-1BkGkHDG0bNZXpTXrjwW0HGf9IDFN40eAWuuvg,39
56
- thoughtleaders_cli-0.5.2.dist-info/licenses/LICENSE,sha256=RUfdfLsn6jygiyrnnVUHt6r4IPwr2rbDm9Kixgtu8fo,1071
57
- thoughtleaders_cli-0.5.2.dist-info/RECORD,,
54
+ thoughtleaders_cli-0.6.2.dist-info/METADATA,sha256=TniBGXT8q8X80Hf-KKDu2xqHETNOCHcFjUJjfdfqOFk,9127
55
+ thoughtleaders_cli-0.6.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
56
+ thoughtleaders_cli-0.6.2.dist-info/entry_points.txt,sha256=umZp-1BkGkHDG0bNZXpTXrjwW0HGf9IDFN40eAWuuvg,39
57
+ thoughtleaders_cli-0.6.2.dist-info/licenses/LICENSE,sha256=RUfdfLsn6jygiyrnnVUHt6r4IPwr2rbDm9Kixgtu8fo,1071
58
+ thoughtleaders_cli-0.6.2.dist-info/RECORD,,
tl_cli/__init__.py CHANGED
@@ -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.2"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tl-cli",
3
- "version": "0.5.2",
3
+ "version": "0.6.2",
4
4
  "description": "ThoughtLeaders CLI — query sponsorship deals, channels, brands, uploads, and intelligence from the terminal",
5
5
  "author": {
6
6
  "name": "ThoughtLeaders",
@@ -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 recommender top-channels "cooking" --limit 50` (then post-filter by `subscribers >= 100000` on the resulting IDs)
22
+ - "/tl mobile-first US cooking channels" → `tl recommender top-channels "cooking" --limit 100` (then narrow by `demographic_device_primary = 'mobile'` / `demographic_usa_share >= 50` with raw SQL on the resulting IDs)
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`
@@ -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
@@ -130,14 +130,20 @@ tl proposals show <id> # Proposal detail (2 credits)
130
130
  tl proposals create --channel <id> --brand <id> # Create proposal (free)
131
131
  tl uploads list [filters...] # Video uploads from ES — list curve, mult 1.0
132
132
  tl uploads show <id> # Upload detail (2 credits)
133
- tl channels list [filters...] # Channel searchlist curve, mult 1.0
134
- tl channels show <id-or-name> # Channel detail (2 credits; accepts numeric ID or name)
133
+ tl channels show <id-or-name> # Channel detail (2 credits; accepts numeric ID or name) for channel search use raw SQL on thoughtleaders_channel
135
134
  tl channels history <id-or-name> # Sponsorship history (5 credits/result, linear)
136
135
  tl channels similar <id-or-name> # Vector-similarity recommender (50 credits flat; Intelligence plan)
137
136
  tl brands show <id-or-name> # Brand detail (1 credit)
138
137
  tl brands history <id-or-name> # Sponsorship history (5 credits/result, linear)
139
138
  tl brands history <query> --channel <id> # Brand mentions on specific channel
140
139
  tl brands similar <id-or-name> # Find similar brands via profile vector KNN (50 credits flat)
140
+ tl recommender tags [query] # List vector tag names — categories, demographics, formats (free)
141
+ tl recommender top-channels "<tag>" # Top channels loaded on a vector tag (50 credits; Intelligence)
142
+ tl recommender top-profiles "<tag>" # Top brand profiles loaded on a vector tag (50 credits)
143
+ tl recommender top-brands "<tag>" # Top brands (deduped from profiles) loaded on a vector tag (50 credits)
144
+ tl recommender inspect-channel <ref> # Show a channel's feature-vector breakdown (50 credits; Intelligence)
145
+ tl recommender inspect-brand <ref> # Show a brand profile's ideal-vector breakdown (50 credits; Intelligence)
146
+ tl recommender similar-to-profile <id> # Channels closest to a brand profile's ideal vector (50 credits; Intelligence)
141
147
  tl snapshots channel <id> # Channel metrics over time — list curve, mult 1.2 (Firebolt-backed)
142
148
  tl snapshots video <id> --channel <id> # Video view curve — list curve, mult 1.2 (--channel required!)
143
149
  tl reports # List saved reports — list curve, mult 1.3
@@ -148,7 +154,7 @@ tl comments add <adlink-id> "msg" # Add comment (free)
148
154
 
149
155
  **"List curve"** above means non-linear pricing: `cost = 1 + mult × 0.126 × n^1.2`. The flat 1-credit setup applies to every list call; the `mult` reflects per-resource complexity. `tl db {pg,fb,es}` shares the same curve at mult=1.4. Concrete totals:
150
156
 
151
- | Rows | mult=1.0 (channels, brands, comments, uploads, sponsorships) | mult=1.2 (snapshots) | mult=1.3 (reports) | mult=1.4 (db.pg / db.fb / db.es) |
157
+ | Rows | mult=1.0 (comments, uploads, sponsorships) | mult=1.2 (snapshots) | mult=1.3 (reports) | mult=1.4 (db.pg / db.fb / db.es) |
152
158
  |---:|---:|---:|---:|---:|
153
159
  | 1 | 1 | 1 | 1 | 1 |
154
160
  | 10 | 3 | 3 | 4 | 4 |
@@ -278,10 +284,10 @@ See [references/business-glossary.md](references/business-glossary.md) for reven
278
284
 
279
285
  | Capability | Status | Workaround |
280
286
  |---|---|---|
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. |
287
+ | 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`. |
288
+ | 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. |
289
+ | **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. |
290
+ | 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
291
  | 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
292
  | 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
293
  | 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 +315,52 @@ tl changelog --md > CHANGELOG.md # Capture for a doc
309
315
  `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
316
 
311
317
  ### Filter syntax
312
- All list commands accept `key:value` filters:
318
+ Structured list commands accept `key:value` filters (use them for trivially simple lookups):
313
319
  ```bash
314
320
  tl sponsorships list status:sold brand:"Nike" purchase-date:2026-01
315
321
  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
322
  ```
322
323
 
323
324
  Date filters accept keywords: `today`, `yesterday`, `tomorrow`.
324
325
 
325
- #### Channel demographic filters
326
+ #### Channel discovery — recommender first, raw SQL second
327
+
328
+ 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."
329
+
330
+ ```bash
331
+ # Discover the right tag name first (free)
332
+ tl recommender tags cooking
333
+ tl recommender tags "usa"
334
+
335
+ # Top channels & profiles loaded on a vector tag (50 credits; Intelligence)
336
+ tl recommender top-channels "Cooking" msn:yes --limit 50
337
+ tl recommender top-channels "Tech" --limit 30
338
+ tl recommender top-brands "USA share" mbn:yes --limit 50
339
+ ```
326
340
 
327
- These filters apply to both `tl channels list` and `tl sponsorships list` (the latter filters by the associated channel's demographics):
341
+ 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
342
 
329
343
  ```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
344
+ # All TPP (TL-managed) channels — pure attribute filter, not a category query
345
+ tl db pg "SELECT id, channel_name, content_category, total_views
346
+ FROM thoughtleaders_channel
347
+ WHERE is_tl_channel = TRUE
348
+ ORDER BY total_views DESC
349
+ LIMIT 200 OFFSET 0"
350
+
351
+ # Mobile-first non-TPP channels device share, not topic
352
+ tl db pg "SELECT id, channel_name, demographic_device_primary, total_views
353
+ FROM thoughtleaders_channel
354
+ WHERE is_tl_channel = FALSE
355
+ AND demographic_device_primary = 'mobile'
356
+ ORDER BY total_views DESC
357
+ LIMIT 100 OFFSET 0"
347
358
  ```
348
359
 
360
+ 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.
361
+
362
+ **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. For MSN-only / non-MSN lookups, run the same raw SQL with `media_selling_network_join_date IS [NOT] NULL` from a context that has access to it (full-access role), or rely on the recommender's MSN-aware filters: `tl recommender top-channels "<tag>" msn:yes|no|all`.
363
+
349
364
  ### Output flags
350
365
  - `--json` — structured JSON (use this for parsing)
351
366
  - `--csv` — CSV output
@@ -382,7 +397,7 @@ Every query costs credits. Before running expensive queries:
382
397
  Users only see data their plan allows:
383
398
  - **Media buyers** see deals where their org is the brand. They see `price` but never `cost`.
384
399
  - **Media sellers** see deals where their org is the publisher. They see `cost` but never `price`.
385
- - **Intelligence plan** required for `tl brands`, full `tl channels list` search, and full `tl uploads list`.
400
+ - **Intelligence plan** required for `tl brands`, the full `tl recommender` surface, and full `tl uploads list`.
386
401
  - **Paid plan** required for `tl snapshots`.
387
402
 
388
403
  ## Important: Status Labels
@@ -417,9 +432,19 @@ tl reports --json # Find the report ID first
417
432
  tl reports run 42 --json
418
433
  ```
419
434
 
420
- "Find mobile-first US channels in cooking":
435
+ "Find Cooking channels with US-heavy mobile audiences":
421
436
  ```bash
422
- tl channels list category:cooking primary-device:mobile min-us-share:50 --json
437
+ # Use the vector recommender for the topic, then narrow with structured filters / SQL on the IDs.
438
+ tl recommender top-channels "Cooking" msn:yes --limit 100 --json \
439
+ | jq -r '.results[].channel_id' \
440
+ | paste -sd, - \
441
+ | xargs -I {} tl db pg "SELECT id, channel_name, total_views, demographic_usa_share
442
+ FROM thoughtleaders_channel
443
+ WHERE id IN ({})
444
+ AND demographic_device_primary = 'mobile'
445
+ AND demographic_usa_share >= 50
446
+ ORDER BY total_views DESC
447
+ LIMIT 50 OFFSET 0" --json
423
448
  ```
424
449
 
425
450
  "Show sold sponsorships targeting mobile US audiences":
@@ -438,3 +463,17 @@ tl channels similar 29834 tpp:yes --limit 30 # TPP (TL-managed)
438
463
  tl channels similar 29834 min-subs:1000000 exclude:477487 --limit 15 # client-side filters
439
464
  ```
440
465
  **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.
466
+
467
+ "Browse the vector recommender" (categories, demographics, formats — `tl recommender tags` is free):
468
+ ```bash
469
+ tl recommender tags # Full tag list (free)
470
+ tl recommender tags cooking # Search tag names by substring
471
+ tl recommender top-channels "Cooking" msn:yes --limit 50 # Top channels loaded on a tag (50 credits)
472
+ tl recommender top-profiles "Cooking" --limit 30 # Top brand profiles for the tag
473
+ tl recommender top-brands "USA share" mbn:yes --limit 30 # Top brands (deduped) — demographic tag, MBN only
474
+ tl recommender top-channels "Tech" exclude-for-profile:842 # Drop channels already proposed for profile 842
475
+ tl recommender inspect-channel 29834 # Per-tag breakdown of a channel's vector
476
+ tl recommender inspect-brand Nike # Per-tag breakdown of a brand's ideal vector
477
+ tl recommender similar-to-profile 842 --limit 30 # Channels closest to a brand profile's ideal vector
478
+ ```
479
+ 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 \
@@ -11,7 +11,7 @@ from tl_cli.filters import parse_filters
11
11
  from tl_cli.hints import detail_hint
12
12
  from tl_cli.output.formatter import detect_format, output, output_single
13
13
 
14
- app = typer.Typer(help="YouTube channels (search, detail, and similar-channel recommendations)")
14
+ app = typer.Typer(help="YouTube channels (detail, history, and similar-channel recommendations)")
15
15
 
16
16
  # Columns for the `similar` endpoint result table. The server enriches every
17
17
  # row so the user can size up each suggestion without follow-up queries.
@@ -25,50 +25,6 @@ SIMILAR_COLUMN_CONFIG = {
25
25
  }
26
26
 
27
27
 
28
- @app.callback(invoke_without_command=True)
29
- def channels(ctx: typer.Context) -> None:
30
- """YouTube channels — search and detail."""
31
- if ctx.invoked_subcommand is None:
32
- ctx.invoke(list_cmd, args=[], json_output=False, csv_output=False, md_output=False, limit=50, offset=0)
33
-
34
-
35
- @app.command("list")
36
- def list_cmd(
37
- args: list[str] = typer.Argument(None, help="Filters (key:value pairs). Run 'tl describe show channels' for available filters."),
38
- json_output: bool = typer.Option(False, "--json", help="JSON output"),
39
- csv_output: bool = typer.Option(False, "--csv", help="CSV output"),
40
- md_output: bool = typer.Option(False, "--md", help="Markdown output"),
41
- toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
42
- limit: int = typer.Option(50, "--limit", "-l", help="Max results"),
43
- offset: int = typer.Option(0, "--offset", help="Pagination offset"),
44
- ) -> None:
45
- """Search channels with optional filters.
46
-
47
- Examples:
48
- tl channels list # List channels
49
- tl channels list category:cooking min-subs:100k # Search with filters
50
- """
51
- fmt = detect_format(json_output, csv_output, md_output, toon_output)
52
- filters = parse_filters(args or [])
53
-
54
- client = get_client()
55
- try:
56
- params = {**filters, "limit": str(limit), "offset": str(offset)}
57
- data = client.get("/channels", params=params)
58
- for r in data.get("results", []):
59
- r["channel_id"] = r.pop("id", None)
60
- output(
61
- data,
62
- fmt,
63
- columns=["channel_id", "name", "url", "msn", "tpp", "subscribers", "gender", "countries", "category", "sponsorship_score", "trend"],
64
- title="Channels",
65
- )
66
- except ApiError as e:
67
- handle_api_error(e)
68
- finally:
69
- client.close()
70
-
71
-
72
28
  @app.command("show")
73
29
  def show_cmd(
74
30
  channel_ref: str = typer.Argument(..., help="Channel ID (numeric) or name (partial match, must be unique)"),
@@ -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()
tl_cli/main.py CHANGED
@@ -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")