thoughtleaders-cli 0.9.1__py3-none-any.whl → 0.9.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.9.1.dist-info → thoughtleaders_cli-0.9.2.dist-info}/METADATA +1 -1
- {thoughtleaders_cli-0.9.1.dist-info → thoughtleaders_cli-0.9.2.dist-info}/RECORD +19 -19
- {thoughtleaders_cli-0.9.1.dist-info → thoughtleaders_cli-0.9.2.dist-info}/WHEEL +1 -1
- tl_cli/__init__.py +1 -1
- tl_cli/_plugin/.claude-plugin/plugin.json +1 -1
- tl_cli/_plugin/skills/tl/SKILL.md +8 -10
- tl_cli/_plugin/skills/tl/references/business-glossary.md +3 -2
- tl_cli/_plugin/skills/tl/references/elasticsearch-schema.md +96 -77
- tl_cli/_plugin/skills/tl/references/postgres-schema.md +51 -5
- tl_cli/_plugin/skills/tl-keyword-research/SKILL.md +2 -2
- tl_cli/_plugin/skills/tl-keyword-research/references/elasticsearch-content-search.md +27 -16
- tl_cli/_plugin/skills/tl-keyword-research/references/help.md +1 -1
- tl_cli/_plugin/skills/tl-keyword-research/scripts/build_report.py +6 -6
- tl_cli/_plugin/skills/tl-keyword-research/scripts/probe.py +9 -1
- tl_cli/_plugin/skills/tl-save-report/SKILL.md +1 -1
- tl_cli/_plugin/skills/tl-save-report/references/report_glossary.md +1 -1
- tl_cli/output/formatter.py +16 -0
- {thoughtleaders_cli-0.9.1.dist-info → thoughtleaders_cli-0.9.2.dist-info}/entry_points.txt +0 -0
- {thoughtleaders_cli-0.9.1.dist-info → thoughtleaders_cli-0.9.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.9.
|
|
3
|
+
Version: 0.9.2
|
|
4
4
|
Summary: ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence
|
|
5
5
|
Project-URL: Homepage, https://thoughtleaders.io
|
|
6
6
|
Project-URL: Repository, https://github.com/ThoughtLeaders-io/thoughtleaders-cli
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
tl_cli/__init__.py,sha256=
|
|
1
|
+
tl_cli/__init__.py,sha256=SS6LM2unjLV5RGfYCqFk1Gy1oudZaGmAocUwDxSiB4A,112
|
|
2
2
|
tl_cli/_completions.py,sha256=kOyEUqC26vbYvyXWi513WX8fF73qQLR5WWuRSe_wqyk,164
|
|
3
3
|
tl_cli/_typer_utils.py,sha256=ZiZsCVmEznPvBw-dYbr3tu3zWZ0iN6kjoQmK3gMqD28,860
|
|
4
4
|
tl_cli/config.py,sha256=UV_OYTXuQnAIqbi_oVCXx0hhIdZWR678RRapVv51UwQ,1859
|
|
@@ -39,9 +39,9 @@ tl_cli/commands/sponsorships.py,sha256=PdWCm9E4mrs_qlHtckMp99JmuRP94hthlqoWMi0k4
|
|
|
39
39
|
tl_cli/commands/uploads.py,sha256=Tf9tqAEm9FGe3A7sr_EDX9OzdNInCmrWNr10wWGuMUo,1526
|
|
40
40
|
tl_cli/commands/whoami.py,sha256=aUXwBRwh1vAGrvz8CKGfHYtEOKJCIDfwrGesKAwYZMk,7866
|
|
41
41
|
tl_cli/output/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
42
|
-
tl_cli/output/formatter.py,sha256=
|
|
42
|
+
tl_cli/output/formatter.py,sha256=2FOsZb9aGcjKAGvumtEyS63cjIk47JcQGNM0K7Gpdp8,23413
|
|
43
43
|
tl_cli/_plugin/.claude-plugin/marketplace.json,sha256=l56PMmyjfGXNGlV30wRyOAe74B6gJNCVNCxgsBbSNxc,446
|
|
44
|
-
tl_cli/_plugin/.claude-plugin/plugin.json,sha256=
|
|
44
|
+
tl_cli/_plugin/.claude-plugin/plugin.json,sha256=MlM05A2LBDupXFroa0AsFQ5Yf1ouQa79wgfhXMt6yeA,466
|
|
45
45
|
tl_cli/_plugin/agents/keyword-context-classifier.md,sha256=8Pk6WqJKuF-ERkBh54Aa-9t60CvjdOIH0RurFlG-T7I,4325
|
|
46
46
|
tl_cli/_plugin/agents/keyword-entity-resolver.md,sha256=fOAeTZa2-h7te8dEXLFcmiL2tj6IXwnQ0ChUrym3yFc,5619
|
|
47
47
|
tl_cli/_plugin/agents/keyword-relevance-validator.md,sha256=uPUqobAVT7xq-brRHtNaFSTns-lYyCu3MwTuU5tn538,3127
|
|
@@ -51,11 +51,11 @@ tl_cli/_plugin/hooks/hooks.json,sha256=FSWibw1xAjA-suFV3fR8btIb2kQ82LQ08otTr-Npm
|
|
|
51
51
|
tl_cli/_plugin/hooks/scripts/load-tl-skill.mjs,sha256=EBsyZ-caei-CBJsRtqzJXJs_20O3H22MuVmDpu96umo,805
|
|
52
52
|
tl_cli/_plugin/hooks/scripts/post-usage.sh,sha256=WVvZLkZik6lbeZ20Kh-wgm4JkRFHFN0Uwl4C8S3Y0sY,759
|
|
53
53
|
tl_cli/_plugin/hooks/scripts/pre-check.sh,sha256=inHopPXQ9h-KRcHuebEIQ-1EZmsTaHhAAMCFQ0MyiEQ,820
|
|
54
|
-
tl_cli/_plugin/skills/tl/SKILL.md,sha256=
|
|
55
|
-
tl_cli/_plugin/skills/tl/references/business-glossary.md,sha256=
|
|
56
|
-
tl_cli/_plugin/skills/tl/references/elasticsearch-schema.md,sha256=
|
|
54
|
+
tl_cli/_plugin/skills/tl/SKILL.md,sha256=A-hdUWXeJKd8fDlhe3qOVd1s1a64E77wldTAQKa-TTs,66805
|
|
55
|
+
tl_cli/_plugin/skills/tl/references/business-glossary.md,sha256=YZ_UWygtAZB8Mfh9NPG2ld0OnmgknSBzBOpVZwN-wzs,19287
|
|
56
|
+
tl_cli/_plugin/skills/tl/references/elasticsearch-schema.md,sha256=x7ZUYGbR6FJG02BSA9BjveoiC4X-7VsYxhLPBK4IH_c,23195
|
|
57
57
|
tl_cli/_plugin/skills/tl/references/firebolt-schema.md,sha256=tysPKBxqQFRzfKi2vmEhHXn8PGaznep7yaKwSWZY3Jo,11110
|
|
58
|
-
tl_cli/_plugin/skills/tl/references/postgres-schema.md,sha256=
|
|
58
|
+
tl_cli/_plugin/skills/tl/references/postgres-schema.md,sha256=weZOzXUmkRsr4JYxv8qBWO0QMkEkUmKGlkUYjygd0Ro,27711
|
|
59
59
|
tl_cli/_plugin/skills/tl-channel-authenticity/.gitignore,sha256=EMeiIlgw10A-WzjTnENZs8jXAH9n2wT0Bn3vQ7tOP-E,121
|
|
60
60
|
tl_cli/_plugin/skills/tl-channel-authenticity/SKILL.md,sha256=4vVIJu7DXjyahvuCWSeYQL2_H2KILKh7s5RyX-fBjpY,6289
|
|
61
61
|
tl_cli/_plugin/skills/tl-channel-authenticity/references/comment-patterns.md,sha256=ne-2mSDxJDKb8HswRltvEIEh7iDDWckDqnWmdFQBbrI,2035
|
|
@@ -75,24 +75,24 @@ tl_cli/_plugin/skills/tl-channel-authenticity/scripts/score.py,sha256=LwhTMf9eyO
|
|
|
75
75
|
tl_cli/_plugin/skills/tl-channel-authenticity/scripts/tl_cli.py,sha256=Mu__ZzcELuQBPJ8Ia6GiLbW1ZtqI1aQklBeRPAw9JNk,9045
|
|
76
76
|
tl_cli/_plugin/skills/tl-channel-authenticity/scripts/video_integrity.py,sha256=Pj9cZkFHX-fwfmkSjicy_B04fQ3TW3gLrGliHwCwX5g,11120
|
|
77
77
|
tl_cli/_plugin/skills/tl-channel-authenticity/scripts/view_curves.py,sha256=wx4cgi_HSOBDEy9bI_8RGnAFpY8q8xHOlF7DYojmi0c,4447
|
|
78
|
-
tl_cli/_plugin/skills/tl-keyword-research/SKILL.md,sha256=
|
|
79
|
-
tl_cli/_plugin/skills/tl-keyword-research/references/elasticsearch-content-search.md,sha256=
|
|
80
|
-
tl_cli/_plugin/skills/tl-keyword-research/references/help.md,sha256=
|
|
81
|
-
tl_cli/_plugin/skills/tl-keyword-research/scripts/build_report.py,sha256=
|
|
78
|
+
tl_cli/_plugin/skills/tl-keyword-research/SKILL.md,sha256=wvLvlumbR5fDxL-PZAqRDu7azYl4MXe1A5vOBn8YghE,38264
|
|
79
|
+
tl_cli/_plugin/skills/tl-keyword-research/references/elasticsearch-content-search.md,sha256=fsXbpI3DPtkOnPEquB11Efur665o963RDN4-WIJCRJs,24263
|
|
80
|
+
tl_cli/_plugin/skills/tl-keyword-research/references/help.md,sha256=yTmoU-Z6rPDjprbDa09nePoON0jnRSh_FVc7lqu1XGs,6802
|
|
81
|
+
tl_cli/_plugin/skills/tl-keyword-research/scripts/build_report.py,sha256=oHCYfhFc006QhAv17R5uahpOMk38N1_YH_Cn6QMhQg4,22136
|
|
82
82
|
tl_cli/_plugin/skills/tl-keyword-research/scripts/expand_entities.py,sha256=yYRfGRTl762Kvpv1xH_3nDZfShaX8BHoTRtmHyCsLk8,10886
|
|
83
83
|
tl_cli/_plugin/skills/tl-keyword-research/scripts/fetch_context.py,sha256=dn4n-WPjz4lib42hrDxrTN_xwqy1GZDpURE-IE6OKkg,6877
|
|
84
|
-
tl_cli/_plugin/skills/tl-keyword-research/scripts/probe.py,sha256=
|
|
84
|
+
tl_cli/_plugin/skills/tl-keyword-research/scripts/probe.py,sha256=eWmN8406QgrAcK_gvxNn63TX_NuF7Qk-X_CYK_MdMdU,28270
|
|
85
85
|
tl_cli/_plugin/skills/tl-keyword-research/scripts/search_channels.py,sha256=qoe70bfpSWujO12eFP-OnfrqwbI7FgWTP_9aWavw3-U,23189
|
|
86
86
|
tl_cli/_plugin/skills/tl-keyword-research/scripts/search_videos.py,sha256=Q_fYr1BzrMr5nh9wC1ruBJuIQFzNBkkBnYNMmsSEnc4,14568
|
|
87
87
|
tl_cli/_plugin/skills/tl-keyword-research/scripts/select_keywords.py,sha256=eiavZfs-i2zAarR4k1_RT5wgqCr21T42S0AmwrXQid8,9359
|
|
88
|
-
tl_cli/_plugin/skills/tl-save-report/SKILL.md,sha256=
|
|
88
|
+
tl_cli/_plugin/skills/tl-save-report/SKILL.md,sha256=peAdgudSuSbj6Xj8P1ek9J-hRNS45DE7XOvUttKdUXs,37240
|
|
89
89
|
tl_cli/_plugin/skills/tl-save-report/references/columns_brands.md,sha256=H5G308GOzMWvWw6smkPGdb2CBuuxkrojCpJgWS9X4sA,3559
|
|
90
90
|
tl_cli/_plugin/skills/tl-save-report/references/columns_channels.md,sha256=g57ET_VI611lB7fVrGwYFvTDqMkNB63OmmfrnbMZF1I,4684
|
|
91
91
|
tl_cli/_plugin/skills/tl-save-report/references/columns_content.md,sha256=Y9YL5sm7zK44vSHTXOV4NY7nSOcJ9i3D0HFCo1g63WU,3466
|
|
92
92
|
tl_cli/_plugin/skills/tl-save-report/references/columns_sponsorships.md,sha256=I--uUuluGz2xBfOCaUq7fc3nrSSSpjB3PdT50GmOY7g,4918
|
|
93
93
|
tl_cli/_plugin/skills/tl-save-report/references/intelligence_filterset_schema.json,sha256=RDzJO6WSTKWpTVgp2AZOP7-TcIq0ox5wIFGazb2ZDto,18498
|
|
94
94
|
tl_cli/_plugin/skills/tl-save-report/references/intelligence_widget_schema.json,sha256=cxdlzmkt5WoI20zGsq_4qENTc6Ral9_vI5YTx2yzf-g,11503
|
|
95
|
-
tl_cli/_plugin/skills/tl-save-report/references/report_glossary.md,sha256=
|
|
95
|
+
tl_cli/_plugin/skills/tl-save-report/references/report_glossary.md,sha256=YkUxEkUVui9HVC45BYBoyKVuyc_sLmf2GkSAEksmzW0,12165
|
|
96
96
|
tl_cli/_plugin/skills/tl-save-report/references/sortable_columns.json,sha256=4X878VptWmS1ETfKZSXJ3U0E0lOw4JtVXsZTVHTsPUc,7571
|
|
97
97
|
tl_cli/_plugin/skills/tl-save-report/references/sponsorship_filterset_schema.json,sha256=Ip97J_BeC6EbEowT5PvO3QxrCsB2sc2IWm_c0M0xXLw,11739
|
|
98
98
|
tl_cli/_plugin/skills/tl-save-report/references/sponsorship_widget_schema.json,sha256=PXEhZzRTNl94fFHpsBuo4Gop-Mdveg6GCaIm-ZGhBns,9587
|
|
@@ -101,8 +101,8 @@ tl_cli/_plugin/skills/tl-top-partnerships/SKILL.md,sha256=jOMMr40XRnAZv-oRLeyKJT
|
|
|
101
101
|
tl_cli/_plugin/skills/tl-top-partnerships/scripts/top_partnerships.py,sha256=OqhoyvFe1zrtrStChcam7oPYgFnSbGHC3WAgLhLIC3w,13738
|
|
102
102
|
tl_cli/_plugin/skills/tl-views-guarantee/SKILL.md,sha256=IH7q1WJDWri9TWJMiga1FMGJO_GKSbWwaDS6CVNZ9c0,9270
|
|
103
103
|
tl_cli/_plugin/skills/tl-views-guarantee/scripts/vg.py,sha256=Qp5poinHEqh9374anq0bLtlxj2YL6ipBicaT960-Cws,15825
|
|
104
|
-
thoughtleaders_cli-0.9.
|
|
105
|
-
thoughtleaders_cli-0.9.
|
|
106
|
-
thoughtleaders_cli-0.9.
|
|
107
|
-
thoughtleaders_cli-0.9.
|
|
108
|
-
thoughtleaders_cli-0.9.
|
|
104
|
+
thoughtleaders_cli-0.9.2.dist-info/METADATA,sha256=peW38EK69xvAHI_7RdN-jNT_K79f2bCbV4k7DjyT8Z0,19083
|
|
105
|
+
thoughtleaders_cli-0.9.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
106
|
+
thoughtleaders_cli-0.9.2.dist-info/entry_points.txt,sha256=umZp-1BkGkHDG0bNZXpTXrjwW0HGf9IDFN40eAWuuvg,39
|
|
107
|
+
thoughtleaders_cli-0.9.2.dist-info/licenses/LICENSE,sha256=RUfdfLsn6jygiyrnnVUHt6r4IPwr2rbDm9Kixgtu8fo,1071
|
|
108
|
+
thoughtleaders_cli-0.9.2.dist-info/RECORD,,
|
tl_cli/__init__.py
CHANGED
|
@@ -242,21 +242,19 @@ tl channels update 12345 '{"demographic_male_share": 62}'
|
|
|
242
242
|
tl channels update 12345 '{"demographic_geo": {"US": 60, "UK": 12, "CA": 8}}'
|
|
243
243
|
tl channels update 12345 '{"demographic_male_share": 55, "demographic_usa_share": 70}'
|
|
244
244
|
tl channels update 12345 '{"outreach_email": "press@creator.com"}'
|
|
245
|
-
tl channels update 12345 '{"all_emails": {"press@creator.com": {"timestamp": "2026-06-25T12:00:00+00:00", "source": "manual"}}}'
|
|
246
245
|
tl channels update 12345 '{"media_selling_network_join_date": "2026-01-15"}'
|
|
247
246
|
```
|
|
248
247
|
|
|
249
|
-
**Channel contact emails.** Besides demographics, `tl channels update` accepts
|
|
250
|
-
contact
|
|
248
|
+
**Channel contact emails.** Besides demographics, `tl channels update` accepts one
|
|
249
|
+
contact field:
|
|
251
250
|
|
|
252
251
|
- **`outreach_email`** — the channel's primary outreach address (a single email string, or `null`).
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
An edit **replaces** the whole `all_emails` object, so include every entry you want to keep.
|
|
252
|
+
|
|
253
|
+
The channel's full email archive (`all_emails` — a JSON object keyed by email address,
|
|
254
|
+
each value recording when and where the address was found) is **not editable here**;
|
|
255
|
+
sending it returns a 400. It is append-only: new addresses are added with the
|
|
256
|
+
internal CLI's `tl-internal channels add-email` command (superuser-only), which never
|
|
257
|
+
modifies or removes existing entries.
|
|
260
258
|
|
|
261
259
|
**MSN membership.** `tl channels update` also accepts **`media_selling_network_join_date`** —
|
|
262
260
|
the date the channel joined the Media Selling Network, as a `YYYY-MM-DD` string. This date is
|
|
@@ -97,7 +97,7 @@ Vocabulary that AMs use about channels, mapped to the actual DB encoding. Most o
|
|
|
97
97
|
| **Brand on a deal** | `adlink → advertiser_profile_id → profile_brands.profile_id → profile_brands.brand_id → brand` | 3-table chain. There is NO direct `brand_id` on adlink. See [postgres-schema.md](postgres-schema.md). |
|
|
98
98
|
| **Channel on a deal** | `adlink.ad_spot_id → adspot.channel_id → channel` | NO direct `channel_id` on adlink. |
|
|
99
99
|
| **Brand-virgin / VPN-virgin (etc.)** | Channel has no `adlink` row joined to any of the target brand_ids | Used in candidate sourcing ("never sponsored by any VPN brand"). Caveat: only catches TL-brokered deals; channels that ran the brand directly (no TL involvement) appear "virgin" but aren't — cross-check ES `sponsored_brand_mentions` before final outreach. |
|
|
100
|
-
| **Channel quality score** *(internal-only)* | `sponsorship_score` on the indexed channel doc + `thoughtleaders_channel.sponsorship_score` (PG) | TL-internal
|
|
100
|
+
| **Channel quality score** *(internal-only)* | `sponsorship_score` on the indexed channel doc + `thoughtleaders_channel.sponsorship_score` (PG) | TL-internal sponsorship track-record score, range 0–10: blends how many distinct brands sponsored the channel over the past 2 years (40%) with how many of them booked repeatedly (60%). Scores below 5 read as little/no track record (the low range is deliberately compressed). **Use it internally to rank/tiebreak candidates, but do NOT quote the raw decimal in AM-facing or external output** — the score isn't documented to AMs and the absolute value isn't meaningful without context. In AM-facing prose, translate to qualitative language: "top-quartile fit," "strongest quality score in the candidate set," "high sponsorship-quality signal." |
|
|
101
101
|
|
|
102
102
|
## Projected Views (PV) — three related but distinct fields
|
|
103
103
|
|
|
@@ -107,6 +107,7 @@ AMs use "PV" loosely. There are three different DB fields, each meaning somethin
|
|
|
107
107
|
|---------|----------|---------------------|
|
|
108
108
|
| **PV (channel baseline)** | `thoughtleaders_channel.projected_views` | Channel-level "typical views per video" used as CPM denominator. ⚠️ Coverage and freshness vary; cross-check Firebolt longform median for hero-tier deals. |
|
|
109
109
|
| **PV (deal-specific)** | `thoughtleaders_adlink.projected_views_at_purchase_date` | Snapshot of projected views at the moment the deal was sold. Use this for historical CPM analysis. |
|
|
110
|
+
| **PV (video, at upload)** — the web app's "video projected views" | `projected_views` on the ES **video** doc | The channel's format-specific PV **frozen when TL first indexed the video** (never updated afterwards). Compare to the channel's current PV to see growth/decline since the upload, or to the video's actual `views` to see over/under-performance vs expectation. See the ES schema reference for the calculation. |
|
|
110
111
|
| **VG (View Guarantee)** | `thoughtleaders_adlink.views_guarantee` | The contractual minimum views the brand is guaranteed. 0/NULL = no guarantee. NOT the same as PV — VG is a contractual floor, PV is an estimate. |
|
|
111
112
|
|
|
112
113
|
When an AM says "what's the PV on this channel?" — they almost always mean `channel.projected_views`. When they say "what was the PV on this deal?" — they mean `adlink.projected_views_at_purchase_date`. When they say "did we hit the VG?" — they mean `adlink.view_guarantee_hit_date IS NOT NULL`.
|
|
@@ -120,7 +121,7 @@ Two derived metrics on the indexed channel doc that AMs use to qualify a channel
|
|
|
120
121
|
| **Fulfillment rate** | `fulfillment_rate` (channel doc, scaled_float) | The share of a channel's content that is sponsored — `sponsored / all` content over the measurement window, expressed as a fraction. Higher = the channel reliably delivers paid integrations. | Quality signal: a high fulfillment rate means past brands have actually run on this channel, not just been pitched. AMs use it to filter out "looks promising but never closes" channels. |
|
|
121
122
|
| **Renewal rate** | `renewal_rate` (channel doc, scaled_float) | The rate at which a brand-channel sponsorship relationship repeats over time, computed from clusters of sponsorship deals between a single subject (channel or brand) and its linked entities, with date-distribution heuristics (default 365-day max interval). | Loyalty signal: a high renewal rate means brands keep coming back to this channel. AMs use it to identify "sticky" channels worth premium positioning, and to flag low-renewal channels as one-shots. |
|
|
122
123
|
|
|
123
|
-
Both metrics live on the channel
|
|
124
|
+
Both metrics live on the **channel docs** in the search index — not on video docs (the embedded `channel.*` object on video docs carries no metrics). Channel pages in TL's product surface these as quality scores; in AM-facing reports, you can quote them as percentages (`0.45 → "45% renewal rate"`).
|
|
124
125
|
|
|
125
126
|
## Industry Terms vs TL Vocabulary
|
|
126
127
|
|
|
@@ -35,107 +35,121 @@ The `doc_type` join field distinguishes video uploads ("articles") from channel
|
|
|
35
35
|
|
|
36
36
|
#### Upload/video Fields (selected — 73 total)
|
|
37
37
|
|
|
38
|
-
Filter with `{"term": {"doc_type": "article"}}`.
|
|
38
|
+
Filter with `{"term": {"doc_type": "article"}}`. Coverage percentages are live `exists` counts (July 2026, ~676M video docs) — they drift slowly as the index grows.
|
|
39
39
|
|
|
40
40
|
| Field | Type | Description |
|
|
41
41
|
|-------|------|-------------|
|
|
42
42
|
| `id` | keyword | Video/article ID. Compound form `<channel_id>:<youtube_id>` (matches PG `adlink.article_id` and ES `_id`). |
|
|
43
|
-
| `title` | text | Video title |
|
|
44
|
-
| `description` | text |
|
|
45
|
-
| `content` | text |
|
|
46
|
-
| `transcript` | text | Raw transcript — stored as YouTube timed-text **XML**, not plain text (see note below) |
|
|
47
|
-
| `transcript_language` | keyword |
|
|
48
|
-
| `summary` | text |
|
|
49
|
-
| `
|
|
50
|
-
| `
|
|
51
|
-
| `
|
|
52
|
-
| `
|
|
53
|
-
| `
|
|
54
|
-
| `
|
|
55
|
-
| `projected_views` | long |
|
|
56
|
-
| `likes` | long | Like count |
|
|
57
|
-
| `comments` | integer | Comment count |
|
|
58
|
-
| `
|
|
59
|
-
| `
|
|
60
|
-
| `
|
|
61
|
-
| `
|
|
62
|
-
| `
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
| `content_aspects` | keyword | Content features/aspects |
|
|
66
|
-
| `language` | keyword | Content language |
|
|
67
|
-
| `country` | keyword | Creator country |
|
|
68
|
-
| `format` | keyword | Platform format |
|
|
69
|
-
| `hashtags` | keyword | Hashtags used |
|
|
70
|
-
| `face_on_screen` | boolean | Whether creator shows face |
|
|
43
|
+
| `title` | text | Video title (~100%) |
|
|
44
|
+
| `description` | text | ⚠️ **Does not exist on video docs** — `exists` matches 0 of ~676M (verified). The video's description text lives in `summary`; `description` is a channel-doc field (the channel's "About this channel" text). |
|
|
45
|
+
| `content` | text | ⚠️ **Podcast episodes only** — the episode's show-notes/body text from the podcast feed (often HTML fragments). ~7% of docs overall; effectively absent on YouTube videos (~256k legacy docs holding flat transcript prose, and 0 YouTube docs since 2025). Never search it for YouTube content — use `summary` / `transcript`. |
|
|
46
|
+
| `transcript` | text | Raw transcript — stored as YouTube timed-text **XML**, not plain text (see note below). ~57% of docs; present on both longform and shorts. |
|
|
47
|
+
| `transcript_language` | keyword | Language code of the caption track the transcript came from (present when `transcript` is) |
|
|
48
|
+
| `summary` | text | ⚠️ **Misleading name — this is the video's creator-written description** (the text under the video: promo links, hashtags, timestamps, subscribe blocks), NOT an AI summary. Verified by sampling old and recent docs. ~86% of docs. This is *the* field for searching video-description text. |
|
|
49
|
+
| `evergreenness` | float | Per-video evergreen score: `(views at age 180d − views at age 30d) / views at age 30d`. ≥ 1 = evergreen (views at day 180 are at least double the day-30 views). Only computed for videos with ≥ 5,000 views published since 2022 (~16% of docs). |
|
|
50
|
+
| `publication_date` | date | When the video was published (~100%) |
|
|
51
|
+
| `discovery_time` | date | When TL first indexed the video. Only ~39% of docs — absent on older docs. |
|
|
52
|
+
| `url` | keyword | Watch/episode URL. **Stored-only: retrievable in `_source` but not searchable** (`exists`/`term` match 0 docs). |
|
|
53
|
+
| `image_url` | keyword | Thumbnail URL on podcast docs; absent on YouTube video docs. Stored-only, not searchable. |
|
|
54
|
+
| `views` | long | View count at the last metrics update (~92%) |
|
|
55
|
+
| `projected_views` | long | **The channel's projected views for this video's format, frozen at the time TL first indexed the video** (~45%) — the web app's "video projected views". TL's prediction of views at age = 30 days, computed **only from the channel's recent uploads of the same content type** (a short's value comes from the channel's shorts, a longform video's from its longform uploads — never mixed): ≥ 4 same-format videos' day-30 views, median-anchored with outliers trimmed. **Never updated after first index**, so it's a snapshot: compare it to the channel doc's current `impression*` to see whether the channel grew or declined since the upload, and to the video's `views` to see whether the video over- or under-performed expectations. Caveats: the format bucket at stamp time is duration-based (≤ 60s → shorts projection; anything longer — including live streams — gets the longform projection), and for channels added to TL after the fact "first indexed" is discovery time, not the publish date. |
|
|
56
|
+
| `likes` | long | Like count (~87%) |
|
|
57
|
+
| `comments` | integer | Comment count (~67%) |
|
|
58
|
+
| `duration` | integer | Video duration in seconds (~100%) |
|
|
59
|
+
| `content_type` | keyword | `longform` / `short` / `live` — the complete value set. ~71% of docs; older docs have none (missing ≠ longform). Podcast/RSS docs have no `content_type`. |
|
|
60
|
+
| `content_aspects` | keyword | Flags: `podcast`, `paid_promotion`, `unlisted` — the complete value set. Only ~2.6% of docs carry any. |
|
|
61
|
+
| `hashtags` | keyword | Hashtags from the video description, stored **without the leading `#`** and lowercase (e.g. `marchmadness`); non-Latin tags appear percent-encoded (`%D0%B0…`) (~32%) |
|
|
62
|
+
| `channel` | object | Embedded channel subset: `channel.id`, `channel.content_category`, `channel.format`, `channel.publication_id`, `channel.country`, `channel.language` — no text fields, no metrics. This is where a video's language/country/format/category live (top-level `language`, `country`, `format`, `content_category` exist only on channel docs). |
|
|
63
|
+
|
|
64
|
+
⚠️ **Channel-doc fields that look like video fields but match 0 video docs:** `total_views`, `engagement`, `duration_live`/`duration_longform`/`duration_shorts`, `language`, `country`, `format`, `content_category`, `face_on_screen`. They live on channel docs (see below); on video docs use the embedded `channel.*` subset where available.
|
|
71
65
|
|
|
72
66
|
#### Brand Mention Fields
|
|
73
67
|
|
|
74
68
|
| Field | Type | Description |
|
|
75
69
|
|-------|------|-------------|
|
|
76
|
-
| `brand_mentions` | nested |
|
|
77
|
-
| `all_brand_mentions` | keyword |
|
|
78
|
-
| `sponsored_brand_mentions` | keyword |
|
|
79
|
-
| `organic_brand_mentions` | keyword |
|
|
80
|
-
| `
|
|
81
|
-
| `not_sponsored_by` | object | Explicitly not sponsored by |
|
|
70
|
+
| `brand_mentions` | nested | Detected brand-mention objects (`id`, `type` organic/sponsored, `field`, `snippet`, `start_ts`/`end_ts`, `position`, `probability`, `detection_tool`) (~12%). Being `nested`, it must be queried with a `nested` query — a plain `{"exists": {"field": "brand_mentions"}}` matches 0 docs. |
|
|
71
|
+
| `all_brand_mentions` | keyword | Brand IDs with any mention — the union of sponsored + organic (~12%) |
|
|
72
|
+
| `sponsored_brand_mentions` | keyword | Brand IDs with a sponsored mention |
|
|
73
|
+
| `organic_brand_mentions` | keyword | Brand IDs with an organic mention |
|
|
74
|
+
| `not_sponsored_by` | object | Brand IDs marked as explicitly *not* sponsoring this video. **Not searchable** (`exists` matches 0 docs). |
|
|
82
75
|
|
|
83
76
|
#### Channel Fields
|
|
84
77
|
|
|
85
|
-
Filter with `{"term": {"doc_type": "channel"}}`.
|
|
78
|
+
Filter with `{"term": {"doc_type": "channel"}}`. ~45.5M channel docs (July 2026); coverage percentages below are live `exists` counts against that total.
|
|
86
79
|
|
|
87
80
|
Contains a denormalized subset of the PostgreSQL channel data.
|
|
88
81
|
|
|
89
|
-
|
|
82
|
+
⚠️ **Channel docs are duplicated** — one channel id can appear as several identical docs (a well-known channel showed 8+ copies). Doc counts ≠ channel counts; dedupe with `collapse` on `id` or aggregate with `cardinality`.
|
|
83
|
+
|
|
84
|
+
⚠️ **Legacy field names** — PostgreSQL and Firebolt renamed these, Elasticsearch did not. Raw ES queries must use the old names; the new names match **0 docs** (verified live, they fail silently):
|
|
85
|
+
|
|
86
|
+
| ES (use this) | PG `thoughtleaders_channel` | Firebolt `channel_metrics` |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `reach` | `subscribers` | `subscribers` |
|
|
89
|
+
| `impression` / `impression_live` / `impression_shorts` | `projected_views*` | `projected_views` |
|
|
90
|
+
| `is_tl_channel` | `is_tpp` | — |
|
|
90
91
|
|
|
91
92
|
| Field | Type | Description |
|
|
92
93
|
|-------|------|-------------|
|
|
93
|
-
| `name` | text | Channel name |
|
|
94
|
-
| `
|
|
95
|
-
| `
|
|
96
|
-
| `
|
|
97
|
-
| `
|
|
98
|
-
| `
|
|
99
|
-
| `
|
|
100
|
-
| `
|
|
101
|
-
| `
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
| `
|
|
106
|
-
| `
|
|
107
|
-
| `
|
|
108
|
-
| `
|
|
109
|
-
| `
|
|
110
|
-
| `
|
|
111
|
-
| `
|
|
112
|
-
| `
|
|
113
|
-
| `
|
|
114
|
-
| `
|
|
115
|
-
| `
|
|
116
|
-
| `
|
|
117
|
-
| `
|
|
118
|
-
| `
|
|
119
|
-
| `
|
|
120
|
-
| `
|
|
121
|
-
| `
|
|
122
|
-
| `
|
|
94
|
+
| `name` | text | Channel display name (~100%) |
|
|
95
|
+
| `description` | text | The channel's creator-written YouTube "About this channel" text (~97%): usually first-person, links, promo. Especially worth investigating if it contains creator contact information. |
|
|
96
|
+
| `description.domains` | text | Same "About this channel" text, additionally indexed so **website domains are searchable as single terms** — `{"term": {"description.domains": "patreon.com"}}` matches channels whose About text *links to* patreon.com anywhere (including inside full URLs), while plain topic words match identically to `description` (verified: same counts). This is the field the platform's `channel_description` report filter actually searches. |
|
|
97
|
+
| `reach` | long | Subscriber count (~98%). ⚠️ NOT ad-industry "reach" (unique audience exposed) — this is the channel's subscriber count. Same data as PG `thoughtleaders_channel.subscribers` / Firebolt `channel_metrics.subscribers`. |
|
|
98
|
+
| `impression` | long | TL's projected views per longform video at age = 30 days — computed only from the channel's recent **longform** videos' day-30 views (≥ 4 required; median-anchored, outliers trimmed) (~27%). This is the channel's *current* projection; a video doc's `projected_views` is the same quantity frozen when that video was first indexed — diff them to see channel growth/decline since the upload. ⚠️ NOT actual views and NOT ad-industry "impressions"; for actual views see `total_views` / the video docs. |
|
|
99
|
+
| `impression_live` | long | Projected views per live stream at age = 30 days, from the channel's live streams only (~6%) |
|
|
100
|
+
| `impression_shorts` | long | Projected views per short at age = 30 days, from the channel's shorts only (~21%) |
|
|
101
|
+
| `total_views` | long | Lifetime actual views across the channel (~68%) |
|
|
102
|
+
| `engagement` | long | Views-per-comment ratio over the channel's last 30 days of uploads: `sum(views) / sum(comments)`, rounded (~25%). **Lower = more engaged audience** (fewer views per comment). Not an engagement count. |
|
|
103
|
+
| `duration_longform` | integer | Average longform video duration in seconds, over the channel's uploads from the trailing 365 days (~46%) |
|
|
104
|
+
| `duration_shorts` | integer | Average short duration, same window (~32%) |
|
|
105
|
+
| `duration_live` | integer | Average live-stream duration, same window (~11%) |
|
|
106
|
+
| `is_tl_channel` | boolean | TPP partner channel (100%) |
|
|
107
|
+
| `is_active` | boolean | Channel is active (100%) |
|
|
108
|
+
| `media_selling_network_join_date` | date | MSN join date; non-null = MSN member (~1%) |
|
|
109
|
+
| `has_outreach_email` | boolean | Has contact email (100%) |
|
|
110
|
+
| `outreach_email` | text | Contact email (~45%) |
|
|
111
|
+
| `social_links` | text | Flat array of the channel's profile URLs (~47%), e.g. `["https://twitter.com/…", "https://instagram.com/…", "https://discord.gg/…"]`. Source is a per-platform map plus a catch-all `_other` map for unrecognized platforms; in ES all of it is flattened into this one URL array (the `_other` URLs are folded in, the platform names are dropped). Occasional stray entries (bare emails, nested arrays) exist. |
|
|
112
|
+
| `male_share` | byte | Male audience % — only ~1.6% of channel docs have demographic data |
|
|
113
|
+
| `usa_share` | byte | US audience % — same ~1.6% coverage |
|
|
114
|
+
| `device` | object | Audience device demographics where known: `device.primary` (most common device) and `device.share` (per-device % map). Very sparse (~0.2%). |
|
|
115
|
+
| `sponsorship_price` | scaled_float | Estimated price of a sponsored video on this channel, from **TL's sponsorship calculator** (~27%). Inputs: the channel's last-30-day views and comments, fulfillment rate, renewal rate, and longform evergreenness. Recomputed on the channel's regular metrics-update cycle, so it moves as the channel's data changes. |
|
|
116
|
+
| `sponsorship_score` | scaled_float | TL-internal sponsorship track-record score, **range 0–10, higher = better** (~96%). Blends how many distinct brands sponsored the channel in the last 2 years (40%) with how many of them booked repeatedly (60%). The scale is deliberately skewed: raw low scores are compressed below 5, so **< 5 reads as weak/no track record and ≥ 5 as a real one**. Internal-only — don't quote the raw decimal externally (see business glossary). |
|
|
117
|
+
| `evergreenness` | float | ⚠️ **Dead — 0 docs.** Use the per-format fields below. |
|
|
118
|
+
| `evergreenness_longform` | scaled_float | Median per-video evergreenness of the channel's longform uploads from the trailing 365 days (~24%). Per-video evergreenness = `(views@180d − views@30d) / views@30d`; ≥ 1 = evergreen (day-180 views at least double day-30). Recomputed on the metrics-update cycle. |
|
|
119
|
+
| `evergreenness_shorts` | scaled_float | Same, for shorts (~20%) |
|
|
120
|
+
| `evergreenness_live` | scaled_float | Same, for live streams (~4%) |
|
|
121
|
+
| `trend` | float | View-trend angle for longform uploads (~13%). Positive = growing views. |
|
|
122
|
+
| `trend_shorts` | scaled_float | View-trend angle for shorts (~12%) |
|
|
123
|
+
| `trend_live` | scaled_float | View-trend angle for live streams (~3%). There is **no `trend_longform`** — the longform trend is the bare `trend`. |
|
|
124
|
+
| `posts_per_90_days` | integer | Longform uploads per 90 days, normalized from the trailing 365 days (~96%) |
|
|
125
|
+
| `posts_per_90_days_shorts` | integer | Shorts per 90 days (~67%) |
|
|
126
|
+
| `posts_per_90_days_live` | integer | Live streams per 90 days (~67%) |
|
|
127
|
+
| `fulfillment_rate` | scaled_float | Share of the channel's longform uploads (trailing 365 days) that carry a sponsored mention (~40%) |
|
|
128
|
+
| `renewal_rate` | scaled_float | Rate at which the channel's sponsoring brands come back (~96%) |
|
|
129
|
+
| `metrics_update_period` | byte | ⚠️ Vestigial — populated on only ~2,300 docs (~0.005%). |
|
|
130
|
+
| `offline_since` | date | ⚠️ **Dead — 0 docs.** Use `is_active`. |
|
|
131
|
+
| `content_category` | integer | TL's own content-category code, 1–22 (~94%). **Not YouTube's categories** — see the category map in `postgres-schema.md`. |
|
|
132
|
+
| `format` | integer | Platform format code (100%): 1 = Newsletter, 3 = Podcast, **4 = YouTube**, 5 = Blog, 7 = Twitch, 8 = TikTok, 9 = Instagram, 10 = LinkedIn. |
|
|
133
|
+
| `face_on_screen` | boolean | ThoughtLeaders-sourced flag: whether the creator shows their face on screen when doing brand sponsorships (~1% of channel docs). |
|
|
123
134
|
|
|
124
135
|
#### AI & Enrichment Fields
|
|
125
136
|
|
|
126
137
|
| Field | Type | Description |
|
|
127
138
|
|-------|------|-------------|
|
|
128
|
-
| `ai` | object | AI-generated
|
|
129
|
-
| `
|
|
130
|
-
| `
|
|
139
|
+
| `ai` | object | **Channel docs only** (0 video docs). Holds exactly the three AI-generated fields below (~93% of channel docs). |
|
|
140
|
+
| `ai.description` | text | AI-generated third-person channel profile, always in English regardless of the channel's language (~93%) |
|
|
141
|
+
| `ai.topic_descriptions` | text | AI-generated prose paragraph describing the channel's content topics. A **single string, not an array**. Only ~37% of channel docs — absence means "not yet generated", not "off-topic". |
|
|
142
|
+
| `ai.brand_safety` | keyword | Brand-safety letter grade `A`–`F` (~93%; A ≈ 91% of graded channels) |
|
|
143
|
+
| `applied_enrichments` | keyword | Enrichment names applied to the video (e.g. `brand_extractor`) |
|
|
144
|
+
| `article_category` | object | ⚠️ **Dead — 0 docs.** |
|
|
131
145
|
|
|
132
146
|
#### System Fields
|
|
133
147
|
|
|
134
148
|
| Field | Type | Description |
|
|
135
149
|
|-------|------|-------------|
|
|
136
|
-
| `@timestamp` | date | Index timestamp |
|
|
150
|
+
| `@timestamp` | date | Index/update timestamp (~92% of video docs) |
|
|
137
151
|
| `doc_type` | join | Parent-child join (channel→video) |
|
|
138
|
-
| `es_index_tag` |
|
|
152
|
+
| `es_index_tag` | keyword | Publication-period tag: quarterly from 2019 (`2025-q2`), yearly 2016–2018 (`2017`), `2015-and-before` for older. **Stored-only — not searchable** (`exists`/`term` match 0 docs). |
|
|
139
153
|
|
|
140
154
|
## Common Query Patterns
|
|
141
155
|
|
|
@@ -170,7 +184,9 @@ tl db es '{
|
|
|
170
184
|
}'
|
|
171
185
|
```
|
|
172
186
|
|
|
173
|
-
### Full-text search on title/
|
|
187
|
+
### Full-text search on title/summary/transcript
|
|
188
|
+
|
|
189
|
+
(`summary` = the video's creator-written description. `description` field is not pupulated for articles, and `content` is podcast-only — see the field table.)
|
|
174
190
|
|
|
175
191
|
```bash
|
|
176
192
|
tl db es '{
|
|
@@ -178,7 +194,7 @@ tl db es '{
|
|
|
178
194
|
"query": {
|
|
179
195
|
"multi_match": {
|
|
180
196
|
"query": "ergonomic keyboard review",
|
|
181
|
-
"fields": ["title^3", "
|
|
197
|
+
"fields": ["title^3", "summary", "transcript"]
|
|
182
198
|
}
|
|
183
199
|
},
|
|
184
200
|
"_source": ["title", "channel.id", "publication_date"]
|
|
@@ -201,7 +217,7 @@ tl db es '{
|
|
|
201
217
|
}'
|
|
202
218
|
```
|
|
203
219
|
|
|
204
|
-
###
|
|
220
|
+
### Aggregation example (aggregations are bounded — see *Accepted query bodies* above)
|
|
205
221
|
|
|
206
222
|
```bash
|
|
207
223
|
tl db es '{
|
|
@@ -245,9 +261,11 @@ Repeat until a page comes back short (`next_search_after` is absent on an empty
|
|
|
245
261
|
|
|
246
262
|
`text` fields on article docs (`title`, `summary`, `transcript`) appear to use the `standard` analyzer (tokenize + lowercase, no stemmer, no English-possessive filter), so inflections, plurals, and possessives are each indexed as distinct terms. For example: `bitcoin` (4,466,300) vs `bitcoins` (489,262). For stemming-style recall, expand the query side with a `bool.should` over the variants.
|
|
247
263
|
|
|
264
|
+
One consequence: URLs in article fields tokenize on punctuation (`substack.com` → `substack`, `com`), so you can't term-match a domain there. The exception is the channel-doc `description.domains` subfield, where whole domains are single searchable terms — use it to find channels by a linked domain (see the channel field table).
|
|
265
|
+
|
|
248
266
|
## Transcript field format
|
|
249
267
|
|
|
250
|
-
The `transcript` field's `_source` is **YouTube timed-text caption XML**, not plain prose. Each caption cue is a `<text start="…" dur="…">` element wrapped in `<transcript
|
|
268
|
+
The `transcript` field's `_source` is **YouTube timed-text caption XML**, not plain prose. Each caption cue is a `<text start="…" dur="…">` element wrapped in `<transcript>`. The inner text is HTML-entity-encoded — on older docs **double-encoded** (an apostrophe is `&#39;`, i.e. an escaped `'`), on recent docs single-encoded (`'`):
|
|
251
269
|
|
|
252
270
|
```xml
|
|
253
271
|
<?xml version="1.0" encoding="utf-8" ?>
|
|
@@ -255,7 +273,7 @@ The `transcript` field's `_source` is **YouTube timed-text caption XML**, not pl
|
|
|
255
273
|
```
|
|
256
274
|
|
|
257
275
|
- **Searching is unaffected** — the field is analyzed as `text`, so `match` / `match_phrase` queries hit the words directly regardless of the markup. The XML only matters when you retrieve and read the raw `_source`.
|
|
258
|
-
- **For plain prose**,
|
|
276
|
+
- **For plain prose**, strip the markup yourself, e.g. `jq -r '.results[0].transcript' | sed -E 's/<[^>]+>/ /g'`, then unescape entities (twice on older docs). Don't reach for the `content` field — it's podcast-only and absent on YouTube docs.
|
|
259
277
|
|
|
260
278
|
## Notes & gotchas
|
|
261
279
|
|
|
@@ -263,5 +281,6 @@ The `transcript` field's `_source` is **YouTube timed-text caption XML**, not pl
|
|
|
263
281
|
- **Add a `publication_date` range filter** whenever the question is time-bounded — the alias is fixed, so this is the only way to narrow the search.
|
|
264
282
|
- `sponsored_brand_mentions` and `organic_brand_mentions` are keyword arrays — use `term` queries.
|
|
265
283
|
- For brand mention details (position, snippet, detection_tool), the data is in the `brand_mentions` nested field.
|
|
284
|
+
- **Stored-only fields** — retrievable in `_source` but invisible to `exists`/`term`/`match` (queries on them silently match 0 docs): `url`, `image_url`, `es_index_tag`, `not_sponsored_by`.
|
|
266
285
|
- **`publication_id` is deprecated** — don't use for joins.
|
|
267
286
|
- No write access. The CLI only exposes `_search` against `tl-platform-*`.
|
|
@@ -126,11 +126,22 @@ A deal is **committed** when it is SOLD, or OPEN with `brand_approval_status` in
|
|
|
126
126
|
|
|
127
127
|
### `thoughtleaders_brand`
|
|
128
128
|
|
|
129
|
+
> 🚨 **Columns that DO NOT exist on `thoughtleaders_brand` — common hallucinations:**
|
|
130
|
+
> - ❌ `domain` / `url` — the website column is `website`.
|
|
131
|
+
> - ❌ `brand_name` — it's plain `name` here (unlike channel, which prefixes its display fields).
|
|
132
|
+
> - ❌ `is_active` — brands have no active/inactive flag; every row is live.
|
|
133
|
+
> - ❌ `sponsored_topics` (or similar topic columns) — the closest fields are `keywords` and `ai_description`.
|
|
134
|
+
> - ❌ `organization_id` — org lives on profile, not brand (see Key Relationships below).
|
|
135
|
+
|
|
129
136
|
| Column | Type | Description |
|
|
130
137
|
|--------|------|-------------|
|
|
131
138
|
| `id` | int | Primary key |
|
|
132
|
-
| `name` | varchar | Brand name |
|
|
139
|
+
| `name` | varchar | Brand name. ⚠️ Plain `name`, not `brand_name`. |
|
|
133
140
|
| `description` | text | Brand description |
|
|
141
|
+
| `website` | varchar | Brand website URL. ⚠️ The column is `website`, NOT `domain`. |
|
|
142
|
+
| `slug` | varchar | TL-platform slug |
|
|
143
|
+
| `keywords` | varchar | Topic/detection keywords for the brand |
|
|
144
|
+
| `ai_description` | JSONB | AI-generated descriptive info about the brand |
|
|
134
145
|
| `creator_id` | int FK | User who created it |
|
|
135
146
|
|
|
136
147
|
#### Junction Tables
|
|
@@ -169,14 +180,14 @@ A channel can have multiple adspots (different sellers: talent manager, direct,
|
|
|
169
180
|
| `subscribers` | bigint | Subscriber count. |
|
|
170
181
|
| `media_selling_network_join_date` | date/timestamptz | When the channel joined the MSN. **MSN membership = this column IS NOT NULL.** |
|
|
171
182
|
| `is_tpp` | boolean | True = TPP channel — TL's closest-partner channels (~144 at 100k+ subscribers), a strict subset of MSN. Prefer when booking: fastest response, easiest to close. ⚠️ **This is not the MSN flag.** For MSN, use `media_selling_network_join_date IS NOT NULL`. |
|
|
172
|
-
| `content_category` | int |
|
|
183
|
+
| `content_category` | int | TL's own content-category code (1–22) — **not YouTube's categories**: 1 Backend Development, 2 Design, 3 Entrepreneurship, 4 Frontend Development, 5 Lifestyle, 6 Marketing, 7 Mobile Development, 8 Sales, 9 Travel, 10 Business, 11 Photography, 12 General Knowledge, 13 Personal Finance, 14 News & Politics, 15 Technology, 16 Gaming, 17 Food, 18 Sports, 19 How To & Crafts, 20 Entertainment, 21 Health & Fitness, 22 Music. The assignment is too unreliable for discovery — do not use it to find channels by topic. **For topic/category discovery, prefer `tl recommender top-channels "<tag>"` |
|
|
173
184
|
| `is_active` | boolean | Whether the channel is active. ⚠️ **Always include `is_active = true` in channel queries** unless explicitly looking for archived rows. |
|
|
174
185
|
| `country` | varchar | Channel's primary country (ISO 3166-1 alpha-2 code, e.g. `US`, `GB`, `BR`). This is often the cleanest answer to "geography" questions on sponsorships. May be NULL or blank. |
|
|
175
186
|
| `language` | varchar | Primary content language. ⚠️ **Short ISO 639 codes — NOT BCP-47.** Mostly 2-letter ISO 639-1 (`en`, `pt`, `hi`) for major languages; occasionally 3-letter ISO 639-2/3 (`arc`, `arz`, `ase`, `ceb`) for languages without a 2-letter code. Filtering with `language = 'en-US'` returns zero rows. **Don't assume `LENGTH(language) = 2`** — that silently drops the 3-letter long-tail. May be NULL. |
|
|
176
187
|
| `last_published` | date | Date of the channel's most recently seen video. Use for "is the channel still active?" filters — e.g. `last_published >= CURRENT_DATE - INTERVAL '120 days'`. |
|
|
177
188
|
| `sponsorship_score` | double precision | TL-internal channel quality score (range 0-10, higher is better, if below 5, the channel is low quality). Useful as a tiebreaker when ranking candidate channels. |
|
|
178
189
|
| `ai_description` | JSON | Descriptive information about a channel. Contains fields such as `description`, `audience`, `topic_descriptions`, and `brand_safety`. Useful as a regex-target for thematic filtering when the recommender results are too coarse (e.g. filtering "technology" down to actual tech reviewers via keywords like `tech|gadget|review|software`). |
|
|
179
|
-
| `evergreenness` | float |
|
|
190
|
+
| `evergreenness` | float | Longform evergreen score: median per-video `(views@180d − views@30d) / views@30d` over the channel's trailing-year uploads; ≥ 1 = evergreen (day-180 views at least double day-30). In ES this lands on the channel doc as `evergreenness_longform`. |
|
|
180
191
|
| `demographic_usa_share` | smallint (0–100) | Percentage of the channel's audience based in the US. Convenience for the common "is this a US-heavy channel?" filter — pre-computed from `demographic_geo['US']`. NULL when the channel has no demographic data. |
|
|
181
192
|
| `demographic_male_share` | smallint (0–100) | Percentage of the channel's audience that's male. `female_share = 100 - demographic_male_share` (no separate column). NULL when the channel has no demographic data. |
|
|
182
193
|
| `demographic_age_median_value` | varchar | The age-bucket label (e.g. `25-34`) corresponding to the median of `demographic_age`, pre-computed on save. Indexed; cheap to filter on. NULL when there's no age data. |
|
|
@@ -290,7 +301,42 @@ JOIN thoughtleaders_profile p ON p.user_id = adspot.publisher_id
|
|
|
290
301
|
```
|
|
291
302
|
Joining `adspot.publisher_id → profile.id` directly mixes ID spaces and returns garbage.
|
|
292
303
|
|
|
293
|
-
## `thoughtleaders_profile`
|
|
304
|
+
## Key columns for the `thoughtleaders_profile` table
|
|
305
|
+
|
|
306
|
+
The profile is the account record for a person/company on the platform. Every brand-side query hops through it (`adlink.advertiser_profile_id → profile → profile_brands → brand`), which makes it the most-joined table after the sponsorships themselves.
|
|
307
|
+
|
|
308
|
+
> 🚨 **Columns that DO NOT exist on `thoughtleaders_profile` — common hallucinations:**
|
|
309
|
+
> - ❌ `name` / `email` — the person's name and email live on `auth_user` (join via `user_id`: `first_name`, `last_name`, `email`). The company name is `organization_name`, denormalized onto the profile.
|
|
310
|
+
> - ❌ `auth_user_id` — the FK column is `user_id`.
|
|
311
|
+
> - ❌ `brand_id` — a profile can have several brands; join `thoughtleaders_profile_brands` on `profile_id`.
|
|
312
|
+
> - ❌ `is_tpp` — that's a channel attribute; a profile's channels come via `thoughtleaders_profile_channels`.
|
|
313
|
+
|
|
314
|
+
| Column | Type | Description |
|
|
315
|
+
|--------|------|-------------|
|
|
316
|
+
| `id` | int | Primary key. ⚠️ NOT the same ID space as `auth_user.id` — never join a `*_id` pointing at users to `profile.id` directly. |
|
|
317
|
+
| `user_id` | int FK | → `auth_user.id` (the person's name and email live there) |
|
|
318
|
+
| `organization_id` | int FK | → `thoughtleaders_organization.id` |
|
|
319
|
+
| `organization_name` | varchar | Denormalized company/organization display name |
|
|
320
|
+
| `persona` | int | Account persona (see constants below) |
|
|
321
|
+
| `is_advertiser` | boolean | Buyer (brand) side capability flag |
|
|
322
|
+
| `is_publisher` | boolean | Seller (channel) side capability flag |
|
|
323
|
+
| `owner_sales_id` | int FK | → `auth_user.id` — TL-side sales owner for this profile |
|
|
324
|
+
| `owner_advertiser_id` | int FK | → `auth_user.id` — TL-side brand account owner |
|
|
325
|
+
| `owner_publisher_id` | int FK | → `auth_user.id` — on advertiser profiles: the adops user in charge |
|
|
326
|
+
| `media_buying_network_join_date` | date | MBN (brand-side buying network) join date. **MBN membership = this column IS NOT NULL.** ⚠️ Don't confuse with the channel's `media_selling_network_join_date` (MSN — the channel-side network). |
|
|
327
|
+
| `superuser_notes` | text | Internal TL notes about the profile |
|
|
328
|
+
| `buying_preference_*` | various | The brand's buying preferences (audience age/gender/geo shares, face-on-screen, custom instructions) |
|
|
329
|
+
|
|
330
|
+
**Profile with the person's name and email:**
|
|
331
|
+
```sql
|
|
332
|
+
SELECT p.id, u.first_name, u.last_name, u.email, p.organization_name
|
|
333
|
+
FROM thoughtleaders_profile p
|
|
334
|
+
JOIN auth_user u ON u.id = p.user_id
|
|
335
|
+
WHERE p.organization_id = 123
|
|
336
|
+
LIMIT 50 OFFSET 0
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### `thoughtleaders_profile` persona constants
|
|
294
340
|
|
|
295
341
|
| Value | Label |
|
|
296
342
|
|-------|-------|
|
|
@@ -298,7 +344,7 @@ Joining `adspot.publisher_id → profile.id` directly mixes ID spaces and return
|
|
|
298
344
|
| 2 | Creator |
|
|
299
345
|
| 3 | Talent Manager |
|
|
300
346
|
| 4 | Media Agency |
|
|
301
|
-
| 5 |
|
|
347
|
+
| 5 | Other |
|
|
302
348
|
|
|
303
349
|
## `thoughtleaders_profile_channels` (Profile ↔ Channel M2M)
|
|
304
350
|
|
|
@@ -563,8 +563,8 @@ raw `|`/`+`/`-` are literal text in a link; `*`/`~` are rejected, enumerate
|
|
|
563
563
|
variants first), and emits:
|
|
564
564
|
|
|
565
565
|
- `filter_set` — platform shape (`keywords`, `keyword_operator`,
|
|
566
|
-
`content_fields`, per-group field/exclude maps). Fields are **
|
|
567
|
-
|
|
566
|
+
`content_fields`, per-group field/exclude maps). Fields are **recognized
|
|
567
|
+
content field names** (`title`, `summary`, `transcript`, `channel_description`,
|
|
568
568
|
`channel_topic_description`, …) — unknown names fail loudly.
|
|
569
569
|
- `report_link` — paste-ready URL that opens the report with the filter applied
|
|
570
570
|
(no saved record, no credits). **The default thing to hand the user.**
|
|
@@ -13,7 +13,7 @@ The content alias holds **both** article docs and channel docs, distinguished by
|
|
|
13
13
|
|
|
14
14
|
| `doc_type` | ~count | what it is | descriptive text fields |
|
|
15
15
|
|---|---|---|---|
|
|
16
|
-
| `article` | ~670M | one video / upload | `title`, `summary`, `transcript
|
|
16
|
+
| `article` | ~670M | one video / upload | `title`, `summary`, `transcript` (or in case of podcast records, `content`) |
|
|
17
17
|
| `channel` | ~44M | one whole channel | `name`, `description`, `ai.description`, `ai.topic_descriptions` |
|
|
18
18
|
|
|
19
19
|
**Always scope by `doc_type`** or your counts silently mix videos and channels:
|
|
@@ -49,9 +49,9 @@ level filters on `format` 4 alone.
|
|
|
49
49
|
|
|
50
50
|
**Article docs** (`doc_type: article`):
|
|
51
51
|
- `title` — video title. Strongest relevance signal (see weighting).
|
|
52
|
-
- `summary` —
|
|
52
|
+
- `summary` — **the video's creator-written description** (the text under the video: promo links, hashtags, subscribe blocks). Misleading name — it is NOT an AI summary. This is the main field for searching video description text.
|
|
53
53
|
- `transcript` — raw transcript. Stored as timed XML (`<text start="..." dur="...">`), so a transcript hit carries timestamps if you parse it.
|
|
54
|
-
- `content` —
|
|
54
|
+
- `content` — ⚠️ podcast episodes' only; usually show notes text or rarely transcripts; effectively absent on YouTube docs. Don't search for YouTube content.
|
|
55
55
|
- `content_type` — `longform` | `short` | `live`. The default probe scope is
|
|
56
56
|
`longform` (best sponsorable-content signal).
|
|
57
57
|
- `channel` — object with `id`, `content_category`, `country`, `language`, `format` (NO descriptive text — see channel docs for that). `format` 4 = YouTube.
|
|
@@ -60,8 +60,8 @@ level filters on `format` 4 alone.
|
|
|
60
60
|
**Channel docs** (`doc_type: channel`):
|
|
61
61
|
- `name` — channel name.
|
|
62
62
|
- `description` — the channel's own (YouTube) description.
|
|
63
|
-
- `ai.description` — AI-written channel description.
|
|
64
|
-
- `ai.topic_descriptions` — AI-written description of the channel's topics
|
|
63
|
+
- `ai.description` — AI-written channel description: third-person, always English regardless of the channel's language (~93% of channel docs).
|
|
64
|
+
- `ai.topic_descriptions` — AI-written description of the channel's topics, a **single prose string, not an array**. **This is the "topic description" field** — prefer it for channel-fit validation; it's a focused, on-topic summary. ⚠️ Only ~37% of channel docs have it — a no-match here doesn't prove the channel is off-topic.
|
|
65
65
|
- `ai.brand_safety` — letter grade (A–F).
|
|
66
66
|
- `content_category`, `country`, `language`, `format`, `reach` (subscribers), `total_views`, `sponsorship_price`, `sponsorship_score`, `outreach_email`, `social_links`, `is_tl_channel`.
|
|
67
67
|
|
|
@@ -70,35 +70,42 @@ level filters on `format` 4 alone.
|
|
|
70
70
|
> `is_tl_channel`→`is_tpp`; the Elasticsearch index **keeps the old names**.
|
|
71
71
|
> Every ES `_source` / `term` / `range` / `sort` on channel docs must use
|
|
72
72
|
> `reach`, `impression`/`impression_live`/`impression_shorts`, `is_tl_channel`.
|
|
73
|
-
> Do not "fix" a probe to the new names — it silently returns nulls
|
|
73
|
+
> Do not "fix" a probe to the new names — it silently returns nulls (verified
|
|
74
|
+
> live: `subscribers`, `projected_views`, `is_tpp` each match **0** docs). Skill
|
|
74
75
|
> *output* uses the new vocabulary (`subscribers`, `is_tpp`); the translation
|
|
75
76
|
> point is `search_channels.py`'s sponsorability block.
|
|
76
77
|
|
|
77
|
-
###
|
|
78
|
+
### Content field names for the deliverable vs ES field names for probes
|
|
78
79
|
|
|
79
|
-
The platform FilterSet / report link uses **
|
|
80
|
+
The platform FilterSet / report link uses its own **content field** names; raw
|
|
80
81
|
`tl db es` probes use the **actual ES field paths**. They differ for channel
|
|
81
82
|
fields — map them:
|
|
82
83
|
|
|
83
|
-
|
|
|
84
|
+
| Content field (filter set / report link) | ES field path (probe query) | doc_type |
|
|
84
85
|
|---|---|---|
|
|
85
86
|
| `title` | `title` | article |
|
|
86
87
|
| `summary` | `summary` | article |
|
|
87
88
|
| `transcript` | `transcript` | article |
|
|
88
89
|
| `content` | `content` | article |
|
|
89
90
|
| `channel.channel_name` | `name` | channel |
|
|
90
|
-
| `channel_description` | `description` | channel |
|
|
91
|
+
| `channel_description` | `description.domains` | channel |
|
|
91
92
|
| `channel_description_ai` | `ai.description` | channel |
|
|
92
93
|
| `channel_topic_description` | `ai.topic_descriptions` | channel |
|
|
93
94
|
| `hashtags` | `hashtags` | article |
|
|
94
95
|
|
|
95
|
-
Use ES paths in `probe.py` / raw queries; use
|
|
96
|
+
Use ES paths in `probe.py` / raw queries; use content field names in
|
|
96
97
|
`build_report.py` / the report link. `build_report.py` rejects any `content_fields`
|
|
97
|
-
value that isn't a
|
|
98
|
+
value that isn't a recognized content field name, so passing a raw ES path (e.g.
|
|
98
99
|
`ai.topic_descriptions`) fails loudly rather than silently mis-filtering.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
100
|
+
|
|
101
|
+
The `channel_description` content field targets `description.domains` — the
|
|
102
|
+
"About this channel" text additionally indexed so **whole domains are single
|
|
103
|
+
searchable terms** (verified live: `patreon.com` matches every channel whose
|
|
104
|
+
About-this-channel text links to patreon.com, including inside full URLs,
|
|
105
|
+
which bare `description` probes
|
|
106
|
+
undercount since URLs there tokenize into word pieces). For domain-shaped
|
|
107
|
+
keywords (e.g. `substack.com`), probe against `description.domains`, not bare
|
|
108
|
+
`description`.
|
|
102
109
|
|
|
103
110
|
### The report link / FilterSet keyword grammar (NOT simple_query_string)
|
|
104
111
|
|
|
@@ -152,6 +159,10 @@ Verified live (topic level, all-time): `fable 5` = `fable-5` = **1,215** docs;
|
|
|
152
159
|
`fable5` = **94**; `fable five` = **118**; `fablefive` = **1**. Four different
|
|
153
160
|
document populations — collapsing them in your head silently drops coverage.
|
|
154
161
|
|
|
162
|
+
(One exception to the punctuation-split rule: the channel-doc
|
|
163
|
+
`description.domains` subfield keeps whole domains as single terms —
|
|
164
|
+
`patreon.com` is one token there. See the content field mapping above.)
|
|
165
|
+
|
|
155
166
|
**Rule:** for any candidate carrying a number, version, model name, or anything
|
|
156
167
|
that could be written solid vs spaced vs hyphenated, **probe each spelling as its
|
|
157
168
|
own candidate** (spaced, solid, hyphenated, spelled-out, and the handle/hashtag
|
|
@@ -420,7 +431,7 @@ id and read `ai.topic_descriptions` / `ai.description`:
|
|
|
420
431
|
Or run the probe with `--level channel` to search channel docs directly.
|
|
421
432
|
|
|
422
433
|
**Channel-field probes approximate the delivered filter, they don't equal it.**
|
|
423
|
-
A delivered filter keyword targeting a channel
|
|
434
|
+
A delivered filter keyword targeting a channel content field
|
|
424
435
|
(`channel_topic_description`, `channel_description`, …) matches **articles**
|
|
425
436
|
whose channel matches — its result counts are videos. Raw probes can't run
|
|
426
437
|
that parent-child join (the join query types are not accepted), so you probe
|
|
@@ -100,7 +100,7 @@ stages, nothing dropped without telling you.
|
|
|
100
100
|
| "one video per channel" | dedupes the trend feed to each channel's best match |
|
|
101
101
|
| "only channels that cover it repeatedly" | focuses the channel table on the recurring tier (default threshold: 3+ matching uploads; say a number to change it) |
|
|
102
102
|
| "last 6 months" (recency) | changes the 12-month window used for "still active" checks |
|
|
103
|
-
| "title matches only" | restricts a keyword to titles — the cleanest field — instead of titles+descriptions+transcripts |
|
|
103
|
+
| "title matches only" | restricts a keyword to titles — the cleanest field — instead of the default titles + video descriptions (the `summary` field) + transcripts |
|
|
104
104
|
| "exclude [sense/word]" | adds an exclusion, scoped so it doesn't over-cut |
|
|
105
105
|
| "broaden it" / "narrow it to X" | changes topic breadth mid-run |
|
|
106
106
|
| "save it as a report" | persists a named, shareable report |
|
|
@@ -97,17 +97,17 @@ REPORT_TYPES = {
|
|
|
97
97
|
"channels": ("thoughtleaders", 3),
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
# Valid
|
|
100
|
+
# Valid content field names (what the FilterSet / report link accept). Both
|
|
101
101
|
# article and channel fields are allowed on any report type — cross-field search
|
|
102
102
|
# (a topic in the video AND in the channel description) is intentional. We
|
|
103
|
-
# validate only that fields are
|
|
103
|
+
# validate only that fields are recognized names, to catch typos / wrong names.
|
|
104
104
|
VALID_CONTENT_FIELDS = {
|
|
105
105
|
"content", "title", "summary", "transcript", "channel.channel_name", "hashtags",
|
|
106
106
|
"channel_description", "channel_description_ai", "channel_topic_description",
|
|
107
107
|
"channel_outreach_email", "channel_social_links",
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
# Default content fields
|
|
110
|
+
# Default content fields by report type.
|
|
111
111
|
DEFAULT_FIELDS = {
|
|
112
112
|
"videos": ["title", "summary", "transcript"],
|
|
113
113
|
"content": ["title", "summary", "transcript"],
|
|
@@ -419,15 +419,15 @@ def main():
|
|
|
419
419
|
for g in groups:
|
|
420
420
|
g["text"] = str(g["text"]).strip()
|
|
421
421
|
|
|
422
|
-
# Validate every content field is a
|
|
423
|
-
#
|
|
422
|
+
# Validate every content field is a recognized name (catches typos /
|
|
423
|
+
# ES-path names like 'ai.topic_descriptions' used by mistake).
|
|
424
424
|
used_fields = set(default_fields)
|
|
425
425
|
for g in groups:
|
|
426
426
|
used_fields.update(g.get("content_fields") or [])
|
|
427
427
|
unknown = sorted(f for f in used_fields if f not in VALID_CONTENT_FIELDS)
|
|
428
428
|
if unknown:
|
|
429
429
|
sys.exit(
|
|
430
|
-
f"unknown content_fields {unknown}; use
|
|
430
|
+
f"unknown content_fields {unknown}; use recognized content field names "
|
|
431
431
|
f"(e.g. title, summary, transcript, channel_description, "
|
|
432
432
|
f"channel_topic_description) — not raw ES paths. Valid: {sorted(VALID_CONTENT_FIELDS)}"
|
|
433
433
|
)
|
|
@@ -37,8 +37,16 @@ import subprocess
|
|
|
37
37
|
import sys
|
|
38
38
|
|
|
39
39
|
# Article-level (doc_type:article) text fields vs channel-level (doc_type:channel).
|
|
40
|
+
# `summary` holds the video's creator-written description (links, hashtags, promo)
|
|
41
|
+
# — not an AI summary. Article-level `description` is empty and `content` is
|
|
42
|
+
# podcast-only; neither belongs in these lists.
|
|
40
43
|
TOPIC_FIELDS = ["title", "summary", "transcript"]
|
|
41
|
-
|
|
44
|
+
# `description.domains` alongside plain `description`: the delivered report
|
|
45
|
+
# filter searches `description.domains`, which matches domain-shaped terms
|
|
46
|
+
# (e.g. "patreon.com") that plain `description` undercounts (URLs there
|
|
47
|
+
# tokenize into word pieces) — probing both keeps counts faithful to what
|
|
48
|
+
# the delivered filter will actually match.
|
|
49
|
+
CHANNEL_FIELDS = ["name", "description", "description.domains", "ai.description", "ai.topic_descriptions"]
|
|
42
50
|
|
|
43
51
|
# The channel-identity field per level — used both to `collapse` samples to
|
|
44
52
|
# distinct channels and to count distinct channels via a cardinality agg.
|
|
@@ -238,7 +238,7 @@ When in doubt, OR. Under AND, expand the keyword set conservatively — every ke
|
|
|
238
238
|
|
|
239
239
|
| `report_type` | Default `content_fields` | When to expand |
|
|
240
240
|
| --- | --- | --- |
|
|
241
|
-
| 1 (CONTENT) | `["title", "summary"
|
|
241
|
+
| 1 (CONTENT) | `["title", "summary"]` (video title + the creator-written video description) | Add `["transcript"]` only if the user explicitly mentioned "transcript" / "spoken-word" / "creators saying". Add `["content"]` only for podcast-focused reports — it's the podcast show-notes field, absent on YouTube docs. |
|
|
242
242
|
| 2 (BRANDS) | `["title", "summary"]` (brand-mention surfaces) | Rarely expanded; brand reports aggregate over mentions, not deep text. |
|
|
243
243
|
| 3 (CHANNELS) | **`["channel.channel_name", "channel_description"]` ONLY** on the first save | Add `channel_description_ai` + `channel_topic_description` only if the narrow set obviously misses channels the session matched. The AI-summarised fields catalogue every topic a channel has *ever* touched — they answer *"has this channel ever mentioned X"* (too broad for discovery) rather than *"is this channel ABOUT X"* (what `channel_name` + `channel_description` answer). Field selection is the bigger dial; keyword pruning is the fine-tune. |
|
|
244
244
|
| 8 (SPONSORSHIPS) | n/a — keyword fields are inert for type 8 | Sponsorships filter by relations, not content text. Don't emit `keywords` / `keyword_operator` / `content_fields` at all for type 8. |
|
|
@@ -123,7 +123,7 @@ The `publish_status` set is `{3, 4, 5, 7, 9, 10}` (3 Sold, 4 Rejected by Brand,
|
|
|
123
123
|
|
|
124
124
|
| User intent | Fields | Why |
|
|
125
125
|
|---|---|---|
|
|
126
|
-
| Match keywords in video transcripts/titles | `content_fields` includes `
|
|
126
|
+
| Match keywords in video transcripts/titles | `content_fields` includes `title`, `transcript` (add `summary` for the video's description text; `content` is podcast-only) | Standard type 1 |
|
|
127
127
|
| Match channel descriptions only | `content_fields = ["channel_description", "channel_description_ai", "channel_topic_description"]` | Standard type 3 |
|
|
128
128
|
| Different keywords need different fields | `keyword_content_fields_map` (per-position) | E.g., brand name → `channel.channel_name`; topic → descriptions |
|
|
129
129
|
| "But not X" | `keywords` includes `X` + `keyword_exclude_map["<index>"] = true` | Substring negation per-position |
|
tl_cli/output/formatter.py
CHANGED
|
@@ -94,11 +94,13 @@ def output(
|
|
|
94
94
|
# Banner still goes to stderr so it surfaces even when stdout is
|
|
95
95
|
# piped through `jq` or redirected to a file.
|
|
96
96
|
_print_quota_notice(data)
|
|
97
|
+
_print_server_warnings(data)
|
|
97
98
|
return
|
|
98
99
|
|
|
99
100
|
if not results:
|
|
100
101
|
err_console.print("[dim]No results found.[/dim]")
|
|
101
102
|
_print_quota_notice(data)
|
|
103
|
+
_print_server_warnings(data)
|
|
102
104
|
_print_usage(usage)
|
|
103
105
|
return
|
|
104
106
|
|
|
@@ -118,6 +120,7 @@ def output(
|
|
|
118
120
|
|
|
119
121
|
_print_pagination_notice(data)
|
|
120
122
|
_print_quota_notice(data)
|
|
123
|
+
_print_server_warnings(data)
|
|
121
124
|
_print_usage(usage)
|
|
122
125
|
_print_breadcrumbs(breadcrumbs)
|
|
123
126
|
|
|
@@ -496,6 +499,17 @@ def _format_wait_clause(data: dict) -> str:
|
|
|
496
499
|
return ""
|
|
497
500
|
|
|
498
501
|
|
|
502
|
+
def _print_server_warnings(data: dict) -> None:
|
|
503
|
+
"""Print advisory `_warnings` from the response envelope (stderr).
|
|
504
|
+
|
|
505
|
+
The server attaches these to queries that ran but did something
|
|
506
|
+
costly or fragile (e.g. a leading-wildcard ILIKE that scans a whole
|
|
507
|
+
table). Advisory only — the results are still valid.
|
|
508
|
+
"""
|
|
509
|
+
for warning in data.get("_warnings") or []:
|
|
510
|
+
err_console.print(f"[bold yellow]Warning:[/bold yellow] [yellow]{warning}[/yellow]")
|
|
511
|
+
|
|
512
|
+
|
|
499
513
|
def _print_quota_notice(data: dict) -> None:
|
|
500
514
|
"""Print a banner when the server signals a billing-quota refusal or
|
|
501
515
|
truncation on a raw-DB call.
|
|
@@ -546,6 +560,7 @@ def output_pricing_estimate(data: dict, fmt: str) -> None:
|
|
|
546
560
|
"""
|
|
547
561
|
if fmt == "json":
|
|
548
562
|
print(_dump_json(data))
|
|
563
|
+
_print_server_warnings(data)
|
|
549
564
|
_print_usage(data.get("usage"))
|
|
550
565
|
return
|
|
551
566
|
|
|
@@ -595,6 +610,7 @@ def output_pricing_estimate(data: dict, fmt: str) -> None:
|
|
|
595
610
|
sub.add_row(path, "column (per row)", f"{_fmt_credits(val)}/row")
|
|
596
611
|
console.print(sub)
|
|
597
612
|
|
|
613
|
+
_print_server_warnings(data)
|
|
598
614
|
_print_usage(data.get("usage"))
|
|
599
615
|
|
|
600
616
|
|
|
File without changes
|
|
File without changes
|