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.
- {thoughtleaders_cli-0.5.2.dist-info → thoughtleaders_cli-0.6.2.dist-info}/METADATA +25 -8
- {thoughtleaders_cli-0.5.2.dist-info → thoughtleaders_cli-0.6.2.dist-info}/RECORD +13 -12
- tl_cli/__init__.py +1 -1
- tl_cli/_plugin/.claude-plugin/plugin.json +1 -1
- tl_cli/_plugin/commands/tl.md +2 -2
- tl_cli/_plugin/skills/tl/SKILL.md +83 -44
- tl_cli/_plugin/skills/tl/references/firebolt-schema.md +6 -5
- tl_cli/commands/channels.py +1 -45
- tl_cli/commands/recommender.py +314 -0
- tl_cli/main.py +2 -0
- {thoughtleaders_cli-0.5.2.dist-info → thoughtleaders_cli-0.6.2.dist-info}/WHEEL +0 -0
- {thoughtleaders_cli-0.5.2.dist-info → thoughtleaders_cli-0.6.2.dist-info}/entry_points.txt +0 -0
- {thoughtleaders_cli-0.5.2.dist-info → thoughtleaders_cli-0.6.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: thoughtleaders-cli
|
|
3
|
-
Version: 0.
|
|
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
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
tl
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
54
|
-
thoughtleaders_cli-0.
|
|
55
|
-
thoughtleaders_cli-0.
|
|
56
|
-
thoughtleaders_cli-0.
|
|
57
|
-
thoughtleaders_cli-0.
|
|
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
tl_cli/_plugin/commands/tl.md
CHANGED
|
@@ -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
|
|
22
|
-
- "/tl mobile-first US cooking channels" → `tl channels
|
|
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.
|
|
70
|
-
- **TPP** (ThoughtLeaders Partner Program, a.k.a. "TL channels") — the smaller, exclusive ~169 channels TL manages directly.
|
|
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)
|
|
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
|
|
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
|
|
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 (
|
|
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`,
|
|
282
|
-
| Cross-reference helpers ("channels proposed to brand X", "channels sponsored by MBN brands in last N days") | **
|
|
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.
|
|
284
|
-
| Pre-insert validation queries (joining `adspot ↔ channel ↔ profile ↔ org` to confirm MSN, integration=1, persona, plan) | **
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
331
|
-
tl
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
tl
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
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
|
|
435
|
+
"Find Cooking channels with US-heavy mobile audiences":
|
|
421
436
|
```bash
|
|
422
|
-
|
|
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
|
|
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
|
|
89
|
-
| Deal / pipeline / sponsorship data | **`tl
|
|
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
|
|
120
|
-
tl channels
|
|
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 \
|
tl_cli/commands/channels.py
CHANGED
|
@@ -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 (
|
|
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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|