trellum 0.1.0__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.
- trellum/AGENTS.md +1132 -0
- trellum/LICENSE +661 -0
- trellum/README.md +1623 -0
- trellum/THIRD-PARTY.md +121 -0
- trellum/__init__.py +29 -0
- trellum/__main__.py +21 -0
- trellum/agent/__init__.py +18 -0
- trellum/agent/agentdoc.py +315 -0
- trellum/agent/agentsetup.py +547 -0
- trellum/assets.py +69 -0
- trellum/cli/__init__.py +192 -0
- trellum/cli/commands/__init__.py +6 -0
- trellum/cli/commands/data.py +234 -0
- trellum/cli/commands/datasource.py +247 -0
- trellum/cli/commands/doctor.py +153 -0
- trellum/cli/commands/guide.py +168 -0
- trellum/cli/commands/metrics.py +235 -0
- trellum/cli/commands/review.py +306 -0
- trellum/cli/commands/serve.py +130 -0
- trellum/cli/commands/setup.py +145 -0
- trellum/cli/commands/validate.py +43 -0
- trellum/cli/console.py +22 -0
- trellum/cli/describe.py +110 -0
- trellum/components/__init__.py +57 -0
- trellum/components/ab_compare.py +406 -0
- trellum/components/ab_methodology.py +113 -0
- trellum/components/base.py +214 -0
- trellum/components/charts/__init__.py +26 -0
- trellum/components/charts/bar.py +411 -0
- trellum/components/charts/base.py +120 -0
- trellum/components/charts/doughnut.py +89 -0
- trellum/components/charts/funnel.py +73 -0
- trellum/components/charts/heatmap.py +139 -0
- trellum/components/charts/line_area.py +254 -0
- trellum/components/charts/scatter.py +121 -0
- trellum/components/charts/treemap.py +83 -0
- trellum/components/controls.py +160 -0
- trellum/components/filterable.py +522 -0
- trellum/components/filters/__init__.py +46 -0
- trellum/components/filters/base.py +62 -0
- trellum/components/filters/date_range.py +196 -0
- trellum/components/filters/dropdown.py +76 -0
- trellum/components/filters/flag.py +38 -0
- trellum/components/filters/slider.py +169 -0
- trellum/components/filters/text.py +56 -0
- trellum/components/filters/toggle.py +66 -0
- trellum/components/header.py +125 -0
- trellum/components/kpis.py +247 -0
- trellum/components/layout.py +254 -0
- trellum/components/live_filterable.py +213 -0
- trellum/components/tables.py +279 -0
- trellum/config.yaml +23 -0
- trellum/data/__init__.py +32 -0
- trellum/data/adhoc.py +119 -0
- trellum/data/connections.py +175 -0
- trellum/data/datasource_config.py +137 -0
- trellum/data/drivers/__init__.py +112 -0
- trellum/data/drivers/bigquery.py +54 -0
- trellum/data/drivers/clickhouse.py +27 -0
- trellum/data/drivers/databricks_driver.py +32 -0
- trellum/data/drivers/duckdb_driver.py +25 -0
- trellum/data/drivers/mysql.py +28 -0
- trellum/data/drivers/postgres.py +27 -0
- trellum/data/drivers/redshift.py +21 -0
- trellum/data/drivers/snowflake_driver.py +35 -0
- trellum/data/drivers/sqlite.py +21 -0
- trellum/data/drivers/sqlserver.py +32 -0
- trellum/data/drivers/trino_driver.py +41 -0
- trellum/data/drivers/vertica.py +47 -0
- trellum/data/http_source.py +49 -0
- trellum/data/live_query_guard.py +276 -0
- trellum/data/query.py +579 -0
- trellum/data/resolvers.py +255 -0
- trellum/data/ssh_tunnel.py +194 -0
- trellum/data/transforms.py +90 -0
- trellum/datasets.py +162 -0
- trellum/demo/README.md +345 -0
- trellum/demo/__init__.py +28 -0
- trellum/demo/__main__.py +172 -0
- trellum/demo/assistant.md +38 -0
- trellum/demo/config.yaml +13 -0
- trellum/demo/data-sources/config.yaml +16 -0
- trellum/demo/data-sources/uploads/ua_budget.csv +325 -0
- trellum/demo/metrics.yaml +244 -0
- trellum/demo/reports/_template/__init__.py +0 -0
- trellum/demo/reports/_template/generator.py +72 -0
- trellum/demo/reports/_template/queries.py +20 -0
- trellum/demo/reports/_template/report.yaml +34 -0
- trellum/demo/reports/cart-funnel/__init__.py +0 -0
- trellum/demo/reports/cart-funnel/custom_sections.py +326 -0
- trellum/demo/reports/cart-funnel/generator.py +209 -0
- trellum/demo/reports/cart-funnel/queries.py +43 -0
- trellum/demo/reports/cart-funnel/report.yaml +40 -0
- trellum/demo/reports/conversion/__init__.py +0 -0
- trellum/demo/reports/conversion/custom_sections.py +164 -0
- trellum/demo/reports/conversion/generator.py +174 -0
- trellum/demo/reports/conversion/queries.py +13 -0
- trellum/demo/reports/conversion/report.yaml +47 -0
- trellum/demo/reports/economy-firehose/__init__.py +0 -0
- trellum/demo/reports/economy-firehose/generator.py +276 -0
- trellum/demo/reports/economy-firehose/queries.py +21 -0
- trellum/demo/reports/economy-firehose/report.yaml +40 -0
- trellum/demo/reports/experiments/__init__.py +0 -0
- trellum/demo/reports/experiments/generator.py +275 -0
- trellum/demo/reports/experiments/queries.py +25 -0
- trellum/demo/reports/experiments/report.yaml +61 -0
- trellum/demo/reports/insert-coin/__init__.py +0 -0
- trellum/demo/reports/insert-coin/custom_sections.py +750 -0
- trellum/demo/reports/insert-coin/generator.py +180 -0
- trellum/demo/reports/insert-coin/queries.py +22 -0
- trellum/demo/reports/insert-coin/report.yaml +30 -0
- trellum/demo/reports/metrics/__init__.py +0 -0
- trellum/demo/reports/metrics/generator.py +1 -0
- trellum/demo/reports/metrics/report.yaml +18 -0
- trellum/demo/reports/monetization/__init__.py +0 -0
- trellum/demo/reports/monetization/custom_sections.py +486 -0
- trellum/demo/reports/monetization/generator.py +254 -0
- trellum/demo/reports/monetization/queries.py +56 -0
- trellum/demo/reports/monetization/report.yaml +34 -0
- trellum/demo/reports/player-overview/__init__.py +0 -0
- trellum/demo/reports/player-overview/custom_sections.py +452 -0
- trellum/demo/reports/player-overview/generator.py +324 -0
- trellum/demo/reports/player-overview/queries.py +49 -0
- trellum/demo/reports/player-overview/report.yaml +42 -0
- trellum/demo/reports/store-health/__init__.py +0 -0
- trellum/demo/reports/store-health/generator.py +232 -0
- trellum/demo/reports/store-health/queries.py +23 -0
- trellum/demo/reports/store-health/report.yaml +32 -0
- trellum/demo/reports/user-event-log/__init__.py +0 -0
- trellum/demo/reports/user-event-log/generator.py +163 -0
- trellum/demo/reports/user-event-log/queries.py +77 -0
- trellum/demo/reports/user-event-log/report.yaml +52 -0
- trellum/demo/tools/__init__.py +1 -0
- trellum/demo/tools/list_reports.py +130 -0
- trellum/demo/tools/make_fixtures.py +1778 -0
- trellum/init/__init__.py +0 -0
- trellum/init/__main__.py +5 -0
- trellum/licensing.py +60 -0
- trellum/meta.py +114 -0
- trellum/metrics.py +598 -0
- trellum/metrics_report.py +188 -0
- trellum/new/__init__.py +0 -0
- trellum/new/__main__.py +5 -0
- trellum/output_backends/__init__.py +3 -0
- trellum/output_backends/backends.py +78 -0
- trellum/output_backends/local.py +39 -0
- trellum/project.py +167 -0
- trellum/rendering/__init__.py +5 -0
- trellum/rendering/artifacts.py +220 -0
- trellum/rendering/cdn.py +269 -0
- trellum/rendering/html_builder.py +531 -0
- trellum/rendering/js_runtime.py +128 -0
- trellum/rendering/sections.py +116 -0
- trellum/report.py +592 -0
- trellum/reporting/__init__.py +18 -0
- trellum/reporting/diagnostics/__init__.py +168 -0
- trellum/reporting/diagnostics/datasets.py +121 -0
- trellum/reporting/diagnostics/filters.py +353 -0
- trellum/reporting/diagnostics/types.py +63 -0
- trellum/reporting/diagnostics/walk.py +166 -0
- trellum/reporting/gallery.py +594 -0
- trellum/review/__init__.py +32 -0
- trellum/review/http.py +185 -0
- trellum/review/inject.py +115 -0
- trellum/review/state.py +216 -0
- trellum/run/__init__.py +0 -0
- trellum/run/__main__.py +5 -0
- trellum/runner/__init__.py +347 -0
- trellum/runner/console.py +35 -0
- trellum/runner/discovery.py +177 -0
- trellum/runner/events.py +263 -0
- trellum/runner/execute.py +443 -0
- trellum/runner/live_query_dev.py +261 -0
- trellum/runner/ports.py +244 -0
- trellum/runner/serve.py +270 -0
- trellum/scaffold/__init__.py +16 -0
- trellum/scaffold/init_project.py +169 -0
- trellum/scaffold/new_report.py +320 -0
- trellum/static/css/base.css +420 -0
- trellum/static/css/components/ab_compare.css +145 -0
- trellum/static/css/components/ab_methodology.css +91 -0
- trellum/static/css/components/chart_base.css +52 -0
- trellum/static/css/components/data_table.css +74 -0
- trellum/static/css/components/date_range_filter.css +63 -0
- trellum/static/css/components/filter_bar.css +202 -0
- trellum/static/css/components/grid.css +47 -0
- trellum/static/css/components/header.css +186 -0
- trellum/static/css/components/kpi_card.css +64 -0
- trellum/static/css/components/live_query.css +96 -0
- trellum/static/css/components/pivot_table.css +25 -0
- trellum/static/css/components/slider_filter.css +55 -0
- trellum/static/css/components/tab_group.css +51 -0
- trellum/static/css/components/toggle.css +29 -0
- trellum/static/css/review.css +82 -0
- trellum/static/js/components/ab_compare.js +192 -0
- trellum/static/js/components/chart_base.js +676 -0
- trellum/static/js/components/data_source.js +5 -0
- trellum/static/js/components/data_table.js +247 -0
- trellum/static/js/components/date_range_filter.js +201 -0
- trellum/static/js/components/doughnut.js +82 -0
- trellum/static/js/components/dropdown_filter.js +191 -0
- trellum/static/js/components/filter_bar.js +190 -0
- trellum/static/js/components/flag_filter.js +30 -0
- trellum/static/js/components/funnel.js +102 -0
- trellum/static/js/components/header.js +120 -0
- trellum/static/js/components/heatmap.js +206 -0
- trellum/static/js/components/kpi_card.js +17 -0
- trellum/static/js/components/kpi_row.js +92 -0
- trellum/static/js/components/live_data_source.js +12 -0
- trellum/static/js/components/pivot_table.js +206 -0
- trellum/static/js/components/scatter.js +122 -0
- trellum/static/js/components/scoped_data_source.js +3 -0
- trellum/static/js/components/slider_filter.js +235 -0
- trellum/static/js/components/text_filter.js +48 -0
- trellum/static/js/components/toggle_filter.js +35 -0
- trellum/static/js/components/treemap.js +141 -0
- trellum/static/js/data_loader.js +459 -0
- trellum/static/js/review/chart_target.js +180 -0
- trellum/static/js/review/dom.js +68 -0
- trellum/static/js/review/end_session.js +25 -0
- trellum/static/js/review/identify.js +71 -0
- trellum/static/js/review/keyboard.js +7 -0
- trellum/static/js/review/log.js +35 -0
- trellum/static/js/review/net.js +58 -0
- trellum/static/js/review/panel.js +13 -0
- trellum/static/js/review/popover.js +78 -0
- trellum/static/js/review/presence.js +14 -0
- trellum/static/js/review/queue.js +94 -0
- trellum/static/js/review/select.js +13 -0
- trellum/static/js/review/state.js +35 -0
- trellum/static/js/review/status.js +90 -0
- trellum/static/js/review/styles.js +6 -0
- trellum/static/js/runtime/aggregate.js +100 -0
- trellum/static/js/runtime/annotations.js +286 -0
- trellum/static/js/runtime/auto_refresh.js +45 -0
- trellum/static/js/runtime/autofill.js +40 -0
- trellum/static/js/runtime/chart_defaults.js +322 -0
- trellum/static/js/runtime/chunk_loader.js +184 -0
- trellum/static/js/runtime/color_registry.js +9 -0
- trellum/static/js/runtime/cross_filter.js +21 -0
- trellum/static/js/runtime/csv_download.js +18 -0
- trellum/static/js/runtime/export.js +62 -0
- trellum/static/js/runtime/filter_engine.js +541 -0
- trellum/static/js/runtime/formatters.js +52 -0
- trellum/static/js/runtime/fw_namespace.js +37 -0
- trellum/static/js/runtime/live_query.js +386 -0
- trellum/static/js/runtime/live_wrap.js +10 -0
- trellum/static/js/runtime/state.js +82 -0
- trellum/static/js/runtime/storage.js +5 -0
- trellum/static/js/runtime/theme.js +190 -0
- trellum/static/js/runtime/ui_handlers.js +152 -0
- trellum/static/js/runtime/url_sync.js +278 -0
- trellum/static/vendor/LICENSES.md +42 -0
- trellum/static/vendor/MANIFEST.json +134 -0
- trellum/static/vendor/UPDATING.md +63 -0
- trellum/static/vendor/chart.umd.min.js +14 -0
- trellum/static/vendor/chartjs-chart-funnel.umd.min.js +16 -0
- trellum/static/vendor/chartjs-chart-geo.umd.min.js +2 -0
- trellum/static/vendor/chartjs-chart-matrix.min.js +8 -0
- trellum/static/vendor/chartjs-chart-sankey.min.js +7 -0
- trellum/static/vendor/chartjs-chart-treemap.min.js +8 -0
- trellum/static/vendor/chartjs-chart-venn.umd.min.js +2 -0
- trellum/static/vendor/chartjs-plugin-annotation.min.js +7 -0
- trellum/static/vendor/chartjs-plugin-datalabels.min.js +7 -0
- trellum/static/vendor/chartjs-plugin-zoom.min.js +7 -0
- trellum/static/vendor/countries-110m.json +1 -0
- trellum/static/vendor/fonts/inter-400.ttf +0 -0
- trellum/static/vendor/fonts/inter-500.ttf +0 -0
- trellum/static/vendor/fonts/inter-600.ttf +0 -0
- trellum/static/vendor/fonts/inter-700.ttf +0 -0
- trellum/static/vendor/hammer.min.js +7 -0
- trellum/static/vendor/html2canvas.min.js +20 -0
- trellum/static/vendor/inter.css +31 -0
- trellum/static/vendor/jspdf.umd.min.js +398 -0
- trellum/static/vendor/licenses/chart.js-4.5.1-LICENSE.md +9 -0
- trellum/static/vendor/licenses/chartjs-adapter-date-fns-3.0.0-LICENSE.md +9 -0
- trellum/static/vendor/licenses/chartjs-chart-funnel-4.2.5-LICENSE.txt +21 -0
- trellum/static/vendor/licenses/chartjs-chart-geo-4.3.3-LICENSE.txt +21 -0
- trellum/static/vendor/licenses/chartjs-chart-matrix-2.0.1-LICENSE.txt +21 -0
- trellum/static/vendor/licenses/chartjs-chart-sankey-0.12.1-LICENSE.txt +21 -0
- trellum/static/vendor/licenses/chartjs-chart-treemap-2.3.1-LICENSE.txt +21 -0
- trellum/static/vendor/licenses/chartjs-chart-venn-4.3.7-LICENSE.txt +21 -0
- trellum/static/vendor/licenses/chartjs-plugin-annotation-3.1.0-LICENSE.md +9 -0
- trellum/static/vendor/licenses/chartjs-plugin-datalabels-2.2.0-LICENSE.md +9 -0
- trellum/static/vendor/licenses/chartjs-plugin-zoom-2.2.0-LICENSE.md +9 -0
- trellum/static/vendor/licenses/hammerjs-2.0.8-LICENSE.md +21 -0
- trellum/static/vendor/licenses/html2canvas-1.4.1-LICENSE.txt +22 -0
- trellum/static/vendor/licenses/inter-4.001-OFL.txt +82 -0
- trellum/static/vendor/licenses/jspdf-2.5.2-LICENSE.txt +22 -0
- trellum/static/vendor/licenses/natural-earth-PUBLIC-DOMAIN.md +13 -0
- trellum/static/vendor/licenses/nouislider-15.8.1-LICENSE.md +21 -0
- trellum/static/vendor/licenses/plotly.js-2.27.0-LICENSE.txt +21 -0
- trellum/static/vendor/licenses/slim-select-2.9.2-LICENSE.txt +21 -0
- trellum/static/vendor/licenses/topojson-client-3.1.0-LICENSE.txt +13 -0
- trellum/static/vendor/licenses/world-atlas-2.0.2-LICENSE.txt +13 -0
- trellum/static/vendor/nouislider.min.css +1 -0
- trellum/static/vendor/nouislider.min.js +1 -0
- trellum/static/vendor/plotly.min.js +8 -0
- trellum/static/vendor/plotly.min.js.LICENSE.txt +53 -0
- trellum/static/vendor/slimselect.css +1 -0
- trellum/static/vendor/slimselect.min.js +1 -0
- trellum/static/vendor/topojson-client.min.js +2 -0
- trellum/stats/__init__.py +75 -0
- trellum/stats/ab/__init__.py +64 -0
- trellum/stats/ab/aggregate.py +250 -0
- trellum/stats/ab/modes.py +504 -0
- trellum/stats/ab/users.py +292 -0
- trellum/stats/bootstrap.py +86 -0
- trellum/stats/cuped.py +78 -0
- trellum/stats/winsor.py +34 -0
- trellum/testing/__init__.py +14 -0
- trellum/testing/conftest.py +96 -0
- trellum/testing/mock_data.py +292 -0
- trellum/testing/runner.py +711 -0
- trellum/testing/test_ab_frontdoor.py +208 -0
- trellum/testing/test_adhoc.py +128 -0
- trellum/testing/test_agentdoc.py +707 -0
- trellum/testing/test_cli_datasource.py +24 -0
- trellum/testing/test_cli_query.py +153 -0
- trellum/testing/test_compatibility.py +633 -0
- trellum/testing/test_components.py +1113 -0
- trellum/testing/test_connections.py +1662 -0
- trellum/testing/test_datasets.py +199 -0
- trellum/testing/test_demo_journey.py +148 -0
- trellum/testing/test_gallery.py +308 -0
- trellum/testing/test_interactions.py +715 -0
- trellum/testing/test_js_runtime.py +1169 -0
- trellum/testing/test_licensing.py +107 -0
- trellum/testing/test_live_query.py +1221 -0
- trellum/testing/test_metrics.py +758 -0
- trellum/testing/test_module_size.py +149 -0
- trellum/testing/test_performance.py +797 -0
- trellum/testing/test_portable_output.py +113 -0
- trellum/testing/test_query_cache.py +118 -0
- trellum/testing/test_review.py +801 -0
- trellum/testing/test_runner.py +100 -0
- trellum/testing/test_ssh_tunnel.py +423 -0
- trellum/testing/test_stats.py +383 -0
- trellum/testing/test_themes.py +116 -0
- trellum/testing/test_update_check.py +324 -0
- trellum/testing/test_validation.py +531 -0
- trellum/testing/visual_regression.py +161 -0
- trellum/themes/__init__.py +107 -0
- trellum/themes/blossom.py +35 -0
- trellum/themes/classic.py +74 -0
- trellum/themes/dark.py +36 -0
- trellum/themes/default.py +3 -0
- trellum/themes/dracula.py +35 -0
- trellum/themes/midnight.py +35 -0
- trellum/themes/money.py +35 -0
- trellum/themes/monokai.py +35 -0
- trellum/themes/nord.py +35 -0
- trellum/themes/ocean.py +35 -0
- trellum/themes/solarized.py +35 -0
- trellum/themes/sunset.py +35 -0
- trellum/themes/theme.py +100 -0
- trellum/update_check.py +183 -0
- trellum/validation/__init__.py +129 -0
- trellum/validation/checks/__init__.py +6 -0
- trellum/validation/checks/annotations.py +113 -0
- trellum/validation/checks/columns.py +373 -0
- trellum/validation/checks/datasource.py +466 -0
- trellum/validation/checks/effectiveness.py +157 -0
- trellum/validation/checks/live_query.py +424 -0
- trellum/validation/checks/metrics.py +155 -0
- trellum/validation/checks/rawhtml.py +195 -0
- trellum/validation/checks/scopes.py +28 -0
- trellum/validation/checks/structural.py +93 -0
- trellum/validation/checks/theme.py +159 -0
- trellum/validation/checks/visibility.py +121 -0
- trellum/validation/checks/yaml_schema.py +50 -0
- trellum/validation/result.py +214 -0
- trellum/validation/walk.py +156 -0
- trellum-0.1.0.dist-info/METADATA +1668 -0
- trellum-0.1.0.dist-info/RECORD +379 -0
- trellum-0.1.0.dist-info/WHEEL +5 -0
- trellum-0.1.0.dist-info/entry_points.txt +2 -0
- trellum-0.1.0.dist-info/licenses/LICENSE +661 -0
- trellum-0.1.0.dist-info/top_level.txt +1 -0
trellum/AGENTS.md
ADDED
|
@@ -0,0 +1,1132 @@
|
|
|
1
|
+
# BI Report Framework -- Agent Workflow Guide
|
|
2
|
+
|
|
3
|
+
This is the **workflow guide** for AI assistants (Claude Code, Cursor, Codex)
|
|
4
|
+
building reports with this framework. It is the only document you need resident.
|
|
5
|
+
|
|
6
|
+
`README.md` is written for people, and it is long. **Do not load it wholesale.**
|
|
7
|
+
Ask for the part you need instead:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
trellum what the framework is, in ~30 lines
|
|
11
|
+
trellum guide <topic> one section of depth, on demand
|
|
12
|
+
trellum checks every validator check id and its level
|
|
13
|
+
trellum validate <dir> the current validation state of a built report
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`trellum guide` topics: `answer`, `queries`, `format`, `generator`,
|
|
17
|
+
`components`, `metrics`, `filters`, `live-queries`, `rawhtml`, `validation`,
|
|
18
|
+
`report-yaml`, `themes`, `review`, `portal`.
|
|
19
|
+
|
|
20
|
+
**Why this shape.** Your whole context is re-read on every API round-trip, so a
|
|
21
|
+
large always-loaded reference is paid for dozens of times per task. A command is
|
|
22
|
+
paid for once, when it is actually needed. Reach for `trellum guide` freely —
|
|
23
|
+
it is cheaper than it looks, and far cheaper than guessing.
|
|
24
|
+
|
|
25
|
+
## Where to look, by question
|
|
26
|
+
|
|
27
|
+
| Question | Answer |
|
|
28
|
+
|---|---|
|
|
29
|
+
| The user wants a number, not a report | `trellum guide answer` — query the source by name, reply with the number |
|
|
30
|
+
| What components exist? | `trellum` — the bare command lists all of them |
|
|
31
|
+
| What does this validator check id mean? | `trellum checks`, then `trellum guide validation` |
|
|
32
|
+
| How do I filter a chart? | `trellum guide filters` |
|
|
33
|
+
| Is this KPI already a defined business metric? | `trellum metrics` — claim it by id, don't re-derive it |
|
|
34
|
+
| Why is my chart empty / not reacting? | `trellum guide format` — it is almost always wide-vs-long |
|
|
35
|
+
| Where does aggregation belong? | `trellum guide queries` — pandas, not SQL |
|
|
36
|
+
| What goes in report.yaml? | `trellum guide report-yaml` |
|
|
37
|
+
| What did the portal publish, and did the build there pass? | `trellum guide portal` — the MCP loop, when `.mcp.json` names a `trellum` server |
|
|
38
|
+
| Everything else | `README.md`, read the relevant section only |
|
|
39
|
+
|
|
40
|
+
**Before writing a generator, read `trellum guide queries` and
|
|
41
|
+
`trellum guide format`.** Those two rules are not guessable from the API, and
|
|
42
|
+
getting them wrong produces a report that builds cleanly and displays nothing
|
|
43
|
+
useful.
|
|
44
|
+
|
|
45
|
+
## When to use the framework
|
|
46
|
+
|
|
47
|
+
| Goal | Where it goes |
|
|
48
|
+
|---|---|
|
|
49
|
+
| Recurring, interactive report — served standalone, on a schedule, or embedded in another application | `reports/{slug}/` (the framework) |
|
|
50
|
+
| A number or an answer — "what was X yesterday", a one-off check, quick exploration | `trellum query "SELECT ..."` or `from trellum import query` in Python, against the configured source by name. No report. See `trellum guide answer` |
|
|
51
|
+
| Unsure | Answer first. Promote to a report when it needs a schedule, more than one reader, a consequential number that should be reviewed, or the same question comes back a third time |
|
|
52
|
+
|
|
53
|
+
Scaffold a new report: `python3 -m trellum.new my-report --studio my-studio --category Revenue`. Build it: `python3 -m trellum.run reports/my-report --no-serve`.
|
|
54
|
+
|
|
55
|
+
**Always pass `--no-serve` unless you have been asked to open the report in a
|
|
56
|
+
browser.** Without it the command starts a preview server on :8050 and never
|
|
57
|
+
exits — the build itself finishes in under a second, but the process stays in
|
|
58
|
+
the foreground until it is killed. Build first, then serve as a separate step
|
|
59
|
+
if a human is going to look at it.
|
|
60
|
+
|
|
61
|
+
**Read files with your file-reading tool, not with `cat`, `ls` or `grep` in a
|
|
62
|
+
shell.** Every shell command starts a process; your native read, glob and search
|
|
63
|
+
tools do not. On the machine these runs were measured on a bare `ls` cost about
|
|
64
|
+
twelve seconds and an identical `Read` cost none — that gap is environment-
|
|
65
|
+
specific, but its direction never is.
|
|
66
|
+
|
|
67
|
+
The trap is that batching looks cheaper: `cat a.py; cat b.py; cat c.py` is one
|
|
68
|
+
command where three reads are three calls. Under a per-command cost that
|
|
69
|
+
reasoning is right, and here it is exactly backwards — three native reads are
|
|
70
|
+
free and the one shell command is not. Use the shell for things that genuinely
|
|
71
|
+
need it: running the build, the validator, git.
|
|
72
|
+
|
|
73
|
+
When you do serve, two things are not guessable and both have cost real time:
|
|
74
|
+
|
|
75
|
+
- **A single report is served at `/`, not at `/<slug>`.** Only `--all --serve`
|
|
76
|
+
puts an index at `/` with reports beneath it. `/<slug>` on a single-report
|
|
77
|
+
server returns an error page.
|
|
78
|
+
- **Wait for the `Serving at ...` line before opening the URL.** `--serve`
|
|
79
|
+
rebuilds the report and binds the port last, so until that line appears the
|
|
80
|
+
URL may still be answered by a previous server — showing a different report
|
|
81
|
+
rather than failing, which is far more confusing than a refused connection.
|
|
82
|
+
|
|
83
|
+
<!-- topic: answer -->
|
|
84
|
+
## Answering a question without a report
|
|
85
|
+
|
|
86
|
+
A user's ask has two shapes, and they are routed differently. **A number or
|
|
87
|
+
an answer** ("what was revenue yesterday?", "how many players churned in
|
|
88
|
+
June?") wants a reply, not a build: no `reports/<slug>/`, no generator, no
|
|
89
|
+
validator. **A report** wants a schedule, an audience beyond the asker, or
|
|
90
|
+
interactive filters — that is the framework proper. **Unsure? Answer first.**
|
|
91
|
+
An answer is one call; a report is a session.
|
|
92
|
+
|
|
93
|
+
How to answer:
|
|
94
|
+
|
|
95
|
+
1. **Metric first.** `python -m trellum metrics` lists the business metrics
|
|
96
|
+
this project defines; `python -m trellum metrics <name>` prints one in
|
|
97
|
+
full, including its canonical `sql`. If the number the user wants is
|
|
98
|
+
defined there, use that derivation — a second hand-written definition of
|
|
99
|
+
"gross revenue" is how two answers come to disagree.
|
|
100
|
+
2. **Query the source by NAME**, never by file path — the path breaks the day
|
|
101
|
+
the source is a remote warehouse; the name does not. From the shell:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
python -m trellum query "SELECT SUM(revenue) AS revenue FROM fact_daily WHERE event_date = :day" --param day=2026-06-15
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
From Python — a script, a notebook, a one-off `python -c`:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from trellum import query
|
|
111
|
+
df = query("warehouse", "SELECT SUM(revenue) AS revenue FROM fact_daily WHERE event_date = :day", {"day": "2026-06-15"})
|
|
112
|
+
print(df)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`sources()` lists what is configured; `connect(name)` returns the raw
|
|
116
|
+
connection for `pandas.read_sql`. All three work from any subdirectory of
|
|
117
|
+
the project: the root is found by walking up to `data-sources/config.yaml`.
|
|
118
|
+
`python -m trellum data` prints each table's columns and date span — query
|
|
119
|
+
outside the span and you get zero rows, not an error.
|
|
120
|
+
3. **Read-only.** Local databases (sqlite, duckdb) are opened read-only from
|
|
121
|
+
both entry points; an answer cannot mutate the warehouse. Remote
|
|
122
|
+
warehouses rely on their own permissions.
|
|
123
|
+
4. **Reply with four things:** the number, the source name it came from, the
|
|
124
|
+
as-of date (the latest date in the data, or the date you filtered on), and
|
|
125
|
+
the SQL you ran — so the user can check it and the next person can repeat
|
|
126
|
+
it.
|
|
127
|
+
|
|
128
|
+
Promote the answer to a report when any of these becomes true: it needs a
|
|
129
|
+
schedule; more than one person will read it; the number is going somewhere
|
|
130
|
+
consequential and should be reviewed; or the same question comes back a third
|
|
131
|
+
time. Then scaffold it — `python -m trellum.new <slug> --studio <studio>
|
|
132
|
+
--category <category>` — and read `python -m trellum guide report` for the
|
|
133
|
+
authoring contract.
|
|
134
|
+
|
|
135
|
+
<!-- topic: queries -->
|
|
136
|
+
## How to write queries (MANDATORY — read before touching `queries.py`)
|
|
137
|
+
|
|
138
|
+
**Exploring the data first?** `python -m trellum query "SELECT ..."` runs
|
|
139
|
+
ad-hoc SQL against a configured source by NAME — never hunt for the database
|
|
140
|
+
file or hardcode its path, both of which break the day the source is a
|
|
141
|
+
remote warehouse. `--param day=2026-06-15` binds `:day`; sqlite sources are
|
|
142
|
+
opened read-only.
|
|
143
|
+
|
|
144
|
+
**The rule for report queries:** SQL pulls raw rows with a date filter.
|
|
145
|
+
Python does the aggregation. Never both in the same query.
|
|
146
|
+
|
|
147
|
+
```sql
|
|
148
|
+
-- ✓ RIGHT — minimal SQL: date filter + column projection, no aggregation
|
|
149
|
+
SELECT event_date, dim_a, dim_b, metric_col
|
|
150
|
+
FROM <schema>.<fact_table>
|
|
151
|
+
WHERE event_date BETWEEN DATE :start_date AND DATE :end_date
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
# ✓ RIGHT — aggregate in the generator with pandas
|
|
156
|
+
df_raw = query_df(conn, queries.MY_QUERY, params={...})
|
|
157
|
+
daily = df_raw.groupby("event_date")["metric_col"].sum().reset_index()
|
|
158
|
+
split = df_raw.groupby(["event_date", "dim_a"])["metric_col"].sum().reset_index()
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
```sql
|
|
162
|
+
-- ✗ WRONG — SQL-side GROUP BY on a fact table
|
|
163
|
+
SELECT event_date, dim_a, SUM(metric_col)
|
|
164
|
+
FROM <schema>.<fact_table>
|
|
165
|
+
WHERE event_date BETWEEN ... GROUP BY 1, 2
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Two reasons this rule is non-negotiable:
|
|
169
|
+
|
|
170
|
+
1. **Memory footprint.** A `GROUP BY + SUM` on a wide fact table makes
|
|
171
|
+
the database pre-allocate several GB for the hash aggregate, and
|
|
172
|
+
local-dev user accounts frequently run under tight resource-pool
|
|
173
|
+
caps. Raw-row retrieval with a date filter stays small — a typical
|
|
174
|
+
30-day window of a fact table fits in tens of MB of pandas.
|
|
175
|
+
2. **Dynamic filtering is the framework's whole point.** The
|
|
176
|
+
client-side `FilterBar` re-aggregates every time the user changes
|
|
177
|
+
a filter. Pre-aggregating in SQL collapses dimensions and breaks
|
|
178
|
+
that interactivity — the chart freezes at whatever grain the SQL
|
|
179
|
+
produced. Raw-row `DataSource`s let the client slice any dimension
|
|
180
|
+
you included.
|
|
181
|
+
|
|
182
|
+
Code smells — triggers to rewrite the query:
|
|
183
|
+
|
|
184
|
+
- `SUM()` / `AVG()` / `COUNT(DISTINCT)` in the SELECT that collapses a
|
|
185
|
+
dimension the user might want to filter by.
|
|
186
|
+
- `GROUP BY` on only a subset of the filterable dimensions. Either
|
|
187
|
+
drop the GROUP BY or group by every dim the client might filter on
|
|
188
|
+
(denormalized grain).
|
|
189
|
+
- `HAVING` — do it in pandas.
|
|
190
|
+
- `JOIN` that explodes row count to attach a dim — better as a pandas
|
|
191
|
+
`.merge()` after both sides load.
|
|
192
|
+
|
|
193
|
+
Acceptable exceptions (rare):
|
|
194
|
+
|
|
195
|
+
- Querying an already-aggregated table (one whose name signals a
|
|
196
|
+
pre-rolled grain like `*_agg_*` / `*_daily_*`): pull columns
|
|
197
|
+
directly, no further SQL aggregation.
|
|
198
|
+
- A `GROUP BY` on the union of every filterable dimension. Preserves
|
|
199
|
+
filterability but compresses duplicates. Use only if raw-row
|
|
200
|
+
retrieval genuinely returns too much data.
|
|
201
|
+
|
|
202
|
+
Project-specific table names, memory caps, and schema particulars live
|
|
203
|
+
in `project_context/chat_rules/` — the chat feature loads them at
|
|
204
|
+
runtime; human readers can look there for the concrete tables to query.
|
|
205
|
+
|
|
206
|
+
<!-- topic: format -->
|
|
207
|
+
## Long format vs wide format (MANDATORY)
|
|
208
|
+
|
|
209
|
+
**Rule:** dimensions stay in **rows**, never in **column names**. One
|
|
210
|
+
row per (date × every breakdown dim), one column per metric.
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
# ✓ RIGHT — long format
|
|
214
|
+
event_date | platform | spender_tier | dau | iap_revenue | ad_revenue
|
|
215
|
+
2026-04-01 | ios | Whale | 12000 | 9800 | 250
|
|
216
|
+
2026-04-01 | android | Whale | 8500 | 5400 | 180
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
```python
|
|
220
|
+
# ✗ WRONG — platform baked into column names ("wide format")
|
|
221
|
+
event_date | spender_tier | dau_ios | dau_android | iap_revenue_ios | iap_revenue_android | ...
|
|
222
|
+
2026-04-01 | Whale | 12000 | 8500 | 9800 | 5400 | ...
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Why long format is the default:
|
|
226
|
+
|
|
227
|
+
1. **Filter coverage.** The client filter engine matches on column
|
|
228
|
+
*values*, not column *names*. A filter on `platform = "ios"` only
|
|
229
|
+
works when there's a `platform` column carrying `"ios"` as a value.
|
|
230
|
+
Wide format makes the filter inert.
|
|
231
|
+
2. **Smaller payload.** Dictionary encoding via `_serialize_columnar`
|
|
232
|
+
compresses high-repetition columns (e.g. `platform` with 8 unique
|
|
233
|
+
values across 50 k rows) to integer indices — typically 60–70 %
|
|
234
|
+
smaller than the wide equivalent with one column per platform.
|
|
235
|
+
3. **Auto-discovery.** Adding a new platform just adds rows to the
|
|
236
|
+
data; charts using `stack_by="platform"` pick it up. Wide format
|
|
237
|
+
forces editing every chart's `y=[col_a, col_b, ...]` list.
|
|
238
|
+
4. **Simpler SQL.** `GROUP BY event_date, platform, ...` instead of
|
|
239
|
+
one `SUM(CASE WHEN platform='ios' THEN dau END) AS dau_ios` per
|
|
240
|
+
metric per platform.
|
|
241
|
+
|
|
242
|
+
Render breakdowns with `stack_by`:
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
# Long format → one line per platform via stack_by
|
|
246
|
+
LineChart(df=df, x="event_date", y="iap_revenue",
|
|
247
|
+
stack_by="platform", dataset_id=ds, y_format="currency",
|
|
248
|
+
title="IAP Revenue by Platform")
|
|
249
|
+
|
|
250
|
+
# Long format + ratio → one line per platform, computing iap/dau per (date, platform)
|
|
251
|
+
LineChart(df=df, x="event_date", dataset_id=ds,
|
|
252
|
+
ratios=[{"numerator": "iap_revenue", "denominator": "dau",
|
|
253
|
+
"label": "IAP ARPDAU"}],
|
|
254
|
+
stack_by="platform", y_format="currency",
|
|
255
|
+
title="ARPDAU (IAP) by Platform")
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
The `chart-filter-coverage` validator catches accidental wide-pivots —
|
|
259
|
+
when a chart's DataSource cannot react to a FilterBar filter because
|
|
260
|
+
the dimension was collapsed into column names. Long format is the
|
|
261
|
+
fix; suppression is reserved for charts that are intentionally a
|
|
262
|
+
fixed rollup (e.g. period-over-period reference rollups).
|
|
263
|
+
|
|
264
|
+
<!-- topic: generator -->
|
|
265
|
+
## The 5-step process to write generator.py
|
|
266
|
+
|
|
267
|
+
1. Subclass `BaseReport` and implement `generate(self, ctx)`.
|
|
268
|
+
2. `conn = ctx.get_connection("primary_warehouse")` (or whatever name the project uses in `data-sources/config.yaml`); query with `query_df(conn, queries.X, params={...})`.
|
|
269
|
+
3. Wrap each DataFrame in a `DataSource` + a `FilterBar` placed together in an **untitled section**: `ctx.add_section("", [DataSource(...), FilterBar(...)])`. Untitled is required for sticky positioning.
|
|
270
|
+
4. Add content sections: `ctx.add_section(title, [...])`.
|
|
271
|
+
5. **Every chart, KPI, and table component MUST carry `dataset_id="..."`** pointing to its DataSource. Without it the component renders statically and silently ignores filters — this is the most common mistake.
|
|
272
|
+
|
|
273
|
+
To copy from, list what this project actually has (`ls reports/`) and open one.
|
|
274
|
+
Report names are not named here on purpose: this file ships with the framework
|
|
275
|
+
and travels into every project, so any slug written down is a report somebody
|
|
276
|
+
else has. A measured run followed three such names and got three "file does not
|
|
277
|
+
exist" errors before it thought to look.
|
|
278
|
+
|
|
279
|
+
<!-- topic: report-yaml -->
|
|
280
|
+
## Write the report.yaml description for discoverability (MANDATORY)
|
|
281
|
+
|
|
282
|
+
A search or assistant layer routes user questions to reports using
|
|
283
|
+
the `description` and `tags` in `report.yaml`, plus the dataset columns it
|
|
284
|
+
indexes from the built output. A vague description ("Revenue report") makes
|
|
285
|
+
the report invisible to it. The description MUST state:
|
|
286
|
+
|
|
287
|
+
1. **Which business questions the report answers** ("how many payers churn
|
|
288
|
+
per week and why"), not just its topic.
|
|
289
|
+
2. **The key metrics and dimensions** it carries (churn rate, revenue at
|
|
290
|
+
risk; split by platform/tier/country).
|
|
291
|
+
3. **Grain and freshness** (weekly cohorts; daily; refreshes every 5 min).
|
|
292
|
+
|
|
293
|
+
The `report-description-weak` validator check WARNs on short descriptions.
|
|
294
|
+
Descriptive column names in DataSources matter for the same reason — the
|
|
295
|
+
assistant reads them from data.json to decide which dataset answers a
|
|
296
|
+
question.
|
|
297
|
+
|
|
298
|
+
<!-- topic: components -->
|
|
299
|
+
## Component Reuse Policy
|
|
300
|
+
|
|
301
|
+
**Before writing any custom HTML, CSS, or JS, name the framework component you ruled out and why.** Most needs are already solved.
|
|
302
|
+
|
|
303
|
+
| Need | Required component |
|
|
304
|
+
|---|---|
|
|
305
|
+
| Filters / dropdowns / sticky filter bar | `DataSource` + `FilterBar` |
|
|
306
|
+
| Section-local filter on same data (no propagate_to wiring) | `ScopedDataSource(id, parent=...)` + section-scoped `FilterBar` |
|
|
307
|
+
| Cascading dropdowns (parent → child option narrowing) | Add `depends_on: "parent_col"` to a dropdown filter spec |
|
|
308
|
+
| KPI cards | `KpiRow(dataset_id=...)` (`agg`: `sum` / `ratio` / `count` / `abssum`) |
|
|
309
|
+
| Time series | `LineChart` |
|
|
310
|
+
| Bar / stacked bar | `BarChart` / `StackedBar` |
|
|
311
|
+
| Area / stacked area | `AreaChart` (`stacked=True`) |
|
|
312
|
+
| Doughnut / pie | `DoughnutChart` |
|
|
313
|
+
| Heatmap / day×hour / cohort grid | `HeatmapChart` |
|
|
314
|
+
| Funnel | `FunnelChart` |
|
|
315
|
+
| Treemap | `TreemapChart` |
|
|
316
|
+
| Scatter / correlation / bubble | `ScatterChart` |
|
|
317
|
+
| Dual-axis bar + line | `ComboChart` |
|
|
318
|
+
| Ratio metrics (ARPDAU, ARPPU, retention, conversion) | `LineChart(ratios=[{numerator, denominator, label}])` — never hand-roll the division in JS |
|
|
319
|
+
| Ratio by category (CPD per comfort_zone, ARPPU by country) | `BarChart(ratios=[...], horizontal=True, sort='desc')` — categorical ratio bars |
|
|
320
|
+
| Tables | `DataTable`, `ComparisonTable`, `PivotTable` |
|
|
321
|
+
| Layout | `Grid`, `Panel`, `SplitPane`, `TabGroup` |
|
|
322
|
+
| Card-panel grid | `Grid(card=True)` |
|
|
323
|
+
| Cross-grain filter sync | `FilterBar(propagate_to={target_ds: {src_col: tgt_col}})` |
|
|
324
|
+
| A/B test report | `ABCompare.from_users(df, variant_col=..., control=..., test=..., metrics=[Metric(...)])` — the front door: a per-user frame in YOUR column names, out comes the finished component with an SRM badge and raw / winsor / CUPED modes (a `Metric` that declares `pre_col` opts into CUPED). Groups fetched separately (one query per arm)? `ABCompare.from_groups({"Control": df_a, "Test": df_b}, metrics=[...])`. Self-contained — no DataSource/FilterBar needed. The raw constructor `ABCompare(rows=..., modes=...)` plus `trellum.stats.ab.{winsorize_user_df, cuped_user_df, bootstrap_ab_cis}` remains for custom row sets. See `trellum/README.md` "A/B Testing". |
|
|
325
|
+
|
|
326
|
+
`RawHTML` is the **exception, not the default**. It is permitted only when **both**:
|
|
327
|
+
|
|
328
|
+
1. The visualization is genuinely novel (force-directed graph, Sankey, etc.) and no combination of framework components can express it.
|
|
329
|
+
2. You have explicitly named the components you considered and why each was insufficient.
|
|
330
|
+
|
|
331
|
+
For complex reports prefer the **hybrid pattern**: framework components for filters/standard charts/KPIs; `RawHTML` only for the genuinely novel section. The custom JS must subscribe to the framework filter engine — `window._fwFilterEngine.subscribe(dsId, id, fn)` — instead of managing its own filter state. See `trellum/README.md` "Custom Dashboards" for the full pattern and the `fw.*` API.
|
|
332
|
+
|
|
333
|
+
### Anti-patterns to avoid
|
|
334
|
+
|
|
335
|
+
- **Hardcoded hex colors anywhere in RawHTML JS/HTML** — the `rawhtml-hardcoded-hex` validator **FAILS** the report on any `#RGB`/`#RRGGBB` literal except `#fff` and `#000`. Use `fw.getThemeColors().chart_colors[i]` for chart palettes and CSS vars (`var(--accent-red)`, `var(--text-main)`, `var(--bg-card)`) for HTML/CSS.
|
|
336
|
+
- **Hardcoded `rgba?()`/`rgb()` literals in RawHTML JS** — `rawhtml-hardcoded-rgba` (WARN) flags numeric color literals that don't update on theme switch. Use `fw.getThemeColors().grid_color` for grid lines, `.tick_color` for axis ticks / legend labels, `.chart_colors[i]` for dataset colors. `rgba(0,0,0,0)` (transparent) is exempt. Suppress for intentional fixed-color semantic annotations.
|
|
337
|
+
- **CSS variable strings in Chart.js color properties** — `rawhtml-css-var-in-chartjs` (WARN) flags patterns like `color: 'var(--text-main)'`. Chart.js has no CSS resolver; the string is used as-is (invalid color). Use `fw.getThemeColors().tick_color` / `.grid_color`, or resolve with `getComputedStyle(document.documentElement).getPropertyValue('--name').trim()`.
|
|
338
|
+
- Re-implementing Slim Select dropdowns (use `FilterBar`; `ScopedDataSource` for section-local; `depends_on` for cascading)
|
|
339
|
+
- Writing custom `getFilteredRows()` / filter state management (use the public `fw.filterEngine.*` API)
|
|
340
|
+
- Polling with `setTimeout(init, 100)` to wait for `_fwFilterEngine` (use `fw.filterEngine.onReady(dsId, fn)`)
|
|
341
|
+
- Referencing `window._fwFilterEngine` directly in RawHTML (it's private; use `fw.filterEngine`)
|
|
342
|
+
- Duplicating a DataFrame to get a separate DataSource for section-local filtering (use `ScopedDataSource(id, parent=...)`)
|
|
343
|
+
- Duplicating KPI rendering when `KpiRow(dataset_id=...)` covers it
|
|
344
|
+
- Building custom layout grids when `Grid` / `Panel` / `SplitPane` suffice
|
|
345
|
+
- Going 100% `RawHTML` when only one or two sections need custom logic
|
|
346
|
+
- LEFT JOINing coarser-grain data (monthly MAU, install cohort) into a finer-grain DataFrame to avoid creating a second DataSource — duplicates rows and silently breaks ratio aggregations under filtering
|
|
347
|
+
|
|
348
|
+
### KpiRow aggregation semantics (exact, from the runtime)
|
|
349
|
+
|
|
350
|
+
Every `agg` recomputes over the **filtered rows**, so pass raw columns and let the runtime do the arithmetic — a pre-divided or pre-summed column cannot re-aggregate. The complete set:
|
|
351
|
+
|
|
352
|
+
| `agg` | Computes | Notes |
|
|
353
|
+
|---|---|---|
|
|
354
|
+
| `sum` | `sum(column)` — or summed across a `columns` list | the default |
|
|
355
|
+
| `count` | number of filtered rows | no `column` needed; add a literal `df["x_n"] = 1` column when you also need a ratio denominator |
|
|
356
|
+
| `abssum` | `sum(abs(column))` | for signed ledgers |
|
|
357
|
+
| `ratio` | `sum(numerator) / abs(sum(denominator))`, ×100 **only** when `format: "percent"` | the scaling follows the FORMAT, so a currency ratio (ARPDAU, AOV, revenue per order) and a plain one (sessions per user, `format: "ratio"` → "2.98x") are ordinary ratio KPIs. Charts do the same: `ratios=[{numerator, denominator, label}]` divides raw and the axis format scales |
|
|
358
|
+
| `avg_by_date` | `sum(column) / count(distinct date_col)` | per-day average; `date_col` defaults to `event_date` |
|
|
359
|
+
| `purchase_pct` | share of `source_col` volume where `type_col` is in `match_values`, ×100 | percent-format only — unlike `ratio`, its ×100 is still unconditional |
|
|
360
|
+
|
|
361
|
+
Where the ×100 lives in `ratio` is the most re-derived fact in measured
|
|
362
|
+
sessions — one agent spent 15 tool calls reading `kpis.py`, `validation.py`
|
|
363
|
+
and the JS runtime to establish it. It is display, not aggregation, and it is
|
|
364
|
+
stated here so the next one does not have to look.
|
|
365
|
+
|
|
366
|
+
<!-- topic: metrics -->
|
|
367
|
+
## Claimed metrics: metrics.yaml, the `{"metric": id}` claim, and `ctx.metrics()`
|
|
368
|
+
|
|
369
|
+
**Before inventing a KPI dict, run `python -m trellum metrics`.** If the
|
|
370
|
+
number you are about to compute is already defined there, **claim it by id**
|
|
371
|
+
instead of writing another `{"label", "agg", "column"}` dict — a second
|
|
372
|
+
hand-written definition of "gross revenue" is how two reports come to
|
|
373
|
+
disagree about what gross revenue is. If it is a business number and it is
|
|
374
|
+
NOT defined, propose adding it to the project-root `metrics.yaml` rather than
|
|
375
|
+
hard-coding a definition only this report knows about. Inline KPI dicts stay
|
|
376
|
+
fully supported — claims are for the numbers that must mean the same thing
|
|
377
|
+
everywhere.
|
|
378
|
+
|
|
379
|
+
A claim replaces the KPI dict with the metric's id:
|
|
380
|
+
|
|
381
|
+
```python
|
|
382
|
+
KpiRow([
|
|
383
|
+
{"metric": "gross_revenue"}, # label, format, agg, column all
|
|
384
|
+
{"metric": "transactions"}, # expand from metrics.yaml
|
|
385
|
+
], dataset_id="rev")
|
|
386
|
+
|
|
387
|
+
KpiCard(metric="gross_revenue", value=total) # static claim: identity only,
|
|
388
|
+
MiniKpi(metric="transactions", value=n) # the value must be supplied
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
The claim expands **at build time** into the exact aggregation config the
|
|
392
|
+
client engine already executes — there is no new computation engine, and a
|
|
393
|
+
claimed KPI filters and re-aggregates exactly like an inline one. Explicit
|
|
394
|
+
keys on the claim dict win over the definition, but the `metric-overridden`
|
|
395
|
+
check WARNs when they conflict: an override means the card no longer computes
|
|
396
|
+
the definition it names.
|
|
397
|
+
|
|
398
|
+
### Getting the rows: datasets and `ctx.metrics()`
|
|
399
|
+
|
|
400
|
+
A metric can **bind to a dataset** — the one place that says "these rows are
|
|
401
|
+
daily, over `event_date`, filterable by these dimensions". Bound metrics are
|
|
402
|
+
fetched with `ctx.metrics()` instead of a hand-written query:
|
|
403
|
+
|
|
404
|
+
```python
|
|
405
|
+
df = ctx.metrics(["gross_revenue", "dau"], by=["platform"], window=90)
|
|
406
|
+
ctx.add_section("", [DataSource("rev", df),
|
|
407
|
+
FilterBar("rev", df, filters=[
|
|
408
|
+
{"column": "event_date", "type": "date_range"},
|
|
409
|
+
{"column": "platform"}])])
|
|
410
|
+
ctx.add_section("Overview", [
|
|
411
|
+
KpiRow([{"metric": "gross_revenue"}, {"metric": "dau"}], dataset_id="rev"),
|
|
412
|
+
LineChart(df, x="event_date", y="total_revenue", stack_by="platform",
|
|
413
|
+
dataset_id="rev", title="Gross Revenue"),
|
|
414
|
+
])
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
It returns a plain long-format DataFrame — the dataset's time column, the
|
|
418
|
+
`by` dimensions, one column per measure the metrics need — so a bare claim on
|
|
419
|
+
a `KpiRow` over it computes by construction and `metric-column-missing`
|
|
420
|
+
passes. **One call covers one dataset**; ids from two datasets raise with the
|
|
421
|
+
split spelled out (two datasets are two DataSources anyway). `by` must name
|
|
422
|
+
declared dimensions of the dataset; anything else raises before a query runs.
|
|
423
|
+
`window` is days back from `ctx.today` or an explicit `(start, end)`; the
|
|
424
|
+
default is the dataset's `lookback`, else 90 days. Identical requests in one
|
|
425
|
+
build cost one fetch, and the generated SQL has a stable column order so it
|
|
426
|
+
hits the query cache across reports.
|
|
427
|
+
|
|
428
|
+
The SQL shortcut selects time + dims + measures with the date filter, and
|
|
429
|
+
GROUPs BY time + dims with `SUM()` when every requested metric is additive
|
|
430
|
+
across dimensions — the one sanctioned exception to "aggregate in pandas",
|
|
431
|
+
because the grouping is over every dimension the FilterBar can use. A
|
|
432
|
+
`count` metric means rows, so its presence keeps native grain. Derived
|
|
433
|
+
per-row columns (`total_revenue: "iap_revenue + ad_revenue"`) live on the
|
|
434
|
+
dataset's `columns:` map and are derived once, there, instead of in every
|
|
435
|
+
report. Raw event tables that would not survive a GROUP BY get a Python
|
|
436
|
+
`provider: module:function` instead, called as `(ctx, req) -> DataFrame`;
|
|
437
|
+
the layer projects and rolls up its frame the same way.
|
|
438
|
+
|
|
439
|
+
`metrics.yaml` format (a LIST, so duplicate ids are lintable):
|
|
440
|
+
|
|
441
|
+
```yaml
|
|
442
|
+
version: 1
|
|
443
|
+
datasets: # optional; files without it stay valid
|
|
444
|
+
daily:
|
|
445
|
+
source: demo_db # a source from data-sources/config.yaml
|
|
446
|
+
table: fact_daily # + table, optional where: "day_number = 1"
|
|
447
|
+
columns: {total_revenue: "iap_revenue + ad_revenue"} # derived per row
|
|
448
|
+
# provider: metrics_data:daily # OR a Python (ctx, req) -> DataFrame
|
|
449
|
+
time: {column: event_date, grain: day} # hour|day|week|month, or time: none
|
|
450
|
+
dimensions: [title, platform, region] # what the dataset can be filtered by
|
|
451
|
+
lookback: 90 # default window, days
|
|
452
|
+
metrics:
|
|
453
|
+
- name: gross_revenue # ^[a-z0-9][a-z0-9_]*$
|
|
454
|
+
label: "Gross Revenue"
|
|
455
|
+
description: >
|
|
456
|
+
IAP plus ad revenue, gross of platform fees, daily grain.
|
|
457
|
+
owner: finance@example.com
|
|
458
|
+
format: currency # any KpiCard format
|
|
459
|
+
agg: sum # sum | abssum | count over `column`,
|
|
460
|
+
column: total_revenue # or agg: ratio with numerator/denominator
|
|
461
|
+
dataset: daily # the binding; absent = listed, not monitored
|
|
462
|
+
time_agg: avg # optional: avg (per period) | last (default: sum)
|
|
463
|
+
sql: "iap_revenue + ad_revenue" # canonical derivation — informational
|
|
464
|
+
dimensions: [event_date, title] # informational
|
|
465
|
+
tags: [revenue]
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
A metric **without** an `agg` spec is *descriptive*: claimable only where a
|
|
469
|
+
`value` is supplied (static KpiCard/MiniKpi). A metric **with** a spec is
|
|
470
|
+
*executable* and can be claimed bare inside a live `KpiRow(dataset_id=...)`.
|
|
471
|
+
`time_agg: avg` makes a bare claim expand to the client's `avg_by_date` over
|
|
472
|
+
the dataset's time column (a DAU card reads "average DAU over the window");
|
|
473
|
+
`time_agg: last` (balances) is rendered from a Python-computed `value=` for
|
|
474
|
+
now. `dataset` and `time_agg` are provenance, not meaning, so they do not
|
|
475
|
+
move `definition_hash`.
|
|
476
|
+
|
|
477
|
+
**`python -m trellum metrics --report`** scaffolds `reports/metrics/`: an
|
|
478
|
+
all-metrics-at-a-glance page for every bound metric — one DataSource per
|
|
479
|
+
dataset, then one block per metric (a KPI and a trend), grouped by tag, plus a
|
|
480
|
+
table of the metrics defined but not bound — from a one-line generator. Bind a
|
|
481
|
+
metric and rebuild; nothing else to edit.
|
|
482
|
+
|
|
483
|
+
The blocks in a tag sit **side by side**, in `Grid(min_width=340)` — an
|
|
484
|
+
overview is metrics read against each other, not one chart per screenful. The
|
|
485
|
+
grid is `auto-fill`, so the number across follows the available width (three
|
|
486
|
+
at the container's 1400px cap, two around 900px, one on a phone) with no
|
|
487
|
+
breakpoints to keep in sync, and each cell is a **tile**: `grid.css` shortens
|
|
488
|
+
its chart to 200px, drops the chart's own title (the block above it already
|
|
489
|
+
carries the name) and hides the breakdown toggle, and measures a `KpiRow`
|
|
490
|
+
against the tile instead of the window. `Grid(min_width=...)` is the general
|
|
491
|
+
primitive, not a metrics-report special case — reach for it whenever a row of
|
|
492
|
+
charts should reflow rather than sit at a fixed column count.
|
|
493
|
+
|
|
494
|
+
Two controls, two scopes. The **date range** is shared: one FilterBar on the
|
|
495
|
+
first time dataset, propagated onto every other time dataset's own time
|
|
496
|
+
column, because "the last 30 days" means the same thing at every grain. A
|
|
497
|
+
dataset with no time axis is left out of it rather than filtered on a column
|
|
498
|
+
it does not have. The **breakdown is per metric**: each trend carries its own
|
|
499
|
+
`stack_by_options` toggle over its dataset's dimensions ("Total" first, so no
|
|
500
|
+
split is the default) — hidden at tile width, where a row of dimension buttons
|
|
501
|
+
is taller than the chart it labels, and back in the single-block view below.
|
|
502
|
+
There is deliberately no report-wide dimension filter — datasets share a time
|
|
503
|
+
axis but not their dimensions.
|
|
504
|
+
|
|
505
|
+
Each block is a `Section` with `anchor="metric-<name>"`, which makes it
|
|
506
|
+
addressable: **`?only=<section id>`** on any report page renders that section
|
|
507
|
+
alone (its ancestors and the shared FilterBar with it, page chrome dropped),
|
|
508
|
+
which is what an `<iframe>` onto a single block loads and what `#<id>` scrolls
|
|
509
|
+
to. The tile grid collapses there — one block is not a grid — so the surviving
|
|
510
|
+
block gets the whole frame at full size, breakdown toggle included. An id the
|
|
511
|
+
build does not have leaves the page whole. Give any generated section an
|
|
512
|
+
`anchor` when its title is not a name you want in a URL.
|
|
513
|
+
|
|
514
|
+
What the build produces: `data.json` gains a top-level `_metrics` block
|
|
515
|
+
(claimed metrics only — label, format, agg spec, version, `definition_hash`,
|
|
516
|
+
claiming component ids) and `_meta.json` gains `metrics_used:
|
|
517
|
+
[{"id", "definition_hash", "version"}, ...]`, so anything reading built
|
|
518
|
+
artifacts can group KPIs by business meaning, and tell a claim's build-time
|
|
519
|
+
definition apart from the metric's current one without opening `data.json`
|
|
520
|
+
(read it via `trellum.meta.normalize_metrics_used`, which also accepts an
|
|
521
|
+
older build's bare-id-list shape).
|
|
522
|
+
|
|
523
|
+
Validation (`metrics` check group): `metrics-yaml-schema` (broken file —
|
|
524
|
+
duplicate ids, bad slugs, unknown format/agg, a `dataset` ref that does not
|
|
525
|
+
exist, a dataset with neither `provider` nor `source` + `table`, a dataset
|
|
526
|
+
without `time`), `metric-undefined` (claim of an id metrics.yaml does not
|
|
527
|
+
define — FAIL), `metric-column-missing` (the claim cannot compute: definition
|
|
528
|
+
columns absent from the DataSource, or a claim with no value where one is
|
|
529
|
+
required — FAIL), `metric-overridden` (WARN), `metric-description-weak`
|
|
530
|
+
(INFO).
|
|
531
|
+
|
|
532
|
+
CLI: `python -m trellum metrics` lists definitions, datasets and claim
|
|
533
|
+
coverage; `python -m trellum metrics <name>` prints one full definition;
|
|
534
|
+
`python -m trellum metrics --lint` reports duplicates and orphans;
|
|
535
|
+
`python -m trellum metrics --report` scaffolds the monitoring report.
|
|
536
|
+
|
|
537
|
+
<!-- topic: rawhtml -->
|
|
538
|
+
## RawHTML chart lifecycle (mandatory when the chart lives inside a `Visible`)
|
|
539
|
+
|
|
540
|
+
If your `RawHTML` creates a Chart.js instance and the `RawHTML` is anywhere inside a toggle-driven `Visible`, the JS **must** patch BOTH `_initToggleVis` and `renderAll`, AND defer the render with `requestAnimationFrame`:
|
|
541
|
+
|
|
542
|
+
```js
|
|
543
|
+
function _refresh() {
|
|
544
|
+
if (fw.filterEngine.isReady(dsId)) _render(canvasId, dsId, fw.filterEngine.getFiltered(dsId));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
fw.filterEngine.subscribe(dsId, 'mychart', _render);
|
|
548
|
+
window.addEventListener('fw-theme-change', _refresh);
|
|
549
|
+
|
|
550
|
+
// _initToggleVis runs on initial page load (renderAll does NOT — the framework
|
|
551
|
+
// calls _renderComps() directly on first load). Without this hook the chart is
|
|
552
|
+
// blank on first load and on URL-preloaded state.
|
|
553
|
+
window._initToggleVis = (function (prev) {
|
|
554
|
+
return function () { if (prev) prev(); requestAnimationFrame(_refresh); };
|
|
555
|
+
})(window._initToggleVis);
|
|
556
|
+
|
|
557
|
+
// renderAll catches scope switches, theme changes, auto-refresh.
|
|
558
|
+
window.renderAll = (function (prev) {
|
|
559
|
+
return function () { if (prev) prev(); requestAnimationFrame(_refresh); };
|
|
560
|
+
})(window.renderAll);
|
|
561
|
+
```
|
|
562
|
+
|
|
563
|
+
Why each piece:
|
|
564
|
+
|
|
565
|
+
| Hook | Catches | Without it |
|
|
566
|
+
|---|---|---|
|
|
567
|
+
| `fw.filterEngine.subscribe(dsId, ...)` | filter changes | chart never updates when user filters |
|
|
568
|
+
| `fw-theme-change` listener | theme switches | chart keeps old palette |
|
|
569
|
+
| `window._initToggleVis` patch | **initial page load + URL preload** | chart blank on first load — only renders after user clicks a toggle |
|
|
570
|
+
| `window.renderAll` patch | scope switches, auto-refresh, theme switches | chart stale after re-renders |
|
|
571
|
+
| `requestAnimationFrame` defer | layout reflow after `_updateToggleVis` flips `display` | Chart.js measures canvas at 0×0 → blank chart even though parent is visible |
|
|
572
|
+
|
|
573
|
+
For multi-canvas patterns (one canvas per per-feature DataSource), use a `_ensureSubs()` that finds canvases by attribute (e.g. `[data-split-ds]`) and lazily subscribes once each, then `requestAnimationFrame(_refreshAll)` from both lifecycle hooks.
|
|
574
|
+
|
|
575
|
+
<!-- topic: filters -->
|
|
576
|
+
## DataSource + FilterBar rules
|
|
577
|
+
|
|
578
|
+
- Every report has at least one `DataSource` + `FilterBar` pair (exceptions: `ABCompare`-only reports; single-day dashboards may omit the date_range filter but should still use `DataSource` for any filterable dimension).
|
|
579
|
+
- `date_range` filter first when there's a time-series dimension; one filter per useful categorical dimension.
|
|
580
|
+
- **One grain = one DataSource.** Different grain (monthly MAU vs daily revenue) gets its own `DataSource`. Use `propagate_to` to sync shared dimensions.
|
|
581
|
+
- `static=True` only when data is fundamentally incompatible with the report's filters (point-in-time snapshot, external system data with different date semantics) — rare.
|
|
582
|
+
|
|
583
|
+
### Two layout patterns: main vs section FilterBar
|
|
584
|
+
|
|
585
|
+
The framework supports two FilterBar placements, and they compose:
|
|
586
|
+
|
|
587
|
+
**Main FilterBar** (the default for most reports). Placed in an
|
|
588
|
+
**untitled section** at the top. Use it for filters that apply across
|
|
589
|
+
the whole report — typically `date_range`, `audience_segment`,
|
|
590
|
+
`platform`, etc. The main FilterBar reaches its primary DataSource
|
|
591
|
+
plus any DataSources listed in `propagate_to`. Sticks to the viewport
|
|
592
|
+
top.
|
|
593
|
+
|
|
594
|
+
```python
|
|
595
|
+
ctx.add_section("", [ # untitled section — sticky top
|
|
596
|
+
DataSource("daily", df_daily, chunk_by="month"),
|
|
597
|
+
DataSource("monthly", df_monthly),
|
|
598
|
+
FilterBar("daily", df_daily, filters=[
|
|
599
|
+
{"column": "event_date", "type": "date_range"},
|
|
600
|
+
{"column": "platform"},
|
|
601
|
+
], propagate_to={
|
|
602
|
+
"monthly": {"event_date": "event_date"},
|
|
603
|
+
}),
|
|
604
|
+
])
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
**Section-scoped FilterBar** (drill-downs). Placed inside a titled
|
|
608
|
+
section right after that section's DataSource(s). Use it when a
|
|
609
|
+
section has a dimension that doesn't make sense for the rest of the
|
|
610
|
+
report — e.g. `chest_level` only applies to chest charts. Section
|
|
611
|
+
FilterBars compose with the main FilterBar: a chart inside a section
|
|
612
|
+
is filtered by the AND of both. Sticks below the main FilterBar while
|
|
613
|
+
its section is in view.
|
|
614
|
+
|
|
615
|
+
Canonical layout for a section-scoped pair (validator recognizes this
|
|
616
|
+
exact order):
|
|
617
|
+
|
|
618
|
+
```python
|
|
619
|
+
ctx.add_section("Chest Daily Trends", [
|
|
620
|
+
DataSource("chest_daily", df_chest),
|
|
621
|
+
FilterBar("chest_daily", df_chest, filters=[
|
|
622
|
+
{"column": "chest_level"},
|
|
623
|
+
{"column": "difficulty_tier"},
|
|
624
|
+
]),
|
|
625
|
+
LineChart(..., dataset_id="chest_daily"),
|
|
626
|
+
LineChart(..., dataset_id="chest_daily"),
|
|
627
|
+
])
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
### Section-local filters on the SAME data — use `ScopedDataSource`
|
|
631
|
+
|
|
632
|
+
When a section needs **another local filter** on data that's already
|
|
633
|
+
loaded via the main DataSource (e.g. "Price Tier" filter that only
|
|
634
|
+
affects one chart, not the whole report), use a `ScopedDataSource`
|
|
635
|
+
instead of a fresh DataSource. It:
|
|
636
|
+
|
|
637
|
+
- inherits all of the parent's filters automatically — no `propagate_to`
|
|
638
|
+
wiring needed
|
|
639
|
+
- ships no extra data — rows are derived from the parent at runtime
|
|
640
|
+
- composes with the section's own FilterBar (parent filters AND child
|
|
641
|
+
filters)
|
|
642
|
+
|
|
643
|
+
```python
|
|
644
|
+
# Untitled top section: ONE base DataSource + main FilterBar
|
|
645
|
+
ctx.add_section("", [
|
|
646
|
+
DataSource("cpd", df),
|
|
647
|
+
FilterBar("cpd", df, filters=[
|
|
648
|
+
{"column": "event_date", "type": "date_range"},
|
|
649
|
+
{"column": "package_group"},
|
|
650
|
+
]),
|
|
651
|
+
])
|
|
652
|
+
|
|
653
|
+
# Drill-down section with its OWN local filter
|
|
654
|
+
ctx.add_section("CPD by Price Point", [
|
|
655
|
+
ScopedDataSource("cpd_pp", parent="cpd"),
|
|
656
|
+
FilterBar("cpd_pp", df, filters=[
|
|
657
|
+
{"column": "price_tier"}, # only affects this section
|
|
658
|
+
]),
|
|
659
|
+
LineChart(df=df, x="event_date", dataset_id="cpd_pp",
|
|
660
|
+
ratios=[{"numerator": "chips", "denominator": "revenue",
|
|
661
|
+
"label": "CPD"}],
|
|
662
|
+
stack_by="price_point_display",
|
|
663
|
+
title="CPD by Price Point"),
|
|
664
|
+
])
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
**Cascading dropdowns** (e.g. *Package Group* → *Package Name*): add
|
|
668
|
+
`depends_on` to the child filter spec. The child's option list narrows
|
|
669
|
+
automatically when the parent selection changes:
|
|
670
|
+
|
|
671
|
+
```python
|
|
672
|
+
FilterBar("cpd_pkg", df, filters=[
|
|
673
|
+
{"column": "package_group"},
|
|
674
|
+
{"column": "package_name", "depends_on": "package_group"},
|
|
675
|
+
])
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
### Picking a filter type
|
|
679
|
+
|
|
680
|
+
A slider fits a continuous numeric range (a discount %, a price, a
|
|
681
|
+
quantity) -- somewhere enumerating every value as `dropdown` options
|
|
682
|
+
would be absurd. A dropdown fits categorical values. A toggle fits a
|
|
683
|
+
handful of distinct values. A date column always stays on `date_range`
|
|
684
|
+
-- never model it as a `slider`, even though both are range-shaped.
|
|
685
|
+
|
|
686
|
+
```python
|
|
687
|
+
FilterBar("orders", df, filters=[
|
|
688
|
+
{"column": "discount_pct", "label": "Discount %",
|
|
689
|
+
"type": "slider", "format": "percent"},
|
|
690
|
+
])
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
`mode="range"` (the default) gives a two-handle min/max band; commits
|
|
694
|
+
land in the filter engine as its own `numrange` mode (numeric
|
|
695
|
+
comparison -- unlike the string-compared `range` mode `date_range`
|
|
696
|
+
uses, which would misorder plain numbers). `mode="single"` snaps to one
|
|
697
|
+
of the column's distinct values and commits through the existing
|
|
698
|
+
`equals` mode, same as a dropdown's single selection. The validator
|
|
699
|
+
flags a slider on a non-numeric column (`filter-slider-not-numeric`).
|
|
700
|
+
|
|
701
|
+
#### Ordinal sliders: sliders over ORDERED CATEGORIES
|
|
702
|
+
|
|
703
|
+
A slider also fits ORDERED categories that have no numeric value of
|
|
704
|
+
their own -- a spender tier, a severity level, a size class. Give it
|
|
705
|
+
`"values"` (the ordered list) instead of letting it derive bounds from
|
|
706
|
+
`df[col].min()/.max()`; the column doesn't need to be numeric:
|
|
707
|
+
|
|
708
|
+
```python
|
|
709
|
+
FilterBar("players", df, filters=[
|
|
710
|
+
{"column": "spender_tier", "label": "Spender Tier", "type": "slider",
|
|
711
|
+
"values": ["non_spender", "minnow", "dolphin", "whale"],
|
|
712
|
+
"labels": {"non_spender": "Non-spender", "minnow": "Minnow",
|
|
713
|
+
"dolphin": "Dolphin", "whale": "Whale"},
|
|
714
|
+
"mode": "range", "default_min": "minnow", "default_max": "whale"},
|
|
715
|
+
])
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
Internally the values map to integer positions (0, 1, 2, ...) so
|
|
719
|
+
noUiSlider can drive them -- pips at each position show `labels`
|
|
720
|
+
(fallback: the raw value) if they fit the slider's width, otherwise
|
|
721
|
+
just the two endpoints. `mode="range"` commits the CONTIGUOUS SPAN of
|
|
722
|
+
selected categories as a list through the existing `'in'` mode --
|
|
723
|
+
exactly what a dropdown multi-select commits, so `propagate_to`,
|
|
724
|
+
`depends_on` cascades, and URL sync all keep working with no changes.
|
|
725
|
+
`mode="single"` commits the selected category through `equals`, same
|
|
726
|
+
as the numeric slider's single mode.
|
|
727
|
+
|
|
728
|
+
Reach for the ordinal slider only when the categories have a real
|
|
729
|
+
order the reader would drag through end to end; an unordered set (e.g.
|
|
730
|
+
`platform`, `country`) stays a `dropdown` -- a slider implies an order
|
|
731
|
+
that doesn't exist there.
|
|
732
|
+
|
|
733
|
+
The validator extends the same slider check for the ordinal case:
|
|
734
|
+
`values` must be a non-empty list of strings and any default must be
|
|
735
|
+
one of them (`filter-slider-not-numeric`, fail); a listed value that
|
|
736
|
+
never occurs in the DataFrame is flagged (`filter-slider-value-unused`,
|
|
737
|
+
warn, not fail) -- a tier legitimately going empty under a narrow date
|
|
738
|
+
filter is normal, not a broken config.
|
|
739
|
+
|
|
740
|
+
### Custom RawHTML lifecycle
|
|
741
|
+
|
|
742
|
+
When you do need custom JS (genuinely novel visualization), always use
|
|
743
|
+
the public `fw.*` API:
|
|
744
|
+
|
|
745
|
+
```js
|
|
746
|
+
fw.filterEngine.onReady('cpd', function() {
|
|
747
|
+
// engine is ready — wire your chart now
|
|
748
|
+
fw.filterEngine.subscribe('cpd', 'my-chart', _render);
|
|
749
|
+
_render(fw.filterEngine.getFiltered('cpd'));
|
|
750
|
+
});
|
|
751
|
+
```
|
|
752
|
+
|
|
753
|
+
**Never** poll with `setTimeout(init, 100)`, **never** reference
|
|
754
|
+
`window._fwFilterEngine` directly, **never** instantiate `new SlimSelect(...)`
|
|
755
|
+
in RawHTML. Each of those is a validator WARN now. Use FilterBar +
|
|
756
|
+
ScopedDataSource for the dropdown UX; use `fw.filterEngine.onReady` for
|
|
757
|
+
ready-detection; use `fw.filterEngine.*` for everything filter-related.
|
|
758
|
+
|
|
759
|
+
Validator constraints:
|
|
760
|
+
|
|
761
|
+
- At most one main FilterBar per report (untitled top section).
|
|
762
|
+
- At most one section-scoped FilterBar per titled section.
|
|
763
|
+
- Section-scoped pair must be laid out as `[DataSource(s) or ScopedDataSource, FilterBar, ...content]` at the start of the section.
|
|
764
|
+
- A `ScopedDataSource`'s `parent` must reference a base `DataSource` (not another scoped child).
|
|
765
|
+
- A filter with `depends_on` must reference another filter on the same FilterBar with a real column in the underlying DataFrame.
|
|
766
|
+
|
|
767
|
+
Use `ScopedDataSource` when a section filters the *same* rows more narrowly. When the grain genuinely differs, give each section its own `DataSource` instead — different data is not a scope of the same data.
|
|
768
|
+
|
|
769
|
+
<!-- topic: live-queries -->
|
|
770
|
+
## Live queries: per-entity lookups a snapshot cannot carry
|
|
771
|
+
|
|
772
|
+
**Compiled is the default; live is the rare exception.** Every component
|
|
773
|
+
you have used so far — `DataSource`, `FilterBar`, every chart, every
|
|
774
|
+
table — computes at build time and ships its rows baked into the artifact.
|
|
775
|
+
That is the right trade almost always: it is fast, it works from a share
|
|
776
|
+
link or an emailed copy, and it needs nothing running to keep working.
|
|
777
|
+
Reach for a live query ONLY when the question is genuinely per-entity and
|
|
778
|
+
the entity count is too large to bake in full — "type a user id, see that
|
|
779
|
+
user's raw events" against a table with a million users, not "show revenue
|
|
780
|
+
by day" or anything a `FilterBar` over a compiled `DataSource` already
|
|
781
|
+
answers. If a compiled component can answer it, use the compiled
|
|
782
|
+
component; a live query is the opt-in exception, not a habit.
|
|
783
|
+
|
|
784
|
+
**The snapshot is ONE slice, never the whole table.** Declaring a live
|
|
785
|
+
query still bakes something in — the build runs the declared query once,
|
|
786
|
+
at build time, for a single parameter set you choose
|
|
787
|
+
(`snapshot_params`), and stores that one result as the ordinary snapshot
|
|
788
|
+
every other dataset already gets. A million-user table does not become a
|
|
789
|
+
million-row artifact; it becomes one example user's rows, plus a promise
|
|
790
|
+
that a serving host can fetch any other user's on demand. Give
|
|
791
|
+
`snapshot_params` so the un-hosted page still shows something real — the
|
|
792
|
+
`live-query-no-snapshot` WARN flags the alternative (an empty control,
|
|
793
|
+
standalone).
|
|
794
|
+
|
|
795
|
+
**A live query is a filter-engine DATASET, not a bespoke control.**
|
|
796
|
+
`ctx.declare_live_query` registers the query; a `LiveDataSource` puts its
|
|
797
|
+
snapshot into the filter engine under a `dataset_id` exactly like an
|
|
798
|
+
ordinary `DataSource`; an ordinary `FilterBar` drives it by naming, on
|
|
799
|
+
each filter, the declared param that filter's column feeds. Every
|
|
800
|
+
chart/table/KPI row built against that `dataset_id` reacts exactly as it
|
|
801
|
+
would to a compiled dataset — it does not know its rows came from a POST
|
|
802
|
+
instead of a local filter.
|
|
803
|
+
|
|
804
|
+
```python
|
|
805
|
+
# generator.py
|
|
806
|
+
events = ctx.declare_live_query(
|
|
807
|
+
"user_events", # slug: ^[a-z0-9][a-z0-9_-]*$
|
|
808
|
+
queries.USER_EVENTS, # SQL with :user_id, :since, :until
|
|
809
|
+
datasource="demo_db", # must be a SQL source — see below
|
|
810
|
+
params=[
|
|
811
|
+
{"name": "user_id", "type": "int", "required": True},
|
|
812
|
+
{"name": "since", "type": "date", "required": True},
|
|
813
|
+
{"name": "until", "type": "date", "required": True},
|
|
814
|
+
],
|
|
815
|
+
snapshot_params={"user_id": 1042, "since": "2026-01-01", "until": "2026-02-01"},
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
ctx.add_section("Events", [
|
|
819
|
+
LiveDataSource("events", query="user_events", df=events),
|
|
820
|
+
FilterBar("events", events, filters=[
|
|
821
|
+
{"type": "text", "column": "user_id", "param": "user_id",
|
|
822
|
+
"label": "User ID", "placeholder": "e.g. 1042"},
|
|
823
|
+
{"type": "date_range", "column": "ts",
|
|
824
|
+
"min_param": "since", "max_param": "until", "label": "Date"},
|
|
825
|
+
]),
|
|
826
|
+
KpiRow(dataset_id="events", kpis=[{"label": "Events", "agg": "count"}]),
|
|
827
|
+
DataTable(events, title="Raw events", dataset_id="events", sortable=True),
|
|
828
|
+
])
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
Param `type` is one of `int|float|str|date|enum`; `enum` carries
|
|
832
|
+
`values: [...]`; `str` may carry `max_length` (default 200). A filter's
|
|
833
|
+
`param` key (or `min_param`/`max_param` for a range-shaped filter —
|
|
834
|
+
`slider`, `date_range`) is what maps it to a declared query param;
|
|
835
|
+
`dropdown`/`toggle`/`flag` bind an `enum` param, `slider`/`date_range` bind
|
|
836
|
+
numeric/date scalars, `text` binds any scalar. Live-bound dropdowns are
|
|
837
|
+
single-select in v1 (`"multi": False`). A second `LiveDataSource` can share
|
|
838
|
+
the first `FilterBar`'s filters instead of rendering its own, via
|
|
839
|
+
`propagate_to` on the `FilterBar` — one filter change then drives two
|
|
840
|
+
queries; see `demo/reports/user-event-log` for a live grouped-aggregate
|
|
841
|
+
query fed this way.
|
|
842
|
+
|
|
843
|
+
The declaration writes `_live_queries.json` (SQL + param schema) beside
|
|
844
|
+
`_meta.json`, for a host to read. The SQL never enters `data.json` or
|
|
845
|
+
`index.html` — the page carries only the query id, the declared param
|
|
846
|
+
schema, and (for the FilterBar's own dataset) the snapshot rows. Commits
|
|
847
|
+
are auto-query for dropdown/toggle/date-preset/slider-release, explicit
|
|
848
|
+
(Enter/blur) for text — typing alone never fires a query.
|
|
849
|
+
|
|
850
|
+
**Combine freely with compiled components.** A live dataset and a compiled
|
|
851
|
+
one can sit in the same report, even the same section — nothing requires
|
|
852
|
+
an all-or-nothing choice. `demo/reports/user-event-log` puts a live
|
|
853
|
+
`FilterBar`-driven KPI row, two charts and a table above a plain compiled
|
|
854
|
+
`LineChart(..., static=True)` showing the same metric aggregated over the
|
|
855
|
+
*entire* history: the live components answer "show me this one thing in
|
|
856
|
+
detail, right now", the compiled chart answers "how does this look in
|
|
857
|
+
aggregate, over everything" — a live per-entity lookup is a poor way to
|
|
858
|
+
answer that, and a compiled aggregate answers it for free. Reach for
|
|
859
|
+
`static=True` on a chart/table sharing a section with live components
|
|
860
|
+
whenever what it shows is a compiled aggregate, not a per-entity slice.
|
|
861
|
+
|
|
862
|
+
**The standalone degradation.** Without a host advertising `live_query_url`
|
|
863
|
+
(standalone build, share link, email snapshot, an old host, or — the
|
|
864
|
+
common case while you are writing the report — an artifact just opened
|
|
865
|
+
from disk), every live `FilterBar` renders server-side disabled with the
|
|
866
|
+
label "Live lookup — available when served by a host; showing data from
|
|
867
|
+
the last build", and the baked snapshot stands. This is not a degraded
|
|
868
|
+
error state; it is correct behavior with nobody to ask. Two hosts
|
|
869
|
+
currently implement the fetch: the portal in production, and `trellum
|
|
870
|
+
serve` for local development — the dev server answers the same request
|
|
871
|
+
shape against the project's own data sources when the report you are
|
|
872
|
+
serving declared a live query, through the identical `coerce_params` /
|
|
873
|
+
`check_sql_safe` / `inject_limit` guard rails
|
|
874
|
+
(`trellum/data/live_query_guard.py`) the portal's production endpoint
|
|
875
|
+
enforces, so what works locally is a real preview of what the portal will
|
|
876
|
+
do. Use `trellum serve` (not a bare file open) while iterating on a live
|
|
877
|
+
query if you need to see it actually fetch.
|
|
878
|
+
|
|
879
|
+
**SQL sources only.** The host executes the manifest's SQL on demand, so
|
|
880
|
+
the datasource must have a connection driver (`sqlite`, `duckdb`,
|
|
881
|
+
`postgres`, `mysql`, `bigquery`, ...). File/API/sheet sources fail the
|
|
882
|
+
`live-query-source-not-sql` check. Referencing an undeclared query id is
|
|
883
|
+
the `live-query-unknown` FAIL; a bad param schema (including `required:
|
|
884
|
+
false`, which literal substitution cannot support) is
|
|
885
|
+
`live-query-param-schema`; the SQL's `:name` placeholders and the declared
|
|
886
|
+
params must agree in both directions (`live-query-sql-params` — an
|
|
887
|
+
undeclared placeholder FAILs, an unused param WARNs); a declared param no
|
|
888
|
+
filter binds is `live-query-param-uncovered` — it could never change from
|
|
889
|
+
its snapshot default. `trellum checks` lists every `live-query-*` id (and
|
|
890
|
+
every other check id) with its level; `trellum guide validation` explains
|
|
891
|
+
suppression.
|
|
892
|
+
|
|
893
|
+
<!-- topic: review -->
|
|
894
|
+
## The live review loop
|
|
895
|
+
|
|
896
|
+
Review mode turns a served report into a feedback surface: the user clicks
|
|
897
|
+
elements in the browser, types change requests, queues them with one optional
|
|
898
|
+
chat message, and hits **Send** — you receive the batch in the terminal with
|
|
899
|
+
each item's section title, component kind and title, DOM id, and selector,
|
|
900
|
+
which map straight to lines of `generator.py`.
|
|
901
|
+
|
|
902
|
+
The loop, from your seat:
|
|
903
|
+
|
|
904
|
+
1. `python -m trellum review start reports/<slug>` — builds if output is
|
|
905
|
+
missing (`--rebuild` to force), starts or reuses the background server,
|
|
906
|
+
enables review mode, and opens the browser. If a server predating review
|
|
907
|
+
mode holds the port, rerun with `--restart-server`.
|
|
908
|
+
2. `python -m trellum review poll` — **BLOCKS** until the user sends
|
|
909
|
+
feedback. From an agent harness, run it with a generous timeout or as a
|
|
910
|
+
tracked background task; if it gets killed, just rerun it — queued
|
|
911
|
+
feedback is never lost. Exit codes: `0` feedback arrived, `2` your
|
|
912
|
+
`--timeout` elapsed, `3` the user ended the session.
|
|
913
|
+
3. Edit `reports/<slug>/generator.py` and rebuild with
|
|
914
|
+
`python -m trellum.run reports/<slug> --no-serve`. The browser notices
|
|
915
|
+
the rebuild and reloads itself within ~2 seconds — never hand-edit
|
|
916
|
+
`output/` HTML, and never restart the server to "refresh".
|
|
917
|
+
4. `python -m trellum review poll --reply "what you changed"` — the reply
|
|
918
|
+
appears in the browser's chat and unlocks the user's Send button, then
|
|
919
|
+
the command waits for the next round.
|
|
920
|
+
5. Repeat until poll exits `3` (the user pressed End, or Send & End — that
|
|
921
|
+
final batch still arrives first). `python -m trellum review end` ends
|
|
922
|
+
it from your side when the user asks you to wrap up in conversation.
|
|
923
|
+
|
|
924
|
+
**Several reports, several agents, one project:** one server serves the
|
|
925
|
+
whole `output/` tree and one review session spans it — every report page
|
|
926
|
+
gets the overlay, and batches are tagged with their report's slug. When more
|
|
927
|
+
than one agent works the same project, each MUST poll with
|
|
928
|
+
`--report <slug>` (and reply with `--report <slug>`): a slug-scoped poll
|
|
929
|
+
drains only that report's batches, so agents never steal each other's
|
|
930
|
+
feedback. A slug-less poll drains everything — fine only when you are the
|
|
931
|
+
only agent. Different projects are automatically separate: each output
|
|
932
|
+
directory gets its own server on its own port (discovered via
|
|
933
|
+
`/_fw/server.json` identity on ports 8050–8069), with fully independent
|
|
934
|
+
review state.
|
|
935
|
+
|
|
936
|
+
The user can also send element-free chat messages; they arrive as a batch
|
|
937
|
+
whose `items` list is empty and whose message is the `note`. And on Chart.js
|
|
938
|
+
charts they can point at the data itself — Alt-click picks the nearest data
|
|
939
|
+
point, dragging marks an x-range — which arrives as a `data` field on the
|
|
940
|
+
item (`chart-point`: series/x/value; `chart-range`: x_from/x_to plus
|
|
941
|
+
per-series n/min/max and the points). That is a data investigation, not a
|
|
942
|
+
styling request: answer it with `trellum query` against the exact dates
|
|
943
|
+
before touching any code. Everything is served from the normal `trellum
|
|
944
|
+
serve` server — review endpoints live under `/_fw/review/`, loopback-only,
|
|
945
|
+
and the on-disk HTML is never modified (the overlay is injected into the
|
|
946
|
+
served copy only).
|
|
947
|
+
|
|
948
|
+
<!-- topic: portal -->
|
|
949
|
+
## The portal loop (when `.mcp.json` names a `trellum` server)
|
|
950
|
+
|
|
951
|
+
A repository wired with `trellum setup portal` publishes to one studio on the
|
|
952
|
+
portal, and that studio is reachable as MCP server `trellum`: the same tools
|
|
953
|
+
the portal's own assistant has, under the key owner's role. The key is
|
|
954
|
+
`TRELLUM_API_KEY` in `.env`; the config files only reference it. Never paste
|
|
955
|
+
it anywhere else, and never put a password, key or token into a tool call.
|
|
956
|
+
|
|
957
|
+
The loop, in order. Local work first — nothing on the portal changes until
|
|
958
|
+
the repository does:
|
|
959
|
+
|
|
960
|
+
1. **Edit** `reports/<slug>/` and build it:
|
|
961
|
+
`python -m trellum.run reports/<slug> --no-serve`.
|
|
962
|
+
2. **Validate**: `python -m trellum validate reports/<slug>` — no FAILs.
|
|
963
|
+
3. **Commit and push.** The portal fetches the branch it is configured for;
|
|
964
|
+
it never reads your working tree.
|
|
965
|
+
4. **`check_repo_changes`** — what the portal would publish: the remote head
|
|
966
|
+
it last saw and when (`remote_checked_at`), the reports added, modified and
|
|
967
|
+
removed since the last publish, data-source declarations that changed, and
|
|
968
|
+
the last error. In `auto` publish mode every fetch publishes itself and
|
|
969
|
+
nothing stays pending; in `manual` mode this is the review step. Say what
|
|
970
|
+
is pending in one line before asking to publish.
|
|
971
|
+
5. **`publish_repo_changes`** (manual mode) — publishes at the reviewed head.
|
|
972
|
+
6. **`run_report`** queues a build; **`get_report_details`** reads the result:
|
|
973
|
+
last run, status, and the validator summary (fail / warn / suppressed) as
|
|
974
|
+
the portal saw it. A FAIL here is the same FAIL `trellum validate` shows
|
|
975
|
+
locally — fix it in the repository, not on the portal.
|
|
976
|
+
7. **Data-source failure** (a build that fails in the driver, or a source the
|
|
977
|
+
portal marks blocked): **`test_data_source`** re-runs the connection test
|
|
978
|
+
and reports the detail line. If credentials are missing or wrong,
|
|
979
|
+
**`configure_data_source`** does not take them — it returns the portal's
|
|
980
|
+
Configure link, and a person types the secret there. Tell the user which
|
|
981
|
+
source, hand them the link, then re-run the test.
|
|
982
|
+
8. **`create_alert`** when the user wants to be told about something: a
|
|
983
|
+
report, plain-language instructions ("tell me if APAC DAU drops sharply"),
|
|
984
|
+
a trigger (after each build, or a schedule) and recipients. `update_alert`
|
|
985
|
+
changes one. The portal's own agent decides at each run whether to alert.
|
|
986
|
+
|
|
987
|
+
Scope: `check_repo_changes`, `list_reports`, `get_report_details`,
|
|
988
|
+
`query_report_data` and `read_doc` work on a `read` key.
|
|
989
|
+
`publish_repo_changes`, `run_report`, `test_data_source`,
|
|
990
|
+
`configure_data_source`, `create_alert` and `update_alert` need `write` scope
|
|
991
|
+
*and* the organization's actions switch on; the harness asks before each
|
|
992
|
+
call, and every call is audited under the key. A viewer gets the read tools
|
|
993
|
+
whatever the key's scope; `check_repo_changes` needs the developer role.
|
|
994
|
+
|
|
995
|
+
The portal never edits the repository, and neither should you through it:
|
|
996
|
+
a fix that belongs in `report.yaml`, a suppression or `metrics.yaml` is a
|
|
997
|
+
commit. A missing secret is never worked around by asking for it in chat.
|
|
998
|
+
|
|
999
|
+
<!-- topic: validation -->
|
|
1000
|
+
## Validator response protocol (mandatory)
|
|
1001
|
+
|
|
1002
|
+
Every report run executes `trellum/validation/` post-generation. Output goes to console + `output/<slug>/_validation.json`.
|
|
1003
|
+
|
|
1004
|
+
A report task is **not done** until you have:
|
|
1005
|
+
|
|
1006
|
+
1. Read the validator output.
|
|
1007
|
+
2. Surfaced every **FAIL** and **WARN** to the user with check ID, affected component/section, and a concrete fix.
|
|
1008
|
+
3. **Fixed every FAIL.** WARNs should be fixed unless intentionally accepted (then suppress in `report.yaml` under `validation.suppress` with a one-line reason for why).
|
|
1009
|
+
4. If the report has zero FAILs and zero WARNs, said so explicitly.
|
|
1010
|
+
5. **Handed the user a clickable link.** The last action before reporting done is `python -m trellum serve --background` — it starts (or reuses) a detached server, prints the URLs, and returns immediately — and your final message to the user must contain the printed report URL. A finished build the user cannot click is not finished. (Keep using `--no-serve` for the build/iterate loop itself; the link is the closing move, not a per-build cost.)
|
|
1011
|
+
|
|
1012
|
+
Severity:
|
|
1013
|
+
|
|
1014
|
+
| Level | Action |
|
|
1015
|
+
|---|---|
|
|
1016
|
+
| **FAIL** | Must fix. Structural bug → broken rendering, missing interactivity, wrong data. Common: missing `dataset_id`, orphan FilterBar, missing DataFrame columns. |
|
|
1017
|
+
| **WARN** | Fix unless accepted + suppressed. Common: non-date x-axis with annotations enabled (silently skipped), hardcoded colors (breaks on theme switch), missing `renderAll` in custom JS. |
|
|
1018
|
+
| **INFO** | No action required. Just confirm intent. |
|
|
1019
|
+
|
|
1020
|
+
Full check list, suppression syntax, and how to add new checks: `trellum/README.md` "Validation".
|
|
1021
|
+
|
|
1022
|
+
### Filter coverage check
|
|
1023
|
+
|
|
1024
|
+
The validator emits **`chart-filter-coverage`** (WARN) for every chart whose `dataset_id` is not reached by one or more FilterBar filters — the most common silent bug, where users see filter dropdowns that change nothing on certain charts. The fix is almost always to extend `FilterBar.propagate_to` with the missing `{src_col: tgt_col}` entry.
|
|
1025
|
+
|
|
1026
|
+
A sibling check, **`chart-value-grain-mismatch`** (WARN), catches a subtler failure mode: the chart's value column is constant within each x-axis value (e.g. a daily total merged on `event_date` only into a date × audience × spender DataSource). Filters reach the DataSource structurally, but the chart's plotted value never changes — ratios stay constant, absolutes inflate by the count of replicated rows. Fix by extracting the column to its own DataSource at native grain (date-only) and filtering by `event_date` only there.
|
|
1027
|
+
|
|
1028
|
+
When a filter genuinely cannot apply to a DataSource (its SQL doesn't carry that dimension and adding it would require fictional attribution — e.g. attaching a `template_type` to a purchase event), accept the gap **per (DataSource, filter_column)** in `report.yaml`:
|
|
1029
|
+
|
|
1030
|
+
```yaml
|
|
1031
|
+
validation:
|
|
1032
|
+
accept_inactive_filters:
|
|
1033
|
+
tc_rev_gop3:
|
|
1034
|
+
- template_type # purchases don't carry a template_type — see queries.py:156
|
|
1035
|
+
tc_boost_gop3:
|
|
1036
|
+
- template_type # boosters live in economy_balance, no template dim
|
|
1037
|
+
- chest_tier
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
This is the **preferred** form because future dead filters on the same DataSource still fire — only the explicitly-listed columns are silenced. The broader `validation.suppress_per_dataset: {ds: [chart-filter-coverage]}` swallows ALL future inactive filters on that DS and should be reserved for DataSources that are fundamentally non-reactive (tiny static rollups feeding one chart).
|
|
1041
|
+
|
|
1042
|
+
Full per-chart, per-filter matrix lives at `output/<slug>/_details.json` (also embedded in `_meta.json` under `details.filter_matrix`) and can be rendered as a coverage table. `details.totals` carries dataset count, chart count, total row count, total serialized data size — so you can see at a glance how heavy a report is. There is also a `data_source: "real" | "mock"` flag and an amber banner when the run was a mock/test build, so synthetic row counts don't get read as production reality.
|
|
1043
|
+
|
|
1044
|
+
With the health view enabled (`?health=1` in the URL, or an embedding application that reports the viewer as an admin), the rendered report itself shows a small **filter-health badge** above each chart whose dimensions are not all reactive — a red `⚠ N/M filters dead` chip that pops a styled per-filter detail panel on hover. Hidden by default for non-admins so end-users see a clean report.
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
### Suppression hierarchy
|
|
1048
|
+
|
|
1049
|
+
Three layers, narrowest → broadest:
|
|
1050
|
+
|
|
1051
|
+
```yaml
|
|
1052
|
+
validation:
|
|
1053
|
+
# Granular, PREFERRED — accept specific (DataSource, filter_column) pairs
|
|
1054
|
+
# as inactive. Any OTHER inactive filter on the same DS still fires.
|
|
1055
|
+
accept_inactive_filters:
|
|
1056
|
+
pf_country_gop3: [platform]
|
|
1057
|
+
pf_cz_gop3: [platform, spender_tier]
|
|
1058
|
+
|
|
1059
|
+
# Per-DataSource — silences the named check ID for ALL filters on that DS.
|
|
1060
|
+
# Use only when the DataSource is fundamentally non-reactive across every
|
|
1061
|
+
# dimension (tiny static rollup feeding one chart).
|
|
1062
|
+
suppress_per_dataset:
|
|
1063
|
+
economy_test_net: [ds-no-filterbar]
|
|
1064
|
+
|
|
1065
|
+
# Global — silences every instance of the named check ID across the report.
|
|
1066
|
+
# Last resort; use for systemic exemptions (e.g. annotations-non-date-x-axis
|
|
1067
|
+
# on a report that is intentionally month-bucketed).
|
|
1068
|
+
suppress:
|
|
1069
|
+
- some-systemic-check
|
|
1070
|
+
```
|
|
1071
|
+
|
|
1072
|
+
Suppressed checks are NOT removed from the result — they're flagged with `suppressed: true` and grouped under **Suppressed** with rationale text. The matrix renders suppressed inactive cells as `✗ⓢ` so a reviewer can audit later. Always pair a suppression with a `# rationale:` YAML comment naming the specific reason.
|
|
1073
|
+
|
|
1074
|
+
## After framework changes (only if explicitly asked)
|
|
1075
|
+
|
|
1076
|
+
If the task explicitly requires modifying `trellum/*`:
|
|
1077
|
+
|
|
1078
|
+
1. **Add or update validation checks in `trellum/validation/`** for the new behavior — non-negotiable. Skipping silently breaks future reports.
|
|
1079
|
+
2. Run unit tests:
|
|
1080
|
+
- `python3 -m pytest trellum/testing/test_components.py -v` — component changes
|
|
1081
|
+
- `python3 -m pytest trellum/testing/test_js_runtime.py -v` — JS runtime changes
|
|
1082
|
+
- `python3 -m pytest trellum/testing/test_interactions.py -v` — theme / rendering changes
|
|
1083
|
+
3. Rebuild a couple of this project's existing reports to verify nothing broke —
|
|
1084
|
+
pick ones that between them cover charts, KPIs, filters and any RawHTML:
|
|
1085
|
+
```bash
|
|
1086
|
+
python3 -m trellum.run reports/<slug> --no-serve
|
|
1087
|
+
```
|
|
1088
|
+
4. For broader coverage, `python3 -m trellum.run --all --no-serve`.
|
|
1089
|
+
|
|
1090
|
+
## This framework ships from the monorepo
|
|
1091
|
+
|
|
1092
|
+
The framework lives at `trellum/` in the Trellum product monorepo and is also
|
|
1093
|
+
published as a standalone wheel. It is not a separate repository or submodule,
|
|
1094
|
+
so framework changes use an ordinary monorepo branch and commit.
|
|
1095
|
+
|
|
1096
|
+
The control plane and framework share one version:
|
|
1097
|
+
`trellum_portal/__init__.py` and `trellum/__init__.py` must agree. After changes
|
|
1098
|
+
merge to `main`, an annotated `vX.Y.Z` tag starts the release workflow, which
|
|
1099
|
+
checks both versions, publishes the product images, and creates the GitHub
|
|
1100
|
+
Release. Do not create releases manually or maintain a moving release branch.
|
|
1101
|
+
The framework workflow builds and tests the standalone wheel; publishing that
|
|
1102
|
+
wheel to PyPI is a separate protected dispatch at the immutable release tag.
|
|
1103
|
+
|
|
1104
|
+
The complete versioning rules, checks, and release procedure are in
|
|
1105
|
+
[`RELEASING.md`](RELEASING.md). Read it before tagging a release or changing
|
|
1106
|
+
either `__version__`.
|
|
1107
|
+
|
|
1108
|
+
## What NOT to do
|
|
1109
|
+
|
|
1110
|
+
- **Do not edit `trellum/*` while building a report.** Reports only touch `reports/{slug}/`. If a task seems to require framework changes, stop and discuss scope with the user — framework changes affect every report.
|
|
1111
|
+
- **Do not run `--all` to verify a single change.** Build only the affected report (`python3 -m trellum.run reports/<slug> --no-serve`).
|
|
1112
|
+
- **Do not start the preview server to check whether a build worked.** The build prints its own result and the validator writes `output/<slug>/_validation.json`. A bare `trellum.run` blocks until killed; `--no-serve` exits.
|
|
1113
|
+
- **Do not write custom Chart.js code for any chart type listed above.** Use the framework component.
|
|
1114
|
+
- **Do not hardcode colors.** CSS variables for HTML/CSS, `getThemeColors()` for Chart.js.
|
|
1115
|
+
- **Do not import Chart.js or other JS libraries.** Bundled in `trellum/static/vendor/`.
|
|
1116
|
+
- **Do not write connection / credential code.** Use `ctx.get_connection()`.
|
|
1117
|
+
- **Do not use Chart.js native `legend: { display: true }` on non-doughnut charts.** The `fwLegend` plugin handles it.
|
|
1118
|
+
- **Do not open `output/<slug>/index.html` as `file://`.** `data.json` is fetched via XHR and requires HTTP — the runner serves automatically on :8050.
|
|
1119
|
+
|
|
1120
|
+
## Licensing (applies to every change)
|
|
1121
|
+
|
|
1122
|
+
Owned Trellum code is AGPL-3.0-only. Third-party and pre-existing contributor
|
|
1123
|
+
notices retain their own terms.
|
|
1124
|
+
|
|
1125
|
+
- **Preserve copyright, modification, and licence notices.** `LICENSE` governs
|
|
1126
|
+
owned code; generated reports carry the runtime notice and exact source link.
|
|
1127
|
+
- **Adding a dependency means updating `THIRD-PARTY.md` in the same commit.**
|
|
1128
|
+
`docs/LICENSING.md` has the audit command. A new licence still needs review
|
|
1129
|
+
because it can change what a downstream user may do with a build.
|
|
1130
|
+
- **Bundling is the stricter case than depending.** Code added under
|
|
1131
|
+
`static/vendor/` ships inside every copy of this repository, so its notice
|
|
1132
|
+
travels with it — record it in `static/vendor/MANIFEST.json`.
|