thoughtleaders-cli 0.9.0__tar.gz → 0.9.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/.claude-plugin/plugin.json +1 -1
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/PKG-INFO +1 -1
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/hooks/scripts/pre-check.sh +4 -6
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/pyproject.toml +1 -1
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl/SKILL.md +10 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl/references/business-glossary.md +1 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl/references/firebolt-schema.md +2 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl/references/postgres-schema.md +1 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-save-report/SKILL.md +1 -1
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-save-report/references/sponsorship_filterset_schema.json +1 -1
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/__init__.py +1 -1
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/client/errors.py +12 -1
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/db.py +56 -23
- thoughtleaders_cli-0.9.1/src/tl_cli/commands/profiles.py +59 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/main.py +2 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/query_history.py +69 -9
- thoughtleaders_cli-0.9.1/tests/test_db_repeat_warning.py +194 -0
- thoughtleaders_cli-0.9.1/tests/test_error_hints.py +45 -0
- thoughtleaders_cli-0.9.1/tests/test_profiles.py +66 -0
- thoughtleaders_cli-0.9.0/tests/test_db_repeat_warning.py +0 -121
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/.claude-plugin/marketplace.json +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/.github/dependabot.yml +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/.github/workflows/ci.yml +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/.github/workflows/cli-integration.yml +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/.github/workflows/python-publish.yml +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/.gitignore +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/AGENTS.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/API.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/CLAUDE.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/LICENSE +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/README.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/agents/keyword-context-classifier.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/agents/keyword-entity-resolver.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/agents/keyword-relevance-validator.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/agents/tl-analyst.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/agents/youtube-comment-classifier.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/hooks/hooks.json +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/hooks/scripts/load-tl-skill.mjs +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/hooks/scripts/post-usage.sh +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl/references/elasticsearch-schema.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-channel-authenticity/.gitignore +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-channel-authenticity/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-channel-authenticity/references/comment-patterns.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-channel-authenticity/references/peer-cohort.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-channel-authenticity/references/red-flags.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-channel-authenticity/references/scoring.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-channel-authenticity/scripts/_io_utf8.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-channel-authenticity/scripts/analyze_channel.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-channel-authenticity/scripts/anomaly_detector.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-channel-authenticity/scripts/comment_analyzer.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-channel-authenticity/scripts/comment_scraper.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-channel-authenticity/scripts/engagement_ratios.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-channel-authenticity/scripts/peer_cohort.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-channel-authenticity/scripts/report.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-channel-authenticity/scripts/resolve_channel.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-channel-authenticity/scripts/score.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-channel-authenticity/scripts/tl_cli.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-channel-authenticity/scripts/video_integrity.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-channel-authenticity/scripts/view_curves.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-keyword-research/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-keyword-research/references/elasticsearch-content-search.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-keyword-research/references/help.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-keyword-research/scripts/build_report.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-keyword-research/scripts/expand_entities.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-keyword-research/scripts/fetch_context.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-keyword-research/scripts/probe.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-keyword-research/scripts/search_channels.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-keyword-research/scripts/search_videos.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-keyword-research/scripts/select_keywords.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-save-report/references/columns_brands.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-save-report/references/columns_channels.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-save-report/references/columns_content.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-save-report/references/columns_sponsorships.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-save-report/references/intelligence_filterset_schema.json +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-save-report/references/intelligence_widget_schema.json +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-save-report/references/report_glossary.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-save-report/references/sortable_columns.json +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-save-report/references/sponsorship_widget_schema.json +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-save-report/references/widgets.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-top-partnerships/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-top-partnerships/scripts/top_partnerships.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-views-guarantee/SKILL.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl-views-guarantee/scripts/vg.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/_completions.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/_typer_utils.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/auth/__init__.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/auth/commands.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/auth/login.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/auth/pkce.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/auth/token_store.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/client/__init__.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/client/http.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/__init__.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/_comments_common.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/balance.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/brands.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/bulk_import.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/changelog.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/channels.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/credits.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/deals.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/describe.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/doctor.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/matches.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/proposals.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/recommender.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/reports.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/schema.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/setup.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/snapshots.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/sponsorships.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/uploads.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/commands/whoami.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/config.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/filters.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/hints.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/output/__init__.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/output/formatter.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/src/tl_cli/self_update.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests/__init__.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests/test_auth.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests/test_brands_winner_channels.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests/test_build_report.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests/test_channels_lookalike.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests/test_describe.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests/test_expand_entities.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests/test_filters.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests/test_http_auth.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests/test_output.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests/test_probe.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests/test_reports.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests/test_scripts_executable.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests/test_search_channels.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests/test_search_videos.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests/test_select_keywords.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests/test_setup.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests/test_sponsorships.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests_cli/AGENTS.md +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests_cli/conftest.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests_cli/test_balance.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests_cli/test_db_es.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests_cli/test_db_fb.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests_cli/test_db_pg.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests_cli/test_schema.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/tests_cli/test_whoami.py +0 -0
- {thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/uv.lock +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.1
|
|
4
4
|
Summary: ThoughtLeaders CLI — query sponsorship data, channels, brands, and intelligence
|
|
5
5
|
Project-URL: Homepage, https://thoughtleaders.io
|
|
6
6
|
Project-URL: Repository, https://github.com/ThoughtLeaders-io/thoughtleaders-cli
|
|
@@ -20,11 +20,9 @@ if ! tl auth status --quiet 2>/dev/null; then
|
|
|
20
20
|
exit 0 # Don't block, just warn
|
|
21
21
|
fi
|
|
22
22
|
|
|
23
|
-
#
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
fi
|
|
28
|
-
fi
|
|
23
|
+
# NOTE: no "add a limit" hint here. List commands are server-limited by
|
|
24
|
+
# default and typically cost far below the warning-worthy range, so the
|
|
25
|
+
# hint was pure noise for agents (it fired on every unlimited list call
|
|
26
|
+
# regardless of cost).
|
|
29
27
|
|
|
30
28
|
exit 0
|
|
@@ -229,12 +229,15 @@ tl <entity> comment-edit <comment-id> "msg" # Edit own comment (author or super
|
|
|
229
229
|
```bash
|
|
230
230
|
tl sponsorships update <id> '<json>' # Edit a sponsorship (adlink)
|
|
231
231
|
tl channels update <id> '<json>' # Edit a channel
|
|
232
|
+
tl profiles update <id> '<json>' # Edit a brand/publisher profile (superuser only)
|
|
232
233
|
```
|
|
233
234
|
|
|
234
235
|
Examples:
|
|
235
236
|
```bash
|
|
236
237
|
tl sponsorships update 98765 '{"publish_status": "sold"}'
|
|
237
238
|
tl sponsorships update 98765 '{"publish_status": 3}'
|
|
239
|
+
tl profiles update 8871 '{"superuser_notes": "VIP account — always cc the AM lead"}'
|
|
240
|
+
tl profiles update 8871 '{"superuser_notes": null}'
|
|
238
241
|
tl channels update 12345 '{"demographic_male_share": 62}'
|
|
239
242
|
tl channels update 12345 '{"demographic_geo": {"US": 60, "UK": 12, "CA": 8}}'
|
|
240
243
|
tl channels update 12345 '{"demographic_male_share": 55, "demographic_usa_share": 70}'
|
|
@@ -260,6 +263,13 @@ the date the channel joined the Media Selling Network, as a `YYYY-MM-DD` string.
|
|
|
260
263
|
itself the membership flag: set it to add the channel to MSN (it then reads as `is_msn` and
|
|
261
264
|
appears in MSN-filtered queries), or send `null` to remove the channel from the network.
|
|
262
265
|
|
|
266
|
+
**Profile notes.** `tl profiles update` (superuser only) edits a brand/publisher profile's
|
|
267
|
+
**`superuser_notes`** — the internal free-text notes field on the customer record
|
|
268
|
+
(`thoughtleaders_profile.superuser_notes`, max 2500 chars). Send a string to set, `null` to
|
|
269
|
+
clear. This is currently the only editable profile field; anything else returns a 400 listing
|
|
270
|
+
the editable surface. Use it for account-handling notes meant for the internal team — it is
|
|
271
|
+
never shown to the customer.
|
|
272
|
+
|
|
263
273
|
### Creating and vetting sponsorships
|
|
264
274
|
|
|
265
275
|
This is the end-to-end workflow for proposing a sponsorship, then moving it through the funnel as the two sides respond. Three create commands plus `tl sponsorships update` cover every state transition the CLI exposes.
|
{thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl/references/business-glossary.md
RENAMED
|
@@ -69,6 +69,7 @@ Maps business terms to database concepts.
|
|
|
69
69
|
| `owner_advertiser_id` | `profile` | **Account owner.** Who owns the brand relationship overall. Often same person as owner_sales on adlinks, but not always. |
|
|
70
70
|
| `owner_publisher_id` | `profile` | Channel relationship owner on the profile level |
|
|
71
71
|
| `owner_sales_id` | `profile` | Sales owner at profile level |
|
|
72
|
+
| `superuser_notes` | `profile` | Internal free-text account-handling notes on the customer record (max 2500 chars). Team-facing only — never shown to the customer. Superusers can edit it through the CLI (see SKILL.md → "Updating records"). |
|
|
72
73
|
|
|
73
74
|
**Key insight:** Ownership exists on both `profile` (account-level) and `adlink` (deal-level). For revenue attribution, always use `adlink.owner_sales_id`.
|
|
74
75
|
|
{thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl/references/firebolt-schema.md
RENAMED
|
@@ -33,10 +33,12 @@ Drop to `tl db fb` only when you need a shape `tl snapshots` doesn't produce (cu
|
|
|
33
33
|
- **SELECT only.** No DDL/DML/transactions/SET/locks.
|
|
34
34
|
- **Single table.** No JOIN, CTE (`WITH`), subquery, set operation, or `LATERAL`.
|
|
35
35
|
- **Only known tables:** `article_metrics`, `channel_metrics`. Other names return `UNKNOWN_TABLE`.
|
|
36
|
+
- **Constant-only probes are allowed:** `SELECT 1` (no FROM, no column references) works as a connectivity check. Anything naming a column or `*` still needs a FROM.
|
|
36
37
|
- **WHERE/HAVING may only reference indexed columns** (`channel_id`/`id` for `article_metrics`; `id` for `channel_metrics`). Filtering by `age`, `publication_date`, `view_count`, `duration`, `scrape_date`, etc. in WHERE returns `NON_INDEXED_FILTER:<col>`. Apply those constraints client-side after fetching.
|
|
37
38
|
- **Leading index column must be equality-or-IN-filtered with literals** (`channel_id = 1` or `channel_id IN (1,2,3)`). Without it: `MISSING_INDEXED_FILTER`.
|
|
38
39
|
- **Trivial-aggregation exception:** a SELECT whose projected expressions are all aggregates and which has no GROUP BY / HAVING may omit WHERE entirely. Use only for tiny sanity checks.
|
|
39
40
|
- **No mandatory LIMIT/OFFSET** — but Firebolt will time out on bad plans, so keep the leading-index filter selective.
|
|
41
|
+
- **Errors carry the real diagnostic.** A query that Firebolt itself rejects (unknown column, syntax error) comes back as a 400 with Firebolt's own error description, plus a rename hint where one is known. Common trap: `channel_metrics` has `subscribers`, not the older `reach`.
|
|
40
42
|
|
|
41
43
|
## Tables
|
|
42
44
|
|
{thoughtleaders_cli-0.9.0 → thoughtleaders_cli-0.9.1}/skills/tl/references/postgres-schema.md
RENAMED
|
@@ -275,6 +275,7 @@ JOIN thoughtleaders_profile_brands pb ON p.id = pb.profile_id
|
|
|
275
275
|
JOIN thoughtleaders_brand b ON pb.brand_id = b.id
|
|
276
276
|
-- NEVER: JOIN brand b ON b.id = a.advertiser_profile_id (different ID spaces, returns wrong data)
|
|
277
277
|
```
|
|
278
|
+
This 3-table join is the one brand path that works in every session — use it in anything reusable.
|
|
278
279
|
|
|
279
280
|
**Adlink → Organization:**
|
|
280
281
|
```sql
|
|
@@ -41,7 +41,7 @@ This skill is self-contained. Every reference it needs is in [`references/`](ref
|
|
|
41
41
|
| **3** | CHANNELS | one YouTube channel (or podcast) | intelligence |
|
|
42
42
|
| **8** | SPONSORSHIPS / Deals | one sponsorship record (AdLink — brand × channel × dates × status × price) | sponsorship |
|
|
43
43
|
|
|
44
|
-
Types 1 / 2 / 3 share the intelligence FilterSet and widget schemas (different rows, same predicate fields). Type 8 has its own schemas (disjoint fields, different aggregators, different data plane —
|
|
44
|
+
Types 1 / 2 / 3 share the intelligence FilterSet and widget schemas (different rows, same predicate fields). Type 8 has its own schemas (disjoint fields, different aggregators, different data plane — a denormalized sponsorship dataset in Postgres rather than Elasticsearch).
|
|
45
45
|
|
|
46
46
|
## When to invoke
|
|
47
47
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"django_model": "dashboard.models.FilterSet",
|
|
11
11
|
"django_enums_module": "dashboard.enums",
|
|
12
12
|
"applies_to_report_types": [8],
|
|
13
|
-
"report_type_meaning": "CAMPAIGN_MANAGEMENT — sponsorship/AdLink-level reporting. Filters are evaluated against
|
|
13
|
+
"report_type_meaning": "CAMPAIGN_MANAGEMENT — sponsorship/AdLink-level reporting. Filters are evaluated against a denormalized sponsorship dataset that already joins adlink, brand, channel, and organization. Direct joins from thoughtleaders_adlink to thoughtleaders_brand or thoughtleaders_channel are NOT possible — those FKs do not exist on the adlink table. See SKILL.md Step 2.V1 (Sponsorship reports section) for the canonical query shape.",
|
|
14
14
|
"note": "Same Django model as the intelligence schema, but only a subset of fields is meaningful for sponsorship reporting. Fields not listed here exist on the model but are inert for type 8."
|
|
15
15
|
},
|
|
16
16
|
|
|
@@ -68,6 +68,17 @@ def handle_api_error(error: ApiError) -> None:
|
|
|
68
68
|
_print_debug(error)
|
|
69
69
|
sys.exit(3)
|
|
70
70
|
else:
|
|
71
|
-
|
|
71
|
+
detail = error.detail or ""
|
|
72
|
+
hint = (error.raw or {}).get("hint") if isinstance(error.raw, dict) else None
|
|
73
|
+
if isinstance(hint, str) and hint:
|
|
74
|
+
# The server sends the remediation both concatenated into
|
|
75
|
+
# `detail` (for older clients) and as a separate `hint` key —
|
|
76
|
+
# render it on its own line so it can't get lost in the error.
|
|
77
|
+
if detail.endswith(hint):
|
|
78
|
+
detail = detail[: -len(hint)].rstrip()
|
|
79
|
+
err.print(f"[red]Error ({error.status_code}):[/red] {detail}")
|
|
80
|
+
err.print(f"[bold yellow]Hint:[/bold yellow] [yellow]{hint}[/yellow]")
|
|
81
|
+
else:
|
|
82
|
+
err.print(f"[red]Error ({error.status_code}):[/red] {detail}")
|
|
72
83
|
_print_debug(error)
|
|
73
84
|
sys.exit(1)
|
|
@@ -5,12 +5,12 @@ import sys
|
|
|
5
5
|
|
|
6
6
|
import typer
|
|
7
7
|
from rich.console import Console
|
|
8
|
-
from tl_cli._typer_utils import AlphaSortedTyperGroup
|
|
9
8
|
|
|
9
|
+
from tl_cli._typer_utils import AlphaSortedTyperGroup
|
|
10
10
|
from tl_cli.client.errors import ApiError, handle_api_error
|
|
11
11
|
from tl_cli.client.http import get_client
|
|
12
12
|
from tl_cli.output.formatter import detect_format, output, output_pricing_estimate
|
|
13
|
-
from tl_cli.query_history import query_hash, record_and_check
|
|
13
|
+
from tl_cli.query_history import note_charge, query_hash, record_and_check
|
|
14
14
|
|
|
15
15
|
app = typer.Typer(cls=AlphaSortedTyperGroup, help="Raw read-only queries against PostgreSQL, Firebolt, or Elasticsearch (full-access only)")
|
|
16
16
|
|
|
@@ -18,7 +18,10 @@ _err = Console(stderr=True)
|
|
|
18
18
|
|
|
19
19
|
_NO_REPEAT_WARNING_OPTION = typer.Option(
|
|
20
20
|
False, "--no-repeat-warning",
|
|
21
|
-
help=
|
|
21
|
+
help=(
|
|
22
|
+
"Suppress the warning shown when the same query is re-run repeatedly after "
|
|
23
|
+
"burning 1000+ credits (also: TL_NO_REPEAT_WARNING=1)."
|
|
24
|
+
),
|
|
22
25
|
)
|
|
23
26
|
|
|
24
27
|
|
|
@@ -30,28 +33,37 @@ def _read_query(query: str | None) -> str:
|
|
|
30
33
|
return sys.stdin.read()
|
|
31
34
|
|
|
32
35
|
|
|
33
|
-
def _warn_if_repeat(
|
|
34
|
-
"""Nudge (on stderr) when this exact query keeps being re-run
|
|
36
|
+
def _warn_if_repeat(digest: str, suppressed: bool) -> None:
|
|
37
|
+
"""Nudge (on stderr) when this exact query keeps being re-run AND the
|
|
38
|
+
repeats have already burned a material amount of credits.
|
|
35
39
|
|
|
36
|
-
Detection is local and best-effort; the query always executes.
|
|
40
|
+
Detection is local and best-effort; the query always executes. Cheap
|
|
41
|
+
repeats stay silent — the warning only fires once the window's runs
|
|
42
|
+
have been billed SPEND_THRESHOLD_CREDITS in total.
|
|
37
43
|
"""
|
|
38
44
|
if suppressed:
|
|
39
45
|
return
|
|
40
|
-
|
|
41
|
-
if
|
|
46
|
+
due = record_and_check(digest)
|
|
47
|
+
if due is None:
|
|
42
48
|
return
|
|
49
|
+
count, spent = due
|
|
43
50
|
_err.print(
|
|
44
51
|
f"[yellow]Repeat query:[/yellow] this exact query has now run {count} times "
|
|
45
|
-
"in the last 5 minutes. Each run is billed and
|
|
46
|
-
"differ — reuse the earlier output (e.g. save it
|
|
47
|
-
"Pass --no-repeat-warning if the repeats are
|
|
52
|
+
f"in the last 5 minutes ({spent:g} credits so far). Each run is billed and "
|
|
53
|
+
"the results are unlikely to differ — reuse the earlier output (e.g. save it "
|
|
54
|
+
"with `--json > file.json`). Pass --no-repeat-warning if the repeats are "
|
|
55
|
+
"deliberate."
|
|
48
56
|
)
|
|
49
57
|
|
|
50
58
|
|
|
51
|
-
def _run(path: str, body: dict, fmt: str, title: str, pricing: bool = False
|
|
59
|
+
def _run(path: str, body: dict, fmt: str, title: str, pricing: bool = False,
|
|
60
|
+
digest: str | None = None) -> None:
|
|
52
61
|
client = get_client()
|
|
53
62
|
try:
|
|
54
63
|
data = client.post(path, json_body=body)
|
|
64
|
+
if digest:
|
|
65
|
+
usage = data.get("usage") or {}
|
|
66
|
+
note_charge(digest, usage.get("credits_charged"))
|
|
55
67
|
if pricing:
|
|
56
68
|
output_pricing_estimate(data, fmt)
|
|
57
69
|
return
|
|
@@ -84,9 +96,19 @@ def pg_cmd(
|
|
|
84
96
|
) -> None:
|
|
85
97
|
"""Run a raw PostgreSQL SELECT query.
|
|
86
98
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
99
|
+
Common traps — check before writing SQL:
|
|
100
|
+
|
|
101
|
+
- Column names follow the current schema: subscribers, projected_views,
|
|
102
|
+
scheduled_date, is_tpp, advertiser_profile_id, views_guarantee — NOT the
|
|
103
|
+
older reach / impression / send_date / is_tl_channel / creator_profile_id
|
|
104
|
+
/ impressions_guarantee.
|
|
105
|
+
- The sponsorship table (thoughtleaders_adlink) hides its relations behind
|
|
106
|
+
joins: no channel_id/channel_name (join thoughtleaders_adspot via
|
|
107
|
+
ad_spot_id, then thoughtleaders_channel) and no brand_id (join
|
|
108
|
+
thoughtleaders_profile via advertiser_profile_id, then
|
|
109
|
+
thoughtleaders_profile_brands).
|
|
110
|
+
- Run `tl schema pg <table>` first — it lists the exact columns your role
|
|
111
|
+
can query plus each table's common traps.
|
|
90
112
|
|
|
91
113
|
Examples:
|
|
92
114
|
tl db pg "SELECT channel_name, subscribers, projected_views FROM thoughtleaders_channel ORDER BY subscribers DESC LIMIT 10"
|
|
@@ -96,11 +118,12 @@ def pg_cmd(
|
|
|
96
118
|
"""
|
|
97
119
|
fmt = detect_format(json_output, csv_output, md_output, toon_output)
|
|
98
120
|
sql = _read_query(query)
|
|
99
|
-
|
|
121
|
+
digest = query_hash("pg", sql, pricing)
|
|
122
|
+
_warn_if_repeat(digest, no_repeat_warning)
|
|
100
123
|
body: dict = {"query": sql}
|
|
101
124
|
if pricing:
|
|
102
125
|
body["pricing"] = True
|
|
103
|
-
_run("/raw/pg", body, fmt, "Postgres results", pricing=pricing)
|
|
126
|
+
_run("/raw/pg", body, fmt, "Postgres results", pricing=pricing, digest=digest)
|
|
104
127
|
|
|
105
128
|
|
|
106
129
|
@app.command("fb")
|
|
@@ -118,19 +141,28 @@ def fb_cmd(
|
|
|
118
141
|
) -> None:
|
|
119
142
|
"""Run a raw Firebolt SELECT query.
|
|
120
143
|
|
|
121
|
-
|
|
122
|
-
|
|
144
|
+
Two tables: article_metrics (per-video daily metrics) and channel_metrics
|
|
145
|
+
(per-channel history). Single-table SELECTs only (no JOINs), and WHERE
|
|
146
|
+
must equality- or IN-filter the leading index column — channel_id for
|
|
147
|
+
article_metrics, id (the channel id) for channel_metrics. Other columns
|
|
148
|
+
can't be filtered server-side: fetch by the indexed key, then narrow
|
|
149
|
+
client-side. Column names follow the current schema (subscribers, not
|
|
150
|
+
the older reach) — run `tl schema fb` to see them.
|
|
123
151
|
|
|
124
152
|
Examples:
|
|
153
|
+
tl db fb "SELECT 1" # connectivity check — the only no-FROM query allowed
|
|
125
154
|
tl db fb "SELECT scrape_date, view_count FROM article_metrics WHERE channel_id = 5607 AND id = 'EjeGzoQI3gQ'"
|
|
155
|
+
tl db fb "SELECT scrape_date, subscribers, total_views FROM channel_metrics WHERE id = 736006 ORDER BY scrape_date"
|
|
156
|
+
tl db fb "SELECT publication_date, view_count FROM article_metrics WHERE channel_id IN (5607, 5608)"
|
|
126
157
|
"""
|
|
127
158
|
fmt = detect_format(json_output, csv_output, md_output, toon_output)
|
|
128
159
|
sql = _read_query(query)
|
|
129
|
-
|
|
160
|
+
digest = query_hash("fb", sql, pricing)
|
|
161
|
+
_warn_if_repeat(digest, no_repeat_warning)
|
|
130
162
|
body: dict = {"query": sql}
|
|
131
163
|
if pricing:
|
|
132
164
|
body["pricing"] = True
|
|
133
|
-
_run("/raw/fb", body, fmt, "Firebolt results", pricing=pricing)
|
|
165
|
+
_run("/raw/fb", body, fmt, "Firebolt results", pricing=pricing, digest=digest)
|
|
134
166
|
|
|
135
167
|
|
|
136
168
|
@app.command("es")
|
|
@@ -166,10 +198,11 @@ def es_cmd(
|
|
|
166
198
|
except json.JSONDecodeError as exc:
|
|
167
199
|
raise typer.BadParameter(f"Query is not valid JSON: {exc}") from exc
|
|
168
200
|
|
|
169
|
-
|
|
201
|
+
digest = query_hash("es", raw, pricing)
|
|
202
|
+
_warn_if_repeat(digest, no_repeat_warning)
|
|
170
203
|
body: dict = {"query": body_query}
|
|
171
204
|
if pricing:
|
|
172
205
|
body["pricing"] = True
|
|
173
206
|
if highlight:
|
|
174
207
|
body["include_highlight"] = True
|
|
175
|
-
_run("/raw/es", body, fmt, "Elasticsearch results", pricing=pricing)
|
|
208
|
+
_run("/raw/es", body, fmt, "Elasticsearch results", pricing=pricing, digest=digest)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""tl profiles — Brand/publisher profile operations."""
|
|
2
|
+
|
|
3
|
+
import json as _json
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
from tl_cli._typer_utils import AlphaSortedTyperGroup
|
|
9
|
+
from tl_cli.client.errors import ApiError, handle_api_error
|
|
10
|
+
from tl_cli.client.http import get_client
|
|
11
|
+
from tl_cli.output.formatter import detect_format, output_single
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(cls=AlphaSortedTyperGroup, help="Brand/publisher profiles (update; full-access only)")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@app.callback()
|
|
17
|
+
def _profiles() -> None:
|
|
18
|
+
"""Brand/publisher profiles.
|
|
19
|
+
|
|
20
|
+
The callback keeps `update` an explicit subcommand — without it Typer
|
|
21
|
+
collapses a single-command group into the group itself.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.command("update")
|
|
26
|
+
def update_cmd(
|
|
27
|
+
profile_id: int = typer.Argument(..., help="Profile ID (numeric)"),
|
|
28
|
+
fields: str = typer.Argument(..., help="JSON object of fields to update"),
|
|
29
|
+
json_output: bool = typer.Option(False, "--json", help="JSON output"),
|
|
30
|
+
toon_output: bool = typer.Option(False, "--toon", help="TOON output (token-efficient for LLMs)"),
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Update a profile. Full-access only.
|
|
33
|
+
|
|
34
|
+
Editable fields: superuser_notes (free-text, max 2500 chars; send null
|
|
35
|
+
to clear). Unknown fields are rejected with a 400 listing the
|
|
36
|
+
offending key.
|
|
37
|
+
|
|
38
|
+
Examples:
|
|
39
|
+
tl profiles update 8871 '{"superuser_notes": "VIP account — always cc the AM lead"}'
|
|
40
|
+
tl profiles update 8871 '{"superuser_notes": null}'
|
|
41
|
+
"""
|
|
42
|
+
fmt = detect_format(json_output, False, False, toon_output)
|
|
43
|
+
try:
|
|
44
|
+
body = _json.loads(fields)
|
|
45
|
+
except _json.JSONDecodeError as exc:
|
|
46
|
+
Console(stderr=True).print(f"[red]Error:[/red] fields argument must be a JSON object: {exc}")
|
|
47
|
+
raise typer.Exit(1)
|
|
48
|
+
if not isinstance(body, dict):
|
|
49
|
+
Console(stderr=True).print("[red]Error:[/red] fields argument must be a JSON object.")
|
|
50
|
+
raise typer.Exit(1)
|
|
51
|
+
|
|
52
|
+
client = get_client()
|
|
53
|
+
try:
|
|
54
|
+
data = client.post(f"/profiles/{profile_id}/edit", json_body=body)
|
|
55
|
+
output_single(data, fmt)
|
|
56
|
+
except ApiError as e:
|
|
57
|
+
handle_api_error(e)
|
|
58
|
+
finally:
|
|
59
|
+
client.close()
|
|
@@ -22,6 +22,7 @@ from tl_cli.commands.channels import app as channels_app
|
|
|
22
22
|
from tl_cli.commands.db import app as db_app
|
|
23
23
|
from tl_cli.commands.deals import app as deals_app
|
|
24
24
|
from tl_cli.commands.matches import app as matches_app
|
|
25
|
+
from tl_cli.commands.profiles import app as profiles_app
|
|
25
26
|
from tl_cli.commands.proposals import app as proposals_app
|
|
26
27
|
from tl_cli.commands.recommender import app as recommender_app
|
|
27
28
|
from tl_cli.commands.sponsorships import app as sponsorships_app
|
|
@@ -93,6 +94,7 @@ app.add_typer(deals_app, name="deals")
|
|
|
93
94
|
app.add_typer(uploads_app, name="uploads")
|
|
94
95
|
app.add_typer(channels_app, name="channels")
|
|
95
96
|
app.add_typer(brands_app, name="brands")
|
|
97
|
+
app.add_typer(profiles_app, name="profiles")
|
|
96
98
|
app.add_typer(recommender_app, name="recommender")
|
|
97
99
|
app.add_typer(snapshots_app, name="snapshots")
|
|
98
100
|
app.add_typer(reports_app, name="reports")
|
|
@@ -25,6 +25,12 @@ WINDOW_S = 300
|
|
|
25
25
|
REPEAT_THRESHOLD = 3
|
|
26
26
|
WARN_INTERVAL_S = 60
|
|
27
27
|
|
|
28
|
+
# Materiality floor: repeats only warn once the credits they have already
|
|
29
|
+
# burned inside the window reach this amount. Cheap queries re-run in a loop
|
|
30
|
+
# are noise to warn about — the warning exists to stop material wasted spend,
|
|
31
|
+
# not to nag an agent that is legitimately polling an inexpensive count.
|
|
32
|
+
SPEND_THRESHOLD_CREDITS = 1000
|
|
33
|
+
|
|
28
34
|
ENV_SUPPRESS = "TL_NO_REPEAT_WARNING"
|
|
29
35
|
|
|
30
36
|
|
|
@@ -40,11 +46,16 @@ def query_hash(engine: str, query: str, pricing: bool = False) -> str:
|
|
|
40
46
|
return hashlib.sha256(basis.encode("utf-8")).hexdigest()
|
|
41
47
|
|
|
42
48
|
|
|
43
|
-
def record_and_check(digest: str) -> int | None:
|
|
44
|
-
"""Record one run of ``digest``; return
|
|
49
|
+
def record_and_check(digest: str) -> tuple[int, float] | None:
|
|
50
|
+
"""Record one run of ``digest``; return ``(run_count, credits_spent)``
|
|
51
|
+
when a warning is due.
|
|
45
52
|
|
|
46
|
-
|
|
47
|
-
|
|
53
|
+
A warning is due when the run count reaches REPEAT_THRESHOLD inside the
|
|
54
|
+
window AND the previous runs have already been billed at least
|
|
55
|
+
SPEND_THRESHOLD_CREDITS in total (charges are attached post-flight via
|
|
56
|
+
``note_charge``). Returns None below either threshold, while a warning
|
|
57
|
+
from the last WARN_INTERVAL_S is still fresh, or when
|
|
58
|
+
TL_NO_REPEAT_WARNING is set.
|
|
48
59
|
"""
|
|
49
60
|
if os.environ.get(ENV_SUPPRESS):
|
|
50
61
|
return None
|
|
@@ -53,20 +64,69 @@ def record_and_check(digest: str) -> int | None:
|
|
|
53
64
|
entry = state.get(digest)
|
|
54
65
|
entry = entry if isinstance(entry, dict) else {}
|
|
55
66
|
runs = _recent_runs(entry, now)
|
|
56
|
-
runs
|
|
67
|
+
spent = sum(charged for _, charged in runs)
|
|
68
|
+
runs.append([now, 0.0])
|
|
57
69
|
warned_at = entry.get("warned_at")
|
|
58
70
|
warning_fresh = isinstance(warned_at, int | float) and now - warned_at < WARN_INTERVAL_S
|
|
59
|
-
due =
|
|
71
|
+
due = (
|
|
72
|
+
len(runs) >= REPEAT_THRESHOLD
|
|
73
|
+
and spent >= SPEND_THRESHOLD_CREDITS
|
|
74
|
+
and not warning_fresh
|
|
75
|
+
)
|
|
60
76
|
state[digest] = {"runs": runs, "warned_at": now if due else warned_at}
|
|
61
77
|
_save(state, now)
|
|
62
|
-
return len(runs) if due else None
|
|
78
|
+
return (len(runs), spent) if due else None
|
|
79
|
+
|
|
63
80
|
|
|
81
|
+
def note_charge(digest: str, charged: object) -> None:
|
|
82
|
+
"""Attach the billed charge to ``digest``'s most recent run.
|
|
64
83
|
|
|
65
|
-
|
|
84
|
+
Called post-flight with ``usage.credits_charged`` from the response
|
|
85
|
+
envelope — the pre-flight ``record_and_check`` can't know what the run
|
|
86
|
+
will cost. Missing/zero/bogus charges are ignored (the run then counts
|
|
87
|
+
as free, which only makes the warning more conservative).
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
amount = float(charged) # type: ignore[arg-type]
|
|
91
|
+
except (TypeError, ValueError):
|
|
92
|
+
return
|
|
93
|
+
if amount <= 0:
|
|
94
|
+
return
|
|
95
|
+
now = time.time()
|
|
96
|
+
state = _load()
|
|
97
|
+
entry = state.get(digest)
|
|
98
|
+
if not isinstance(entry, dict):
|
|
99
|
+
return
|
|
100
|
+
runs = _recent_runs(entry, now)
|
|
101
|
+
if not runs:
|
|
102
|
+
return
|
|
103
|
+
runs[-1][1] = amount
|
|
104
|
+
state[digest] = {"runs": runs, "warned_at": entry.get("warned_at")}
|
|
105
|
+
_save(state, now)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _recent_runs(entry: dict, now: float) -> list[list[float]]:
|
|
109
|
+
"""Window-fresh ``[timestamp, credits_charged]`` pairs from ``entry``.
|
|
110
|
+
|
|
111
|
+
Pre-charge-tracking state files stored bare timestamps; those are read
|
|
112
|
+
as charge-0 runs rather than resetting the user's history.
|
|
113
|
+
"""
|
|
66
114
|
raw = entry.get("runs")
|
|
67
115
|
if not isinstance(raw, list):
|
|
68
116
|
return []
|
|
69
|
-
|
|
117
|
+
runs: list[list[float]] = []
|
|
118
|
+
for item in raw:
|
|
119
|
+
if isinstance(item, int | float):
|
|
120
|
+
item = [item, 0.0]
|
|
121
|
+
if (
|
|
122
|
+
isinstance(item, list)
|
|
123
|
+
and len(item) == 2
|
|
124
|
+
and isinstance(item[0], int | float)
|
|
125
|
+
and isinstance(item[1], int | float)
|
|
126
|
+
and now - item[0] < WINDOW_S
|
|
127
|
+
):
|
|
128
|
+
runs.append([float(item[0]), float(item[1])])
|
|
129
|
+
return runs
|
|
70
130
|
|
|
71
131
|
|
|
72
132
|
def _load() -> dict:
|