Compare commits

...

354 Commits

Author SHA1 Message Date
Thomas Schmidt
349f48ce44 Basic sql generation draft 2025-03-26 17:03:56 +01:00
Thomas Schmidt
b08b714b0e Do not convert tool names (#55505) 2025-03-24 07:39:43 +01:00
Tamás Benkő
523cd3812b Always set :queryable-foreign-key-tables for models 2025-03-20 22:45:46 +03:00
Tamás Benkő
d67c024717 Remove unused schema definition 2025-03-20 22:45:09 +03:00
Tamás Benkő
e52be87e3a Convert history sent by the FE 2025-03-20 22:44:39 +03:00
metamben
4f83b5eff3 Merge branch 'master' into metabot-v3-main 2025-03-20 21:42:49 +03:00
metamben
02ef883215 Expose table references for metrics (#55456)
Fixes BOT-66
2025-03-20 21:39:37 +03:00
metamben
a654c4025b Expose field values for metrics, models, and tables (#55413)
Fixes BOT-69
2025-03-20 21:32:33 +03:00
metamben
8c236418de Normalize metadata IDs (#55383)
Fixes BOT-68
2025-03-20 21:26:20 +03:00
metamben
ffb7af68c8 Keep history including the initial user message (#55473) 2025-03-20 21:25:25 +03:00
metamben
5333eeda6b Merge branch 'master' into metabot-v3-main 2025-03-19 20:53:04 +03:00
metamben
9981e3db93 Merge branch 'master' into metabot-v3-main 2025-03-18 22:39:39 +03:00
Tamás Benkő
93ab435a9d Merge remote-tracking branch 'origin/master' into metabot-v3-main
# Conflicts:
#	.github/workflows/uberjar.yml
#	.loki/reference/chrome_laptop_Design_System_Icons_List.png
#	frontend/src/metabase/query_builder/components/view/View/ViewNativeQueryEditor/ViewNativeQueryEditor.jsx
2025-03-18 22:38:59 +03:00
metamben
805f066b8c Add answer source tool (#55172) 2025-03-18 12:36:57 +03:00
Tamás Benkő
8af934fecd fix: Allow strings as tool call arguments 2025-03-17 15:07:51 +03:00
metamben
79a24d5800 Merge branch 'master' into metabot-v3-main 2025-03-17 14:40:56 +03:00
Tamás Benkő
ccc58d6578 Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-03-16 18:00:15 +03:00
Tamás Benkő
c06e62576b Merge remote-tracking branch 'origin/master' into metabot-v3-main
# Conflicts:
#	.loki/reference/chrome_laptop_Design_System_Icons_List.png
2025-03-14 10:59:57 +03:00
metamben
2aec76218d Add query model tool (#55089)
Fixes BOT-61
2025-03-14 10:46:52 +03:00
metamben
c14d51b22e Add support for temporal extraction filters (#55037)
* Add support for temporal extraction filters

Fixes BOT-60.

Actually, both extraction and bucketing is supported. The following values can be provided in the filter's `bucket`
property:

```
millisecond
second
minute
hour
day
week
month
quarter
year
second-of-minute
minute-of-hour
hour-of-day
day-of-week
day-of-month
day-of-year
week-of-year
month-of-year
quarter-of-year
year-of-era
```

The values of the form x-of-y result in extraction, the other result in truncation. The extractions `week-of-year` and
`day-of-week` have ISO semantics.

* Placate kondo
2025-03-13 20:41:53 +03:00
metamben
ef69ec3129 Merge branch 'master' into metabot-v3-main 2025-03-12 14:52:36 +03:00
Tamás Benkő
4fee5cd3c4 Update expected native query result text 2025-03-11 23:48:06 +03:00
Tamás Benkő
644f935654 Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-03-11 23:11:48 +03:00
Tamás Benkő
2e6734d105 Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-03-11 22:55:57 +03:00
metamben
1db581dd1b Merge branch 'master' into metabot-v3-main 2025-03-10 15:18:55 +03:00
metamben
71d913d8d1 Merge branch 'master' into metabot-v3-main 2025-03-09 14:11:37 +03:00
metamben
51ac49727e Merge branch 'master' into metabot-v3-main 2025-03-08 01:56:24 +03:00
metamben
806d623462 Remove dummy tools and Metabot loop related stuff (#54812)
* Remove dummy tools and clean up
2025-03-08 01:56:13 +03:00
metamben
8f62da32cc Merge branch 'master' into metabot-v3-main 2025-03-07 13:49:24 +03:00
metamben
edd7bc6a9c Distinguish temporal types and add semantic type (#54741)
Fixes BOT-56.
2025-03-07 13:49:10 +03:00
metamben
8f62fb33a5 Merge branch 'master' into metabot-v3-main 2025-03-06 17:55:00 +03:00
metamben
15f333d81f Add type agnostic and disjunctive filters (#54731)
Fixes BOT-54.
2025-03-06 17:54:51 +03:00
metamben
2543a6af72 Merge branch 'master' into metabot-v3-main 2025-03-06 15:01:23 +03:00
Tamás Benkő
961a73ff4c Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-03-05 21:36:59 +03:00
Tamás Benkő
383591f75b Update icon list in Loki 2025-03-05 21:36:19 +03:00
Tamás Benkő
2e1c2d7205 Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-03-05 17:12:37 +03:00
Tamás Benkő
57ca9f0aac Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-03-05 17:10:36 +03:00
metamben
4c1fe3d767 Merge branch 'master' into metabot-v3-main 2025-03-04 14:27:27 +03:00
Tamás Benkő
8c758e3674 Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-03-03 23:56:37 +03:00
Tamás Benkő
2bf18a43ec Send "user_is_viewing" to the agent in the context 2025-03-03 23:55:44 +03:00
metamben
4846e8a94b Merge branch 'master' into metabot-v3-main 2025-03-03 19:44:48 +03:00
metamben
a62e4e10ef Merge branch 'master' into metabot-v3-main 2025-03-03 18:31:54 +03:00
metamben
9778f8c12b Convert dummy tools to normal tools (#54519)
Fixes BOT-30
Fixes BOT-31
Fixes BOT-32
Fixes BOT-33
Fixes BOT-34
Fixes BOT-35
2025-03-03 18:31:43 +03:00
metamben
486d792c9a Remove V1 metabot agent endpoint (#54380)
* Remove V1 metabot agent endpoint

Fixes BOT-36
2025-03-03 17:18:23 +03:00
Tamás Benkő
4975f725a9 Merge remote-tracking branch 'origin/metabot-bot-47-tool-api-endpoint-tests' into metabot-v3-main 2025-03-03 17:09:45 +03:00
Tamás Benkő
b5e38e84b5 Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-03-03 17:06:48 +03:00
Tamás Benkő
ce62f30ffc Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-03-03 17:06:16 +03:00
Tamás Benkő
8374ed8450 Fix namespace 2025-02-28 23:37:38 +03:00
metamben
e2660bf3ef Merge branch 'master' into metabot-v3-main 2025-02-28 23:28:36 +03:00
Alexander Polyankin
08ebf5a3e3 Merge remote-tracking branch 'origin/metabot-v3-main' into metabot-v3-main 2025-02-28 15:20:15 -05:00
Alexander Polyankin
485df761d0 Use CSS colors 2025-02-28 15:20:06 -05:00
Alexander Polyankin
f2f4f57866 Use CSS colors 2025-02-28 15:19:45 -05:00
Alexander Polyankin
3abeeb9ab2 Use CSS colors 2025-02-28 15:15:56 -05:00
metamben
cb22a6bcf4 Merge branch 'master' into metabot-v3-main 2025-02-28 23:11:50 +03:00
Tamás Benkő
ad34b73d94 Placate clj-kondo 2025-02-28 22:49:37 +03:00
metamben
351daa8851 Merge branch 'master' into metabot-v3-main 2025-02-28 16:46:47 +03:00
Tamás Benkő
61b153a918 Run tests with the metabot-v3 feature enabled 2025-02-27 18:32:27 +03:00
Tamás Benkő
d1b0bf48fd Add V2 tool API endpoint tests
Fixes BOT-47.
2025-02-27 18:05:43 +03:00
metamben
1671d222cd Merge branch 'master' into metabot-v3-main 2025-02-27 17:09:46 +03:00
metamben
c0ef616c6d Merge branch 'master' into metabot-v3-main 2025-02-26 16:27:15 +03:00
Ngoc Khuat
78743ba645 fix whitespace 🤦 2025-02-26 15:32:03 +07:00
Ngoc Khuat
01e98249a2 fix invalid reducers 2025-02-26 15:13:46 +07:00
Ngoc Khuat
1dabc6ccbb Merge branch 'master' into metabot-v3-main 2025-02-26 14:55:59 +07:00
metamben
752287a56d Merge branch 'master' into metabot-v3-main 2025-02-25 20:23:55 +03:00
metamben
865d8a0d81 Generate site-wide session tokens for the AI service (#54269)
* Generate site-wide session tokens for the AI service

Fixes BOT-38.

This PR also allows the V2 tool API endpoints to be used by other existing authentication methods like API keys and
Metabase sessions.
2025-02-25 20:23:42 +03:00
metamben
26dc17b31d Merge branch 'master' into metabot-v3-main 2025-02-25 16:53:05 +03:00
metamben
2f6a39b40e Merge branch 'master' into metabot-v3-main 2025-02-24 22:10:14 +03:00
Tamás Benkő
dfab957510 Fix tests 2025-02-24 21:15:30 +03:00
metamben
ea3b49aa0a Merge branch 'master' into metabot-v3-main 2025-02-24 18:48:30 +03:00
metamben
5d81a60a64 Introduce V2 tool endpoints (#54192) 2025-02-24 18:48:17 +03:00
metamben
10bd22a3b9 Merge branch 'master' into metabot-v3-main 2025-02-21 16:45:24 +03:00
Ngoc Khuat
1e75309a10 Merge branch 'master' into metabot-v3-main 2025-02-21 11:27:27 +07:00
metamben
68d24b95b4 Merge branch 'master' into metabot-v3-main 2025-02-20 18:46:51 +03:00
metamben
0574137a29 Merge branch 'master' into metabot-v3-main 2025-02-19 17:48:32 +03:00
metamben
455713606e Merge branch 'master' into metabot-v3-main 2025-02-18 17:44:02 +03:00
metamben
fcf86ea731 Skip NOT IN condition if there are no used-ids (#53845) 2025-02-18 17:43:46 +03:00
Mihael Konjević
168c055c8f Keep Metabot palette item stable between query changes (#53827) 2025-02-18 12:41:24 +01:00
Tamás Benkő
1e715f418b Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-02-17 18:46:06 +03:00
Tamás Benkő
5aca4ec11d Fix test 2025-02-17 18:45:41 +03:00
Tamás Benkő
27045cf9a7 Remove .clj-kondo/src/hooks/metabase/models/setting.clj changes 2025-02-17 17:06:36 +03:00
Tamás Benkő
d358286383 Merge remote-tracking branch 'origin/master' into metabot-v3-main
# Conflicts:
#	.clj-kondo/src/hooks/metabase/models/setting.clj
2025-02-17 17:03:06 +03:00
metamben
c19b8310dd Fix module dependencies (#53763)
* Fix module dependencies
2025-02-17 16:03:54 +03:00
metamben
3b535a542c Add find-outliers tool endpoint (#53624)
* Add find-outliers tool endpoint
* Also fix the decoding of find-metric tool API results.
* Add tests for explicit query handling.
* Add context when running the query for finding outliers
2025-02-14 17:00:24 +03:00
metamben
291d9e12e1 Merge branch 'master' into metabot-v3-main 2025-02-14 16:56:41 +03:00
Thomas Schmidt
bd1da7cdb9 Include query in request to ai-service (#53745) 2025-02-14 14:48:37 +01:00
Tamás Benkő
96cd8ba5b7 Appease the module checker even more vigorously 2025-02-14 00:44:10 +03:00
Tamás Benkő
b5e2e66b27 Skip SQL fixer e2e test and mute the module linter for now 2025-02-14 00:17:44 +03:00
Tamás Benkő
5b8a479fe4 Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-02-14 00:13:41 +03:00
Tamás Benkő
bf5df2cd38 Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-02-13 20:10:58 +03:00
Tamás Benkő
98332749de Address linter issues 2025-02-13 20:09:29 +03:00
metamben
827c0c2eab Merge branch 'master' into metabot-v3-main 2025-02-13 18:21:44 +03:00
Tamás Benkő
e74c254612 Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-02-13 18:20:33 +03:00
Tamás Benkő
04b7020aef Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-02-13 18:19:51 +03:00
Romeo Van Snick
edb6b83feb Fix loop in codemirror (#53599)
* Correctly compare selections

* Add reproduction for QUE-614

* Use github issue number in regression

* Unnest issue from other issue reproduction

* Log in before test
2025-02-13 15:16:42 +01:00
Thomas Schmidt
10550112f5 Only send path for xray dashboard not full link (#53594) 2025-02-13 12:34:55 +01:00
Mihael Konjevic
5c99127943 Merge branch 'master' into metabot-v3-main 2025-02-12 20:56:08 +01:00
Mihael Konjević
5b5ab1fa16 Metabot v3 command palette item (#53568)
* Initial implementation of Metabot palette action

* Use default styles, correct ordering of palette items, tests

* Setup tests with metabot_v3 feature enabled

* Recompute "mainReducers" on demand in the renderWithProviders function

This solves a race condition where reducers were computed before the
enterprise plugins were required, which made it impossible to test their
behavior (in this case in PaletteResult tests). makeMainReducers function is
added which computes the reducers object, and the previous behavior is
preserved (mainReducers const points to the output of the makeMainReducers fn)
2025-02-12 19:40:26 +01:00
metamben
76d904ba3a Merge branch 'master' into metabot-v3-main 2025-02-12 19:21:20 +03:00
metamben
ca3698711b SQL fixer: generate schema_ddl (#53381)
* Generate ddl_schema
2025-02-12 19:21:05 +03:00
metamben
271fab9a30 Add find-metric tool endpoint (#53492)
* Add find-metric tool endpoint

* Add missing result conversion functions
2025-02-12 19:20:01 +03:00
metamben
ff0ed9be47 Add filter-records tool endpoint (#53561)
* Add filter-records tool endpoint

* Adapt tests
2025-02-12 18:40:00 +03:00
Tamás Benkő
a69afcb929 Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-02-12 17:59:55 +03:00
Tamás Benkő
c4b2f29b0f Remove unused imports 2025-02-11 20:02:45 +03:00
Tamás Benkő
44a15f3a77 Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-02-11 19:52:14 +03:00
Tamás Benkő
6630e72403 Stop re-using metabase.api.card/get-card
This function is private now, and it's also doing more than what we need right now.
2025-02-11 19:51:35 +03:00
Tamás Benkő
318e3e6b6f Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-02-11 19:10:06 +03:00
Thomas Schmidt
91948e24ab Redirect user when navigate_to property is present in agent response (#53518) 2025-02-11 16:36:04 +01:00
Tamás Benkő
6c20dc9c17 Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-02-11 17:54:38 +03:00
metamben
a6f8cc0919 Fix input encoding for the create-dashboard-subscription tool (#53486) 2025-02-10 23:45:17 +03:00
Alexander Polyankin
3ebda589f5 Merge remote-tracking branch 'refs/remotes/origin/master' into metabot-v3-main
# Conflicts:
#	frontend/src/metabase/query_builder/components/NativeQueryEditor/CodeMirrorEditor/extensions.tsx
2025-02-10 11:05:15 -05:00
Tamás Benkő
e51023ca43 Remove describeEE and onlyOnEE 2025-02-08 02:33:40 +03:00
metamben
44da1f4d67 Merge branch 'master' into metabot-v3-main 2025-02-07 23:24:46 +03:00
metamben
4a1c0c5029 Check table_id parameter in generate-insights tool (#53310) 2025-02-07 20:33:35 +03:00
metamben
f2792cdbfd Merge branch 'master' into metabot-v3-main 2025-02-07 18:47:20 +03:00
metamben
10c042dbdc Add create-dashboard-subscription and generate-insights endpoints (#53253)
* Add create-dashboard-subscription and generate-insights endpoints

* Rename session_id to conversation_id for the tool endpoints
2025-02-07 18:46:40 +03:00
metamben
7badf9005d Merge branch 'master' into metabot-v3-main 2025-02-07 16:39:14 +03:00
metamben
c3d157b9cd Merge branch 'master' into metabot-v3-main 2025-02-07 01:27:05 +03:00
Tamás Benkő
aeaa811281 Fix ns require formatting 2025-02-07 01:26:33 +03:00
metamben
d44226c7fa Merge branch 'master' into metabot-v3-main 2025-02-07 01:24:12 +03:00
Alexander Polyankin
c4578261a8 Fix SQL button tests (#53198) 2025-02-06 11:38:10 -05:00
metamben
ca0352d6ad Merge branch 'master' into metabot-v3-main 2025-02-06 17:03:41 +03:00
metamben
32dc706a39 Merge branch 'master' into metabot-v3-main 2025-02-05 17:40:15 +03:00
metamben
1b44cb8bb9 Implement query-metric tool endpoint (#53195) 2025-02-05 17:40:01 +03:00
Alexander Polyankin
9fa2f59d64 Autofix SQL button (#53153) 2025-02-04 11:32:49 -05:00
metamben
e62bfc2004 Merge branch 'master' into metabot-v3-main 2025-02-04 18:08:04 +03:00
metamben
95726e9d1b Add /api/ee/ai-fix-sql/fix endpoint (#53143)
* Add POST /api/ee/ai-sql-fixer/fix endpoint

* Never prettify the generated SQL
2025-02-04 18:07:06 +03:00
metamben
ec7595557a Merge branch 'master' into metabot-v3-main 2025-02-04 16:20:13 +03:00
metamben
da64908493 Merge branch 'master' into metabot-v3-main 2025-02-04 01:26:31 +03:00
metamben
9bd61f3557 Merge branch 'master' into metabot-v3-main 2025-02-03 21:26:55 +03:00
metamben
49a83e9f41 Merge branch 'master' into metabot-v3-main 2025-02-03 16:38:28 +03:00
metamben
bddc369c0d Merge branch 'master' into metabot-v3-main 2025-02-02 14:31:29 +03:00
metamben
baa6baf893 Merge branch 'master' into metabot-v3-main 2025-02-01 02:43:33 +03:00
metamben
c9fe612a50 Merge branch 'master' into metabot-v3-main 2025-01-31 18:13:39 +03:00
metamben
6480f7b841 Add standalone tests for the invite-user tool (#52984)
* Add standalone tests for the invite-user tool
2025-01-31 18:13:26 +03:00
Tamás Benkő
64fa2f2bc1 Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-01-31 18:07:36 +03:00
Tamás Benkő
4c690a44dc Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-01-31 18:07:13 +03:00
metamben
d74631e48b Merge branch 'master' into metabot-v3-main 2025-01-31 00:37:25 +03:00
metamben
c9f5a0e3b6 Merge branch 'master' into metabot-v3-main 2025-01-30 17:57:42 +03:00
metamben
123ec0e340 Add standalone filter-records tests (#52933)
* Add standalone filter-records tests
2025-01-30 17:57:33 +03:00
metamben
3929342ac0 Merge branch 'master' into metabot-v3-main 2025-01-30 00:39:04 +03:00
metamben
ed537dcbc7 Add standalone find-outliers tests (#52906)
* Add standalone find-outliers tests
2025-01-30 00:38:46 +03:00
Tamás Benkő
60b85b61ac Switch to api.macros/defendpoint 2025-01-29 18:23:57 +03:00
metamben
e3347d10a5 Merge branch 'master' into metabot-v3-main 2025-01-29 17:24:25 +03:00
Tamás Benkő
ca0cd2b5a5 Add back React import 2025-01-28 21:53:33 +03:00
metamben
10107fcf5c Merge branch 'master' into metabot-v3-main 2025-01-28 21:39:38 +03:00
Tamás Benkő
42aa717241 Add missing type keyword 2025-01-28 21:37:13 +03:00
Tamás Benkő
cb30ee8a4b Fix wrong merge 2025-01-28 21:30:14 +03:00
metamben
dee6da84cc Merge branch 'master' into metabot-v3-main 2025-01-28 21:16:32 +03:00
metamben
9d6db122ca Add tests for the query-metric tool (#52765)
* Ignore bucketing for non-temporal columns

This was implemented in #52271 but has been reverted by a bad merge.

* Use long names when generating columns

The standard names can easily result in duplicates which will certainly confuse the agent.

* Break long lines

* Add tests for the query-metric tool

* Handle the strange metric-id error cases explicitly
2025-01-28 21:16:14 +03:00
metamben
bdccec2d1f Add tests for the generate-insights tool (#52767) 2025-01-28 21:15:17 +03:00
metamben
653797622b Merge branch 'master' into metabot-v3-main 2025-01-28 16:55:33 +03:00
Tamás Benkő
77fb7c62fa Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-01-27 18:33:01 +03:00
Tamás Benkő
c1eb2559ec Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-01-27 18:32:33 +03:00
Aleksandr Lesnenko
8643fc3aef update import 2025-01-24 16:08:10 -05:00
metamben
67abded9e8 Add default_time_dimension_field_id and adhoc context handling (#52683)
* Add default_time_dimension_field_id and adhoc context handling

* Add show-results-to-user dummy call for adhoc queries in context
2025-01-24 23:33:49 +03:00
Tamás Benkő
dab935167b Fix formatting 2025-01-24 22:54:54 +03:00
metamben
087ad1a2b1 Merge branch 'master' into metabot-v3-main 2025-01-24 22:15:47 +03:00
metamben
c3cbeffc70 Merge branch 'master' into metabot-v3-main 2025-01-24 21:45:31 +03:00
Tamás Benkő
75b33df5bc Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-01-24 20:36:28 +03:00
metamben
b87ce054cb Switch the uberjar build branch to metabot-v3-main (#52678)
* Switch the build branch to metabot-v3-main

* Switch containerize_test_and_push_container too
2025-01-24 20:32:36 +03:00
metamben
89490c9a16 Merge branch 'master' into metabot-v3-main 2025-01-23 22:37:23 +03:00
metamben
9b78a0014f Return a friendly message when the number of round trips is exceeded (#52621)
Fixes #52591.
2025-01-23 22:36:57 +03:00
metamben
74c29161f3 Re-enable the filter-records tool (#52615)
It turns out that a fair number of our test cases rely on this tool, and they worked before, so it might be stable
enough.
2025-01-23 21:01:50 +03:00
metamben
59dcb7f115 Stop checking that parameter names are kebab-case (#52613) 2025-01-23 20:59:15 +03:00
metamben
2f817742e6 Consistently generate and look for query_id and not query-id (#52599) 2025-01-23 18:04:18 +03:00
metamben
be840bb7bd Prevent converting :structured-content to JSON twice (#52556) 2025-01-23 16:54:21 +03:00
metamben
086783dd68 Merge branch 'master' into metabot-v3-main 2025-01-23 16:42:51 +03:00
metamben
3cd107d6d3 Ignore result-columns for saved questions (#52535)
The result-columns of saved pivot questions are a mess, the names and the effective types don't match the values.
Instead of using these columns, we generate the columns from the dataset-query field.

The assumption is that the result-columns for saved questions (but not for models) don't play an important role.
2025-01-23 00:16:29 +03:00
metamben
2d47254583 Disable Metabot's filter-records tool (#52546)
Fixes #52499
2025-01-23 00:16:03 +03:00
Tamás Benkő
ec6033870c Merge remote-tracking branch 'origin/master' into metabot-v3-main 2025-01-23 00:05:13 +03:00
metamben
db9e5b1a49 Merge branch 'master' into metabot-v3-main 2025-01-22 23:14:38 +03:00
metamben
96c9346eda Use integers for report_id, strings for table_id (#52528) 2025-01-22 20:28:11 +03:00
metamben
57af6af23a Add standalone test for the create-dashboard-subscription tool (#52519) 2025-01-22 20:27:49 +03:00
metamben
8598fc59d5 Merge branch 'master' into metabot-v3-main 2025-01-22 16:54:50 +03:00
John Swanson
6bcbe2f35a Don't send queries to llm (#51599)
* Don't send queries to the LLM

This introduces `envelope/llm-message`, a private function that formats
messages for the LLM. Currently it does two things: removes `query`s and
json-encodes the structured content.

There's also `envelope/llm-history`, which replaces
`envelope/full-history` by providing the full history appropriate for
the LLM (instead of the full "internal" history for our own use).

* Rework confusing fn

Split this out into a few functions:

- `stringified-content` takes a message and returns the stringified
content (from `structured-content` or `content` as appropriate)

- `stringify-content` takes a message and returns a message with a
stringified `:content` form (and no `:structured-content`)

- `maybe-remove-query` takes a message and removes the `:query` if
necessary

Now `llm-message` just threads the message through `maybe-remove-query`
and then `stringify-content` rather than a confusing `cond->` form.
2025-01-21 21:11:04 +03:00
metamben
57af242b8f Disable invite-user tool (#52450) 2025-01-21 20:35:18 +03:00
metamben
22a3a5fe44 Merge branch 'master' into metabot-v3-main 2025-01-21 18:35:53 +03:00
metamben
8ddeee90f3 Support find-outliers for reports and queries in simple cases (#52414)
When the result has a temporal column, the first in the list is taken as the basis of the analysis.
2025-01-21 17:27:47 +03:00
metamben
74574ed2aa Merge branch 'master' into metabot-v3-second-try-rebased 2025-01-21 00:22:01 +03:00
metamben
d3804ab2bc Merge branch 'master' into metabot-v3-second-try-rebased 2025-01-20 21:11:53 +03:00
metamben
c76e64407b Merge branch 'master' into metabot-v3-second-try-rebased 2025-01-20 19:40:33 +03:00
metamben
7330ca489e Merge branch 'master' into metabot-v3-second-try-rebased 2025-01-20 17:05:33 +03:00
Mihael Konjević
7a0db83be5 Implement redirect reaction for Metabot (#52289)
* Implement redirect reaction for Metabot

Redirect reaction redirects the user to the results page generated by a tool.
Currently `query-metric` and `generate-insights` tools use this reaction.

This also introduces a concept of "terminating" reactions. When a tool returns a
terminating reaction, this tool call result is not sent back to the LLM, instead
it returns the envelope and the frontend handles any sideffects.
2025-01-20 13:26:31 +01:00
metamben
abdfa33583 Ignore archived metrics in the find-metric tool (#52322)
* Ignore archived metrics in the `find-metric` tool

* Use yarn.lock generated by yarn
2025-01-17 22:07:15 +03:00
Tamás Benkő
dee067ffff Merge remote-tracking branch 'origin/master' into metabot-v3-second-try-rebased 2025-01-17 19:33:57 +03:00
Tamás Benkő
8cbea88330 Fix formatting 2025-01-17 18:58:38 +03:00
metamben
d4944b478b Normalize tool error responses (#52230) 2025-01-17 18:02:28 +03:00
metamben
5603565c7c Look for field_granularity instead of field-granularity (#52271)
* Look for field_granularity instead of field-granularity

* Don't report an error when bucketing non-temporal columns

Just ignore the bucket instead.
2025-01-17 17:50:53 +03:00
metamben
fd44da515e Handle dashcards without card when creating subscriptions (#52248) 2025-01-17 17:50:41 +03:00
metamben
56419f6be9 Merge branch 'master' into metabot-v3-second-try-rebased 2025-01-17 16:44:16 +03:00
metamben
43dab70197 Merge branch 'master' into metabot-v3-second-try-rebased 2025-01-16 23:30:08 +03:00
metamben
26bdc4ca18 Distinguish structured and textual tool results (#51698)
* Distinguish structured and textual tool results
2025-01-16 15:58:33 +03:00
metamben
c17bbb8ca3 Merge branch 'master' into metabot-v3-second-try-rebased 2025-01-16 15:57:27 +03:00
metamben
d30c95d30b Merge branch 'master' into metabot-v3-second-try-rebased 2025-01-16 00:41:22 +03:00
metamben
ce8558949a Return metric IDs as plain integers instead of card__<id> (#52059)
See https://metaboat.slack.com/archives/C07SJT1P0ET/p1736237110786299?thread_ts=1735243354.390349&cid=C07SJT1P0ET for context.
2025-01-15 23:52:37 +03:00
Thomas Schmidt
d027f538f1 Update tool spec with examples (#52195)
* Update tool spec with examples

* Support string table_id values in filter-records

---------

Co-authored-by: Tamás Benkő <tamas@metabase.com>
2025-01-15 19:57:43 +03:00
metamben
13a73a6b9a Implement show-results-to-user tool (#52121) 2025-01-14 23:12:42 +03:00
metamben
80c322f8b1 Merge branch 'master' into metabot-v3-second-try-rebased 2025-01-14 16:56:04 +03:00
metamben
acc331c5fb Merge branch 'master' into metabot-v3-second-try-rebased 2025-01-13 21:43:31 +03:00
metamben
4edd922f10 Merge branch 'master' into metabot-v3-second-try-rebased 2025-01-13 21:06:06 +03:00
Tamás Benkő
6108f5d699 Merge remote-tracking branch 'origin/master' into metabot-v3-second-try-rebased 2025-01-13 19:20:40 +03:00
Tamás Benkő
83af40a49d Fix linter errors from wrong merge 2025-01-13 19:19:55 +03:00
Tamás Benkő
71d828be07 Fix require from wrong merge 2025-01-13 18:49:51 +03:00
metamben
129f1ce7f4 Merge branch 'master' into metabot-v3-second-try-rebased 2025-01-13 18:24:23 +03:00
Tamás Benkő
0e0aac3f46 Merge remote-tracking branch 'origin/master' into metabot-v3-second-try-rebased 2025-01-13 18:11:23 +03:00
metamben
474ecbeec5 Properly report unknown users from create-dashboard-subscription (#51662) 2025-01-13 17:41:26 +03:00
metamben
1b3ed70dec Fix typo (#51702) 2024-12-31 15:03:26 +03:00
metamben
91abc51ec5 Merge branch 'master' into metabot-v3-second-try-rebased 2024-12-31 12:37:47 +03:00
John Swanson
1c02aebe07 Find outliers tool (#51534)
* QOL improvements for the REPL namespace

* Find outliers tool

There are also some unrelated changes I threw in here:

- remove a bit of unnecessary serialization of output (we do this
automatically now)

- delete a stale tool that is no longer part of toolbox v1 (I noticed
the LLM trying to call it)

* CR resp

* Userland query

* Userland query *with default constraints*

* kondo
2024-12-27 12:17:41 -08:00
John Swanson
0af736a234 Adjust response format for invite-user (#51536) 2024-12-27 12:17:23 -08:00
metamben
0c3b93131c Generate field_ids by index (#51606)
* Identify fields by their index

* Generate short field IDs

* Truncate columns to day when filtering without time component

* Decode tool history contents when searching for generated queries
2024-12-27 01:04:37 +03:00
metamben
ada0e86826 Merge branch 'master' into metabot-v3-second-try-rebased 2024-12-23 14:33:12 +03:00
Alexander Polyankin
160be30f2e Fix tests 2024-12-22 15:11:14 -05:00
Alexander Polyankin
f6d87b174d Fix tests 2024-12-22 15:10:57 -05:00
metamben
1d8350e6b7 Merge branch 'master' into metabot-v3-second-try-rebased 2024-12-21 02:29:19 +03:00
metamben
88605995e1 Merge branch 'master' into metabot-v3-second-try-rebased 2024-12-20 17:24:51 +03:00
Tamás Benkő
1516add0f2 Handwave FE merge 2024-12-20 00:03:19 +03:00
Tamás Benkő
751c628779 Use metabase.util.json instead of cheshire.core 2024-12-20 00:03:19 +03:00
Tamás Benkő
1c7d841199 Fix bad var ref 2024-12-20 00:03:19 +03:00
Alexander Polyankin
73c8f0e87c Add user_is_viewing to FE->BE context (#51482)
* Add user_is_viewing to FE->BE context

* Use the context provided by the FE

* Fix bad merge

---------

Co-authored-by: Tamás Benkő <tamas@metabase.com>
2024-12-20 00:03:19 +03:00
metamben
5306cc9e37 Send current_user_time in LLM context (#51477) 2024-12-20 00:03:19 +03:00
John Swanson
1545b358dd green CI? (#51459)
* green CI?

* don't swap in test context for dummy tools

* convert non-string tool responses to json

* CR feedback and fix circular dep

* clean up reaction handling in envelope

* fix another test
2024-12-20 00:03:19 +03:00
metamben
2d68c5569e Register generate-insights and invite-user (#51470) 2024-12-20 00:03:19 +03:00
metamben
e0f0fac373 Implement filter-records (#51452)
* Implement filter-data

* Switch dummy tools to new tool parameters

* Rename filter-data to filter-records

* Register find-metric tool

* Select only the fields from table we really need
2024-12-20 00:03:19 +03:00
John Swanson
c98477e607 fix dummy tools (#51457) 2024-12-20 00:03:19 +03:00
John Swanson
974424174c Rework flow to allow for a query store (#51425)
* Rework flow to allow for a query store

Dummy tools need to be able to store queries for later use. Right now
dummy tools work differently than regular tools: they can
only (effectively) return an `output`. They can't change context, they
can't return reactions, etc.

I was imagining that when we wanted to add queries to the query store,
we'd do so in the `envelope`, and probably do this in a functional way
by returning `:queries` or a `:query-id->query` map from the tool call
itself. Then, we'd write a function in `envelope` that would basically
just store this state for later retrieval.

The issue we had is that (before this commit) we don't actually invoke
dummy tools in a way that changes the envelope - we just invoke them as
needed before sending the history to the LLM.

So, this PR reworks things a bit:

- we invoke the dummy tool calls once, immediately after creating the
envelope

- right now, this only means that we store the results of those tool
calls in the env, for later retrieval to send to the LLM when we are
actually making a call. (we don't just store the tool calls in normal
history, because we don't want to return them to the FE when we
respond).

- in the immediate future though, we'll want to *also*, in addition to
adding tool `output` to the response, handle other tool results. One
thing could be changing the context in response to a dummy tool call -
which seems maybe a bit crazy, but on the other hand maybe dummy tool
calls should be able to do anything a regular tool call can do?

- more immediately, we'll probably want to handle a new key from tool
invocations, `:query-id->query-map`, that will store queries for later
retrieval.

* Add a function to find a query

* refactor `invoke-tool` to take entire env

I also changed the response handling so that if you don't return
`:context` it's ignored, rather than associng `nil` onto the existing
env.

* actually use `find-query` in generate-insights
2024-12-20 00:03:19 +03:00
Thomas Schmidt
2df3d84fed Sync tool schemas with ai-service (#51446) 2024-12-20 00:03:19 +03:00
metamben
f1d23fa5d9 Implement run-query (#51420)
* Implement run-query as dummy tool

* Return kebab-cased keys from query-metric tool

The keys will be automatically converted to snake_case in the message.

* Skip context processing of the previous attempt
2024-12-20 00:03:19 +03:00
metamben
de6b854244 Implement query-metrics and get-metric-details tools (#51315)
* Implement ISO day of week extraction (#50475)

* Implement ISO day of week extraction

Fixes #50424

* Support display-name for ISO get-day-of-week exclusion expressions (#50606)

* Support display-name for ISO get-day-of-week exclusion expressions

Fixes #50574

* Support extraction expressions in filter-args-display-name (#50694)

Fixes #50670

* Add get-metric-details and query-metric tools

* Pass history to tool invocations

* Tool implementations (#51244)

* Skeleton for `generate-insights` tool

For now I just implemented the easy things: tables, metrics, and
reports (which I think equal cards on our end?), and totally skipped
`query_id`.

* Pull in all the tools from ai-service

* `get-report-details` and `get-metric-details`

* find metrics using the select metrics endpoint

* reimplement invite-user tool

skipping all confirmation for now

* Update enterprise/backend/src/metabase_enterprise/metabot_v3/tools/generate_insights.clj

Co-authored-by: metamben <103100869+metamben@users.noreply.github.com>

---------

Co-authored-by: metamben <103100869+metamben@users.noreply.github.com>

* Align table and report details with metric details

* Fix tool JSON schemata

---------

Co-authored-by: John Swanson <john.swanson@metabase.com>
2024-12-20 00:03:19 +03:00
metamben
b6af5b1a2e Implement get-table-details dummy tool (#51143)
* Implement get-table-details dummy tool

* Only return readable tables
2024-12-20 00:03:19 +03:00
metamben
15adc8994c Send auth token via request header instead of the body (#51174)
* Send auth token via request header instead of the body

* Remove instance-info from request body
2024-12-20 00:03:19 +03:00
metamben
da69aaf7d0 Implement dashboard subscription using dummy tools (#51074) 2024-12-20 00:03:19 +03:00
Alexander Polyankin
e1c39f6b5e Add dashboard_id to FE->BE context (#51027) 2024-12-20 00:03:19 +03:00
Sloan Sparger
c76b952c5b [Metabot] Fix e2e failures (#50112)
* force ci run

* test fix

* fix people tests

* revert router changes

* removes force enabling metabot that does not do anything

* disable metabot tests since the token feature was disabled

* change

* change

* fix documentation
2024-12-20 00:03:19 +03:00
Alexander Polyankin
0ad961096b Throw error 2024-12-20 00:03:19 +03:00
Alexander Polyankin
8fac6159ec Hacky multi-tool chain (#50179)
* Hacky multi-tool chain

* Hacky multi-tool chain
2024-12-20 00:03:19 +03:00
Alexander Polyankin
409d7a2251 Add granularity 2024-12-20 00:03:19 +03:00
Alexander Polyankin
325c6a17a1 filter-data and aggregate-data tools (#50168) 2024-12-20 00:03:19 +03:00
Sloan Sparger
6a6312c8e2 [Metabot] UI Access improvements (#49859)
* auto close the metabot input on route changes

* prevents auto-closing metabot if the base path segment is the same

* pr feedback

* makes metabot only visible while the user is signed in

* add test coverage

* fix infinite type instantiation errors
2024-12-20 00:03:19 +03:00
Alexander Polyankin
3a98e2d44f Bring back change-chart-appearance tool (#50104) 2024-12-20 00:03:19 +03:00
Alexander Polyankin
a604ab351b change-query cleanup (#49921) 2024-12-20 00:03:19 +03:00
Sloan Sparger
560f2e1455 [Metabot] Auto-close on route change (#49810)
* auto close the metabot input on route changes

* prevents auto-closing metabot if the base path segment is the same

* pr feedback
2024-12-20 00:03:19 +03:00
Sloan Sparger
a283463d05 [Metabot] Sessions (#49785)
* base e2e tests

* unit tests

* fe implementation for sessions

* BE: Send back a `session_id` if missing

* change to uuid

* send the session id along to ai-service

* pr feedback

* require `session_id` to be passed, and be a UUID

* backend: send a dummy session id to unbreak stats (#49894)

* backend: send a dummy session id to unbreak stats

* preserve session-id across calls

one shortcoming: it does not preserve the session id across FE
invocations. Need to send it back and the FE parrot it back, and the
envelope create function should reuse that when present

```
{
  "messages" : [ {
    "role" : "user",
    "content" : "filter to where the state is CA"
  } ],
  "context" : {...}
  "tools" : [,,,],
  "instance_info" : {,,,},
  "user_id" : 1,
  "session_id" : "6e67dafe-30e6-4b0a-8387-5dc5968e7f28" // <----
}

"LLM -----------------> BE"
{
  "message" : {
    "content" : "",
    "role" : "assistant",
    "tool_calls" : [ {
      "id" : "call_NpA5WEA2gzrRDWt7pC8i0GZR",
      "name" : "change_query",
      "arguments" : "{\"relative_date_filters\":null,\"specific_date_filters\":null,\"boolean_filters\":null,\"breakouts\":null,\"string_filters\":[{\"column\":\"State\",\"operator\":\"=\",\"value\":\"CA\"}],\"order_bys\":null,\"limits\":null,\"number_filters\":null,\"aggregations\":null}"
    } ]
  },
  "metadata" : {
    "usage" : {
      "model" : "gpt-4o-mini-2024-07-18",
      "total" : 7404,
      "prompt" : 7335,
      "completion" : 69,
      "estimated_costs" : 0.0011519999999999998
    }
  }
}

"BE -----------------> LLM"
{
  "messages" : [ {
    "role" : "user",
    "content" : "filter to where the state is CA"
  }, {
    "content" : "",
    "role" : "assistant",
    "tool_calls" : [ {
      "id" : "call_NpA5WEA2gzrRDWt7pC8i0GZR",
      "name" : "change_query",
      "arguments" : "{\"order-bys\":null,\"specific-date-filters\":null,\"breakouts\":null,\"boolean-filters\":null,\"relative-date-filters\":null,\"string-filters\":[{\"column\":\"State\",\"operator\":\"=\",\"value\":\"CA\"}],\"number-filters\":null,\"limits\":null,\"aggregations\":null}"
    } ]
  }, {
    "role" : "tool",
    "tool_call_id" : "call_NpA5WEA2gzrRDWt7pC8i0GZR",
    "content" : "success"
  } ],
  "context" : {},
  "tools" : []..,
  "instance_info" : {,,,}
  },
  "user_id" : 1,
  "session_id" : "6e67dafe-30e6-4b0a-8387-5dc5968e7f28" // <---
}
```

This session_id above is missing in the BE -> FE and then on subsequent FE -> BE payloads

---------

Co-authored-by: dan sutton <dan@dpsutton.com>

* right some wrongs from merge conflict resolutions

* fix

* fix

---------

Co-authored-by: John Swanson <john.swanson@metabase.com>
Co-authored-by: dan sutton <dan@dpsutton.com>
2024-12-20 00:03:19 +03:00
Sloan Sparger
ecc3e7ba0b [Metabot] Initial e2e + unit tests (#49732)
* base e2e tests

* unit tests

* pr feedback

* run metabot tests

* force enable metabot for testing

* test something in ci

* test something in ci 2

* test something in ci 3

* test something in ci 4

* test something in ci 5

* fix for now
2024-12-20 00:03:19 +03:00
John Swanson
57f58d6009 backend: send a dummy session id to unbreak stats (#49894)
* backend: send a dummy session id to unbreak stats

* preserve session-id across calls

one shortcoming: it does not preserve the session id across FE
invocations. Need to send it back and the FE parrot it back, and the
envelope create function should reuse that when present

```
{
  "messages" : [ {
    "role" : "user",
    "content" : "filter to where the state is CA"
  } ],
  "context" : {...}
  "tools" : [,,,],
  "instance_info" : {,,,},
  "user_id" : 1,
  "session_id" : "6e67dafe-30e6-4b0a-8387-5dc5968e7f28" // <----
}

"LLM -----------------> BE"
{
  "message" : {
    "content" : "",
    "role" : "assistant",
    "tool_calls" : [ {
      "id" : "call_NpA5WEA2gzrRDWt7pC8i0GZR",
      "name" : "change_query",
      "arguments" : "{\"relative_date_filters\":null,\"specific_date_filters\":null,\"boolean_filters\":null,\"breakouts\":null,\"string_filters\":[{\"column\":\"State\",\"operator\":\"=\",\"value\":\"CA\"}],\"order_bys\":null,\"limits\":null,\"number_filters\":null,\"aggregations\":null}"
    } ]
  },
  "metadata" : {
    "usage" : {
      "model" : "gpt-4o-mini-2024-07-18",
      "total" : 7404,
      "prompt" : 7335,
      "completion" : 69,
      "estimated_costs" : 0.0011519999999999998
    }
  }
}

"BE -----------------> LLM"
{
  "messages" : [ {
    "role" : "user",
    "content" : "filter to where the state is CA"
  }, {
    "content" : "",
    "role" : "assistant",
    "tool_calls" : [ {
      "id" : "call_NpA5WEA2gzrRDWt7pC8i0GZR",
      "name" : "change_query",
      "arguments" : "{\"order-bys\":null,\"specific-date-filters\":null,\"breakouts\":null,\"boolean-filters\":null,\"relative-date-filters\":null,\"string-filters\":[{\"column\":\"State\",\"operator\":\"=\",\"value\":\"CA\"}],\"number-filters\":null,\"limits\":null,\"aggregations\":null}"
    } ]
  }, {
    "role" : "tool",
    "tool_call_id" : "call_NpA5WEA2gzrRDWt7pC8i0GZR",
    "content" : "success"
  } ],
  "context" : {},
  "tools" : []..,
  "instance_info" : {,,,}
  },
  "user_id" : 1,
  "session_id" : "6e67dafe-30e6-4b0a-8387-5dc5968e7f28" // <---
}
```

This session_id above is missing in the BE -> FE and then on subsequent FE -> BE payloads

---------

Co-authored-by: dan sutton <dan@dpsutton.com>
2024-12-20 00:03:19 +03:00
Alexander Polyankin
782d167f75 Query tool - no context, fetching metadata on demand (#49812) 2024-12-20 00:03:19 +03:00
John Swanson
a90addd18c Move user_id out of instance_info (#49787) 2024-12-20 00:03:19 +03:00
Aleksandr Lesnenko
9c6a2571d4 merge more viz tools 2024-12-20 00:03:19 +03:00
Aleksandr Lesnenko
2dc9749a64 group into other 2024-12-20 00:03:19 +03:00
Aleksandr Lesnenko
aa4d5180da merge a few viz settings tools into one 2024-12-20 00:03:19 +03:00
Aleksandr Lesnenko
27a1329c00 metabot v3: change series settings tool (#49644)
* change series settings tool

* metabot v3: change column settings tool (#49646)

* change column settings tool

* metabot v3: stacked setting tool (#49649)

* stacked setting tool

* metabot v3: add change goal line tool (#49650)

* add change goal line tool

* y axis range tool (#49651)
2024-12-20 00:03:19 +03:00
Aleksandr Lesnenko
6ed742c176 change axes names tool (#49640) 2024-12-20 00:03:19 +03:00
Aleksandr Lesnenko
888333c654 provide complete visualization settings to llm (#49639) 2024-12-20 00:03:19 +03:00
Alexander Polyankin
b6a9a3d851 Single query tool (#49722)
* MetaBot v3 PoC

* API endpoint

* Fix formatting

* Change tools shape to JSON schema and update API shapes

* initial FE prototype

* FE - update styles, add toggle button for chat, put behind feature flag, PR feedback

* FE - remove previous message history snapshot from being saved on each history entry

* FE - hide behind feature flag

* FE - use history not messages key on agent requests

* Enforce tool naming rules

* Add encoding for reactions

* don't use ::metabot-v3.client/history schema until it exists

put :any in there, we should use the schema when we know what it looks
like

* `defreaction` can be a plain function.

* Remove empty file

* Code cleanup

* Make sure history includes the previous user message

* `:context` should get snake-case encoding for AI Proxy

* Fix encoding of requests => AI Proxy

* Make sure we use :additionalProperties, not :additional_properties

* Fix error with API endpoint and add (mocked) e2e tests for the endpoint

* Some improvements to error handling in client and REPL.

* Add placeholder tool call result messages

* FE - account for schema changes

* Decode api request into internal format

```clojure
api=> (mc/decode [:maybe ::metabot-v3.client.schema/messages]
                 [{:role "user"
                   :content "Invite sloan to metabase"}
                  {:content "Sorry, I cant help you with that. Could you please provide the email address of the user you want to invite to Metabase?"
                   :role "assistant"}]
                 (mtx/transformer
                   {:name :api-request}))
[{:role :user, :content "Invite sloan to metabase"}
 {:content "Sorry, I cant help you with that. Could you please provide the email address of the user you want to invite to Metabase?",
  :role :assistant}]
```

This just ensures that the role gets keywordized.

`:role "user"` -> `:role :user`.

* ensure we decode LLM response

LLM sends back the tool we are using as "invite-user". But our dispatch
stuff needs it to end up as `:metabot.tool/invite-user`. Just a simple
namespace for the keyword.

```shell
❯ echo '{"message":"sloan@metabase.com","context":{},"history":[{"role":"user","content":"Invite sloan to metabase"},{"content":"Sorry, I cant help you with that. Could you please provide the email address of the user you want to invite to Metabase?","role":"assistant"}]}' | http post "localhost:3000/api/ee/metabot-v3/agent" x-api-key:$KEY -pb
{
    "history": [
        {
            "content": "Invite sloan to metabase",
            "role": "user"
        },
        {
            "content": "Sorry, I cant help you with that. Could you please provide the email address of the user you want to invite to Metabase?",
            "role": "assistant"
        },
        {
            "content": "sloan@metabase.com",
            "role": "user"
        },
        {
            "content": "",
            "role": "assistant",
            "tool-calls": [
                {
                    "arguments": {
                        "email": "sloan@metabase.com"
                    },
                    "id": "call_yt9LvJS6bmB2oAekhU94VwqR",
                    "name": "metabot.tool/invite-user"
                }
            ]
        }
    ],
    "reactions": [
        {
            "email": "sloan@metabase.com",
            "type": "metabot.reaction/user-invite-sent"
        }
    ]
}

```

* decode tools

```clojure
api=> (mc/decode ::metabot-v3.client.schema/messages
                 [{:content "",
                   :role "assistant",
                   :tool-calls [{:id "call_Ma3wQEylZIT27t8z4lx8ch2y",
                                 :name "metabot.tool/say-hello",
                                 :arguments {"name" "User", "greeting" "hi"}}]}]
                 (mtx/transformer {:name :api-request}))
[{:content "",
  :role :assistant,
  :tool-calls [{:id "call_Ma3wQEylZIT27t8z4lx8ch2y",
                :name :metabot.tool/say-hello,
                :arguments {"name" "User", "greeting" "hi"}}]}]
```

from the api:

```shell
❯ echo '{"message":"sloan@metabase.com","context":{},"history":[{"role":"user","content":"Invite sloan to metabase"},{"content":"Sorry, I cant help you with that. Could you please provide the email address of the user you want to invite to Metabase?","role":"assistant"}]}' | http post "localhost:3000/api/ee/metabot-v3/agent" x-api-key:$KEY -pb | jq
 '.reactions'
[
  {
    "type": "metabot.reaction/user-invite-sent",
    "email": "sloan@metabase.com"
  }
]
```

and

```
❯ echo '{"message":"hi","context":{},"history":[{"role":"user","content":"hi"},{"content":"","role":"assistant","tool-calls":[{"id":"call_Ma3wQEylZIT27t8z4lx8ch2y","name":"metabot.tool/say-hello","arguments":{"name":"User","greeting":"hi"}}]}]}' | http post "localhost:3000/api/ee/metabot-v3/agent" x-api-key:$KEY -pb | jq '.reactions'
[
  {
    "type": "metabot.reaction/message",
    "message": "Sorry I don't understand that. Could you please provide more context or let me know how I can assist you?",
    "repl/message_color": "green",
    "repl/message_emoji": "🤖"
  }
]
```

* Add tool-call-ids to less recent history items

* Playing around with other tools

* FE - placeholder for demo steps which are ready for their be reactions

* FE - update how metabot can be accessed

* Add `defreactions` for the new reactions

* include comparison operators

* include other tools

* small fixes

* oops - value is a string or number, not an enum

* An actually working schema for filter value

* go to question tool/reaction

* tell the LLM not to change display with filter

* small fixes for malli schemas

* change the question to sales over time

* hardcode the default hosted proxy URL

* correct URL for ai-proxy

* FE - connect fe reaction implementation with real BE reactions

* FE - shorter animation duration

* change Ask Metabot to Metabot request

* hardcode URL in the getter instead of default

oop, `(premium-features/is-hosted?)` isn't available at compile time so
we need to just hardcode this in the getter. This means we can't
actually set the setting now, but that should be fine for now.

* Respond with simple messages after invite/favorite

* Hide any error details for the demo

* simpler error message

* FE - impl loading state

* Improve invite user schema descriptions

* Round trips to the LLM

* Hopefully fix clj-kondo hook test

This is my first time actually touching a hook, so hopefully I didn't
mess it up.

* Fix a few more tests

* Log traffic to `llm-payloads` file

```json
"FE -----------------> BE"
{
  "message" : "invite thomas to metabase",
  "context" : { },
  "history" : [ ]
}

"BE -----------------> LLM"
{
  "messages" : [ {
    "role" : "user",
    "content" : "invite thomas to metabase"
  } ],
  "context" : { },
  "tools" : [ {
    "name" : "apply_visualizations",
    ...
    }]}

"LLM -----------------> BE"
{
  "message" : {
    "content" : "Could you please provide me with Thomas's email address so I can send the invitation to Metabase?",
    "role" : "assistant"
  },
  "metadata" : {
    "usage" : {
      "model" : "gpt-4o-mini-2024-07-18",
      "total" : 1163,
      "prompt" : 1142,
      "completion" : 21,
      "estimated_costs" : 1.8704999999999997E-4
    }
  }
}
...
"BE -----------------> FE"
{
  "reactions" : [ {
    "type" : "metabot.reaction/message",
    "repl/message_color" : "green",
    "repl/message_emoji" : "🤖",
    "message" : "Thomas has been successfully invited to Metabase! If you need anything else, just let me know."
  } ],
  "history" : [ {
    "role" : "user",
    "content" : "invite thomas to metabase"
    ...
```

* Fix the REPL namespace

* FE - multi-line editor, css based animations, error handling, dismissable messages, updated loading state

* max 6 round trips

* Remove unused tools + real invite users

* Oops, fix tool requirements

* fix token feature test

* another small test fix

* oops - eastwood

* delete empty config

* FE - implments context solution so any component or route can set context for metabot, adds handling for change display type tool

* Observability, fix endpoint URL, and fix table viz

* Rework, but turn off, the invite tool

* Don't log tool args/results

* only log paylods in dev

* FE - impl hide/show columns reaction

* check metabot-v3 feature

* add tests for tools on the backend

* FE - make Dan happy

* FE - make Dan happier

* lint

* FE - huge code improvement pass, keep isProcessing state for metabot and await completion of each reaction before processing next, allow reactions to stop further reaction processing in the case of an error

* gate metabot on a different feature for now

* Some documentation

also moved `handle-response` to `handle-envelope`, since that's what
it's doing now.

* add llm-payloads to gitignore (#49505)

* Add back backend support for adding a user

After talking with Sloan, we reworked the reactions here a bit.

The `confirmation` reaction has a `description` and `options` (the
options here means "the options the user can choose between", not
something like "arguments").

The keys of the options are just names for the choices, in this case
"yes" and "no". When the frontend makes the choice, it carries out the
reactions located under that key in the `confirmation` reaction.

* FE - documentation and make context deregistering O(1)

* FE - fix enter key bug

* Include description in column context

* Always send the LLM's messages as reactions

Previously, we would only send the LLM's message as a reaction if there
were no other reactions. I think it makes more sense to send a list
containing:

- all reactions explicitly set by tool call invocations, PLUS

- all messages from the LLM since your last message (formatted as
reaction messages)

* Fix double message on the who's your favorite tool

* Try something with invite user, and fix up setting

* WIP tools testing

* add ? to confirmation description

* fix confirmation flow

* don't send tool call id on tool invocation

* set default ai host to localhost

* exclude metabot-v3 tests from test run

* FE - invite user improvements, implement confirmation + writeback + api call reactions, update users page to use RTKQuery to allow for reactive updates

* oops, fix failing tool tests

* use the real metabot feature

* Add sort-query tool

* Add direction

* Fix formatting

* Fix formatting

* Fix lint

* Add `aggregate_query` tool (#49622)

* aggregate_query tool

* Fix lint

* Add `limit_query` tool (#49629)

* limit_query tool

* Add breakout_query tool (#49631)

* Fix conflicts

* Single tool for queries

* Add grouping

* Add aggregations

* Add aggregations

* Add aggregations

* Add aggregations

* Add filters

* Cleanup

* Cleanup

* Cleanup

* Booleans

* Dates

* Dates

---------

Co-authored-by: Cam Saul <github@camsaul.com>
Co-authored-by: Sloan Sparger <sloansparger@gmail.com>
Co-authored-by: dan sutton <dan@dpsutton.com>
Co-authored-by: John Swanson <john.swanson@metabase.com>
Co-authored-by: Maz Ameli <maz@metabase.com>
Co-authored-by: Thomas Schmidt <thomas.heinz.schmidt@gmail.com>
Co-authored-by: Aleksandr Lesnenko <alxnddr@users.noreply.github.com>
2024-12-20 00:03:19 +03:00
Alexander Polyankin
af08a7488c Add sort_query tool (#49619)
* MetaBot v3 PoC

* API endpoint

* Fix formatting

* Change tools shape to JSON schema and update API shapes

* initial FE prototype

* FE - update styles, add toggle button for chat, put behind feature flag, PR feedback

* FE - remove previous message history snapshot from being saved on each history entry

* FE - hide behind feature flag

* FE - use history not messages key on agent requests

* Enforce tool naming rules

* Add encoding for reactions

* don't use ::metabot-v3.client/history schema until it exists

put :any in there, we should use the schema when we know what it looks
like

* `defreaction` can be a plain function.

* Remove empty file

* Code cleanup

* Make sure history includes the previous user message

* `:context` should get snake-case encoding for AI Proxy

* Fix encoding of requests => AI Proxy

* Make sure we use :additionalProperties, not :additional_properties

* Fix error with API endpoint and add (mocked) e2e tests for the endpoint

* Some improvements to error handling in client and REPL.

* Add placeholder tool call result messages

* FE - account for schema changes

* Decode api request into internal format

```clojure
api=> (mc/decode [:maybe ::metabot-v3.client.schema/messages]
                 [{:role "user"
                   :content "Invite sloan to metabase"}
                  {:content "Sorry, I cant help you with that. Could you please provide the email address of the user you want to invite to Metabase?"
                   :role "assistant"}]
                 (mtx/transformer
                   {:name :api-request}))
[{:role :user, :content "Invite sloan to metabase"}
 {:content "Sorry, I cant help you with that. Could you please provide the email address of the user you want to invite to Metabase?",
  :role :assistant}]
```

This just ensures that the role gets keywordized.

`:role "user"` -> `:role :user`.

* ensure we decode LLM response

LLM sends back the tool we are using as "invite-user". But our dispatch
stuff needs it to end up as `:metabot.tool/invite-user`. Just a simple
namespace for the keyword.

```shell
❯ echo '{"message":"sloan@metabase.com","context":{},"history":[{"role":"user","content":"Invite sloan to metabase"},{"content":"Sorry, I cant help you with that. Could you please provide the email address of the user you want to invite to Metabase?","role":"assistant"}]}' | http post "localhost:3000/api/ee/metabot-v3/agent" x-api-key:$KEY -pb
{
    "history": [
        {
            "content": "Invite sloan to metabase",
            "role": "user"
        },
        {
            "content": "Sorry, I cant help you with that. Could you please provide the email address of the user you want to invite to Metabase?",
            "role": "assistant"
        },
        {
            "content": "sloan@metabase.com",
            "role": "user"
        },
        {
            "content": "",
            "role": "assistant",
            "tool-calls": [
                {
                    "arguments": {
                        "email": "sloan@metabase.com"
                    },
                    "id": "call_yt9LvJS6bmB2oAekhU94VwqR",
                    "name": "metabot.tool/invite-user"
                }
            ]
        }
    ],
    "reactions": [
        {
            "email": "sloan@metabase.com",
            "type": "metabot.reaction/user-invite-sent"
        }
    ]
}

```

* decode tools

```clojure
api=> (mc/decode ::metabot-v3.client.schema/messages
                 [{:content "",
                   :role "assistant",
                   :tool-calls [{:id "call_Ma3wQEylZIT27t8z4lx8ch2y",
                                 :name "metabot.tool/say-hello",
                                 :arguments {"name" "User", "greeting" "hi"}}]}]
                 (mtx/transformer {:name :api-request}))
[{:content "",
  :role :assistant,
  :tool-calls [{:id "call_Ma3wQEylZIT27t8z4lx8ch2y",
                :name :metabot.tool/say-hello,
                :arguments {"name" "User", "greeting" "hi"}}]}]
```

from the api:

```shell
❯ echo '{"message":"sloan@metabase.com","context":{},"history":[{"role":"user","content":"Invite sloan to metabase"},{"content":"Sorry, I cant help you with that. Could you please provide the email address of the user you want to invite to Metabase?","role":"assistant"}]}' | http post "localhost:3000/api/ee/metabot-v3/agent" x-api-key:$KEY -pb | jq
 '.reactions'
[
  {
    "type": "metabot.reaction/user-invite-sent",
    "email": "sloan@metabase.com"
  }
]
```

and

```
❯ echo '{"message":"hi","context":{},"history":[{"role":"user","content":"hi"},{"content":"","role":"assistant","tool-calls":[{"id":"call_Ma3wQEylZIT27t8z4lx8ch2y","name":"metabot.tool/say-hello","arguments":{"name":"User","greeting":"hi"}}]}]}' | http post "localhost:3000/api/ee/metabot-v3/agent" x-api-key:$KEY -pb | jq '.reactions'
[
  {
    "type": "metabot.reaction/message",
    "message": "Sorry I don't understand that. Could you please provide more context or let me know how I can assist you?",
    "repl/message_color": "green",
    "repl/message_emoji": "🤖"
  }
]
```

* Add tool-call-ids to less recent history items

* Playing around with other tools

* FE - placeholder for demo steps which are ready for their be reactions

* FE - update how metabot can be accessed

* Add `defreactions` for the new reactions

* include comparison operators

* include other tools

* small fixes

* oops - value is a string or number, not an enum

* An actually working schema for filter value

* go to question tool/reaction

* tell the LLM not to change display with filter

* small fixes for malli schemas

* change the question to sales over time

* hardcode the default hosted proxy URL

* correct URL for ai-proxy

* FE - connect fe reaction implementation with real BE reactions

* FE - shorter animation duration

* change Ask Metabot to Metabot request

* hardcode URL in the getter instead of default

oop, `(premium-features/is-hosted?)` isn't available at compile time so
we need to just hardcode this in the getter. This means we can't
actually set the setting now, but that should be fine for now.

* Respond with simple messages after invite/favorite

* Hide any error details for the demo

* simpler error message

* FE - impl loading state

* Improve invite user schema descriptions

* Round trips to the LLM

* Hopefully fix clj-kondo hook test

This is my first time actually touching a hook, so hopefully I didn't
mess it up.

* Fix a few more tests

* Log traffic to `llm-payloads` file

```json
"FE -----------------> BE"
{
  "message" : "invite thomas to metabase",
  "context" : { },
  "history" : [ ]
}

"BE -----------------> LLM"
{
  "messages" : [ {
    "role" : "user",
    "content" : "invite thomas to metabase"
  } ],
  "context" : { },
  "tools" : [ {
    "name" : "apply_visualizations",
    ...
    }]}

"LLM -----------------> BE"
{
  "message" : {
    "content" : "Could you please provide me with Thomas's email address so I can send the invitation to Metabase?",
    "role" : "assistant"
  },
  "metadata" : {
    "usage" : {
      "model" : "gpt-4o-mini-2024-07-18",
      "total" : 1163,
      "prompt" : 1142,
      "completion" : 21,
      "estimated_costs" : 1.8704999999999997E-4
    }
  }
}
...
"BE -----------------> FE"
{
  "reactions" : [ {
    "type" : "metabot.reaction/message",
    "repl/message_color" : "green",
    "repl/message_emoji" : "🤖",
    "message" : "Thomas has been successfully invited to Metabase! If you need anything else, just let me know."
  } ],
  "history" : [ {
    "role" : "user",
    "content" : "invite thomas to metabase"
    ...
```

* Fix the REPL namespace

* FE - multi-line editor, css based animations, error handling, dismissable messages, updated loading state

* max 6 round trips

* Remove unused tools + real invite users

* Oops, fix tool requirements

* fix token feature test

* another small test fix

* oops - eastwood

* delete empty config

* FE - implments context solution so any component or route can set context for metabot, adds handling for change display type tool

* Observability, fix endpoint URL, and fix table viz

* Rework, but turn off, the invite tool

* Don't log tool args/results

* only log paylods in dev

* FE - impl hide/show columns reaction

* check metabot-v3 feature

* add tests for tools on the backend

* FE - make Dan happy

* FE - make Dan happier

* lint

* FE - huge code improvement pass, keep isProcessing state for metabot and await completion of each reaction before processing next, allow reactions to stop further reaction processing in the case of an error

* gate metabot on a different feature for now

* Some documentation

also moved `handle-response` to `handle-envelope`, since that's what
it's doing now.

* add llm-payloads to gitignore (#49505)

* Add back backend support for adding a user

After talking with Sloan, we reworked the reactions here a bit.

The `confirmation` reaction has a `description` and `options` (the
options here means "the options the user can choose between", not
something like "arguments").

The keys of the options are just names for the choices, in this case
"yes" and "no". When the frontend makes the choice, it carries out the
reactions located under that key in the `confirmation` reaction.

* FE - documentation and make context deregistering O(1)

* FE - fix enter key bug

* Include description in column context

* Always send the LLM's messages as reactions

Previously, we would only send the LLM's message as a reaction if there
were no other reactions. I think it makes more sense to send a list
containing:

- all reactions explicitly set by tool call invocations, PLUS

- all messages from the LLM since your last message (formatted as
reaction messages)

* Fix double message on the who's your favorite tool

* Try something with invite user, and fix up setting

* WIP tools testing

* add ? to confirmation description

* fix confirmation flow

* don't send tool call id on tool invocation

* set default ai host to localhost

* exclude metabot-v3 tests from test run

* FE - invite user improvements, implement confirmation + writeback + api call reactions, update users page to use RTKQuery to allow for reactive updates

* oops, fix failing tool tests

* use the real metabot feature

* Add sort-query tool

* Add direction

* Fix formatting

* Fix formatting

* Fix lint

* Add `aggregate_query` tool (#49622)

* aggregate_query tool

* Fix lint

* Add `limit_query` tool (#49629)

* limit_query tool

* Add breakout_query tool (#49631)

* Fix conflicts

---------

Co-authored-by: Cam Saul <github@camsaul.com>
Co-authored-by: Sloan Sparger <sloansparger@gmail.com>
Co-authored-by: dan sutton <dan@dpsutton.com>
Co-authored-by: John Swanson <john.swanson@metabase.com>
Co-authored-by: Maz Ameli <maz@metabase.com>
Co-authored-by: Thomas Schmidt <thomas.heinz.schmidt@gmail.com>
Co-authored-by: Aleksandr Lesnenko <alxnddr@users.noreply.github.com>
2024-12-20 00:03:19 +03:00
John Swanson
8933965e77 Instance info
For now, put `user-id` into the instance info. I'll move this over once
we've added that field to the schema on the `ai-service` side.
2024-12-20 00:03:19 +03:00
Sloan Sparger
d4c0b24558 FE - change error messages 2024-12-20 00:03:19 +03:00
Sloan Sparger
7b34e3c04f FE - copy changes 2024-12-20 00:03:19 +03:00
Sloan Sparger
b498a5ad59 FE - removes the feature from being hardcoded as enabled 2024-12-20 00:03:19 +03:00
John Swanson
4530bb4472 use the real metabot feature 2024-12-20 00:03:19 +03:00
John Swanson
f7bc32c37f oops, fix failing tool tests 2024-12-20 00:03:19 +03:00
Sloan Sparger
b11ace9933 FE - invite user improvements, implement confirmation + writeback + api call reactions, update users page to use RTKQuery to allow for reactive updates 2024-12-20 00:03:19 +03:00
John Swanson
4ac8936dda exclude metabot-v3 tests from test run 2024-12-20 00:03:18 +03:00
John Swanson
da80d576fc set default ai host to localhost 2024-12-20 00:03:18 +03:00
John Swanson
75af27d1ef don't send tool call id on tool invocation 2024-12-20 00:03:18 +03:00
John Swanson
b71f81de3a fix confirmation flow 2024-12-20 00:03:18 +03:00
John Swanson
8ae959af40 add ? to confirmation description 2024-12-20 00:03:18 +03:00
John Swanson
54bea92e33 WIP tools testing 2024-12-20 00:03:18 +03:00
John Swanson
c1cec51039 Try something with invite user, and fix up setting 2024-12-20 00:03:18 +03:00
John Swanson
619faab76a Fix double message on the who's your favorite tool 2024-12-20 00:03:18 +03:00
John Swanson
fa679be3f5 Always send the LLM's messages as reactions
Previously, we would only send the LLM's message as a reaction if there
were no other reactions. I think it makes more sense to send a list
containing:

- all reactions explicitly set by tool call invocations, PLUS

- all messages from the LLM since your last message (formatted as
reaction messages)
2024-12-20 00:03:18 +03:00
Thomas Schmidt
3e151e39ce Include description in column context 2024-12-20 00:03:18 +03:00
Sloan Sparger
2086a5bcee FE - fix enter key bug 2024-12-20 00:03:18 +03:00
Sloan Sparger
94658fdbab FE - documentation and make context deregistering O(1) 2024-12-20 00:03:18 +03:00
John Swanson
c4d52fb574 Add back backend support for adding a user
After talking with Sloan, we reworked the reactions here a bit.

The `confirmation` reaction has a `description` and `options` (the
options here means "the options the user can choose between", not
something like "arguments").

The keys of the options are just names for the choices, in this case
"yes" and "no". When the frontend makes the choice, it carries out the
reactions located under that key in the `confirmation` reaction.
2024-12-20 00:03:18 +03:00
Aleksandr Lesnenko
1444a637e3 add llm-payloads to gitignore (#49505) 2024-12-20 00:03:18 +03:00
John Swanson
fdbbe3e246 Some documentation
also moved `handle-response` to `handle-envelope`, since that's what
it's doing now.
2024-12-20 00:03:18 +03:00
John Swanson
4d722317bf gate metabot on a different feature for now 2024-12-20 00:03:18 +03:00
Sloan Sparger
07fb93c1dd FE - huge code improvement pass, keep isProcessing state for metabot and await completion of each reaction before processing next, allow reactions to stop further reaction processing in the case of an error 2024-12-20 00:03:18 +03:00
John Swanson
3082c28ce6 lint 2024-12-20 00:03:18 +03:00
Sloan Sparger
c43ffdf1f6 FE - make Dan happier 2024-12-20 00:03:18 +03:00
Sloan Sparger
30e1b63230 FE - make Dan happy 2024-12-20 00:03:18 +03:00
John Swanson
35f2719fc7 add tests for tools on the backend 2024-12-20 00:03:18 +03:00
John Swanson
5d2b8b4192 check metabot-v3 feature 2024-12-20 00:03:18 +03:00
Sloan Sparger
dd305e34c2 FE - impl hide/show columns reaction 2024-12-20 00:03:18 +03:00
dan sutton
7c47d4908f only log paylods in dev 2024-12-20 00:03:18 +03:00
John Swanson
718602271b Don't log tool args/results 2024-12-20 00:03:18 +03:00
John Swanson
cbbc868d07 Rework, but turn off, the invite tool 2024-12-20 00:03:18 +03:00
John Swanson
ca5aadc113 Observability, fix endpoint URL, and fix table viz 2024-12-20 00:03:18 +03:00
Sloan Sparger
313f03307a FE - implments context solution so any component or route can set context for metabot, adds handling for change display type tool 2024-12-20 00:03:18 +03:00
John Swanson
e92eba2a34 delete empty config 2024-12-20 00:03:18 +03:00
John Swanson
eefe925be2 oops - eastwood 2024-12-20 00:03:18 +03:00
John Swanson
6ed9935bf0 another small test fix 2024-12-20 00:03:18 +03:00
John Swanson
7032631e79 fix token feature test 2024-12-20 00:03:18 +03:00
John Swanson
b0e5c8f306 Oops, fix tool requirements 2024-12-20 00:03:18 +03:00
John Swanson
06b61e9d11 Remove unused tools + real invite users 2024-12-20 00:03:18 +03:00
John Swanson
74c7533d9f max 6 round trips 2024-12-20 00:03:18 +03:00
Sloan Sparger
eca6d725db FE - multi-line editor, css based animations, error handling, dismissable messages, updated loading state 2024-12-20 00:03:18 +03:00
John Swanson
1ee39a695f Fix the REPL namespace 2024-12-20 00:03:18 +03:00
dan sutton
7d97203f80 Log traffic to llm-payloads file
```json
"FE -----------------> BE"
{
  "message" : "invite thomas to metabase",
  "context" : { },
  "history" : [ ]
}

"BE -----------------> LLM"
{
  "messages" : [ {
    "role" : "user",
    "content" : "invite thomas to metabase"
  } ],
  "context" : { },
  "tools" : [ {
    "name" : "apply_visualizations",
    ...
    }]}

"LLM -----------------> BE"
{
  "message" : {
    "content" : "Could you please provide me with Thomas's email address so I can send the invitation to Metabase?",
    "role" : "assistant"
  },
  "metadata" : {
    "usage" : {
      "model" : "gpt-4o-mini-2024-07-18",
      "total" : 1163,
      "prompt" : 1142,
      "completion" : 21,
      "estimated_costs" : 1.8704999999999997E-4
    }
  }
}
...
"BE -----------------> FE"
{
  "reactions" : [ {
    "type" : "metabot.reaction/message",
    "repl/message_color" : "green",
    "repl/message_emoji" : "🤖",
    "message" : "Thomas has been successfully invited to Metabase! If you need anything else, just let me know."
  } ],
  "history" : [ {
    "role" : "user",
    "content" : "invite thomas to metabase"
    ...
```
2024-12-20 00:03:18 +03:00
John Swanson
53df1cc48f Fix a few more tests 2024-12-20 00:03:18 +03:00
John Swanson
445689bd61 Hopefully fix clj-kondo hook test
This is my first time actually touching a hook, so hopefully I didn't
mess it up.
2024-12-20 00:03:18 +03:00
John Swanson
1afd8db757 Round trips to the LLM 2024-12-20 00:03:18 +03:00
Thomas Schmidt
f36b61d095 Improve invite user schema descriptions 2024-12-20 00:03:18 +03:00
Sloan Sparger
1320442e81 FE - impl loading state 2024-12-20 00:03:18 +03:00
John Swanson
9a5c9f6b43 simpler error message 2024-12-20 00:03:18 +03:00
John Swanson
a77f8d3186 Hide any error details for the demo 2024-12-20 00:03:18 +03:00
John Swanson
d18a41c615 Respond with simple messages after invite/favorite 2024-12-20 00:03:18 +03:00
John Swanson
23e07e333b hardcode URL in the getter instead of default
oop, `(premium-features/is-hosted?)` isn't available at compile time so
we need to just hardcode this in the getter. This means we can't
actually set the setting now, but that should be fine for now.
2024-12-20 00:03:18 +03:00
Maz Ameli
c032343abb change Ask Metabot to Metabot request 2024-12-20 00:03:18 +03:00
Sloan Sparger
85454134b8 FE - shorter animation duration 2024-12-20 00:03:18 +03:00
Sloan Sparger
75a61b3ac1 FE - connect fe reaction implementation with real BE reactions 2024-12-20 00:03:18 +03:00
John Swanson
3855c55a9a correct URL for ai-proxy 2024-12-20 00:03:18 +03:00
John Swanson
7023192f86 hardcode the default hosted proxy URL 2024-12-20 00:03:18 +03:00
John Swanson
e52e7b9ca4 change the question to sales over time 2024-12-20 00:03:18 +03:00
John Swanson
1917280df3 small fixes for malli schemas 2024-12-20 00:03:18 +03:00
John Swanson
6a5ac5a8c5 tell the LLM not to change display with filter 2024-12-20 00:03:18 +03:00
John Swanson
54d966fb98 go to question tool/reaction 2024-12-20 00:03:18 +03:00
John Swanson
89e065a77a An actually working schema for filter value 2024-12-20 00:03:18 +03:00
John Swanson
f46fb69020 oops - value is a string or number, not an enum 2024-12-20 00:03:18 +03:00
John Swanson
e5d0c154e6 small fixes 2024-12-20 00:03:18 +03:00
dan sutton
c937c135db include other tools 2024-12-20 00:03:18 +03:00
dan sutton
0052fdfdec include comparison operators 2024-12-20 00:03:18 +03:00
John Swanson
3847d4067c Add defreactions for the new reactions 2024-12-20 00:03:18 +03:00
Sloan Sparger
ba6d81f561 FE - update how metabot can be accessed 2024-12-20 00:03:18 +03:00
Sloan Sparger
50f3c789b8 FE - placeholder for demo steps which are ready for their be reactions 2024-12-20 00:03:18 +03:00
John Swanson
9c71ce697a Playing around with other tools 2024-12-20 00:03:18 +03:00
John Swanson
ba2c9e9eca Add tool-call-ids to less recent history items 2024-12-20 00:03:18 +03:00
dan sutton
4a61e5a42c decode tools
```clojure
api=> (mc/decode ::metabot-v3.client.schema/messages
                 [{:content "",
                   :role "assistant",
                   :tool-calls [{:id "call_Ma3wQEylZIT27t8z4lx8ch2y",
                                 :name "metabot.tool/say-hello",
                                 :arguments {"name" "User", "greeting" "hi"}}]}]
                 (mtx/transformer {:name :api-request}))
[{:content "",
  :role :assistant,
  :tool-calls [{:id "call_Ma3wQEylZIT27t8z4lx8ch2y",
                :name :metabot.tool/say-hello,
                :arguments {"name" "User", "greeting" "hi"}}]}]
```

from the api:

```shell
❯ echo '{"message":"sloan@metabase.com","context":{},"history":[{"role":"user","content":"Invite sloan to metabase"},{"content":"Sorry, I cant help you with that. Could you please provide the email address of the user you want to invite to Metabase?","role":"assistant"}]}' | http post "localhost:3000/api/ee/metabot-v3/agent" x-api-key:$KEY -pb | jq
 '.reactions'
[
  {
    "type": "metabot.reaction/user-invite-sent",
    "email": "sloan@metabase.com"
  }
]
```

and

```
❯ echo '{"message":"hi","context":{},"history":[{"role":"user","content":"hi"},{"content":"","role":"assistant","tool-calls":[{"id":"call_Ma3wQEylZIT27t8z4lx8ch2y","name":"metabot.tool/say-hello","arguments":{"name":"User","greeting":"hi"}}]}]}' | http post "localhost:3000/api/ee/metabot-v3/agent" x-api-key:$KEY -pb | jq '.reactions'
[
  {
    "type": "metabot.reaction/message",
    "message": "Sorry I don't understand that. Could you please provide more context or let me know how I can assist you?",
    "repl/message_color": "green",
    "repl/message_emoji": "🤖"
  }
]
```
2024-12-20 00:03:18 +03:00
dan sutton
7007095b35 ensure we decode LLM response
LLM sends back the tool we are using as "invite-user". But our dispatch
stuff needs it to end up as `:metabot.tool/invite-user`. Just a simple
namespace for the keyword.

```shell
❯ echo '{"message":"sloan@metabase.com","context":{},"history":[{"role":"user","content":"Invite sloan to metabase"},{"content":"Sorry, I cant help you with that. Could you please provide the email address of the user you want to invite to Metabase?","role":"assistant"}]}' | http post "localhost:3000/api/ee/metabot-v3/agent" x-api-key:$KEY -pb
{
    "history": [
        {
            "content": "Invite sloan to metabase",
            "role": "user"
        },
        {
            "content": "Sorry, I cant help you with that. Could you please provide the email address of the user you want to invite to Metabase?",
            "role": "assistant"
        },
        {
            "content": "sloan@metabase.com",
            "role": "user"
        },
        {
            "content": "",
            "role": "assistant",
            "tool-calls": [
                {
                    "arguments": {
                        "email": "sloan@metabase.com"
                    },
                    "id": "call_yt9LvJS6bmB2oAekhU94VwqR",
                    "name": "metabot.tool/invite-user"
                }
            ]
        }
    ],
    "reactions": [
        {
            "email": "sloan@metabase.com",
            "type": "metabot.reaction/user-invite-sent"
        }
    ]
}

```
2024-12-20 00:03:18 +03:00
dan sutton
d32f8c5940 Decode api request into internal format
```clojure
api=> (mc/decode [:maybe ::metabot-v3.client.schema/messages]
                 [{:role "user"
                   :content "Invite sloan to metabase"}
                  {:content "Sorry, I cant help you with that. Could you please provide the email address of the user you want to invite to Metabase?"
                   :role "assistant"}]
                 (mtx/transformer
                   {:name :api-request}))
[{:role :user, :content "Invite sloan to metabase"}
 {:content "Sorry, I cant help you with that. Could you please provide the email address of the user you want to invite to Metabase?",
  :role :assistant}]
```

This just ensures that the role gets keywordized.

`:role "user"` -> `:role :user`.
2024-12-20 00:03:18 +03:00
Sloan Sparger
a1174e82b1 FE - account for schema changes 2024-12-20 00:03:18 +03:00
Cam Saul
6b500e15f9 Add placeholder tool call result messages 2024-12-20 00:03:18 +03:00
Cam Saul
b02e3c9aa1 Some improvements to error handling in client and REPL. 2024-12-20 00:03:18 +03:00
Cam Saul
6abab13f62 Fix error with API endpoint and add (mocked) e2e tests for the endpoint 2024-12-20 00:03:18 +03:00
Cam Saul
f9919999ff Make sure we use :additionalProperties, not :additional_properties 2024-12-20 00:03:18 +03:00
Cam Saul
22ae538b0c Fix encoding of requests => AI Proxy 2024-12-20 00:03:18 +03:00
Cam Saul
ae093274ff :context should get snake-case encoding for AI Proxy 2024-12-20 00:03:18 +03:00
Cam Saul
ac3a171308 Make sure history includes the previous user message 2024-12-20 00:03:18 +03:00
Cam Saul
de00816b7b Code cleanup 2024-12-20 00:03:18 +03:00
Cam Saul
2c647c0637 Remove empty file 2024-12-20 00:03:18 +03:00
Cam Saul
c4866381a3 defreaction can be a plain function. 2024-12-20 00:03:17 +03:00
dan sutton
749a5e6a56 don't use ::metabot-v3.client/history schema until it exists
put :any in there, we should use the schema when we know what it looks
like
2024-12-20 00:03:17 +03:00
Cam Saul
556acb9b10 Add encoding for reactions 2024-12-20 00:03:17 +03:00
Cam Saul
d60072bcc9 Enforce tool naming rules 2024-12-20 00:03:17 +03:00
Sloan Sparger
d478a79856 FE - use history not messages key on agent requests 2024-12-20 00:03:17 +03:00
Sloan Sparger
6067de5ba7 FE - hide behind feature flag 2024-12-20 00:03:17 +03:00
Sloan Sparger
b98ed219e8 FE - remove previous message history snapshot from being saved on each history entry 2024-12-20 00:03:17 +03:00
Sloan Sparger
d6f4458fba FE - update styles, add toggle button for chat, put behind feature flag, PR feedback 2024-12-20 00:03:17 +03:00
Sloan Sparger
4b5457848f initial FE prototype 2024-12-20 00:03:17 +03:00
Cam Saul
acd4ed01c2 Change tools shape to JSON schema and update API shapes 2024-12-20 00:03:17 +03:00
Cam Saul
06212efee6 Fix formatting 2024-12-20 00:03:17 +03:00
Cam Saul
2c4c399e29 API endpoint 2024-12-20 00:03:17 +03:00
Cam Saul
71ac4f02f1 MetaBot v3 PoC 2024-12-20 00:03:17 +03:00
149 changed files with 7708 additions and 260 deletions

View File

@@ -626,6 +626,10 @@
metabase-enterprise.advanced-permissions.models.permissions.group-manager}
:uses #{api audit driver enterprise/impersonation lib models permissions premium-features query-processor util enterprise/sandbox}}
enterprise/ai-sql-fixer
{:api #{metabase-enterprise.ai-sql-fixer.api}
:uses #{api driver enterprise/metabot-v3 query-analysis query-processor util}}
enterprise/airgap
{:api #{}
:uses #{premium-features util}}
@@ -696,6 +700,11 @@
{:api #{metabase-enterprise.llm.api}
:uses #{analytics analyze api models query-processor util}}
enterprise/metabot-v3
{:api #{metabase-enterprise.metabot-v3.api metabase-enterprise.metabot-v3.core
metabase-enterprise.metabot-v3.tools.api}
:uses #{api config db legacy-mbql lib models premium-features public-settings pulse query-processor request util}}
enterprise/query-reference-validation
{:api #{metabase-enterprise.query-reference-validation.api}
:uses #{api models public-settings request util}}

View File

@@ -0,0 +1,34 @@
(ns hooks.metabase.models.setting-test
(:require
[clj-kondo.hooks-api :as hooks]
[clojure.test :refer :all]
[hooks.metabase.models.setting]))
#_{:clj-kondo/ignore [:metabase/validate-deftest]}
(deftest ^:parallel defsetting-test
(let [node (-> '(defsetting active-users-count
(deferred-tru "Cached number of active users. Refreshed every 5 minutes.")
:visibility :admin
:type :integer
:default 0
:getter (fn []
(if-not ((requiring-resolve 'metabase.db/db-is-set-up?))
0
(cached-active-users-count))))
pr-str
hooks/parse-string)]
(is (= '(let [_ (deferred-tru "Cached number of active users. Refreshed every 5 minutes.")
_ {:default 0
:type :integer
:getter (fn []
(if-not ((requiring-resolve 'metabase.db/db-is-set-up?))
0
(cached-active-users-count)))
:visibility :admin}]
:active-users-count
(defn active-users-count "Docstring." [])
(defn active-users-count! "Docstring." [_value-or-nil]))
(-> {:node node}
hooks.metabase.models.setting/defsetting
:node
hooks/sexpr)))))

View File

@@ -4,7 +4,7 @@ on:
push:
branches:
- "master"
- metabot-v3-second-try
- metabot-v3-main
- feature-visualizer
- "internal-tools"
paths-ignore:
@@ -170,8 +170,8 @@ jobs:
push: true
# TODO: remove when we're done testing metabot
- name: Retag and push images if metabot-v3-second-try (ee) - should be cached at this point
if: ${{ (github.ref_name == 'metabot-v3-second-try') && matrix.edition == 'ee' }}
- name: Retag and push images if metabot-v3-main (ee) - should be cached at this point
if: ${{ (github.ref_name == 'metabot-v3-main') && matrix.edition == 'ee' }}
uses: docker/build-push-action@v3
with:
context: bin/docker/.

4
.gitignore vendored
View File

@@ -125,5 +125,9 @@ modules/drivers/*/.lsp/*
# jar downloader
/jars/
# metabot
llm-payloads
/bin/bb

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 162 KiB

View File

@@ -309,6 +309,7 @@
:test
{:extra-paths ["test_config"]
:exec-fn metabase.test-runner/find-and-run-tests-cli
:exec-args {:exclude-tags [:metabot-v3/e2e]}
:jvm-opts ["-Dmb.run.mode=test"
"-Dmacaw.run.mode=test"
"-Dmb.db.in.memory=true"

View File

@@ -0,0 +1,78 @@
import type { StaticResponse } from "cypress/types/net-stubbing";
import { newButton } from "./e2e-ui-elements-helpers";
export function assertChatVisibility(visiblility: "visible" | "not.visible") {
cy.findByTestId("metabot-chat").should(
visiblility === "visible" ? "be.visible" : "not.exist",
);
}
export function openMetabotViaShortcutKey(assertVisibility = true) {
if (assertVisibility) {
assertChatVisibility("not.visible");
}
cy.realPress(["Meta", "b"]);
if (assertVisibility) {
assertChatVisibility("visible");
}
}
export function closeMetabotViaShortcutKey(assertVisibility = true) {
if (assertVisibility) {
assertChatVisibility("visible");
}
cy.realPress(["Meta", "b"]);
if (assertVisibility) {
assertChatVisibility("not.visible");
}
}
export function openMetabotViaNewMenu(assertVisibility = true) {
if (assertVisibility) {
assertChatVisibility("not.visible");
}
newButton("Metabot request").click();
if (assertVisibility) {
assertChatVisibility("visible");
}
}
export function closeMetabotViaCloseButton(assertVisibility = true) {
cy.findByTestId("metabot-close-chat").click();
if (assertVisibility) {
assertChatVisibility("not.visible");
}
}
export function metabotChatInput() {
return cy.findByTestId("metabot-chat-input");
}
export function sendMetabotMessage(input: string) {
metabotChatInput()
.should("not.be.disabled")
.click()
.should("be.focused")
.type(input)
.type("{Enter}");
}
export function userMessages() {
return cy.findAllByTestId("metabot-chat-message");
}
export const mockMetabotResponse = (response: StaticResponse) => {
return cy
.intercept("POST", "/api/ee/metabot-v3/agent", req => {
req.reply(response);
})
.as("metabotAgent");
};

View File

@@ -21,6 +21,7 @@ export * from "./e2e-filter-helpers";
export * from "./e2e-ldap-helpers";
export * from "./e2e-misc-helpers";
export * from "./e2e-mock-app-settings-helpers";
export * from "./e2e-metabot-helpers";
export * from "./e2e-models-metadata-helpers";
export * from "./e2e-native-editor-helpers";
export * from "./e2e-notebook-helpers";

View File

@@ -459,9 +459,8 @@ describe("scenarios > admin > people", () => {
assertTableRowsCount(PAGE_SIZE);
cy.findByLabelText("Previous page").should("be.disabled");
// cy.findByLabelText("Next page").click();
cy.findByTestId("next-page-btn").click();
waitForUserRequests();
cy.findByLabelText("Next page").click();
cy.wait("@users");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Loading...").should("not.exist");
@@ -473,7 +472,6 @@ describe("scenarios > admin > people", () => {
cy.findByLabelText("Next page").should("be.disabled");
cy.findByLabelText("Previous page").click();
cy.wait("@users");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Loading...").should("not.exist");

View File

@@ -0,0 +1,120 @@
import {
assertChatVisibility,
closeMetabotViaCloseButton,
closeMetabotViaShortcutKey,
metabotChatInput,
mockMetabotResponse,
openMetabotViaNewMenu,
openMetabotViaShortcutKey,
popover,
restore,
sendMetabotMessage,
setTokenFeatures,
userMessages,
} from "e2e/support/helpers";
describe("Metabot UI", () => {
beforeEach(() => {
restore();
cy.signInAsAdmin();
cy.intercept("POST", "/api/ee/metabot-v3/agent").as("agentReq");
cy.intercept("GET", "/api/session/properties").as("sessionProperties");
});
describe("OSS", { tags: "@OSS" }, () => {
beforeEach(() => {
cy.visit("/");
cy.wait("@sessionProperties");
});
it("should not be avaiable in OSS", () => {
openMetabotViaShortcutKey(false);
assertChatVisibility("not.visible");
cy.findByLabelText("Navigation bar").within(() => {
cy.findByText("New").click();
});
popover().findByText("Metabot request").should("not.exist");
assertChatVisibility("not.visible");
});
});
// token feature has been disabled so skipping for now - there's currently decent coverage for UI related things in our unit tests
describe.skip("EE", () => {
beforeEach(() => {
setTokenFeatures("all");
cy.visit("/");
cy.wait("@sessionProperties");
});
// FIXME: shortcut keys aren't working in CI only, but work locally
it.skip("should be able to be opened and closed", () => {
openMetabotViaNewMenu();
closeMetabotViaCloseButton();
openMetabotViaShortcutKey();
closeMetabotViaShortcutKey();
});
it("should allow a user to send a message to the agent and handle successful or failed responses", () => {
openMetabotViaNewMenu();
userMessages().should("not.exist");
mockMetabotResponse({
statusCode: 200,
delay: 100, // small delay to detect loading state
body: whoIsYourFavoriteResponse,
});
sendMetabotMessage("Who is your favorite?");
metabotChatInput()
.should("have.attr", "placeholder")
.and("eq", "Doing science...");
userMessages()
.should("exist")
.should("have.text", "You are... but don't tell anyone!");
mockMetabotResponse({
statusCode: 500,
body: whoIsYourFavoriteResponse,
});
sendMetabotMessage("Who is your favorite?");
userMessages()
.should("exist")
.should("have.text", "I'm currently offline, try again later.");
});
});
});
const whoIsYourFavoriteResponse = {
reactions: [
{
type: "metabot.reaction/message",
message: "You are... but don't tell anyone!",
},
],
history: [
{
role: "user",
content: "Who is your favorite?",
},
{
content: "",
role: "assistant",
"tool-calls": [
{
id: "call_PVmnR8mcnYFF2AmqupSKzJDh",
name: "who-is-your-favorite",
arguments: {},
},
],
},
{
role: "tool",
"tool-call-id": "call_PVmnR8mcnYFF2AmqupSKzJDh",
content: "You are... but don't tell anyone!",
},
{
content: "You are... but don't tell anyone!",
role: "assistant",
},
],
};

View File

@@ -47,7 +47,9 @@ describe("issue 19180", () => {
cy.wait("@cardQuery");
cy.button("Cancel").click();
H.tableInteractive();
cy.findByText("Query results will appear here.").should("not.exist");
cy.findByText("Here's where your results will appear").should(
"not.exist",
);
},
);
});

View File

@@ -1134,7 +1134,9 @@ describe("issue 34514", () => {
H.tableInteractive().should("not.exist");
cy.findByTestId("query-visualization-root").within(() => {
cy.findByText("We're experiencing server issues").should("not.exist");
cy.findByText("Query results will appear here.").should("be.visible");
cy.findByText("Here's where your results will appear").should(
"be.visible",
);
});
}
});
@@ -1434,3 +1436,18 @@ describe("issue 51925", () => {
});
});
});
describe("issue 53649", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
});
it("should not get caught in an infinite loop when opening the native editor (metabase#53649)", () => {
H.startNewNativeModel();
// If the app freezes, this won't work
H.NativeEditor.type("select 1");
H.NativeEditor.get().should("contain", "select 1");
});
});

View File

@@ -948,7 +948,7 @@ describe("issue 27257", () => {
it("should not drop numeric filter widget value on refresh even if it's zero (metabase#27257)", () => {
cy.reload();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Query results will appear here.");
cy.findByText("Here's where your results will appear");
cy.findByDisplayValue("0");
});
});

View File

@@ -0,0 +1,40 @@
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
const { H } = cy;
const FIX_MESSAGE = "Fixes applied. Run your query to view results.";
describe.skip("scenarios > native > ai sql fixer", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.setTokenFeatures("all");
cy.intercept("POST", "/api/ee/ai-sql-fixer/fix").as("fixSql");
});
it("should be able to fix SQL with AI", () => {
H.visitQuestionAdhoc({
display: "table",
dataset_query: {
database: SAMPLE_DB_ID,
type: "native",
native: {
query: "SELECT1 * FROM ORDERS",
},
},
});
cy.wait("@fixSql");
cy.findByTestId("query-visualization-root").within(() => {
cy.button(/Have Metabot fix it/).click();
cy.findByText(FIX_MESSAGE).should("be.visible");
});
H.runNativeQuery();
H.NativeEditor.get()
.should("be.visible")
.and("contain", "SELECT * FROM ORDERS");
cy.findByTestId("query-visualization-root").within(() => {
H.tableInteractive().should("be.visible");
cy.findByText(FIX_MESSAGE).should("not.exist");
});
});
});

View File

@@ -668,7 +668,7 @@ describe("issue 34330", () => {
it("should only call the autocompleter with all text typed (metabase#34330)", () => {
cy.findByTestId("query-visualization-root")
.findByText("Query results will appear here.")
.findByText("Here's where your results will appear")
.should("be.visible");
H.NativeEditor.type("SEAT", { delay: 10 });

View File

@@ -33,7 +33,9 @@ describe("issue 11727", { tags: "@external" }, () => {
H.runNativeQuery({ wait: false });
cy.findByText("Doing science...").should("be.visible");
cy.get("body").type("{cmd}{enter}");
cy.findByText("Query results will appear here.").should("be.visible");
cy.findByText("Here's where your results will appear").should(
"be.visible",
);
});
});
});

View File

@@ -300,7 +300,7 @@ describe("scenarios > question > native", () => {
);
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Query results will appear here.").should("be.visible");
cy.findByText("Here's where your results will appear").should("be.visible");
});
it("should allow to preview a fully parameterized query", () => {

View File

@@ -500,7 +500,7 @@ describe("issue 30165", () => {
cy.get("@dataset.all").should("have.length", 0);
cy.get("@cardQuery.all").should("have.length", 0);
cy.findByTestId("query-builder-main")
.findByText("Query results will appear here.")
.findByText("Here's where your results will appear")
.should("be.visible");
});
});

View File

@@ -0,0 +1,210 @@
(ns metabase-enterprise.ai-sql-fixer.api
"`/api/ee/ai-sql-fixer/` routes"
(:require
[clojure.set :as set]
[metabase-enterprise.metabot-v3.core :as metabot-v3]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.driver.util :as driver.u]
[metabase.query-analysis.core :as query-analyzer]
[metabase.query-processor.middleware.permissions :as qp.perms]
[metabase.util :as u]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]
[toucan2.realize :as t2.realize])
(:import
(java.io StringWriter Writer)
(org.apache.commons.text.similarity LevenshteinDistance)))
(set! *warn-on-reflection* true)
;; some arbitrary limits
(def ^:private max-schema-sample-tables
"If the number of tables in the database doesn't exceed this number, we send them all to the agent."
10)
(def ^:private max-schema-candidate-tables
"The maximum number of tables we process to find the ones that might be relevant for the SQL query to fix."
10000)
(def ^:private max-distance
"The maximal edit distance at which we consider two table (or schema) names similar."
4)
(def ^:private ^LevenshteinDistance matcher (LevenshteinDistance. (int max-distance)))
(defn- similar?
[left right]
(nat-int? (.apply matcher (u/lower-case-en left) (u/lower-case-en right))))
(defn- matching-tables?
[{tschema :schema, tname :name} {uschema :schema, uname :name} {:keys [match-schema?]}]
(and (similar? uname tname)
(or (not match-schema?)
(nil? uschema)
(nil? tschema)
(similar? uschema tschema))))
(defn- find-matching-tables
[database-id unrecognized-tables used-ids]
(into []
(keep (fn [table]
(when (some #(matching-tables? table % {:match-schema? false}) unrecognized-tables)
(t2.realize/realize table))))
(t2/reducible-select [:model/Table :id :name :schema]
:db_id database-id
:active true
:visibility_type nil
(cond-> {:limit max-schema-candidate-tables}
(seq used-ids) (assoc :where [:not-in :id used-ids])))))
(defn- used-tables
[{:keys [database] :as query}]
(let [queried-tables (->> (query-analyzer/tables-for-native query :all-drivers-trusted? true)
:tables
(map #(set/rename-keys % {:table :name, :table-id :id})))
{recognized-tables true, unrecognized-tables false} (group-by t2/instance? queried-tables)]
(concat recognized-tables
(find-matching-tables database unrecognized-tables (map :id recognized-tables)))))
(defn- format-escaped
[^String identifier ^Writer writer]
(if (re-matches #"[\p{Alpha}_][\p{Alnum}_]*" identifier)
(.append writer identifier)
(do
(.append writer \")
(doseq [^Character c identifier]
(when (= c \")
(.append writer c))
(.append writer c))
(.append writer \"))))
(defn- format-field-ddl
[{:keys [^String database_type], fname :name} ^Writer writer]
(format-escaped fname writer)
(.append writer " ")
(.append writer database_type))
(defn- format-table-ddl
[{:keys [schema fields], tname :name} ^Writer writer]
(.append writer "CREATE TABLE ")
(when (seq schema)
(format-escaped schema writer)
(.append writer \.))
(format-escaped tname writer)
(.append writer " (")
(when (seq fields)
(.append writer "\n ")
(format-field-ddl (first fields) writer)
(doseq [field (rest fields)]
(.append writer ",\n ")
(format-field-ddl field writer)))
(.append writer "\n);"))
(defn- format-schema-ddl
[tables]
(let [sw (StringWriter.)]
(doseq [table tables]
(format-table-ddl table sw)
(.append sw \newline))
(str sw)))
(defn- schema-sample
([query]
(schema-sample query nil))
([{:keys [database] :as query} {:keys [all-tables-limit] :or {all-tables-limit max-schema-sample-tables}}]
(let [tables (t2/select [:model/Table :id :name :schema]
:db_id database
:active true
:visibility_type nil
{:limit (inc all-tables-limit)})
tables (if (> (count tables) all-tables-limit)
(used-tables query)
tables)
tables (t2/hydrate tables :fields)]
(format-schema-ddl tables))))
(defn- extract-table-names-from-sql
"Extract table names from SQL text for table relevance matching"
[sql-text]
(when sql-text
(let [words (re-seq #"[a-zA-Z_][a-zA-Z0-9_]*" sql-text)]
(set words))))
(defn- table-relevance-score
"Calculate a relevance score for a table based on presence of its name in the query/instructions"
[table-name table-words]
(if (contains? table-words table-name)
2 ; Direct match
0)) ; No match
(defn- select-relevant-tables
"Select most relevant tables based on SQL query content or instructions"
[tables text max-tables]
(let [table-words (extract-table-names-from-sql text)]
(->> tables
(map #(assoc % :relevance-score (table-relevance-score (:name %) table-words)))
(sort-by :relevance-score >)
(take max-tables)
(map #(dissoc % :relevance-score)))))
(defn- structured-tables
"Return table data in the new AI-service format, filtered by relevance if needed"
[query & [instructions]]
(let [tables (t2/select [:model/Table :id :name :schema :description]
:db_id (:database query)
:active true
:visibility_type nil)
tables (t2/hydrate tables :fields)
sql-text (-> query :native :query)
relevance-text (or instructions sql-text "")
tables-to-use (if (> (count tables) max-schema-sample-tables)
(select-relevant-tables tables relevance-text max-schema-sample-tables)
tables)]
(map (fn [t]
{:name (:name t)
:schema (:schema t)
:description (or (:description t) "")
:columns (map (fn [f]
{:name (:name f)
:data_type (:database_type f)
:description (or (:description f) "")})
(:fields t))})
tables-to-use)))
(api.macros/defendpoint :post "/fix"
"Suggest fixes for a SQL query."
[_route-params
_query-params
{:keys [query error_message]} :- [:map
[:query [:map
[:database ms/PositiveInt]]]
[:error_message :string]]]
(qp.perms/check-current-user-has-adhoc-native-query-perms query)
(let [driver (-> query :database driver.u/database->driver)]
(-> (metabot-v3/fix-sql
{:sql (-> query :native :query)
:dialect driver
:error_message error_message
:schema_ddl (schema-sample query)})
(select-keys [:fixes]))))
(api.macros/defendpoint :post "/generate"
"Generate a SQL statement from instructions."
[_route-params
_query-params
{:keys [query instructions]} :- [:map
[:query [:map
[:database ms/PositiveInt]]]
[:instructions :string]]]
(qp.perms/check-current-user-has-adhoc-native-query-perms query)
(let [driver (-> query :database driver.u/database->driver)]
(-> (metabot-v3/generate-sql
{:instructions instructions
:dialect driver
:tables (structured-tables query instructions)})
(select-keys [:generated_sql]))))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/ai-sql-fixer` routes."
(api.macros/ns-handler *ns* +auth))

View File

@@ -7,6 +7,7 @@
(:require
[metabase-enterprise.advanced-config.api.logs]
[metabase-enterprise.advanced-permissions.api.routes]
[metabase-enterprise.ai-sql-fixer.api]
[metabase-enterprise.api.routes.common :as ee.api.common]
[metabase-enterprise.audit-app.api.routes]
[metabase-enterprise.billing.api.routes]
@@ -14,6 +15,8 @@
[metabase-enterprise.data-editing.api]
[metabase-enterprise.gsheets.api :as gsheets.api]
[metabase-enterprise.llm.api]
[metabase-enterprise.metabot-v3.api]
[metabase-enterprise.metabot-v3.tools.api]
[metabase-enterprise.query-reference-validation.api]
[metabase-enterprise.sandbox.api.routes]
[metabase-enterprise.scim.routes]
@@ -32,9 +35,11 @@
:attached-dwh (deferred-tru "Attached DWH")
:audit-app (deferred-tru "Audit app")
:collection-cleanup (deferred-tru "Collection Cleanup")
:ai-sql-fixer (deferred-tru "AI SQL Fixer")
:etl-connections (deferred-tru "ETL Connections")
:table-data-editing (deferred-tru "Editing Table Data")
:llm-autodescription (deferred-tru "LLM Auto-description")
:metabot-v3 (deferred-tru "MetaBot")
:query-reference-validation (deferred-tru "Query Reference Validation")
:scim (deferred-tru "SCIM configuration")
:serialization (deferred-tru "Serialization")
@@ -60,6 +65,7 @@
"/api/ee routes. The following routes are NICE and do follow the `/ee/<feature>/` naming convention. Please add new
routes here and follow the convention."
{"/advanced-permissions" (premium-handler metabase-enterprise.advanced-permissions.api.routes/routes :advanced-permissions)
"/ai-sql-fixer" (premium-handler metabase-enterprise.ai-sql-fixer.api/routes :ai-sql-fixer)
"/audit-app" (premium-handler metabase-enterprise.audit-app.api.routes/routes :audit-app)
"/autodescribe" (premium-handler 'metabase-enterprise.llm.api :llm-autodescription)
"/billing" metabase-enterprise.billing.api.routes/routes
@@ -68,6 +74,8 @@
(premium-handler :attached-dwh)
(premium-handler :etl-connections))
"/logs" (premium-handler 'metabase-enterprise.advanced-config.api.logs :audit-app)
"/metabot-v3" (premium-handler metabase-enterprise.metabot-v3.api/routes :metabot-v3)
"/metabot-tools" metabase-enterprise.metabot-v3.tools.api/routes
"/query-reference-validation" (premium-handler metabase-enterprise.query-reference-validation.api/routes :query-reference-validation)
"/scim" (premium-handler metabase-enterprise.scim.routes/routes :scim)
"/serialization" (premium-handler metabase-enterprise.serialization.api/routes :serialization)

View File

@@ -0,0 +1,58 @@
(ns metabase-enterprise.metabot-v3.api
"`/api/ee/metabot-v3/` routes"
(:require
[malli.core :as mc]
[malli.transform :as mtx]
[metabase-enterprise.metabot-v3.client.schema :as metabot-v3.client.schema]
[metabase-enterprise.metabot-v3.context :as metabot-v3.context]
[metabase-enterprise.metabot-v3.envelope :as metabot-v3.envelope]
[metabase-enterprise.metabot-v3.reactions :as metabot-v3.reactions]
[metabase-enterprise.metabot-v3.tools.api :as metabot-v3.tools.api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.util :as u]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]))
(mu/defn ^:private encode-reactions [reactions :- [:sequential ::metabot-v3.reactions/reaction]]
(mc/encode [:sequential ::metabot-v3.reactions/reaction]
reactions
(mtx/transformer
{:name :api-response}
(mtx/key-transformer {:encode u/->snake_case_en}))))
(defn request
"Handles an incoming request, making all required tool invocation, LLM call loops, etc."
[message context history conversation_id state]
(let [initial-message (metabot-v3.envelope/user-message message)
history (conj (vec history) initial-message)
env (-> {:context (metabot-v3.context/create-context context)
:conversation-id conversation_id
:messages history
:state state}
metabot-v3.tools.api/handle-envelope)
messages (:messages env)]
{:reactions (-> messages metabot-v3.envelope/reactions encode-reactions)
:history (into history messages)
:state (metabot-v3.envelope/state env)}))
(api.macros/defendpoint :post "/v2/agent"
"Send a chat message to the LLM via the AI Proxy."
[_route-params
_query-params
{:keys [message context conversation_id history state] :as body}
:- [:map
[:message ms/NonBlankString]
[:context ::metabot-v3.context/context]
[:conversation_id ms/UUIDString]
[:history [:maybe ::metabot-v3.client.schema/messages]]
[:state :map]]]
(metabot-v3.context/log body :llm.log/fe->be)
(doto (assoc
(request message context history conversation_id state)
:conversation_id conversation_id)
(metabot-v3.context/log :llm.log/be->fe)))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/metabot-v3` routes."
(api.macros/ns-handler *ns* +auth))

View File

@@ -0,0 +1,202 @@
(ns metabase-enterprise.metabot-v3.client
(:require
[clj-http.client :as http]
[clojure.string :as str]
[malli.core :as mc]
[malli.transform :as mtx]
[metabase-enterprise.metabot-v3.client.schema :as metabot-v3.client.schema]
[metabase-enterprise.metabot-v3.context :as metabot-v3.context]
[metabase-enterprise.metabot-v3.util :as metabot-v3.u]
[metabase.api.common :as api]
[metabase.models.setting :refer [defsetting]]
[metabase.premium-features.core :as premium-features]
[metabase.public-settings :as public-settings]
[metabase.util :as u]
[metabase.util.i18n :refer [deferred-tru]]
[metabase.util.json :as json]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.o11y :refer [with-span]]))
(set! *warn-on-reflection* true)
(defsetting ai-proxy-base-url
(deferred-tru "URL for the a AI Proxy service")
:type :string
:encryption :no
:default "http://localhost:8000"
:visibility :internal
:export? false)
(def ^:dynamic ^:private *debug* false)
(defn- ->json-bytes ^bytes [x]
(with-open [os (java.io.ByteArrayOutputStream.)
w (java.io.OutputStreamWriter. os)]
(json/encode-to x w nil)
(.toByteArray os)))
(defn- request-headers
[]
{"Accept" "application/json"
"Content-Type" "application/json;charset=UTF-8"
"x-metabase-instance-token" (premium-features/premium-embedding-token)})
(defn- build-request-options [body]
(merge
{:headers (request-headers)
:body (->json-bytes body)
:follow-redirects true
:throw-exceptions false}
(when *debug*
{:debug true})))
(mu/defn- maybe-parse-response-body-as-json :- :map
[response :- :map]
(let [json? (some-> (get-in response [:headers "Content-Type"]) (str/starts-with? "application/json"))]
(cond-> response
json? (update :body #(json/decode % true)))))
(defn- post! [url options]
(let [response-metadata (promise)
response-status (promise)]
(with-span :info {:name :metabot-v3.client/request
:url url
:metadata response-metadata
:status response-status}
(u/prog1 (maybe-parse-response-body-as-json (http/post url options))
(deliver response-metadata (some-> <> :body :metadata))
(deliver response-status (some-> <> :status))))))
(defn- agent-v2-endpoint-url []
(str (ai-proxy-base-url) "/v2/agent"))
(defn- metric-selection-endpoint-url []
(str (ai-proxy-base-url) "/v1/select-metric"))
(defn- find-outliers-endpoint-url []
(str (ai-proxy-base-url) "/v1/find-outliers"))
(defn- fix-sql-endpoint []
(str (ai-proxy-base-url) "/v1/sql/fix"))
(defn- generate-sql-endpoint []
(str (ai-proxy-base-url) "/v1/sql/generate"))
(mu/defn request :- ::metabot-v3.client.schema/ai-proxy.response-v2
"Make a V2 request to the AI Proxy."
[{:keys [context messages conversation-id session-id state]}
:- [:map
[:context :map]
[:messages ::metabot-v3.client.schema/messages]
[:conversation-id :string]
[:session-id :string]
[:state :map]]]
(premium-features/assert-has-feature :metabot-v3 "MetaBot")
(try
(let [url (agent-v2-endpoint-url)
body (-> {:messages messages
:context context}
(metabot-v3.u/recursive-update-keys metabot-v3.u/safe->snake_case_en)
(assoc :conversation_id conversation-id
:state state
:user_id api/*current-user-id*))
_ (metabot-v3.context/log body :llm.log/be->llm)
_ (log/debugf "V2 request to AI Proxy:\n%s" (u/pprint-to-str body))
options (cond-> {:headers {"Accept" "application/json"
"Content-Type" "application/json;charset=UTF-8"
"x-metabase-instance-token" (premium-features/premium-embedding-token)
"x-metabase-session-token" session-id
"x-metabase-url" (public-settings/site-url)}
:body (->json-bytes body)
:throw-exceptions false}
*debug* (assoc :debug true))
response (post! url options)]
(metabot-v3.context/log (:body response) :llm.log/llm->be)
(log/debugf "Response from AI Proxy:\n%s" (u/pprint-to-str (select-keys response #{:body :status :headers})))
(if (= (:status response) 200)
(u/prog1 (mc/decode ::metabot-v3.client.schema/ai-proxy.response-v2
(:body response)
(mtx/transformer
(mtx/json-transformer)
{:name :api-response}
(mtx/key-transformer {:decode u/->kebab-case-en})))
(log/debugf "Response (decoded):\n%s" (u/pprint-to-str <>)))
(throw (ex-info (format "Error: unexpected status code: %d %s" (:status response) (:reason-phrase response))
{:request (assoc options :body body)
:response response}))))
(catch Throwable e
(throw (ex-info (format "Error in request to AI Proxy: %s" (ex-message e)) {} e)))))
(mu/defn select-metric-request
"Make a request to AI Service to select a metric."
[metrics :- [:sequential ::metabot-v3.client.schema/metric]
query :- :string]
(try
(let [url (metric-selection-endpoint-url)
body {:metrics metrics
:query query}
options (build-request-options body)
response (post! url options)]
(if (= (:status response) 200)
(u/prog1 (:body response)
(log/debugf "Response:\n%s" (u/pprint-to-str <>)))
(throw (ex-info (format "Error: unexpected status code: %d %s" (:status response) (:reason-phrase response))
{:request (assoc options :body body)
:response response}))))
(catch Throwable e
(throw (ex-info (format "Error in request to AI Service: %s" (ex-message e))
{}
e)))))
(mu/defn find-outliers-request
"Make a request to AI Service to find outliers"
[values :- [:sequential [:map [:dimension :any] [:value :any]]]]
(try
(let [url (find-outliers-endpoint-url)
body {:values values}
options (build-request-options body)
response (post! url options)]
(if (= (:status response) 200)
(u/prog1 (:body response)
(log/debugf "Response:\n%s" (u/pprint-to-str <>)))
(throw (ex-info (format "Error: unexpected status code: %d %s" (:status response) (:reason-phrase response))
{:request (assoc options :body body)
:response response}))))
(catch Throwable e
(throw (ex-info (format "Error in request to AI service: %s" (ex-message e))
{}
e)))))
(mu/defn fix-sql
"Ask the AI service to propose fixes a SQL query and a given error."
[body :- [:map
[:sql :string]
[:dialect :keyword]
[:error_message :string]
[:schema_ddl {:optional true} :string]]]
(let [url (fix-sql-endpoint)
options (build-request-options body)
response (post! url options)]
(if (= (:status response) 200)
(:body response)
(throw (ex-info (format "Error in request to AI service: unexpected status code: %d %s"
(:status response) (:reason-phrase response))
{:request (assoc options :body body)
:response response})))))
(mu/defn generate-sql
"Ask the AI service to generate SQL given instructions, dialect, and an optional schema_ddl."
[body :- [:map
[:instructions :string]
[:dialect :keyword]
[:schema_ddl {:optional true} :string]]]
(let [url (generate-sql-endpoint)
options (build-request-options body)
response (post! url options)]
(if (= (:status response) 200)
(:body response)
(throw (ex-info (format "Error calling AI service for SQL generation: %d %s"
(:status response) (:reason-phrase response))
{:request (assoc options :body body)
:response response})))))

View File

@@ -0,0 +1,41 @@
(ns metabase-enterprise.metabot-v3.client.schema
(:require
[metabase-enterprise.metabot-v3.tools.interface :as metabot-v3.tools.interface]
[metabase.util :as u]
[metabase.util.malli.registry :as mr]))
(mr/def ::role
[:enum
{:encode/api-request u/->snake_case_en
:decode/api-request keyword}
:system :user :assistant :tool])
(mr/def ::message.tool-call
[:map
[:id {:description "Internal ID used by the LLM."} :string]
[:name ::metabot-v3.tools.interface/metadata.name]
[:arguments [:or :string [:map-of ::metabot-v3.tools.interface/metadata.parameter.name :any]]]])
(mr/def ::message
[:map
#_{:decode/api-response #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->kebab-case-en)
:encode/api-request #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->snake_case_en)}
[:role ::role]
[:content {:optional true} [:maybe :string]]
[:tool-calls {:optional true} [:maybe [:sequential ::message.tool-call]]]])
(mr/def ::messages
[:sequential ::message])
(mr/def ::metric
"A metric as sent to the AI Service"
[:map
[:id integer?]
[:name :string]
[:description [:maybe :string]]])
(mr/def ::ai-proxy.response-v2
"Schema of the V2 AI agent response."
[:map
[:messages ::messages]
[:state :map]])

View File

@@ -0,0 +1,56 @@
(ns metabase-enterprise.metabot-v3.context
(:require
[clojure.java.io :as io]
[metabase.config :as config]
[metabase.util.json :as json]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr])
(:import
(java.time OffsetDateTime)
(java.time.format DateTimeFormatter)))
(set! *warn-on-reflection* true)
;; todo: remove this before shipping. This is quick and dirty
(defn log
"Log a payload. Direction should be `:llm.log/fe->be` or similar. This should not be shipping in this form. This is
not a rolling log, or logging to the console. This pretty prints to the file `llm-payloads`. It is explicitly useful
for dev work and not production.
Examples calls are ;; using doto
(doto (request message context history)
(metabot-v3.context/log :llm.log/be->fe))
;; or just regularly
(metabot-v3.context/log _body :llm.log/fe->be)"
[payload direction]
(when config/is-dev?
(let [directions {:llm.log/fe->be "\"FE -----------------> BE\""
:llm.log/be->llm "\"BE -----------------> LLM\""
:llm.log/llm->be "\"LLM -----------------> BE\""
:llm.log/be->fe "\"BE -----------------> FE\""}]
(with-open [^java.io.BufferedWriter w (io/writer "llm-payloads" :append true)]
(io/copy (directions direction (name direction)) w)
(.newLine w)
(let [payload' (json/encode payload {:pretty true})]
(io/copy payload' w))
(.newLine w)
(.newLine w)))))
(mr/def ::context
[:map-of :keyword :any])
(defn- set-user-time
[context {:keys [date-format] :or {date-format DateTimeFormatter/ISO_INSTANT}}]
(let [offset-time (or (some-> context :current_time_with_timezone OffsetDateTime/parse)
(OffsetDateTime/now))]
(-> context
(dissoc :current_time_with_timezone)
(assoc :current_user_time (.format ^DateTimeFormatter date-format offset-time)))))
(mu/defn create-context :- ::context
"Create a tool context."
([context :- ::context]
(create-context context nil))
([context :- ::context
opts :- [:maybe [:map-of :keyword :any]]]
(set-user-time context opts)))

View File

@@ -0,0 +1,13 @@
(ns metabase-enterprise.metabot-v3.core
"API namespace for the `metabase-enterprise.metabot-v3` module."
(:require
[metabase-enterprise.metabot-v3.api]
[metabase-enterprise.metabot-v3.client]
[potemkin :as p]))
(p/import-vars
[metabase-enterprise.metabot-v3.api
routes]
[metabase-enterprise.metabot-v3.client
fix-sql
generate-sql])

View File

@@ -0,0 +1,253 @@
(ns metabase-enterprise.metabot-v3.dummy-tools
(:require
[medley.core :as m]
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription]
[metabase-enterprise.metabot-v3.tools.filters]
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
[metabase.api.common :as api]
[metabase.legacy-mbql.normalize :as mbql.normalize]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.metadata.jvm :as lib.metadata.jvm]
[metabase.lib.types.isa :as lib.types.isa]
[metabase.lib.util :as lib.util]
[metabase.models.field-values :as field-values]
[metabase.util :as u]
[toucan2.core :as t2]))
(defn get-current-user
"Get information about the current user."
[]
(if-let [{:keys [id email first_name last_name]}
(or (some-> api/*current-user* deref)
(t2/select-one [:model/User :id :email :first_name :last_name] api/*current-user-id*))]
{:structured-output {:id id
:type :user
:name (str first_name " " last_name)
:email-address email}}
{:output "current user not found"}))
(defn get-dashboard-details
"Get information about the dashboard with ID `dashboard-id`."
[{:keys [dashboard-id]}]
(if-let [dashboard (t2/select-one [:model/Dashboard :id :description :name] dashboard-id)]
{:structured-output (assoc dashboard :type :dashboard)}
{:output "dashboard not found"}))
(defn- add-field-values
[cols]
(if-let [field-ids (seq (keep :id cols))]
(let [id->values (field-values/batched-get-latest-full-field-values field-ids)]
(map #(m/assoc-some % :field-values (-> % :id id->values :values)) cols))
cols))
(defn- add-table-reference
[query col]
(cond-> col
(and (:fk-field-id col)
(:table-id col))
(assoc :table-reference (-> (lib/display-name query (lib.metadata/field query (:fk-field-id col)))
lib.util/strip-id))))
(defn metric-details
"Get metric details as returned by tools."
([id]
(when-let [card (metabot-v3.tools.u/get-card id)]
(metric-details card (lib.metadata.jvm/application-database-metadata-provider (:database_id card)))))
([card metadata-provider]
(let [id (:id card)
metric-query (lib/query metadata-provider (lib.metadata/card metadata-provider id))
breakouts (lib/breakouts metric-query)
base-query (lib/remove-all-breakouts metric-query)
visible-cols (->> (lib/visible-columns base-query)
(map #(add-table-reference base-query %)))
filterable-cols (->> (lib/filterable-columns base-query)
add-field-values
(map #(add-table-reference base-query %)))
default-temporal-breakout (->> breakouts
(map #(lib/find-matching-column % visible-cols))
(m/find-first lib.types.isa/temporal?))
field-id-prefix (metabot-v3.tools.u/card-field-id-prefix id)]
{:id id
:type :metric
:name (:name card)
:description (:description card)
:default_time_dimension_field_id (when default-temporal-breakout
(-> (metabot-v3.tools.u/->result-column
metric-query default-temporal-breakout visible-cols field-id-prefix)
:field_id))
:queryable_dimensions (mapv #(metabot-v3.tools.u/->result-column metric-query % visible-cols field-id-prefix)
filterable-cols)})))
(comment
(binding [api/*current-user-permissions-set* (delay #{"/"})]
(metric-details 135))
-)
(defn- convert-metric
[db-metric metadata-provider]
(-> db-metric (metric-details metadata-provider)
(select-keys [:id :type :name :description :default_time_dimension_field_id])))
(declare ^:private table-details)
(defn- foreign-key-tables
[metadata-provider fields]
(when-let [target-field-ids (->> fields
(into #{} (keep :fk-target-field-id))
not-empty)]
(let [table-ids (t2/select-fn-set :table_id :model/Field :id [:in target-field-ids])]
(lib.metadata/bulk-metadata metadata-provider :metadata/table table-ids)
(->> table-ids
(into [] (keep #(table-details % {:include-foreign-key-tables? false
:metadata-provider metadata-provider})))
not-empty))))
(defn- table-details
[id {:keys [include-foreign-key-tables? metadata-provider]}]
(when-let [base (if metadata-provider
(lib.metadata/table metadata-provider id)
(metabot-v3.tools.u/get-table id :db_id :description))]
(let [mp (or metadata-provider
(lib.metadata.jvm/application-database-metadata-provider (:db_id base)))
table-query (lib/query mp (lib.metadata/table mp id))
cols (-> (lib/returned-columns table-query)
add-field-values)
field-id-prefix (metabot-v3.tools.u/table-field-id-prefix id)]
(-> {:id id
:type :table
:fields (into [] (map-indexed #(metabot-v3.tools.u/->result-column table-query %2 %1 field-id-prefix)) cols)
:name (lib/display-name table-query)}
(m/assoc-some :description (:description base)
:metrics (not-empty (mapv #(convert-metric % mp) (lib/available-metrics table-query)))
:queryable-foreign-key-tables (when include-foreign-key-tables?
(foreign-key-tables mp cols)))))))
(defn- card-details
"Get details for a card."
([id]
(when-let [card (metabot-v3.tools.u/get-card id)]
(card-details card (lib.metadata.jvm/application-database-metadata-provider (:database_id card)))))
([base metadata-provider]
(let [id (:id base)
card-metadata (lib.metadata/card metadata-provider id)
dataset-query (get card-metadata :dataset-query)
;; pivot questions have strange result-columns so we work with the dataset-query
card-type (:type base)
card-query (lib/query metadata-provider (if (and (#{:question} card-type)
(#{:pivot} (:display base))
(#{:query} (:type dataset-query)))
dataset-query
card-metadata))
cols (-> (lib/returned-columns card-query)
add-field-values)
field-id-prefix (metabot-v3.tools.u/card-field-id-prefix id)]
(-> {:id id
:type card-type
:fields (into [] (map-indexed #(metabot-v3.tools.u/->result-column card-query %2 %1 field-id-prefix)) cols)
:name (lib/display-name card-query)
:queryable-foreign-key-tables (vec (foreign-key-tables metadata-provider cols))}
(m/assoc-some :description (:description base)
:metrics (not-empty (mapv #(convert-metric % metadata-provider) (lib/available-metrics card-query))))))))
(defn- cards-details
[card-type database-id cards]
(let [mp (lib.metadata.jvm/application-database-metadata-provider database-id)
detail-fn (case card-type
:metric metric-details
:model card-details)]
(lib.metadata/bulk-metadata mp :metadata/card (map :id cards))
(map #(-> % (detail-fn mp) (assoc :type card-type)) cards)))
(defn answer-sources
"Get the details metrics and models from the collection with name `collection-name`."
[metabot-collection]
(let [metrics-and-models (metabot-v3.tools.u/get-metrics-and-models metabot-collection)
{metrics :metric, models :model}
(->> (for [[[card-type database-id] cards] (group-by (juxt :type :database_id) metrics-and-models)
detail (cards-details card-type database-id cards)]
detail)
(group-by :type))]
{:structured-output {:metrics (vec metrics)
:models (vec models)}}))
(comment
(binding [api/*current-user-permissions-set* (delay #{"/"})
api/*current-user-id* 2
api/*is-superuser?* true]
#_(table-details 30 nil)
(card-details 110)
#_(metric-details 108)
#_(answer-sources "__METABOT__"))
-)
(defn get-table-details
"Get information about the table or model with ID `table-id`.
`table-id` is string either encoding an integer that is the ID of a table
or a string containing the prefix card__ and the ID of a model (card) as suffix.
Alternatively, `table-id` can be an integer ID of a table.
`model-id` is an integer ID of a model (card). Exactly one of `table-id` or `model-id`
should be supplied."
[{:keys [model-id table-id]}]
(let [details (cond
(int? model-id) (card-details model-id)
(int? table-id) (table-details table-id {:include-foreign-key-tables? true})
(string? table-id) (if-let [[_ card-id] (re-matches #"card__(\d+)" table-id)]
(card-details (parse-long card-id))
(if (re-matches #"\d+" table-id)
(table-details (parse-long table-id) {:include-foreign-key-tables? true})
"invalid table_id"))
:else "invalid arguments")]
(if (map? details)
{:structured-output details}
{:output (or details "table not found")})))
(comment
(binding [api/*current-user-permissions-set* (delay #{"/"})
api/*current-user-id* 2
api/*is-superuser?* true]
(let [id #_"card__137" #_"card__136" #_27 "27"]
(get-table-details {:table-id id})))
-)
(defn get-metric-details
"Get information about the metric with ID `metric-id`."
[{:keys [metric-id]}]
(let [details (if (int? metric-id)
(metric-details metric-id)
"invalid metric_id")]
(if (map? details)
{:structured-output details}
{:output (or details "metric not found")})))
(defn get-report-details
"Get information about the report (card) with ID `report-id`."
[{:keys [report-id]}]
(let [details (if (int? report-id)
(let [details (card-details report-id)]
(some-> details
(select-keys [:id :type :description :name])
(assoc :result_columns (:fields details))))
"invalid report_id")]
(if (map? details)
{:structured-output details}
{:output (or details "report not found")})))
(defn- execute-query
[query-id legacy-query]
(let [legacy-query (mbql.normalize/normalize legacy-query)
field-id-prefix (metabot-v3.tools.u/query-field-id-prefix query-id)
mp (lib.metadata.jvm/application-database-metadata-provider (:database legacy-query))
query (lib/query mp legacy-query)
returned-cols (lib/returned-columns query)]
{:type :query
:query_id query-id
:query legacy-query
:result_columns (into []
(map-indexed #(metabot-v3.tools.u/->result-column query %2 %1 field-id-prefix))
returned-cols)}))
(defn get-query-details
"Get the details of a (legacy) query."
[{:keys [query]}]
{:structured-output (execute-query (u/generate-nano-id) query)})

View File

@@ -0,0 +1,45 @@
(ns metabase-enterprise.metabot-v3.envelope
"(Because 'context' was already taken.)
The 'envelope' holds the context for our conversation with the LLM. Specifically, it bundles up the history, and the
context into one convenient location, with a simple API for querying and modifying.")
(defn history
"Gets the history from the envelope."
[e]
(:history e))
(defn state
"Gets the state from the envelope."
[e]
(:state e))
(defn- message->reactions
[msg]
(let [message-reaction (when-let [message (not-empty (:content msg))]
{:type :metabot.reaction/message
:repl/message-color :green
:repl/message-emoji "🤖"
:message message})
navigate-reaction (when-let [nav-path (:navigate-to msg)]
{:type :metabot.reaction/redirect
:url nav-path})]
(filter some? [message-reaction navigate-reaction])))
(defn reactions
"Gets the reactions from the LLM."
[messages]
(->> messages
(filter #(= (:role %) :assistant))
(mapcat message->reactions)
(into [])))
(defn user-message
"Create a user message structure with `msg` as content."
[msg]
{:role :user :content msg})
(defn add-message
"Given a raw message, add it to the envelope."
[e msg]
(update e :history conj msg))

View File

@@ -0,0 +1,85 @@
(ns metabase-enterprise.metabot-v3.reactions
"Schemas for various reactions.
All reactions must have a `:type` with the namespace `metabot.reaction`, but declaring a schema for it is optional.
If you want to declare a schema, you can use [[defreaction]]."
(:require
[malli.core :as mc]
[metabase.util :as u]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]))
(mr/def ::reaction-type
"A MetaBot v3 reaction type keyword e.g. `:metabot.reaction/message`"
[:fn
{:error/message "Reaction type must be a kebab-case keyword starting whose namespace is `metabot.reaction`."}
(fn [x]
(and (qualified-keyword? x)
(= (u/->kebab-case-en (u/qualified-name x)) (u/qualified-name x))
(= (namespace x) "metabot.reaction")))])
;;; TODO -- need Kondo hook that registers the keyword
(mu/defn defreaction
"Declare a new reaction type and the schema for it.
The schema name matches the reaction-name."
[reaction-name :- ::reaction-type
schema]
(derive reaction-name :metabot/registered-action)
(mr/register! reaction-name schema))
(defn- known-reaction-types
"Reaction types with schemas that were declared with [[defreaction]]."
[]
(descendants :metabot/registered-action))
;;; this is just a placeholder so LSP can register the place it lives for jump-to-definition functionality. Actual
;;; schema gets created below by [[reaction-schema]] and [[update-reaction-schema!]]
(mr/def ::reaction any?)
(defn- reaction-schema
"Build the schema for `::reaction`."
[]
[:and
[:map
[:type ::reaction-type]]
(into [:multi
{:dispatch :type}
[::mc/default :any]]
(map (fn [reaction-type]
[reaction-type reaction-type]))
(known-reaction-types))])
(defn- update-reaction-schema! []
(log/debug "Updating reaction schema")
(mr/register! :metabase-enterprise.metabot-v3.reactions/reaction (reaction-schema)))
(update-reaction-schema!)
;;; when the descendants of `:metabot/registered-action` change update the `::reaction` schema
(add-watch #'clojure.core/global-hierarchy
::update-reaction-schema
(fn [_key _ref old-hierarchy new-hierarchy]
(when-not (= (descendants old-hierarchy :metabot/registered-action)
(descendants new-hierarchy :metabot/registered-action))
(update-reaction-schema!))))
;;;;
;;;; Reaction definitions
;;;;
(defreaction :metabot.reaction/message
[:map
[:type [:= :metabot.reaction/message]]
[:message :string]])
(defreaction :metabot.reaction/run-query
[:map
[:type [:= :metabot.reaction/run-query]]
[:dataset_query :map]])
(defreaction :metabot.reaction/redirect
[:map
[:type [:= :metabot.reaction/redirect]]
[:url :string]])

View File

@@ -0,0 +1,127 @@
(ns metabase-enterprise.metabot-v3.repl
"Basic user message => AI Proxy => response chat flow. This namespace implements a text-based REPL you can use from
Clojure or the CLI with
clj -X:ee:metabot-v3/repl"
(:require
[clojure.string :as str]
[metabase-enterprise.metabot-v3.api :as api]
[metabase-enterprise.metabot-v3.reactions :as metabot-v3.reactions]
[metabase.db :as mdb]
[metabase.util :as u]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defmulti ^:private handle-reaction
{:arglists '([reaction])}
:type)
(defmethod handle-reaction :metabot.reaction/message
[{:keys [message], repl-emoji :repl/emoji, repl-message-color :repl/message-color, :as _reaction}]
(let [message (cond->> message
repl-emoji (str repl-emoji " ")
repl-message-color (u/colorize repl-message-color))]
#_{:clj-kondo/ignore [:discouraged-var]}
(println message)))
(defmethod handle-reaction :default
[reaction]
#_{:clj-kondo/ignore [:discouraged-var]}
(println (u/format-color :magenta "<REACTION>\n%s" (u/pprint-to-str reaction))))
(mu/defn- handle-reactions
[reactions :- [:sequential ::metabot-v3.reactions/reaction]]
(doseq [reaction reactions]
(handle-reaction reaction)))
(defn user-repl
"REPL for interacting with MetaBot."
([history context conversation-id] (user-repl history context conversation-id {}))
([history context conversation-id state]
(when-let [{history' :history, state' :state} (try
(when-let [input (try
#_{:clj-kondo/ignore [:discouraged-var]}
(print "\n> ")
(flush)
(read-line)
(catch Throwable _))]
#_{:clj-kondo/ignore [:discouraged-var]}
(println "🗨 " (u/colorize :blue input))
(when (and (not (#{"quit" "exit" "bye" "goodbye" "\\q"} input))
(not (str/blank? input)))
(let [result (api/request input context history conversation-id state)]
(handle-reactions (:reactions result))
result)))
(catch Throwable e
#_{:clj-kondo/ignore [:discouraged-var]}
(println (u/pprint-to-str :red e))
history))]
(recur history' context conversation-id state'))))
(defn user-repl-cli
"CLI entrypoint for using the MetaBot REPL.
clj -X:ee:metabot-v3/repl"
[_options]
(mdb/setup-db! :create-sample-content? false)
#_{:clj-kondo/ignore [:discouraged-var]}
(println "Starting MetaBot REPL... 🤖")
(user-repl [] {} (str (random-uuid)))
(System/exit 0))
(comment
(require 'metabase.api.common)
(binding [metabase.api.common/*current-user-permissions-set* (delay #{"/"})
metabase.api.common/*current-user-id* 2
metabase.api.common/*is-superuser?* true]
(user-repl [] {} (str (random-uuid))))
(defn test-context
[]
(let [test-query {:dataset_query {:database 1, :type "query", :query {:source-table 27}},
:display "table",
:visualization_settings {},
:type "question"}]
{:user_is_viewing [{:type :dashboard
:id 5
:parameters []
:is-embedded false}
{:type :table
:id 27}
{:type :model
:id 18}
{:type :metric
:id 135}
{:type :report
:id 43}
{:type :adhoc
:query test-query}]}))
(defn- user-repl-with-context [context]
(user-repl [] context (str (random-uuid))))
#_{:clj-kondo/ignore [:metabase/modules]}
(require '[metabase.test :as mt])
;; Discounted orders only
(mt/with-test-user :crowberto
(user-repl-with-context
{:user_is_viewing [{:type :table :ref 5}]}))
;; subscribe me to this dashboard every week on monday
(mt/with-test-user :crowberto
(user-repl-with-context
{:user_is_viewing [{:type :dashboard :ref 11}]}))
;; filter on the last 6 months
;; actually, only filter for current year
(mt/with-test-user :crowberto
(user-repl-with-context
{:user_is_viewing [{:type :report :ref 111}]}))
;; any outliers?
;; breakout by plan
(mt/with-test-user :crowberto
(user-repl-with-context
{:user_is_viewing [{:type :metric :id 122}]})))

View File

@@ -0,0 +1,689 @@
(ns metabase-enterprise.metabot-v3.tools.api
"Code for handling tool requests from the AI service."
(:require
[buddy.core.hash :as buddy-hash]
[buddy.sign.jwt :as jwt]
[clj-time.core :as time]
[malli.core :as mc]
[malli.transform :as mtx]
[medley.core :as m]
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
[metabase-enterprise.metabot-v3.context :as metabot-v3.context]
[metabase-enterprise.metabot-v3.dummy-tools :as metabot-v3.dummy-tools]
[metabase-enterprise.metabot-v3.envelope :as envelope]
[metabase-enterprise.metabot-v3.reactions]
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription :as metabot-v3.tools.create-dashboard-subscription]
[metabase-enterprise.metabot-v3.tools.filters :as metabot-v3.tools.filters]
[metabase-enterprise.metabot-v3.tools.find-metric :as metabot-v3.tools.find-metric]
[metabase-enterprise.metabot-v3.tools.find-outliers :as metabot-v3.tools.find-outliers]
[metabase-enterprise.metabot-v3.tools.generate-insights :as metabot-v3.tools.generate-insights]
[metabase-enterprise.metabot-v3.util :as metabot-v3.u]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :as api.routes.common]
[metabase.legacy-mbql.schema :as mbql.s]
[metabase.lib.schema.temporal-bucketing :as lib.schema.temporal-bucketing]
[metabase.models.setting :as setting :refer [defsetting]]
[metabase.request.core :as request]
[metabase.util.i18n :refer [deferred-tru]]
[metabase.util.log :as log]
[metabase.util.malli.registry :as mr]
[metabase.util.malli.schema :as ms]))
(defsetting site-uuid-for-metabot-tools
"UUID that we use for encrypting JWT tokens given to the AI service to make callbacks with."
:encryption :when-encryption-key-set
:visibility :internal
:sensitive? true
:feature :metabot-v3
:doc false
:export? false
:base setting/uuid-nonce-base)
(defsetting metabot-ai-service-token-ttl
(deferred-tru "The number of seconds the tokens passed to AI service should be valid.")
:type :integer
:visibility :settings-manager
:default 180
:feature :metabot-v3
:doc false
:export? true
:audit :never)
(defn- get-ai-service-token
[user-id]
(let [secret (buddy-hash/sha256 (site-uuid-for-metabot-tools))
claims {:user user-id, :exp (time/plus (time/now) (time/seconds (metabot-ai-service-token-ttl)))}]
(jwt/encrypt claims secret {:alg :dir, :enc :a128cbc-hs256})))
(defn- decode-ai-service-token
[token]
(try
(when (string? token)
(-> token
(jwt/decrypt (buddy-hash/sha256 (site-uuid-for-metabot-tools)))
:user))
(catch Exception e
(log/error e "Bad AI service token")
nil)))
(defn handle-envelope
"Executes the AI loop in the context of a new session. Returns the response of the AI service."
[e]
(let [session-id (get-ai-service-token api/*current-user-id*)]
(try
(metabot-v3.client/request (assoc e :session-id session-id))
(catch Exception ex
(let [d (ex-data ex)]
(if-let [assistant-message (:assistant-message d)]
(envelope/add-message (or (:envelope d) e)
{:role :assistant
:content assistant-message})
(throw ex)))))))
(mr/def ::bucket
(into [:enum {:error/message "Valid bucket"}]
(map name)
lib.schema.temporal-bucketing/ordered-datetime-bucketing-units))
(mr/def ::existence-filter
[:map
[:field_id :string]
[:operation [:enum
"is-null" "is-not-null"
"string-is-empty" "string-is-not-empty"
"is-true" "is-false"]]])
(mr/def ::temporal-extraction-filter
[:map
[:field_id :string]
[:operation [:enum
"year-equals" "year-not-equals"
"quarter-equals" "quarter-not-equals"
"month-equals" "month-not-equals"
"day-of-week-equals" "day-of-week-not-equals"
"hour-equals" "hour-not-equals"
"minute-equals" "minute-not-equals"
"second-equals" "second-not-equals"]]
[:value :int]])
(mr/def ::disjunctive-temporal-extraction-filter
[:map
[:field_id :string]
[:operation [:enum
"year-equals" "year-not-equals"
"quarter-equals" "quarter-not-equals"
"month-equals" "month-not-equals"
"day-of-week-equals" "day-of-week-not-equals"
"hour-equals" "hour-not-equals"
"minute-equals" "minute-not-equals"
"second-equals" "second-not-equals"]]
[:values [:sequential :int]]])
(mr/def ::temporal-filter
[:map
[:field_id :string]
[:bucket {:optional true} ::bucket]
[:operation [:enum
"equals" "not-equals"
"greater-than" "greater-than-or-equal"
"less-than" "less-than-or-equal"
"date-equals" "date-not-equals"
"date-before" "date-on-or-before"
"date-after" "date-on-or-after"]]
[:value [:or :string :int]]])
(mr/def ::disjunctive-temporal-filter
[:map
[:field_id :string]
[:bucket {:optional true} ::bucket]
[:operation [:enum
"equals" "not-equals"
"greater-than" "greater-than-or-equal"
"less-than" "less-than-or-equal"]]
[:values [:sequential [:or :string :int]]]])
(mr/def ::string-filter
[:map
[:field_id :string]
[:operation [:enum
"equals" "not-equals"
"string-equals" "string-not-equals"
"string-contains" "string-not-contains"
"string-starts-with" "string-ends-with"]]
[:value :string]])
(mr/def ::disjunctive-string-date-filter
[:map
[:field_id :string]
[:operation [:enum
"equals" "not-equals"
"string-contains" "string-not-contains"
"string-starts-with" "string-ends-with"]]
[:values [:sequential :string]]])
(mr/def ::numeric-filter
[:map
[:field_id :string]
[:operation [:enum
"equals" "not-equals"
"greater-than" "greater-than-or-equal"
"less-than" "less-than-or-equal"
"number-equals" "number-not-equals"
"number-greater-than" "number-greater-than-or-equal"
"number-less-than" "number-less-than-or-equal"]]
[:value [:or :int :double]]])
(mr/def ::disjunctive-numeric-filter
[:map
[:field_id :string]
[:operation [:enum
"equals" "not-equals"
"number-equals" "number-not-equals"]]
[:values [:sequential [:or :int :double]]]])
(mr/def ::filter
[:or
::existence-filter
::temporal-extraction-filter ::disjunctive-temporal-extraction-filter
::temporal-filter ::disjunctive-temporal-filter
::string-filter ::disjunctive-string-date-filter
::numeric-filter ::disjunctive-numeric-filter])
(mr/def ::group-by
[:map
[:field_id :string]
[:field_granularity {:optional true} [:maybe [:enum "day" "week" "month" "quarter" "year"]]]])
(mr/def ::query-metric-arguments
[:map
{:encode/tool-api-request #(#_metabot-v3.u/recursive-update-keys update-keys % metabot-v3.u/safe->kebab-case-en)}
[:metric_id :int]
[:filters {:optional true} [:maybe [:sequential ::filter]]]
[:group_by {:optional true} [:maybe [:sequential ::group-by]]]])
(mr/def ::aggregation
[:map
[:field_id :string]
[:bucket {:optional true} ::bucket]
[:function [:enum "avg" "count" "count-distinct" "max" "min" "sum"]]])
(mr/def ::field
[:map
[:field_id :string]
[:bucket {:optional true} ::bucket]])
(mr/def ::query-model-arguments
[:map
{:encode/tool-api-request #(#_metabot-v3.u/recursive-update-keys update-keys % metabot-v3.u/safe->kebab-case-en)}
[:model_id :int]
[:fields {:optional true} [:maybe [:sequential {:min 1} ::field]]]
[:filters {:optional true} [:maybe [:sequential ::filter]]]
[:aggregations {:optional true} [:maybe [:sequential ::aggregation]]]
[:group_by {:optional true} [:maybe [:sequential ::group-by]]]])
(mr/def ::field-type
[:enum "boolean" "date" "datetime" "time" "number" "string"])
(mr/def ::column
[:map
[:field_id :string]
[:name :string]
[:type [:maybe ::field-type]]
[:description {:optional true} [:maybe :string]]
[:semantic_type {:optional true} [:maybe :string]]
[:field_values {:optional true} [:or
[:sequential :boolean]
[:sequential :double]
[:sequential :int]
[:sequential :string]]]])
(mr/def ::columns
[:sequential ::column])
(mr/def ::filtering-result
[:or
[:map
{:decode/tool-api-response #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->snake_case_en)}
[:structured_output
[:map
[:type [:= :query]]
[:query_id :string]
[:query mbql.s/Query]
[:result_columns [:sequential ::column]]]]]
[:map
[:output :string]]])
(mr/def ::tool-request [:map [:conversation_id ms/UUIDString]])
(mr/def ::subscription-schedule
(let [days ["sunday" "monday" "tuesday" "wednesday" "thursday" "friday" "saturday"]]
[:or
[:map
[:frequency [:= "hourly"]]]
[:map
[:frequency [:= "daily"]]
[:hour :int]]
[:map
[:frequency [:= "weekly"]]
[:hour :int]
[:day_of_week (into [:enum] days)]]
[:map
[:frequency [:= "monthly"]]
[:hour :int]
[:day_of_month (into [:enum "first-calendar-day" "middle-of-month" "last-calendar-day"]
(for [fl ["first" "last"]
day days]
(str fl "-" day)))]]]))
(mr/def ::create-dashboard-subscription-arguments
[:map
{:encode/tool-api-request #(#_metabot-v3.u/recursive-update-keys update-keys % metabot-v3.u/safe->kebab-case-en)}
[:dashboard_id :int]
[:email :string]
[:schedule ::subscription-schedule]])
(mr/def ::filter-records-arguments
[:map
{:encode/tool-api-request #(#_metabot-v3.u/recursive-update-keys update-keys % metabot-v3.u/safe->kebab-case-en)}
[:data_source [:or
[:map
[:query [:map
[:database :int]]]
[:query_id {:optional true} :string]]
[:map [:report_id :int]]
[:map [:table_id :string]]]]
[:filters [:sequential ::filter]]])
(mr/def ::basic-metric
[:map
[:id :int]
[:type [:= :metric]]
[:name :string]
[:description {:optional true} [:maybe :string]]
[:default_time_dimension_field_id {:optional true} [:maybe :string]]])
(mr/def ::full-metric
[:merge ::basic-metric [:map [:queryable_dimensions ::columns]]])
(mr/def ::find-metric-result
[:or
[:map
{:decode/tool-api-response #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->snake_case_en)}
[:structured_output [:map
[:id :int]
[:type [:= :metric]]
[:name :string]
[:description [:maybe :string]]
[:default_time_dimension_field_id [:maybe ::column]]
[:queryable_dimensions [:sequential ::column]]]]]
[:map [:output :string]]])
(mr/def ::find-outliers-arguments
[:map
{:encode/tool-api-request #(#_metabot-v3.u/recursive-update-keys update-keys % metabot-v3.u/safe->kebab-case-en)}
[:data_source [:or
[:map
[:query [:map
[:database :int]]]
[:query_id {:optional true} :string]
[:result_field_id :string]]
[:map
[:metric_id :int]]
[:map
[:report_id :int]
[:result_field_id :string]]
[:map
[:table_id :string]
[:result_field_id :string]]]]])
(mr/def ::find-outliers-result
[:or
[:map
{:decode/tool-api-response #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->snake_case_en)}
[:structured_output [:sequential
[:map
[:dimension :any]
[:value [:or :int :double]]]]]]
[:map [:output :string]]])
(mr/def ::generate-insights-arguments
[:map
{:encode/tool-api-request #(#_metabot-v3.u/recursive-update-keys update-keys % metabot-v3.u/safe->kebab-case-en)}
[:for [:or
[:map [:metric_id :int]]
[:map [:table_id :string]]
[:map [:report_id :int]]
[:map [:query :map]]]]])
(mr/def ::get-current-user-result
[:or
[:map
{:decode/tool-api-response #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->snake_case_en)}
[:structured_output [:map
[:id :int]
[:type [:= :user]]
[:name :string]
[:email_address :string]]]]
[:map [:output :string]]])
(mr/def ::get-dashboard-details-result
[:or
[:map
{:decode/tool-api-response #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->snake_case_en)}
[:structured_output [:map
[:id :int]
[:type [:= :dashboard]]
[:name :string]
[:description {:optional true} :string]]]]
[:map [:output :string]]])
(mr/def ::get-metric-details-result
[:or
[:map
{:decode/tool-api-response #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->snake_case_en)}
[:structured_output ::full-metric]]
[:map [:output :string]]])
(mr/def ::get-query-details-result
[:or
[:map
{:decode/tool-api-response
(fn [result]
(let [query (:query ((some-fn :structured-output :structured_output) result))]
(-> result
(m/dissoc-in [:structured-output :query] [:structured_output :query])
(metabot-v3.u/recursive-update-keys metabot-v3.u/safe->snake_case_en)
(assoc-in [:structured_output :query] query))))}
[:structured_output [:map
[:type [:= :query]]
[:query_id :string]
[:query :map]
[:result_columns ::columns]]]]
[:map [:output :string]]])
(mr/def ::get-report-details-result
[:or
[:map
{:decode/tool-api-response #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->snake_case_en)}
[:structured_output [:map
[:id :int]
[:type [:= :question]]
[:name :string]
[:description {:optional true} [:maybe :string]]
[:result_columns ::columns]]]]
[:map [:output :string]]])
(mr/def ::get-table-details-arguments
[:and
[:map
{:encode/tool-api-request #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->kebab-case-en)}
[:model_id {:optional true} :int]
[:table_id {:optional true} [:or :int :string]]]
[:fn {:error/message "Exactly one of model_id and table_id required"}
#(= (count (select-keys % [:model_id :table_id])) 1)]])
(mr/def ::basic-table
[:map
[:id :int]
[:type [:enum :model :table]]
[:name :string]
[:fields ::columns]
[:description {:optional true} [:maybe :string]]
[:metrics {:optional true} [:sequential ::basic-metric]]])
(mr/def ::full-table
[:merge
::basic-table
[:map [:queryable_foreign_key_tables {:optional true} [:sequential ::basic-table]]]])
(mr/def ::get-table-details-result
[:or
[:map
{:decode/tool-api-response #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->snake_case_en)}
[:structured_output ::full-table]]
[:map [:output :string]]])
(mr/def ::answer-sources-result
[:or
[:map
{:decode/tool-api-response #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->snake_case_en)}
[:structured_output [:map
[:metrics [:sequential ::full-metric]]
[:models [:sequential ::full-table]]]]]
[:map [:output :string]]])
(def metabot-collection-name
"The name of the collection exposed by the answer-sources tool."
"__METABOT__")
(api.macros/defendpoint :post "/answer-sources" :- [:merge ::answer-sources-result ::tool-request]
"Create a dashboard subscription."
[_route-params
_query-params
{:keys [conversation_id] :as body} :- ::tool-request]
(metabot-v3.context/log (assoc body :api :answer-sources) :llm.log/llm->be)
(doto (-> (mc/decode ::answer-sources-result
(metabot-v3.dummy-tools/answer-sources metabot-collection-name)
(mtx/transformer {:name :tool-api-response}))
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm)))
(api.macros/defendpoint :post "/create-dashboard-subscription" :- [:merge
[:map [:output :string]]
::tool-request]
"Create a dashboard subscription."
[_route-params
_query-params
{:keys [arguments conversation_id] :as body} :- [:merge
[:map [:arguments ::create-dashboard-subscription-arguments]]
::tool-request]]
(metabot-v3.context/log (assoc body :api :create-dashboard-subscription) :llm.log/llm->be)
(let [arguments (mc/encode ::create-dashboard-subscription-arguments
arguments (mtx/transformer {:name :tool-api-request}))]
(doto (-> (metabot-v3.tools.create-dashboard-subscription/create-dashboard-subscription arguments)
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm))))
(api.macros/defendpoint :post "/filter-records" :- [:merge ::filtering-result ::tool-request]
"Construct a query from a metric."
[_route-params
_query-params
{:keys [arguments conversation_id] :as body} :- [:merge
[:map [:arguments ::filter-records-arguments]]
::tool-request]]
(metabot-v3.context/log (assoc body :api :filter-records) :llm.log/llm->be)
(let [arguments (mc/encode ::filter-records-arguments
arguments (mtx/transformer {:name :tool-api-request}))]
(doto (-> (mc/decode ::filtering-result
(metabot-v3.tools.filters/filter-records arguments)
(mtx/transformer {:name :tool-api-response}))
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm))))
(api.macros/defendpoint :post "/find-metric" :- [:merge ::find-metric-result ::tool-request]
"Find a metric matching a description."
[_route-params
_query-params
{:keys [arguments conversation_id] :as body} :- [:merge
[:map [:arguments [:map [:message :string]]]]
::tool-request]]
(metabot-v3.context/log (assoc body :api :find-metric) :llm.log/llm->be)
(doto (-> (mc/decode ::find-metric-result
(metabot-v3.tools.find-metric/find-metric arguments)
(mtx/transformer {:name :tool-api-response}))
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm)))
(api.macros/defendpoint :post "/find-outliers" :- [:merge ::find-outliers-result ::tool-request]
"Find outliers in the values provided by a data source for a given column."
[_route-params
_query-params
{:keys [arguments conversation_id] :as body} :- [:merge
[:map [:arguments ::find-outliers-arguments]]
::tool-request]]
(metabot-v3.context/log (assoc body :api :find-outliers) :llm.log/llm->be)
(let [arguments (mc/encode ::find-outliers-arguments
arguments (mtx/transformer {:name :tool-api-request}))]
(doto (-> (mc/decode ::find-outliers-result
(metabot-v3.tools.find-outliers/find-outliers arguments)
(mtx/transformer {:name :tool-api-response}))
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm))))
(api.macros/defendpoint :post "/generate-insights" :- [:merge
[:map
[:output :string]
[:reactions [:sequential :metabot.reaction/redirect]]]
::tool-request]
"Generate insights."
[_route-params
_query-params
{:keys [arguments conversation_id] :as body} :- [:merge
[:map [:arguments ::generate-insights-arguments]]
::tool-request]]
(metabot-v3.context/log (assoc body :api :generate-insights) :llm.log/llm->be)
(let [arguments (mc/encode ::generate-insights-arguments
arguments (mtx/transformer {:name :tool-api-request}))]
(doto (-> (metabot-v3.tools.generate-insights/generate-insights arguments)
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm))))
(api.macros/defendpoint :post "/get-current-user" :- [:merge ::get-current-user-result ::tool-request]
"Get information about user that started the conversation."
[_route-params
_query-params
{:keys [conversation_id] :as body} :- ::tool-request]
(metabot-v3.context/log (assoc body :api :get-current-user) :llm.log/llm->be)
(doto (-> (mc/decode ::get-current-user-result
(metabot-v3.dummy-tools/get-current-user)
(mtx/transformer {:name :tool-api-response}))
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm)))
(api.macros/defendpoint :post "/get-dashboard-details" :- [:merge ::get-dashboard-details-result ::tool-request]
"Get information about a given dashboard."
[_route-params
_query-params
{:keys [arguments conversation_id] :as body} :- [:merge
[:map [:arguments [:map [:dashboard_id :int]]]]
::tool-request]]
(metabot-v3.context/log (assoc body :api :get-dashboard-details) :llm.log/llm->be)
(doto (-> (mc/decode ::get-dashboard-details-result
(metabot-v3.dummy-tools/get-dashboard-details
(metabot-v3.u/recursive-update-keys arguments metabot-v3.u/safe->kebab-case-en))
(mtx/transformer {:name :tool-api-response}))
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm)))
(api.macros/defendpoint :post "/get-metric-details" :- [:merge ::get-metric-details-result ::tool-request]
"Get information about a given metric."
[_route-params
_query-params
{:keys [arguments conversation_id] :as body} :- [:merge
[:map [:arguments [:map [:metric_id :int]]]]
::tool-request]]
(metabot-v3.context/log (assoc body :api :get-metric-details) :llm.log/llm->be)
(doto (-> (mc/decode ::get-metric-details-result
(metabot-v3.dummy-tools/get-metric-details
(metabot-v3.u/recursive-update-keys arguments metabot-v3.u/safe->kebab-case-en))
(mtx/transformer {:name :tool-api-response}))
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm)))
(api.macros/defendpoint :post "/get-query-details" :- [:merge ::get-query-details-result ::tool-request]
"Get information about a given query."
[_route-params
_query-params
{:keys [arguments conversation_id] :as body} :- [:merge
[:map [:arguments [:map [:query :map]]]]
::tool-request]]
(metabot-v3.context/log (assoc body :api :get-query-details) :llm.log/llm->be)
(doto (-> (mc/decode ::get-query-details-result
(metabot-v3.dummy-tools/get-query-details arguments)
(mtx/transformer {:name :tool-api-response}))
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm)))
(api.macros/defendpoint :post "/get-report-details" :- [:merge ::get-report-details-result ::tool-request]
"Get information about a given report."
[_route-params
_query-params
{:keys [arguments conversation_id] :as body} :- [:merge
[:map [:arguments [:map [:report_id :int]]]]
::tool-request]]
(metabot-v3.context/log (assoc body :api :get-report-details) :llm.log/llm->be)
(doto (-> (mc/decode ::get-report-details-result
(metabot-v3.dummy-tools/get-report-details
(metabot-v3.u/recursive-update-keys arguments metabot-v3.u/safe->kebab-case-en))
(mtx/transformer {:name :tool-api-response}))
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm)))
(api.macros/defendpoint :post "/get-table-details" :- [:merge ::get-table-details-result ::tool-request]
"Get information about a given table or model."
[_route-params
_query-params
{:keys [arguments conversation_id] :as body} :- [:merge
[:map [:arguments ::get-table-details-arguments]]
::tool-request]]
(metabot-v3.context/log (assoc body :api :get-table-details) :llm.log/llm->be)
(doto (-> (mc/decode ::get-table-details-result
(metabot-v3.dummy-tools/get-table-details
(metabot-v3.u/recursive-update-keys arguments metabot-v3.u/safe->kebab-case-en))
(mtx/transformer {:name :tool-api-response}))
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm)))
(api.macros/defendpoint :post "/query-metric" :- [:merge ::filtering-result ::tool-request]
"Construct a query from a metric."
[_route-params
_query-params
{:keys [arguments conversation_id] :as body} :- [:merge
[:map [:arguments ::query-metric-arguments]]
::tool-request]]
(metabot-v3.context/log (assoc body :api :query-metric) :llm.log/llm->be)
(let [arguments (mc/encode ::query-metric-arguments
arguments (mtx/transformer {:name :tool-api-request}))]
(doto (-> (mc/decode ::filtering-result
(metabot-v3.tools.filters/query-metric arguments)
(mtx/transformer {:name :tool-api-response}))
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm))))
(api.macros/defendpoint :post "/query-model" :- [:merge ::filtering-result ::tool-request]
"Construct a query from a model."
[_route-params
_query-params
{:keys [arguments conversation_id] :as body} :- [:merge
[:map [:arguments ::query-model-arguments]]
::tool-request]]
(metabot-v3.context/log (assoc body :api :query-model) :llm.log/llm->be)
(let [arguments (mc/encode ::query-model-arguments
arguments (mtx/transformer {:name :tool-api-request}))]
(doto (-> (mc/decode ::filtering-result
(metabot-v3.tools.filters/query-model arguments)
(mtx/transformer {:name :tool-api-response}))
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm))))
(defn- enforce-authentication
"Middleware that returns a 401 response if no `ai-session` can be found for `request`."
[handler]
(with-meta
(fn [{:keys [headers] :as request} respond raise]
(if-let [user-id (decode-ai-service-token (get headers "x-metabase-session"))]
(request/with-current-user user-id
(handler request respond raise))
(if (:metabase-user-id request)
(handler request respond raise)
(respond request/response-unauthentic))))
(meta handler)))
(def ^{:arglists '([handler])} +tool-session
"Wrap `routes` so they may only be accessed with proper authentication credentials."
(api.routes.common/wrap-middleware-for-open-api-spec-generation enforce-authentication))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/metabot-tools` routes."
(api.macros/ns-handler *ns* +tool-session))

View File

@@ -0,0 +1,50 @@
(ns metabase-enterprise.metabot-v3.tools.create-dashboard-subscription
(:require
[metabase.api.common :as api]
^{:clj-kondo/ignore [:deprecated-namespace]}
[metabase.pulse.core :as pulse]
[metabase.util :as u]
[toucan2.core :as t2]))
(defn create-dashboard-subscription
"Create a dashboard subscription."
[{:keys [dashboard-id email schedule]}]
(if (int? dashboard-id)
(let [dashboard (-> (t2/select-one :model/Dashboard :id dashboard-id)
(t2/hydrate [:dashcards :card]))
cards (for [{:keys [id card]} (:dashcards dashboard)
:when (-> card :id int?)]
(-> card
(select-keys [:id :name :collection_id :description :display :parameter_mappings])
(assoc :dashboard_card_id id :dashboard_id dashboard-id)))
recipient-id (t2/select-one-fn :id :model/User :email email)
recipient {:id recipient-id}
{:keys [frequency hour day_of_week day_of_month]} schedule
channel {:channel_type :email
:enabled true
:recipients [recipient]
:schedule_day (or (some-> day_of_week (subs 0 3) u/lower-case-en)
(some->> day_of_month
u/lower-case-en
(re-find #"^(?:first|last)-(mon|tue|wed|thu|fri|sat|sun)")
second))
:schedule_frame (some->> day_of_month (re-find #"^(?:first|mid|last)"))
:schedule_hour hour
:schedule_type frequency}
pulse-data (-> dashboard
(select-keys [:collection_id :collection_position :name :parameters])
(assoc :dashboard_id dashboard-id
:creator_id api/*current-user-id*
:skip_if_empty false))]
{:output
(cond
(nil? recipient-id)
"no user with this email found"
(nil? dashboard)
"no dashboard with this dashboard_id found"
:else
(do (pulse/create-pulse! (map pulse/card->ref cards) [channel] pulse-data)
"success"))})
{:output "invalid dashboard_id"}))

View File

@@ -0,0 +1,348 @@
(ns metabase-enterprise.metabot-v3.tools.filters
(:require
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
[metabase.api.common :as api]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.metadata.jvm :as lib.metadata.jvm]
[metabase.lib.types.isa :as lib.types.isa]
[metabase.lib.util :as lib.util]
[metabase.util :as u]))
(defn- apply-bucket
[column bucket]
(let [bucket (keyword bucket)]
(case bucket
:week-of-year (lib/get-week column :iso)
:day-of-week (lib/get-day-of-week column :iso)
(lib/with-temporal-bucket column bucket))))
(defn- bucketed-column
[{:keys [column bucket]}]
(cond-> column
(and bucket
(lib.types.isa/temporal? column))
(apply-bucket bucket)))
(defn- add-filter
[query llm-filter]
(let [llm-filter (update llm-filter :operation keyword)
{:keys [operation value values]} llm-filter
expr (bucketed-column llm-filter)
filter
(case operation
:is-null (lib/is-null expr)
:is-not-null (lib/not-null expr)
:string-is-empty (lib/is-empty expr)
:string-is-not-empty (lib/not-empty expr)
:is-true (lib/= expr true)
:is-false (lib/= expr false)
:equals (if values
(apply lib/= expr values)
(lib/= expr value))
:not-equals (if values
(apply lib/!= expr values)
(lib/!= expr value))
:greater-than (lib/> expr value)
:greater-than-or-equal (lib/>= expr value)
:less-than (lib/< expr value)
:less-than-or-equal (lib/<= expr value)
:year-equals (if values
(apply lib/= (lib/get-year expr) values)
(lib/= (lib/get-year expr) value))
:year-not-equals (if values
(apply lib/!= (lib/get-year expr) values)
(lib/!= (lib/get-year expr) value))
:quarter-equals (if values
(apply lib/= (lib/get-quarter expr) values)
(lib/= (lib/get-quarter expr) value))
:quarter-not-equals (if values
(apply lib/!= (lib/get-quarter expr) values)
(lib/!= (lib/get-quarter expr) value))
:month-equals (if values
(apply lib/= (lib/get-month expr) values)
(lib/= (lib/get-month expr) value))
:month-not-equals (if values
(apply lib/!= (lib/get-month expr) values)
(lib/!= (lib/get-month expr) value))
:day-of-week-equals (if values
(apply lib/= (lib/get-day-of-week expr :iso) values)
(lib/= (lib/get-day-of-week expr :iso) value))
:day-of-week-not-equals (if values
(apply lib/!= (lib/get-day-of-week expr :iso) values)
(lib/!= (lib/get-day-of-week expr :iso) value))
:hour-equals (if values
(apply lib/= (lib/get-hour expr) values)
(lib/= (lib/get-hour expr) value))
:hour-not-equals (if values
(apply lib/!= (lib/get-hour expr) values)
(lib/!= (lib/get-hour expr) value))
:minute-equals (if values
(apply lib/= (lib/get-minute expr) values)
(lib/= (lib/get-minute expr) value))
:minute-not-equals (if values
(apply lib/!= (lib/get-minute expr) values)
(lib/!= (lib/get-minute expr) value))
:second-equals (if values
(apply lib/= (lib/get-second expr) values)
(lib/= (lib/get-second expr) value))
:second-not-equals (if values
(apply lib/!= (lib/get-second expr) values)
(lib/!= (lib/get-second expr) value))
:date-equals (if values
(apply lib/= expr values)
(lib/= expr value))
:date-not-equals (if values
(apply lib/!= expr values)
(lib/!= expr value))
:date-before (lib/< expr value)
:date-on-or-before (lib/<= expr value)
:date-after (lib/> expr value)
:date-on-or-after (lib/>= expr value)
:string-equals (if values
(apply lib/= expr values)
(lib/= expr value))
:string-not-equals (if values
(apply lib/!= expr values)
(lib/!= expr value))
:string-contains (if values
(apply lib/contains expr values)
(lib/contains expr value))
:string-not-contains (if values
(apply lib/not (lib/contains expr values))
(lib/not (lib/contains expr value)))
:string-starts-with (if values
(apply lib/starts-with expr values)
(lib/starts-with expr value))
:string-ends-with (if values
(apply lib/ends-with expr values)
(lib/ends-with expr value))
:number-equals (if values
(apply lib/= expr values)
(lib/= expr value))
:number-not-equals (if values
(apply lib/!= expr values)
(lib/!= expr value))
:number-greater-than (lib/> expr value)
:number-greater-than-or-equal (lib/>= expr value)
:number-less-than (lib/< expr value)
:number-less-than-or-equal (lib/<= expr value)
(throw (ex-info (str "unknown filter operation " operation) {:agent-error? true})))]
(lib/filter query filter)))
(defn- add-breakout
[query {:keys [column field_granularity]}]
(let [expr (cond-> column
(and field_granularity
(lib.types.isa/temporal? column))
(lib/with-temporal-bucket (keyword field_granularity)))]
(lib/breakout query expr)))
(defn- query-metric*
[{:keys [metric-id filters group-by] :as _arguments}]
(let [card (metabot-v3.tools.u/get-card metric-id)
mp (lib.metadata.jvm/application-database-metadata-provider (:database_id card))
base-query (->> (lib/query mp (lib.metadata/card mp metric-id))
lib/remove-all-breakouts)
visible-cols (lib/visible-columns base-query)
filter-field-id-prefix (metabot-v3.tools.u/card-field-id-prefix metric-id)
query (as-> base-query $q
(reduce add-filter
$q
(map #(metabot-v3.tools.u/resolve-column % filter-field-id-prefix visible-cols) filters))
(reduce add-breakout
$q
(map #(metabot-v3.tools.u/resolve-column % filter-field-id-prefix visible-cols) group-by)))
query-id (u/generate-nano-id)
query-field-id-prefix (metabot-v3.tools.u/query-field-id-prefix query-id)
returned-cols (lib/returned-columns query)]
{:type :query
:query_id query-id
:query (lib/->legacy-MBQL query)
:result_columns (into []
(map-indexed #(metabot-v3.tools.u/->result-column query %2 %1 query-field-id-prefix))
returned-cols)}))
(comment
(binding [api/*current-user-permissions-set* (delay #{"/"})]
(let [id 135]
(query-metric* {:metric-id id})))
-)
(defn query-metric
"Create a query based on a metric."
[{:keys [metric-id] :as arguments}]
(try
(if (int? metric-id)
{:structured-output (query-metric* arguments)}
{:output (str "Invalid metric_id " metric-id)})
(catch Exception e
(if (= (:status-code (ex-data e)) 404)
{:output (str "No metric found with metric_id " metric-id)}
(metabot-v3.tools.u/handle-agent-error e)))))
(comment
(binding [api/*current-user-permissions-set* (delay #{"/"})
api/*current-user-id* 2
api/*is-superuser?* true]
(query-metric* {:metric-id 135
:filters
[{:operation "number-greater-than",
:field_id "field_[27]_[:field {:base-type :type/Float, :effective-type :type/Float} 257]",
:value 35}],
:group-by nil}))
-)
(defn- add-aggregation
[query aggregation]
(let [expr (bucketed-column aggregation)
agg-expr (case (-> aggregation :function keyword)
:count (lib/count)
:count-distinct (lib/distinct expr)
:sum (lib/sum expr)
:min (lib/min expr)
:max (lib/max expr)
:avg (lib/avg expr))]
(lib/aggregate query agg-expr)))
(defn- expression?
[expr-or-column]
(vector? expr-or-column))
(defn- add-fields
[query projection]
(->> projection
(map (fn [[expr-or-column expr-name]]
(if (expression? expr-or-column)
(lib/expression-ref query expr-name)
expr-or-column)))
(lib/with-fields query)))
(defn- query-model*
[{:keys [model-id fields filters aggregations group-by] :as _arguments}]
(let [card (metabot-v3.tools.u/get-card model-id)
mp (lib.metadata.jvm/application-database-metadata-provider (:database_id card))
base-query (lib/query mp (lib.metadata/card mp model-id))
visible-cols (lib/visible-columns base-query)
filter-field-id-prefix (metabot-v3.tools.u/card-field-id-prefix model-id)
resolve-visible-column #(metabot-v3.tools.u/resolve-column % filter-field-id-prefix visible-cols)
projection (map (comp (juxt bucketed-column (fn [{:keys [column bucket]}]
(let [column (cond-> column
bucket (assoc :unit (keyword bucket)))]
(lib/display-name base-query -1 column :long))))
resolve-visible-column)
fields)
reduce-query (fn [query f coll] (reduce f query coll))
query (-> base-query
(reduce-query (fn [query [expr-or-column expr-name]]
(lib/expression query expr-name expr-or-column))
(filter (comp expression? first) projection))
(add-fields projection)
(reduce-query add-filter (map resolve-visible-column filters))
(reduce-query add-aggregation (map resolve-visible-column aggregations))
(reduce-query add-breakout (map resolve-visible-column group-by)))
query-id (u/generate-nano-id)
query-field-id-prefix (metabot-v3.tools.u/query-field-id-prefix query-id)
returned-cols (lib/returned-columns query)]
{:type :query
:query_id query-id
:query (lib/->legacy-MBQL query)
:result_columns (into []
(map-indexed #(metabot-v3.tools.u/->result-column query %2 %1 query-field-id-prefix))
returned-cols)}))
(defn query-model
"Create a query based on a model."
[{:keys [model-id] :as arguments}]
(try
(if (int? model-id)
{:structured-output (query-model* arguments)}
{:output (str "Invalid model_id " model-id)})
(catch Exception e
(if (= (:status-code (ex-data e)) 404)
{:output (str "No model found with model_id " model-id)}
(metabot-v3.tools.u/handle-agent-error e)))))
(defn- base-query
[data-source]
(let [{:keys [table_id query query_id report_id]} data-source
model-id (lib.util/legacy-string-table-id->card-id table_id)
handle-query (fn [query query_id]
(let [mp (lib.metadata.jvm/application-database-metadata-provider (:database query))]
[(if query_id
(metabot-v3.tools.u/query-field-id-prefix query_id)
metabot-v3.tools.u/any-prefix-pattern)
(-> (lib/query mp query) lib/append-stage)]))]
(cond
model-id
(if-let [model (metabot-v3.tools.u/get-card model-id)]
(let [mp (lib.metadata.jvm/application-database-metadata-provider (:database_id model))]
[(metabot-v3.tools.u/card-field-id-prefix model-id)
(lib/query mp (lib.metadata/card mp model-id))])
(throw (ex-info (str "No table found with table_id " table_id) {:agent-error? true
:data_source data-source})))
table_id
(let [table_id (cond-> table_id
(string? table_id) parse-long)]
(if-let [table (metabot-v3.tools.u/get-table table_id :db_id)]
(let [mp (lib.metadata.jvm/application-database-metadata-provider (:db_id table))]
[(metabot-v3.tools.u/table-field-id-prefix table_id)
(lib/query mp (lib.metadata/table mp table_id))])
(throw (ex-info (str "No table found with table_id " table_id) {:agent-error? true
:data_source data-source}))))
report_id
(if-let [card (metabot-v3.tools.u/get-card report_id)]
(let [mp (lib.metadata.jvm/application-database-metadata-provider (:database_id card))]
[(metabot-v3.tools.u/card-field-id-prefix report_id)
(lib/query mp (cond-> (lib.metadata/card mp report_id)
;; pivot questions have strange result-columns so we work with the dataset-query
(#{:question} (:type card)) (get :dataset-query)))])
(throw (ex-info (str "No report found with report_id " report_id) {:agent-error? true
:data_source data-source})))
query
(handle-query query query_id)
:else
(throw (ex-info "Invalid data_source" {:agent-error? true
:data_source data-source})))))
(defn filter-records
"Add `filters` to the query referenced by `data-source`"
[{:keys [data-source filters] :as _arguments}]
(try
(let [[filter-field-id-prefix base] (base-query data-source)
returned-cols (lib/returned-columns base)
query (reduce add-filter base (map #(metabot-v3.tools.u/resolve-column % filter-field-id-prefix returned-cols) filters))
query-id (u/generate-nano-id)
query-field-id-prefix (metabot-v3.tools.u/query-field-id-prefix query-id)]
{:structured-output
{:type :query
:query_id query-id
:query (lib/->legacy-MBQL query)
:result_columns (into []
(map-indexed #(metabot-v3.tools.u/->result-column query %2 %1 query-field-id-prefix))
(lib/returned-columns query))}})
(catch Exception ex
(metabot-v3.tools.u/handle-agent-error ex))))
(comment
(require '[metabase.query-processor :as qp]
'[toucan2.core :as t2])
(t2/select :model/Field)
(binding [api/*current-user-permissions-set* (delay #{"/"})
api/*current-user-id* 2
api/*is-superuser?* true]
(-> (filter-records #_{:data-source {:table_id 3}
:filters [{:operation "number-greater-than"
:field_id "t3/6"
:value 50}]}
{:data-source {:table_id 1}
:filters [{:operation "greater-than"
:bucket "month-of-year"
:field_id "t1/3"
:value #_"2020-01-01" 1}]})
:structured-output :query qp/process-query :data :native_form :query))
-)

View File

@@ -0,0 +1,20 @@
(ns metabase-enterprise.metabot-v3.tools.find-metric
(:require
[metabase-enterprise.metabot-v3.client :as client]
[metabase-enterprise.metabot-v3.dummy-tools :as dummy-tools]
[toucan2.core :as t2]))
(defn find-metric
"Find a metric described by `message`.
See [[metabase-enterprise.metabot-v3.dummy-tools/metric-details]] for the output if a metric can be found."
[{:keys [message]}]
(let [{:keys [id]} (client/select-metric-request
(t2/select [:model/Card :id :name :description]
:type [:= "metric"]
:archived [:= false])
message)]
(if-let [result (when id
(dummy-tools/metric-details id))]
{:structured-output result}
{:output "Metric not found."})))

View File

@@ -0,0 +1,81 @@
(ns metabase-enterprise.metabot-v3.tools.find-outliers
(:require
[medley.core :as m]
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
[metabase.api.common :as api]
[metabase.lib.types.isa :as lib.types.isa]
[metabase.query-processor :as qp]
[metabase.util :as u]
[toucan2.core :as t2]))
(defn- checked-card-dataset-query
[card-id]
(-> (t2/select-one [:model/Card :collection_id :dataset_query] card-id)
api/read-check
:dataset_query))
(defn- find-dataset-query
[{:keys [query query_id report_id metric_id] :as data-source}]
(letfn [(handle-query [query query_id]
(api/read-check :model/Database (:database query))
[(if query_id
(metabot-v3.tools.u/query-field-id-prefix query_id)
metabot-v3.tools.u/any-prefix-pattern)
query])]
(cond
metric_id (if (int? metric_id)
[(metabot-v3.tools.u/card-field-id-prefix metric_id)
(checked-card-dataset-query metric_id)]
(throw (ex-info "Invalid metric_id as data_source" {:agent-error? true
:data_source data-source})))
report_id (if (int? report_id)
[(metabot-v3.tools.u/card-field-id-prefix report_id)
(checked-card-dataset-query report_id)]
(throw (ex-info "Invalid report_id as data_source" {:agent-error? true
:data_source data-source})))
query (handle-query query query_id)
:else (throw (ex-info "Invalid data_source" {:agent-error? true
:data_source data-source})))))
(defn find-outliers
"Find outliers in the values provided by `data-source` for a given column."
[{:keys [data-source]}]
(let [{:keys [metric_id result_field_id]} data-source]
(try
(let [[field-id-prefix dataset-query] (find-dataset-query data-source)
{:keys [data]} (u/prog1 (-> dataset-query
(qp/userland-query-with-default-constraints {:context :ad-hoc})
qp/process-query)
(when-not (= :completed (:status <>))
(throw (ex-info "Unexpected error running query" {:agent-error? true
:status (:status <>)}))))
dimension-col-idx (or (->> data
:cols
(map-indexed vector)
(m/find-first (fn [[_i col]]
(lib.types.isa/temporal? (u/normalize-map col))))
first)
(throw (ex-info "No temporal dimension found. Outliers can only be detected when a temporal dimension is available."
{:agent-error? true})))
value-col-idx (if metric_id
(or (->> data
:cols
(map-indexed vector)
(m/find-first (fn [[_i col]]
(lib.types.isa/numeric? (u/normalize-map col))))
first)
(throw (ex-info "Could not determine result field."
{:agent-error? true})))
(metabot-v3.tools.u/resolve-column-index result_field_id field-id-prefix))]
(when-not (< -1 value-col-idx (-> data :rows first count))
(throw (ex-info (str "Invalid result_field_id " result_field_id)
{:agent-error? true})))
{:structured-output (->> data
:rows
(map (fn [row]
{:dimension (nth row dimension-col-idx)
:value (nth row value-col-idx)}))
(metabot-v3.client/find-outliers-request))})
(catch Exception e
(metabot-v3.tools.u/handle-agent-error e)))))

View File

@@ -0,0 +1,32 @@
(ns metabase-enterprise.metabot-v3.tools.generate-insights
(:require
[buddy.core.codecs :as codecs]
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
[metabase.util.json :as json]))
(set! *warn-on-reflection* true)
(defn generate-insights
"Generate insights."
[{what-for :for}]
(try
(let [[k id] (some #(find what-for %) [:metric_id :table_id :report_id :query])
entity-type (case k
(:metric_id :report_id) "question"
:table_id "table"
(:query :query_id) "adhoc"
(throw (ex-info (str "Cannot generate insights for " what-for) {:agent-error? true})))
entity-id (case k
:query (-> id json/encode .getBytes codecs/bytes->b64-str)
id)
results-url (str "/auto/dashboard/" entity-type "/" entity-id)]
(when (and (= k :table_id)
(not (int? id))
(not (and (string? id)
(re-matches #"(?:card__)?\d+" id))))
(throw (ex-info "Invalid table_id" {:agent-error? true
:table_id id})))
{:output results-url
:reactions [{:type :metabot.reaction/redirect :url results-url}]})
(catch Exception e
(metabot-v3.tools.u/handle-agent-error e))))

View File

@@ -0,0 +1,41 @@
(ns metabase-enterprise.metabot-v3.tools.interface
(:require
[metabase.lib.schema.common :as lib.schema.common]
[metabase.util :as u]
[metabase.util.malli.registry :as mr]))
(set! *warn-on-reflection* true)
(mr/def ::metadata.parameter.type
:keyword)
(mr/def ::metadata.parameter.name
[:and
{:decode/metadata-file (fn [x]
(keyword (u/->kebab-case-en x)))
:encode/api-request (fn [x]
(u/->snake_case_en (name x)))
:decode/api-response (fn [x]
(keyword (u/->kebab-case-en x)))}
:keyword])
(mr/def ::metadata.parameter
[:and
[:map
[:type {:optional true} [:or
::metadata.parameter.type
[:set ::metadata.parameter.type]]]
[:anyOf {:optional true} [:sequential [:ref ::metadata.parameter]]]
[:description {:optional true} [:maybe ::lib.schema.common/non-blank-string]]]
[:fn
{:error/message "metadata.parameter must specify either `:type`` or `:anyOf`, but not both."}
(fn [query]
(= (count (select-keys query [:type :anyOf])) 1))]])
(mr/def ::metadata.name
[:and
{:encode/api-request (fn [x] (u/->snake_case_en (name x)))
:decode/metadata-file keyword
:decode/api-request keyword
:decode/api-response keyword}
:keyword])

View File

@@ -0,0 +1,16 @@
(ns metabase-enterprise.metabot-v3.tools.invite-user
(:require
[metabase.api.user :as api.user]))
(defn invite-user
"Send a Metabase invitation to the address `email`."
[{:keys [email]}]
(try
(api.user/invite-user {:email email})
{:output (format "%s has been invited to Metabase." email)}
(catch clojure.lang.ExceptionInfo e
(let [d (ex-data e)]
(if-let [message (when (= (:status-code d) 400)
(-> d :errors :email))]
{:output message}
(throw e))))))

View File

@@ -0,0 +1,18 @@
(ns metabase-enterprise.metabot-v3.tools.show-results-to-user
(:require
[buddy.core.codecs :as codecs]
[metabase.public-settings :as public-settings]
[metabase.util.json :as json]))
(set! *warn-on-reflection* true)
(defn show-results-to-user
"Generate link where the query can be seen."
[{:keys [query] :as _arguments}]
(let [query-hash (-> {:dataset_query query}
json/encode
(.getBytes "UTF-8")
codecs/bytes->b64-str)
results-url (str "/question#" query-hash)]
{:output (str "Results can be seen at: " (public-settings/site-url) results-url)
:reactions [{:type :metabot.reaction/redirect, :url results-url}]}))

View File

@@ -0,0 +1,129 @@
(ns metabase-enterprise.metabot-v3.tools.util
(:require
[clojure.string :as str]
[medley.core :as m]
[metabase.api.common :as api]
[metabase.lib.core :as lib]
[metabase.lib.types.isa :as lib.types.isa]
[metabase.models.collection :as collection]
[metabase.models.interface :as mi]
[metabase.util :as u]
[toucan2.core :as t2]))
(defn handle-agent-error
"Return an agent output for agent errors, re-throw `e` otherwise."
[e]
(if (-> e ex-data :agent-error?)
{:output (ex-message e)}
(throw e)))
(defn convert-field-type
"Return tool type for `column`."
[column]
(let [column (u/normalize-map column)]
(cond
(lib.types.isa/boolean? column) "boolean"
(lib.types.isa/string-or-string-like? column) "string"
(lib.types.isa/numeric? column) "number"
(isa? (:effective-type column) :type/DateTime) "datetime"
(isa? (:effective-type column) :type/Time) "time"
(lib.types.isa/temporal? column) "date")))
(defn table-field-id-prefix
"Return the field ID prefix for `table-id`."
[table-id]
(str "t" table-id "/"))
(defn card-field-id-prefix
"Return the field ID prefix for a model or a metric with ID `card-id`."
[card-id]
(str "c" card-id "/"))
(defn query-field-id-prefix
"Return the field ID prefix for `query-id`."
[query-id]
(str "q" query-id "/"))
(def any-prefix-pattern
"A prefix pattern accepting columns from any entity."
#"^.*/(\d+)")
(defn- find-column-indexes
[col-to-find cols]
(keep-indexed (fn [i col]
(when (= col (select-keys col-to-find (keys col)))
i))
cols))
(defn ->result-column
"Return tool result columns for `column` of `query`. The position of `column` is determined by `index-or-columns`
unless the latter is a sequence of columns, in which case the index of `column` in `index-or-columns` is used.
The ID is then generated by prefixing the position with `field-id-prefix`."
[query column index-or-columns field-id-prefix]
(let [pos (if (sequential? index-or-columns)
(first (find-column-indexes column index-or-columns))
index-or-columns)
semantic-type (:semantic-type column)]
(-> {:field_id (str field-id-prefix pos)
:name (lib/display-name query column)
:type (convert-field-type column)}
(m/assoc-some :description (:description column)
:semantic-type (some-> semantic-type name u/->snake_case_en)
:field-values (:field-values column)
:table-reference (:table-reference column)))))
(defn resolve-column-index
"Resolve the reference `field_id` to the index of the result columns in the entity with `field-id-prefix`."
[field_id field-id-prefix]
(if (string? field-id-prefix)
(if (str/starts-with? field_id field-id-prefix)
(-> field_id (subs (count field-id-prefix)) parse-long)
(throw (ex-info (str "field " field_id " not found") {:agent-error? true
:expected-prefix field-id-prefix})))
(if-let [id-str (when (instance? java.util.regex.Pattern field-id-prefix)
(-> (re-matches field-id-prefix field_id)
second))]
(parse-long id-str)
(throw (ex-info (str "invalid field_id " field_id " for prefix " field-id-prefix)
{:agent-error? true
:expected-prefix (str field-id-prefix)
:field_id field_id})))))
(defn resolve-column
"Resolve the reference `field_id` in filter `item` by finding the column in `columns` specified by `field_id`.
`field-id-prefix` is used to check if the filter refers to a column from the right entity."
[{:keys [field_id] :as item} field-id-prefix columns]
(let [index (resolve-column-index field_id field-id-prefix)]
(assoc item :column (nth columns index))))
(defn get-table
"Get the `fields` of the table with ID `id`."
[id & fields]
(when-let [table (t2/select-one (into [:model/Table :id] fields) id)]
(when (mi/can-read? table)
table)))
(defn get-card
"Retrieve the card with `id` from the app DB."
[id]
(-> (t2/select-one :model/Card :id id)
api/read-check
(t2/hydrate :based_on_upload :creator :can_write :can_run_adhoc_query [:collection :is_personal])
(api/present-in-trash-if-archived-directly (collection/trash-collection-id))))
(defn get-metrics-and-models
"Retrieve the metric and model cards from the collection with name `collection-name` from the app DB."
[collection-name]
(let [collection-query {:select [:id]
:from :collection
:where [:= :name collection-name]}]
(-> (t2/select :model/Card :type [:in [:metric :model]] :collection_id [:in collection-query] {:order-by [:id]})
(->> (filter mi/can-read?))
(t2/hydrate :based_on_upload :creator :can_write :can_run_adhoc_query [:collection :is_personal])
(api/present-in-trash-if-archived-directly (collection/trash-collection-id)))))
(comment
(binding [api/*current-user-permissions-set* (delay #{"/"})]
(let [collection-name "Usage analytics"]
(get-metrics-and-models collection-name)))
-)

View File

@@ -0,0 +1,23 @@
(ns metabase-enterprise.metabot-v3.util
(:require
[clojure.walk :as walk]
[metabase.util :as u]))
(defn- safe-case-updater
[f]
#(cond-> % (or (string? %) (keyword? %)) f))
(def safe->kebab-case-en
"Convert strings or keywords to kebab-case."
(safe-case-updater u/->kebab-case-en))
(def safe->snake_case_en
"Convert strings or keywords to snake_case"
(safe-case-updater u/->snake_case_en))
(defn recursive-update-keys
"Recursively convert map keys in `form` with `f`."
[form f]
(walk/walk #(cond-> % (coll? %) (recursive-update-keys f))
#(cond-> % (map? %) (update-keys f))
form))

View File

@@ -0,0 +1,77 @@
(ns metabase-enterprise.ai-sql-fixer.api-test
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[metabase-enterprise.ai-sql-fixer.api :as ai-sql-fixer.api]
[metabase.test :as mt]))
(deftest ^:parallel format-escpaed-test
(are [in out] (= out
(with-out-str (#'ai-sql-fixer.api/format-escaped in *out*)))
"almallama" "almallama"
"alma llama" "\"alma llama\""
"\"alma\" llama" "\"\"\"alma\"\" llama\""))
(deftest ^:parallel schema-sample-test
(mt/test-driver :h2
(let [query {:database (mt/id)
:native {:query (str "SELECT * FROM x.orders1"
" INNER JOIN products ON orders1.product_id = products.id"
" INNER JOIN ANALYTIC_EVENT ON true")}}
normalize #(into #{} (str/split % #"(?<=;)\n"))]
(is (= (-> (str "CREATE TABLE PUBLIC.PRODUCTS (\n"
" ID BIGINT,\n"
" EAN CHARACTER VARYING,\n"
" TITLE CHARACTER VARYING,\n"
" CATEGORY CHARACTER VARYING,\n"
" VENDOR CHARACTER VARYING,\n"
" PRICE DOUBLE PRECISION,\n"
" RATING DOUBLE PRECISION,\n"
" CREATED_AT TIMESTAMP WITH TIME ZONE\n"
");\n"
"CREATE TABLE PUBLIC.ORDERS (\n"
" ID BIGINT,\n"
" USER_ID INTEGER,\n"
" PRODUCT_ID INTEGER,\n"
" SUBTOTAL DOUBLE PRECISION,\n"
" TAX DOUBLE PRECISION,\n"
" TOTAL DOUBLE PRECISION,\n"
" DISCOUNT DOUBLE PRECISION,\n"
" CREATED_AT TIMESTAMP WITH TIME ZONE,\n"
" QUANTITY INTEGER\n"
");\n"
"CREATE TABLE PUBLIC.USERS (\n"
" ID BIGINT,\n"
" NAME CHARACTER VARYING,\n"
" LAST_LOGIN TIMESTAMP,\n"
" PASSWORD CHARACTER VARYING\n"
");\n")
normalize)
(-> (#'ai-sql-fixer.api/schema-sample query {:all-tables-limit 5})
normalize))))))
(deftest ^:parallel no-used-table-test
(mt/test-driver :postgres
(let [query {:database (mt/id)
:native {:query "SELECT * FROM x.orders1"}}
normalize #(into #{} (str/split % #"(?<=;)\n"))]
(is (= (-> (str "CREATE TABLE public.orders (\n"
" id int4,\n"
" user_id int4,\n"
" product_id int4,\n"
" subtotal float8,\n"
" tax float8,\n"
" total float8,\n"
" discount float8,\n"
" created_at timestamptz,\n"
" quantity int4\n"
");\n"
"CREATE TABLE public.users (\n"
" id int4,\n"
" name text,\n"
" last_login timestamp,\n"
" password text\n"
");\n")
normalize)
(-> (#'ai-sql-fixer.api/schema-sample query {:all-tables-limit 5})
normalize))))))

View File

@@ -23,6 +23,8 @@
:embedding-sdk
:hosting
:llm-autodescription
:metabot-v3
:ai-sql-fixer
:no-upsell
:official-collections
:query-reference-validation
@@ -54,6 +56,8 @@
:embedding_sdk true
:hosting true
:llm_autodescription true
:metabot_v3 true
:ai_sql_fixer true
:official_collections true
:query_reference_validation true
:sandboxes true

View File

@@ -0,0 +1,42 @@
(ns metabase-enterprise.metabot-v3.api-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
[metabase-enterprise.metabot-v3.tools.api :as metabot-v3.tools.api]
[metabase.test :as mt]))
(set! *warn-on-reflection* true)
(deftest agent-test
(let [ai-requests (atom [])
conversation-id (str (random-uuid))
question "what can you do?"
navigation-target "url"
historical-message {:role "user", :content "hello?"}
agent-message {:role :assistant, :navigate-to navigation-target}
agent-state {:key "value"}]
(mt/with-premium-features #{:metabot-v3}
(testing "Trivial request"
(with-redefs [metabot-v3.client/request (fn [e]
(swap! ai-requests conj e)
{:messages [agent-message]
:state agent-state})]
(let [response (mt/user-http-request :rasta :post 200 "ee/metabot-v3/v2/agent"
{:message question
:context {}
:conversation_id conversation-id
:history [historical-message]
:state {}})]
(is (=? [{:context {:current_user_time (every-pred string? java.time.Instant/parse)}
:messages [(update historical-message :role keyword) {:role :user, :content question}]
:state {}
:conversation-id conversation-id
:session-id #(#'metabot-v3.tools.api/decode-ai-service-token %)}]
@ai-requests))
(is (=? {:reactions [{:type "metabot.reaction/redirect", :url navigation-target}]
:history [historical-message
{:role "user", :content question}
(update agent-message :role name)]
:state agent-state
:conversation_id conversation-id}
response))))))))

View File

@@ -0,0 +1,18 @@
(ns metabase-enterprise.metabot-v3.reactions-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.reactions :as metabot-v3.reactions]
[metabase.util.malli.registry :as mr]))
(deftest ^:parallel reaction-schema-test
(testing "Known/registered reactions"
(is (mr/validate
::metabot-v3.reactions/reaction
{:type :metabot.reaction/message, :message "Hello"}))
(is (not (mr/validate
::metabot-v3.reactions/reaction
{:type :metabot.reaction/message}))))
(testing "Should allow unregistered reactions"
(mr/validate
::metabot-v3.reactions/reaction
{:type :metabot.reaction/unknown-does-not-exist})))

View File

@@ -0,0 +1,509 @@
(ns metabase-enterprise.metabot-v3.tools.api-test
(:require
[clojure.test :refer :all]
[medley.core :as m]
[metabase-enterprise.metabot-v3.tools.api :as metabot-v3.tools.api]
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription :as metabot-v3.tools.create-dashboard-subscription]
[metabase-enterprise.metabot-v3.tools.filters :as metabot-v3.tools.filters]
[metabase-enterprise.metabot-v3.tools.find-metric :as metabot-v3.tools.find-metric]
[metabase-enterprise.metabot-v3.tools.find-outliers :as metabot-v3.tools.find-outliers]
[metabase-enterprise.metabot-v3.tools.generate-insights :as metabot-v3.tools.generate-insights]
[metabase.legacy-mbql.normalize :as mbql.normalize]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.metadata.jvm :as lib.metadata.jvm]
[metabase.models.field-values :as field-values]
[metabase.test :as mt]
[toucan2.core :as t2]))
(defn- ai-session-token
([] (ai-session-token :rasta))
([user]
(-> user mt/user->id (#'metabot-v3.tools.api/get-ai-service-token))))
(deftest create-dashboard-subscription-test
(mt/with-premium-features #{:metabot-v3}
(let [tool-requests (atom [])
conversation-id (str (random-uuid))
output (str (random-uuid))
ai-token (ai-session-token)]
(with-redefs [metabot-v3.tools.create-dashboard-subscription/create-dashboard-subscription
(fn [arguments]
(swap! tool-requests conj arguments)
{:output output})]
(let [response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/create-dashboard-subscription"
{:request-options {:headers {"x-metabase-session" ai-token}}}
{:arguments {:dashboard_id 1
:email "user@example.com"
:schedule {:frequency "hourly"}}
:conversation_id conversation-id})]
(is (=? {:output output
:conversation_id conversation-id}
response)))))))
(deftest filter-records-test
(doseq [data-source [{:query {:database 1}, :query_id "query ID"}
{:query {:database 1}}
{:report_id 1}
{:table_id "1"}]]
(mt/with-premium-features #{:metabot-v3}
(let [tool-requests (atom [])
conversation-id (str (random-uuid))
output (str (random-uuid))
ai-token (ai-session-token)]
(with-redefs [metabot-v3.tools.filters/filter-records
(fn [arguments]
(swap! tool-requests conj arguments)
{:structured-output output})]
(let [filters [{:field_id "q2a/1", :operation "is-not-null"}
{:field_id "q2a/2", :operation "equals", :value "3"}
{:field_id "q2a/3", :operation "equals", :values ["3" "4"]}
{:field_id "q2a/5", :operation "not-equals", :values [3 4]}
{:field_id "q2a/6", :operation "month-equals", :values [4 5 9]}
{:field_id "c2a/6", :bucket "week-of-year" :operation "not-equals", :values [14 15 19]}
{:field_id "q2a/6", :operation "year-equals", :value 2008}]
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/filter-records"
{:request-options {:headers {"x-metabase-session" ai-token}}}
{:arguments {:data_source data-source
:filters filters}
:conversation_id conversation-id})]
(is (=? {:structured_output output
:conversation_id conversation-id}
response))))))))
(deftest find-metric-test
(mt/with-premium-features #{:metabot-v3}
(let [tool-requests (atom [])
conversation-id (str (random-uuid))
output (str (random-uuid))
ai-token (ai-session-token)]
(with-redefs [metabot-v3.tools.find-metric/find-metric
(fn [arguments]
(swap! tool-requests conj arguments)
{:structured-output output})]
(let [response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/find-metric"
{:request-options {:headers {"x-metabase-session" ai-token}}}
{:arguments {:message "search text"}
:conversation_id conversation-id})]
(is (=? {:structured_output output
:conversation_id conversation-id}
response)))))))
(deftest find-outliers-test
(doseq [data-source [{:query {:database 1}, :query_id "query ID", :result_field_id "q1233/2"}
{:query {:database 1}, :result_field_id "q1233/2"}
{:metric_id 1}
{:report_id 1, :result_field_id "c131/3"}
{:table_id "card__1", :result_field_id "t42/7"}]]
(mt/with-premium-features #{:metabot-v3}
(let [tool-requests (atom [])
conversation-id (str (random-uuid))
output (str (random-uuid))
ai-token (ai-session-token)]
(with-redefs [metabot-v3.tools.find-outliers/find-outliers
(fn [arguments]
(swap! tool-requests conj arguments)
{:structured-output output})]
(let [response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/find-outliers"
{:request-options {:headers {"x-metabase-session" ai-token}}}
{:arguments {:data_source data-source}
:conversation_id conversation-id})]
(is (=? {:structured_output output
:conversation_id conversation-id}
response))))))))
(deftest generate-insights-test
(doseq [data-source [{:query {:database 1}}
{:metric_id 1}
{:report_id 1}
{:table_id "card__1"}]]
(mt/with-premium-features #{:metabot-v3}
(let [tool-requests (atom [])
conversation-id (str (random-uuid))
output (str (random-uuid))
redirect-url "https://example.com/redirect-target"
ai-token (ai-session-token)]
(with-redefs [metabot-v3.tools.generate-insights/generate-insights
(fn [arguments]
(swap! tool-requests conj arguments)
{:output output
:reactions [{:type :metabot.reaction/redirect
:url redirect-url}]})]
(let [response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/generate-insights"
{:request-options {:headers {"x-metabase-session" ai-token}}}
{:arguments {:for data-source}
:conversation_id conversation-id})]
(is (=? {:output output
:reactions [{:type "metabot.reaction/redirect"
:url redirect-url}]
:conversation_id conversation-id}
response))))))))
(deftest query-metric-test
(mt/with-premium-features #{:metabot-v3}
(let [tool-requests (atom [])
conversation-id (str (random-uuid))
output (str (random-uuid))
ai-token (ai-session-token)]
(with-redefs [metabot-v3.tools.filters/query-metric
(fn [arguments]
(swap! tool-requests conj arguments)
{:structured-output output})]
(let [filters [{:field_id "c2/7", :operation "number-greater-than", :value 50}
{:field_id "c2/3", :operation "equals", :values ["3" "4"]}
{:field_id "c2/5", :operation "not-equals", :values [3 4]}
{:field_id "c2/6", :operation "month-equals", :values [4 5 9]}
{:field_id "c2/6", :bucket "day-of-month" :operation "not-equals", :values [14 15 19]}
{:field_id "c2/6", :bucket "day-of-week" :operation "equals", :values [1 7]}
{:field_id "c2/6", :operation "year-equals", :value 2008}]
breakouts [{:field_id "c2/4", :field_granularity "week"}
{:field_id "c2/6", :field_granularity "day"}]
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/query-metric"
{:request-options {:headers {"x-metabase-session" ai-token}}}
{:arguments {:metric_id 1
:filters filters
:group_by breakouts}
:conversation_id conversation-id})]
(is (= {:structured_output output
:conversation_id conversation-id}
response)))))))
(deftest query-model-test
(mt/with-premium-features #{:metabot-v3}
(let [tool-requests (atom [])
conversation-id (str (random-uuid))
output (str (random-uuid))
ai-token (ai-session-token)]
(with-redefs [metabot-v3.tools.filters/query-model
(fn [arguments]
(swap! tool-requests conj arguments)
{:structured-output output})]
(let [fields [{:field_id "c2/8", :bucket "year-of-era"}
{:field_id "c2/9"}]
filters [{:field_id "c2/7", :operation "number-greater-than", :value 50}
{:field_id "c2/3", :operation "equals", :values ["3" "4"]}
{:field_id "c2/5", :operation "not-equals", :values [3 4]}
{:field_id "c2/6", :operation "month-equals", :values [4 5 9]}
{:field_id "c2/6", :bucket "day-of-month" :operation "not-equals", :values [14 15 19]}
{:field_id "c2/6", :bucket "day-of-week" :operation "equals", :values [1 7]}
{:field_id "c2/6", :operation "year-equals", :value 2008}]
aggregations [{:field_id "c2/10", :bucket "week", :function "count-distinct"}
{:field_id "c2/11", :function "sum"}]
breakouts [{:field_id "c2/4", :field_granularity "week"}
{:field_id "c2/6", :field_granularity "day"}]
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/query-model"
{:request-options {:headers {"x-metabase-session" ai-token}}}
{:arguments {:model_id 1
:fields fields
:filters filters
:aggregations aggregations
:group_by breakouts}
:conversation_id conversation-id})]
(is (= {:structured_output output
:conversation_id conversation-id}
response)))))))
(defn- int-sequence?
[coll]
(boolean (and (seqable? coll) (seq coll) (every? int? coll))))
(defn- string-sequence?
[coll]
(boolean (and (seqable? coll) (seq coll) (every? string? coll))))
(defn- ensure-field-values!
[table-name-or-id]
(run! field-values/create-or-update-full-field-values!
(t2/reducible-select :model/Field :table_id (cond-> table-name-or-id (keyword? table-name-or-id) mt/id))))
(deftest answer-sources-test
(mt/with-premium-features #{:metabot-v3}
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
model-source-query (lib/query mp (lib.metadata/table mp (mt/id :products)))
metric-source-query (-> model-source-query
(lib/aggregate (lib/avg (lib.metadata/field mp (mt/id :products :rating))))
(lib/breakout (lib/with-temporal-bucket
(lib.metadata/field mp (mt/id :products :created_at)) :week)))
metric-data {:name "Metrica"
:description "Metric description"
:dataset_query (lib/->legacy-MBQL metric-source-query)
:type :metric}
model-data {:name "Model model"
:description "Model desc"
:dataset_query (lib/->legacy-MBQL model-source-query)
:type :model}]
(with-redefs [metabot-v3.tools.api/metabot-collection-name (str (random-uuid))]
(mt/with-temp [:model/Collection {collection-id :id} {:name metabot-v3.tools.api/metabot-collection-name}
:model/Card {metric-id :id} (assoc metric-data :collection_id collection-id)
:model/Card _ignored metric-data
:model/Card _ignored model-data
:model/Card {model-id :id} (assoc model-data :collection_id collection-id)]
(let [model-metric-base-query (lib/query mp (lib.metadata/card mp model-id))
rating-column (m/find-first (comp #{"RATING"} :name) (lib/visible-columns model-metric-base-query))
created-at-column (m/find-first (comp #{"CREATED_AT"} :name) (lib/breakoutable-columns model-metric-base-query))
model-metric-source-query (-> model-metric-base-query
(lib/aggregate (lib/avg rating-column))
(lib/breakout (lib/with-temporal-bucket created-at-column :week)))
model-metric-data {:name "Model metric"
:description "Model metric desc"
:dataset_query (lib/->legacy-MBQL model-metric-source-query)
:type :metric}]
(mt/with-temp [:model/Card {model-metric-id :id} (assoc model-metric-data :collection_id collection-id)]
(ensure-field-values! :products)
(let [conversation-id (str (random-uuid))
ai-token (ai-session-token)
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/answer-sources"
{:request-options {:headers {"x-metabase-session" ai-token}}}
{:conversation_id conversation-id})
expected-fields
[{:name "ID", :type "number", :semantic_type "pk"}
{:name "Ean", :type "string", :field_values string-sequence?}
{:name "Title", :type "string", :semantic_type "title", :field_values string-sequence?}
{:name "Category", :type "string", :semantic_type "category", :field_values string-sequence?}
{:name "Vendor", :type "string", :semantic_type "company", :field_values string-sequence?}
{:name "Price", :type "number"}
{:name "Rating", :type "number", :semantic_type "score"}
{:name "Created At", :type "datetime", :semantic_type "creation_timestamp"}]]
(is (=? {:structured_output
{:metrics [(-> metric-data
(select-keys [:name :description])
(assoc :id metric-id
:type "metric"
:default_time_dimension_field_id (format "c%d/%d" metric-id 7)
:queryable_dimensions
(map-indexed #(assoc %2 :field_id (format "c%d/%d" metric-id %1))
expected-fields)))
(-> model-metric-data
(select-keys [:name :description])
(assoc :id model-metric-id
:type "metric"
:default_time_dimension_field_id (format "c%d/%d" model-metric-id 7)
:queryable_dimensions
(map-indexed #(assoc %2 :field_id (format "c%d/%d" model-metric-id %1))
expected-fields)))]
:models [(-> model-data
(select-keys [:name :description])
(assoc :id model-id
:type "model"
:fields (map-indexed #(assoc %2 :field_id (format "c%d/%d" model-id %1))
expected-fields)
:metrics
[{:id model-metric-id
:name "Model metric"
:description "Model metric desc"
:default_time_dimension_field_id (format "c%d/%d" model-metric-id 7)}]))]}
:conversation_id conversation-id}
response))))))))))
(deftest ^:parallel get-current-user-test
(mt/with-premium-features #{:metabot-v3}
(let [conversation-id (str (random-uuid))
ai-token (ai-session-token)
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-current-user"
{:request-options {:headers {"x-metabase-session" ai-token}}}
{:conversation_id conversation-id})]
(is (=? {:structured_output {:id (mt/user->id :rasta)
:type "user"
:name "Rasta Toucan"
:email_address "rasta@metabase.com"}
:conversation_id conversation-id}
response)))))
(deftest ^:parallel get-dashboard-details-test
(mt/with-premium-features #{:metabot-v3}
(let [dash-data {:name "dashing dash", :description "dash description"}]
(mt/with-temp [:model/Dashboard {dash-id :id} dash-data]
(let [conversation-id (str (random-uuid))
ai-token (ai-session-token)
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-dashboard-details"
{:request-options {:headers {"x-metabase-session" ai-token}}}
{:arguments {:dashboard_id dash-id}
:conversation_id conversation-id})]
(is (=? {:structured_output (assoc dash-data :id dash-id, :type "dashboard")
:conversation_id conversation-id}
response)))))))
(deftest get-metric-details-test
(mt/with-premium-features #{:metabot-v3}
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
source-query (-> (lib/query mp (lib.metadata/table mp (mt/id :orders)))
(lib/aggregate (lib/avg (lib.metadata/field mp (mt/id :orders :subtotal))))
(lib/breakout (lib/with-temporal-bucket
(lib.metadata/field mp (mt/id :orders :created_at)) :week)))
metric-data {:name "Metrica"
:description "Metric description"
:dataset_query (lib/->legacy-MBQL source-query)
:type :metric}]
(mt/with-temp [:model/Card {metric-id :id} metric-data]
(ensure-field-values! :orders)
(ensure-field-values! :products)
(let [conversation-id (str (random-uuid))
ai-token (ai-session-token)
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-metric-details"
{:request-options {:headers {"x-metabase-session" ai-token}}}
{:arguments {:metric_id metric-id}
:conversation_id conversation-id})
expected-fields
[{:name "ID", :type "number", :semantic_type "pk"}
{:name "User ID", :type "number", :semantic_type "fk"}
{:name "Product ID", :type "number", :semantic_type "fk"}
{:name "Subtotal", :type "number"}
{:name "Tax", :type "number"}
{:name "Total", :type "number"}
{:name "Discount", :type "number", :semantic_type "discount"}
{:name "Created At", :type "datetime", :semantic_type "creation_timestamp"}
{:name "Quantity", :type "number", :semantic_type "quantity", :field_values int-sequence?}
{:name "ID", :type "number", :semantic_type "pk", :table_reference "User"}
{:name "Address", :type "string", :table_reference "User"}
{:name "Email", :type "string", :semantic_type "email", :table_reference "User"}
{:name "Password", :type "string", :table_reference "User"}
{:name "Name", :type "string", :semantic_type "name", :table_reference "User"}
{:name "City", :type "string", :semantic_type "city", :table_reference "User"}
{:name "Longitude", :type "number", :semantic_type "longitude", :table_reference "User"}
{:name "State", :type "string", :semantic_type "state", :table_reference "User"}
{:name "Source", :type "string", :semantic_type "source", :table_reference "User"}
{:name "Birth Date", :type "date", :table_reference "User"}
{:name "Zip", :type "string", :table_reference "User"}
{:name "Latitude", :type "number", :semantic_type "latitude", :table_reference "User"}
{:name "Created At", :type "datetime", :semantic_type "creation_timestamp", :table_reference "User"}
{:name "ID", :type "number", :semantic_type "pk", :table_reference "Product"}
{:name "Ean", :type "string", :field_values string-sequence?, :table_reference "Product"}
{:name "Title", :type "string"
:semantic_type "title"
:field_values string-sequence?
:table_reference "Product"}
{:name "Category"
:type "string"
:semantic_type "category"
:field_values string-sequence?
:table_reference "Product"}
{:name "Vendor", :type "string"
:semantic_type "company"
:field_values string-sequence?
:table_reference "Product"}
{:name "Price", :type "number", :table_reference "Product"}
{:name "Rating", :type "number", :semantic_type "score", :table_reference "Product"}
{:name "Created At", :type "datetime", :semantic_type "creation_timestamp", :table_reference "Product"}]]
(is (=? {:structured_output (-> metric-data
(select-keys [:name :description])
(assoc :id metric-id
:type "metric"
:default_time_dimension_field_id (format "c%d/%d" metric-id 7)
:queryable_dimensions
(map-indexed #(assoc %2 :field_id (format "c%d/%d" metric-id %1))
expected-fields)))
:conversation_id conversation-id}
response)))))))
(deftest ^:parallel get-query-details-test
(mt/with-premium-features #{:metabot-v3}
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
source-query (-> (lib/query mp (lib.metadata/table mp (mt/id :products)))
(lib/aggregate (lib/avg (lib.metadata/field mp (mt/id :products :rating))))
(lib/breakout (lib/with-temporal-bucket
(lib.metadata/field mp (mt/id :products :created_at)) :week)))
query (lib/->legacy-MBQL source-query)
conversation-id (str (random-uuid))
ai-token (ai-session-token)
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-query-details"
{:request-options {:headers {"x-metabase-session" ai-token}}}
{:arguments {:query query}
:conversation_id conversation-id})
generated-id (-> response :structured_output :query_id)]
(is (=? {:structured_output {:type "query"
:query_id string?
:query query
:result_columns
[{:field_id (str "q" generated-id "/0"), :name "Created At: Week", :type "datetime"}
{:field_id (str "q" generated-id "/1"), :name "Average of Rating", :type "number"}]}
:conversation_id conversation-id}
;; normalize query to convert strings like "field" to keywords
(update-in response [:structured_output :query] mbql.normalize/normalize))))))
(deftest ^:parallel get-report-details-test
(mt/with-premium-features #{:metabot-v3}
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
source-query (-> (lib/query mp (lib.metadata/table mp (mt/id :products)))
(lib/aggregate (lib/avg (lib.metadata/field mp (mt/id :products :rating))))
(lib/breakout (lib/with-temporal-bucket
(lib.metadata/field mp (mt/id :products :created_at)) :week)))
question-data {:name "Question?"
:description "Descriptive?"
:dataset_query (lib/->legacy-MBQL source-query)
:type :question}]
(mt/with-temp [:model/Card {question-id :id} question-data]
(let [conversation-id (str (random-uuid))
ai-token (ai-session-token)
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-report-details"
{:request-options {:headers {"x-metabase-session" ai-token}}}
{:arguments {:report_id question-id}
:conversation_id conversation-id})]
(is (=? {:structured_output (-> question-data
(select-keys [:name :description])
(assoc :id question-id
:type "question"
:result_columns
(map-indexed #(assoc %2 :field_id (format "c%d/%d" question-id %1))
[{:name "Created At: Week", :type "datetime"}
{:name "Average of Rating", :type "number"}])))
:conversation_id conversation-id}
response)))))))
(deftest ^:parallel get-model-details-test
(mt/with-premium-features #{:metabot-v3}
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
source-query (-> (lib/query mp (lib.metadata/table mp (mt/id :products)))
(lib/aggregate (lib/avg (lib.metadata/field mp (mt/id :products :rating))))
(lib/breakout (lib/with-temporal-bucket
(lib.metadata/field mp (mt/id :products :created_at)) :week)))
model-data {:name "Model model"
:description "Model desc"
:dataset_query (lib/->legacy-MBQL source-query)
:type :model}]
(mt/with-temp [:model/Card {model-id :id} model-data]
(doseq [arguments [{:model_id model-id}
{:table_id (str "card__" model-id)}]]
(let [conversation-id (str (random-uuid))
ai-token (ai-session-token)
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-table-details"
{:request-options {:headers {"x-metabase-session" ai-token}}}
{:arguments arguments
:conversation_id conversation-id})]
(is (=? {:structured_output (-> model-data
(select-keys [:name :description])
(assoc :id model-id
:type "model"
:fields
(map-indexed #(assoc %2 :field_id (format "c%d/%d" model-id %1))
[{:name "Created At: Week", :type "datetime"}
{:name "Average of Rating", :type "number"}])))
:conversation_id conversation-id}
response))))))))
(deftest get-table-details-test
(mt/with-premium-features #{:metabot-v3}
(let [table-id (mt/id :products)]
(ensure-field-values! table-id)
(doseq [arg-id [table-id (str table-id)]]
(let [conversation-id (str (random-uuid))
ai-token (ai-session-token)
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-table-details"
{:request-options {:headers {"x-metabase-session" ai-token}}}
{:arguments {:table_id arg-id}
:conversation_id conversation-id})
expected-fields
[{:name "ID", :type "number", :semantic_type "pk"}
{:name "Ean", :type "string", :field_values string-sequence?}
{:name "Title", :type "string", :semantic_type "title", :field_values string-sequence?}
{:name "Category", :type "string", :semantic_type "category", :field_values string-sequence?}
{:name "Vendor", :type "string", :semantic_type "company", :field_values string-sequence?}
{:name "Price", :type "number"}
{:name "Rating", :type "number", :semantic_type "score"}
{:name "Created At", :type "datetime", :semantic_type "creation_timestamp"}]]
(is (=? {:structured_output {:name "Products"
:id table-id
:type "table"
:fields (map-indexed #(assoc %2 :field_id (format "t%d/%d" table-id %1))
expected-fields)}
:conversation_id conversation-id}
response)))))))

View File

@@ -0,0 +1,52 @@
(ns metabase-enterprise.metabot-v3.tools.create-dashboard-subscription-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription
:as metabot-v3.tools.create-dashboard-subscription]
[metabase.api.common :as api]
[metabase.test :as mt]))
(deftest create-dashboard-subscription-test
(mt/with-model-cleanup [:model/Pulse]
(mt/with-temp [:model/User {:keys [email] user-id :id} {:email "email@example.com"}
:model/Collection {collection-id :id} {}
:model/Dashboard {dashboard-id :id} {:collection_id collection-id}
:model/Card {card-id-0 :id} {}
:model/DashboardTab {dashboard-tab-id-0 :id} {:dashboard_id dashboard-id
:position 0}
:model/DashboardCard _normal-dashcard-0 {:dashboard_id dashboard-id
:card_id card-id-0
:dashboard_tab_id dashboard-tab-id-0
:row 1
:col 0}
:model/Card {card-id-1 :id} {}
:model/DashboardTab {dashboard-tab-id-1 :id} {:dashboard_id dashboard-id
:position 1}
:model/DashboardCard _normal-dashcard-1 {:dashboard_id dashboard-id
:card_id card-id-1
:dashboard_tab_id dashboard-tab-id-1
:row 2
:col 3}
:model/DashboardCard _cardless-dashcard {:dashboard_id dashboard-id
:dashboard_tab_id dashboard-tab-id-0
:row 0
:col 0}]
(let [invoke-tool #(binding [api/*current-user-id* user-id]
(metabot-v3.tools.create-dashboard-subscription/create-dashboard-subscription %))
base-data {:dashboard-id dashboard-id
:email email
:schedule {:frequency "monthly"
:day_of_month "last-sunday"
:hour 7}}]
(testing "Subscription can be created"
(is (= {:output "success"}
(invoke-tool base-data))))
(testing "Return an error message if the user cannot be found"
(is (= {:output "no user with this email found"}
(invoke-tool (update base-data :email str ".hu")))))
(testing "Return an error message if the dashboard-id is invalid"
(is (= {:output "invalid dashboard_id"}
(invoke-tool (update base-data :dashboard-id str)))))
(testing "Return an error message if the dashboard cannot be found"
(is (= {:output "no dashboard with this dashboard_id found"}
(invoke-tool (update base-data :dashboard-id -)))))))))

View File

@@ -0,0 +1,453 @@
(ns metabase-enterprise.metabot-v3.tools.filters-test
(:require
[clojure.test :refer :all]
[medley.core :as m]
[metabase-enterprise.metabot-v3.dummy-tools :as metabot-v3.dummy-tools]
[metabase-enterprise.metabot-v3.tools.filters :as metabot-v3.tools.filters]
[metabase.lib.convert :as lib.convert]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.metadata.jvm :as lib.metadata.jvm]
[metabase.test :as mt]
[metabase.util :as u]))
(defn- by-name
[dimensions dimension-name]
(let [pred (if (string? dimension-name)
(comp #{dimension-name} :name)
(every-pred (comp #{(first dimension-name)} :table-reference)
(comp #{(second dimension-name)} :name)))]
(m/find-first pred dimensions)))
(deftest ^:parallel query-metric-test
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
created-at-meta (lib.metadata/field mp (mt/id :orders :created_at))
metric-query (-> (lib/query mp (lib.metadata/table mp (mt/id :orders)))
(lib/aggregate (lib/avg (lib.metadata/field mp (mt/id :orders :subtotal))))
(lib/breakout (lib/with-temporal-bucket created-at-meta :month)))
legacy-metric-query (lib.convert/->legacy-MBQL metric-query)]
(mt/with-temp [:model/Card {metric-id :id} {:dataset_query legacy-metric-query
:database_id (mt/id)
:name "Average Order Value"
:description "The average subtotal of orders."
:type :metric}]
(testing "User has to have execution rights."
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You don't have permissions to do that."
(metabot-v3.tools.filters/query-metric
{:metric-id metric-id
:filters []
:group-by []}))))
(mt/with-current-user (mt/user->id :crowberto)
(let [metric-details (metabot-v3.dummy-tools/metric-details metric-id)
->field-id #(u/prog1 (-> metric-details :queryable_dimensions (by-name %) :field_id)
(when-not <>
(throw (ex-info (str "Column " % " not found") {:column %}))))]
(testing "Trivial query works."
(is (=? {:structured-output {:type :query,
:query_id string?
:query {:database (mt/id)
:type :query
:query {:source-table (mt/id :orders)
:aggregation [[:metric metric-id]]}}}}
(metabot-v3.tools.filters/query-metric
{:metric-id metric-id
:filters []
:group-by []}))))
(testing "Filtering and grouping works and ignores bucketing for non-temporal columns."
(is (=? {:structured-output {:type :query,
:query_id string?
:query {:database (mt/id)
:type :query
:query {:source-table (mt/id :orders)
:aggregation [[:metric metric-id]]
:breakout [[:field (mt/id :products :category)
{:base-type :type/Text
:source-field (mt/id :orders :product_id)}]]
:filter
[:and
[:= [:field (mt/id :people :state)
{:base-type :type/Text
:source-field (mt/id :orders :user_id)}]
"TX"]
[:> [:field (mt/id :orders :discount)
{:base-type :type/Float}]
3]]}}}}
(metabot-v3.tools.filters/query-metric
{:metric-id metric-id
:filters [{:field_id (->field-id ["User" "State"])
:operation "string-equals"
:value "TX"}
{:field_id (->field-id "Discount")
:operation "number-greater-than"
:value 3}]
:group-by [{:field_id (->field-id ["Product" "Category"])
:field_granularity "year"}]}))))
(testing "Temporal bucketing works for temporal columns."
(is (=? {:structured-output {:type :query,
:query_id string?
:query {:database (mt/id)
:type :query
:query {:source-table (mt/id :orders)
:aggregation [[:metric metric-id]]
:filter [:and
[:!=
[:get-week
[:field (mt/id :orders :created_at)
{:base-type :type/DateTimeWithLocalTZ}]
:iso]
1 2 3]
[:=
[:field (mt/id :orders :created_at)
{:base-type :type/DateTimeWithLocalTZ
:temporal-unit :month-of-year}]
6 7 8]]
:breakout [[:field (mt/id :orders :created_at)
{:base-type :type/DateTimeWithLocalTZ
:temporal-unit :week}]]}}}}
(metabot-v3.tools.filters/query-metric
{:metric-id metric-id
:filters [{:field_id (->field-id "Created At")
:bucket "week-of-year"
:operation "not-equals"
:values [1 2 3]}
{:field_id (->field-id "Created At")
:bucket "month-of-year"
:operation "equals"
:values [6 7 8]}]
:group-by [{:field_id (->field-id "Created At")
:field_granularity "week"}]}))))
(testing "Multi-value filtering works"
(is (=? {:structured-output {:type :query,
:query_id string?
:query {:database (mt/id)
:type :query
:query {:source-table (mt/id :orders)
:aggregation [[:metric metric-id]]
:filter
[:and
[:starts-with {}
[:field (mt/id :people :state)
{:base-type :type/Text
:source-field (mt/id :orders :user_id)}]
"A" "G"]
[:!=
[:field (mt/id :orders :discount) {:base-type :type/Float}]
3 42]]}}}}
(metabot-v3.tools.filters/query-metric
{:metric-id metric-id
:filters [{:field_id (->field-id ["User" "State"])
:operation "string-starts-with"
:values ["A" "G"]}
{:field_id (->field-id "Discount")
:operation "not-equals"
:values [3 42]}]})))))
(testing "Missing metric results in an error."
(is (= {:output (str "No metric found with metric_id " Integer/MAX_VALUE)}
(metabot-v3.tools.filters/query-metric {:metric-id Integer/MAX_VALUE}))))
(testing "Invalid metric-id results in an error."
(is (= {:output (str "Invalid metric_id " metric-id)}
(metabot-v3.tools.filters/query-metric {:metric-id (str metric-id)}))))))))
(deftest ^:parallel query-model-test
(mt/with-temp [:model/Card {model-id :id} {:dataset_query (mt/mbql-query orders {})
:database_id (mt/id)
:name "Orders Model"
:description "The _real_ orders."
:type :model}]
(testing "User has to have execution rights."
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You don't have permissions to do that."
(metabot-v3.tools.filters/query-model
{:model-id model-id
:filters []
:group-by []}))))
(mt/with-current-user (mt/user->id :crowberto)
(let [model-details (-> (metabot-v3.dummy-tools/get-table-details {:model-id model-id})
:structured-output)
model-card-id (str "card__" model-id)
->field-id #(u/prog1 (-> model-details :fields (by-name %) :field_id)
(when-not <>
(throw (ex-info (str "Column " % " not found") {:column %}))))
order-created-at-field-id (->field-id "Created At")]
(testing "Trivial query works."
(is (=? {:structured-output {:type :query,
:query_id string?
:query (mt/mbql-query orders {:source-table model-card-id})}}
(metabot-v3.tools.filters/query-model
{:model-id model-id
:filters []
:group-by []}))))
(testing "Filtering, aggregation and grouping works and ignores bucketing for non-temporal columns."
(is (=? {:structured-output {:type :query,
:query_id string?
:query (mt/mbql-query orders
{:source-table model-card-id
:aggregation [[:sum [:field "SUBTOTAL" {}]]]
:breakout [[:field "PRODUCT_ID" {}]]
:filter [:> [:field "DISCOUNT" {}] 3]})}}
(metabot-v3.tools.filters/query-model
{:model-id model-id
:filters [{:field_id (->field-id "Discount")
:operation "number-greater-than"
:value 3}]
:aggregations [{:field_id (->field-id "Subtotal")
:bucket "day-of-month" ; ignored
:function "sum"}]
:group-by [{:field_id (->field-id "Product ID")
:field_granularity "year"}]}))))
(testing "Temporal bucketing works for temporal columns."
(is (=? {:structured-output {:type :query,
:query_id string?
:query (mt/mbql-query orders
{:source-table model-card-id
:aggregation [[:min [:field "CREATED_AT"
{:base-type :type/DateTimeWithLocalTZ
:temporal-unit :hour-of-day}]]
[:avg [:field "CREATED_AT"
{:base-type :type/DateTimeWithLocalTZ
:temporal-unit :hour-of-day}]]
[:max [:field "CREATED_AT"
{:base-type :type/DateTimeWithLocalTZ
:temporal-unit :hour-of-day}]]]
:filter [:and
[:!= [:get-week [:field "CREATED_AT" {}] :iso] 1 2 3]
[:=
[:field "CREATED_AT"
{:base-type :type/DateTimeWithLocalTZ
:temporal-unit :month-of-year}]
6 7 8]]
:breakout [[:field "PRODUCT_ID" {}]
[:field "CREATED_AT"
{:base-type :type/DateTimeWithLocalTZ
:temporal-unit :week}]]})}}
(metabot-v3.tools.filters/query-model
{:model-id model-id
:filters [{:field_id order-created-at-field-id
:bucket "week-of-year"
:operation "not-equals"
:values [1 2 3]}
{:field_id order-created-at-field-id
:bucket "month-of-year"
:operation "equals"
:values [6 7 8]}]
:aggregations [{:field_id order-created-at-field-id
:bucket "hour-of-day"
:function "min"}
{:field_id order-created-at-field-id
:bucket "hour-of-day"
:function "avg"}
{:field_id order-created-at-field-id
:bucket "hour-of-day"
:function "max"}]
:group-by [{:field_id (->field-id "Product ID")}
{:field_id order-created-at-field-id
:field_granularity "week"}]}))))
(testing "Fields can be selected"
(is (=? {:structured-output
{:type :query,
:query_id string?
:query (mt/mbql-query orders
{:source-table model-card-id
:expressions {"Created At: Day of week" [:get-day-of-week [:field "CREATED_AT" {}] :iso]}
:fields [[:field "CREATED_AT" {:base-type :type/DateTimeWithLocalTZ, :temporal-unit :day-of-month}]
[:expression "Created At: Day of week" {:base-type :type/Integer}]
[:field "TOTAL" {:base-type :type/Float}]]
:filter [:!= [:field "USER_ID" {}] 3 42]})}}
(metabot-v3.tools.filters/query-model
{:model-id model-id
:fields [{:field_id order-created-at-field-id
:bucket "day-of-month"}
{:field_id order-created-at-field-id
:bucket "day-of-week"}
{:field_id (->field-id "Total")}]
:filters [{:field_id (->field-id "User ID")
:operation "not-equals"
:values [3 42]}]})))))
(testing "Missing model results in an error."
(is (= {:output (str "No model found with model_id " Integer/MAX_VALUE)}
(metabot-v3.tools.filters/query-model {:model-id Integer/MAX_VALUE}))))
(testing "Invalid model-id results in an error."
(is (= {:output (str "Invalid model_id " model-id)}
(metabot-v3.tools.filters/query-model {:model-id (str model-id)})))))))
(deftest ^:parallel filter-records-table-test
(testing "User has to have execution rights, otherwise the table should be invisible."
(is (= {:output (str "No table found with table_id " (mt/id :orders))}
(metabot-v3.tools.filters/filter-records
{:data-source {:table_id (mt/id :orders)}
:filters []}))))
(mt/with-current-user (mt/user->id :crowberto)
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
table-id (mt/id :orders)
table-details (#'metabot-v3.dummy-tools/table-details table-id {:metadata-provider mp})
->field-id #(u/prog1 (-> table-details :fields (by-name %) :field_id)
(when-not <>
(throw (ex-info (str "Column " % " not found") {:column %}))))]
(testing "Trivial query works."
(is (=? {:structured-output {:type :query,
:query_id string?
:query {:database (mt/id)
:type :query
:query {:source-table table-id}}}}
(metabot-v3.tools.filters/filter-records
{:data-source {:table_id table-id}
:filters []}))))
(testing "Filtering works."
(is (=? {:structured-output {:type :query,
:query_id string?
:query {:database (mt/id)
:type :query
:query {:source-table (mt/id :orders)
:filter
[:and
[:=
[:get-day-of-week
[:field (mt/id :orders :created_at)
{:base-type :type/DateTimeWithLocalTZ}]
:iso]
1 7]
[:>
[:field (mt/id :orders :discount) {:base-type :type/Float}]
3]]}}}}
(metabot-v3.tools.filters/filter-records
{:data-source {:table_id table-id}
:filters [{:field_id (->field-id "Created At")
:bucket "day-of-week"
:operation "equals"
:values [1 7]}
{:field_id (->field-id "Discount")
:operation "number-greater-than"
:value 3}]})))))
(testing "Missing table results in an error."
(is (= {:output (str "No table found with table_id " Integer/MAX_VALUE)}
(metabot-v3.tools.filters/filter-records
{:data-source {:table_id Integer/MAX_VALUE}}))))))
(deftest ^:parallel filter-records-model-test
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
query (lib/query mp (lib.metadata/table mp (mt/id :orders)))
legacy-query (lib.convert/->legacy-MBQL query)]
(mt/with-temp [:model/Card {model-id :id} {:dataset_query legacy-query
:database_id (mt/id)
:name "Orders"
:description "The orders."
:type :model}]
(testing "User has to have execution rights."
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You don't have permissions to do that."
(metabot-v3.tools.filters/filter-records
{:data-source {:table_id (str "card__" model-id)}
:filters []}))))
(mt/with-current-user (mt/user->id :crowberto)
(let [model-details (#'metabot-v3.dummy-tools/card-details model-id)
table-id (str "card__" model-id)
->field-id #(u/prog1 (-> model-details :fields (by-name %) :field_id)
(when-not <>
(throw (ex-info (str "Column " % " not found") {:column %}))))]
(testing "Trivial query works."
(is (=? {:structured-output {:type :query,
:query_id string?
:query {:database (mt/id)
:type :query
:query {:source-table table-id}}}}
(metabot-v3.tools.filters/filter-records
{:data-source {:table_id table-id}
:filters []}))))
(testing "Filtering works."
(is (=? {:structured-output {:type :query,
:query_id string?
:query {:database (mt/id)
:type :query
:query {:source-table table-id
:filter [:> [:field "DISCOUNT" {:base-type :type/Float}] 3]}}}}
(metabot-v3.tools.filters/filter-records
{:data-source {:table_id table-id}
:filters [{:field_id (->field-id "Discount")
:operation "number-greater-than"
:value 3}]})))))
(testing "Missing table results in an error."
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Not found."
(metabot-v3.tools.filters/filter-records
{:data-source {:table_id (str "card__" Integer/MAX_VALUE)}}))))))))
(deftest ^:parallel filter-records-report-test
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
table-id (mt/id :orders)
query (lib/query mp (lib.metadata/table mp table-id))
legacy-query (lib.convert/->legacy-MBQL query)]
(mt/with-temp [:model/Card {card-id :id} {:dataset_query legacy-query
:database_id (mt/id)
:name "Orders"
:description "The orders."
:type :question}]
(testing "User has to have execution rights."
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You don't have permissions to do that."
(metabot-v3.tools.filters/filter-records
{:data-source {:report_id card-id}
:filters []}))))
(mt/with-current-user (mt/user->id :crowberto)
(let [report-details (#'metabot-v3.dummy-tools/card-details card-id)
->field-id #(u/prog1 (-> report-details :fields (by-name %) :field_id)
(when-not <>
(throw (ex-info (str "Column " % " not found") {:column %}))))]
(testing "Trivial query works."
(is (=? {:structured-output {:type :query,
:query_id string?
:query {:database (mt/id)
:type :query
:query {:source-table table-id}}}}
(metabot-v3.tools.filters/filter-records
{:data-source {:report_id card-id}
:filters []}))))
(testing "Filtering works."
(is (=? {:structured-output {:type :query,
:query_id string?
:query {:database (mt/id)
:type :query
:query {:source-table table-id
:filter [:>
[:field
(mt/id :orders :discount)
{:base-type :type/Float}]
3]}}}}
(metabot-v3.tools.filters/filter-records
{:data-source {:report_id card-id}
:filters [{:field_id (->field-id "Discount")
:operation "number-greater-than"
:value 3}]})))))
(testing "Missing table results in an error."
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Not found."
(metabot-v3.tools.filters/filter-records
{:data-source {:report_id Integer/MAX_VALUE}}))))))))
(deftest ^:parallel filter-records-query-test
(let [query-id (u/generate-nano-id)
mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
table-id (mt/id :orders)
query (lib/query mp (lib.metadata/table mp table-id))
legacy-query (lib.convert/->legacy-MBQL query)
query-details (#'metabot-v3.dummy-tools/execute-query query-id legacy-query)
->field-id #(u/prog1 (-> query-details :result_columns (by-name %) :field_id)
(when-not <>
(throw (ex-info (str "Column " % " not found") {:column %}))))
input {:data-source {:query_id query-id}
:filters [{:field_id (->field-id "Discount")
:operation "number-greater-than"
:value 3}]}
expected {:structured-output {:type :query,
:query_id string?
:query {:database (mt/id)
:type :query
:query {:source-query {:source-table table-id}
:filter [:>
[:field
"DISCOUNT"
{:base-type :type/Float}]
3]}}}}]
(testing "Filtering works."
(testing "new tool call with query and query_id"
(is (=? expected
(metabot-v3.tools.filters/filter-records
(assoc input :data-source (select-keys query-details [:query :query_id]))))))
(testing "new tool call with just query"
(is (=? expected
(metabot-v3.tools.filters/filter-records
(assoc input :data-source (select-keys query-details [:query])))))))))

View File

@@ -0,0 +1,148 @@
(ns metabase-enterprise.metabot-v3.tools.find-outliers-test
(:require
[clojure.test :refer :all]
[medley.core :as m]
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
[metabase-enterprise.metabot-v3.dummy-tools :as metabot-v3.dummy-tools]
[metabase-enterprise.metabot-v3.tools.find-outliers :as metabot-v3.tools.find-outliers]
[metabase.lib.convert :as lib.convert]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.metadata.jvm :as lib.metadata.jvm]
[metabase.test :as mt]
[metabase.util :as u]))
(defn- by-name
[dimensions dimension-name]
(m/find-first (comp #{dimension-name} :name) dimensions))
(defn- test-card
[]
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
created-at-meta (lib.metadata/field mp (mt/id :orders :created_at))
query (-> (lib/query mp (lib.metadata/table mp (mt/id :orders)))
(lib/aggregate (lib/avg (lib.metadata/field mp (mt/id :orders :subtotal))))
(lib/breakout (lib/with-temporal-bucket created-at-meta :year)))
legacy-query (lib.convert/->legacy-MBQL query)]
{:dataset_query legacy-query
:database_id (mt/id)
:name "Average Order Value"
:description "The average subtotal of orders."}))
(def ^:private test-dimensions
[{:dimension "2016-01-01T00:00:00Z", :value "54.43"}
{:dimension "2017-01-01T00:00:00Z", :value "54.66"}
{:dimension "2018-01-01T00:00:00Z", :value "83.72"}
{:dimension "2019-01-01T00:00:00Z", :value "84.07"}
{:dimension "2020-01-01T00:00:00Z", :value "84.68"}])
(def ^:private normalize-value-xf
(map (fn [dimension] (update dimension :value #(-> % str (subs 0 5))))))
(defn- ai-find-outliers
"Simulates the AI service finding the outliers in `dimensions`."
[dimensions]
(into [] (comp (take 2) normalize-value-xf) dimensions))
(def ^:private test-outliers
(ai-find-outliers test-dimensions))
(defn- execute-test!
[call-tool]
(testing "User has to have execution rights."
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You don't have permissions to do that."
(call-tool))))
(testing "Request gets forwarded to AI service."
(let [input-dimensions (atom nil)]
(mt/with-current-user (mt/user->id :crowberto)
(with-redefs [metabot-v3.client/find-outliers-request
(fn [dimensions] (ai-find-outliers (reset! input-dimensions dimensions)))]
(is (= {:structured-output test-outliers}
(call-tool)))
(is (= test-dimensions
(into [] normalize-value-xf @input-dimensions))))))))
(deftest metric-find-outliers-test
(mt/with-temp [:model/Card {metric-id :id} (assoc (test-card) :type :metric)]
(execute-test! #(metabot-v3.tools.find-outliers/find-outliers
{:data-source {:metric_id metric-id}}))))
(deftest report-find-outliers-test
(mt/with-temp [:model/Card {report-id :id} (assoc (test-card) :type :question)]
(let [report-details (mt/with-current-user (mt/user->id :crowberto)
(#'metabot-v3.dummy-tools/card-details report-id))
->field-id #(u/prog1 (-> report-details :fields (by-name %) :field_id)
(when-not <>
(throw (ex-info (str "Column " % " not found") {:column %}))))
result-field-id (->field-id "Average of Subtotal")]
(execute-test! #(metabot-v3.tools.find-outliers/find-outliers
{:data-source {:report_id report-id
:result_field_id result-field-id}})))))
(deftest query-find-outliers-test
(let [query-id (u/generate-nano-id)
query-details (#'metabot-v3.dummy-tools/execute-query query-id (:dataset_query (test-card)))
->field-id #(u/prog1 (-> query-details :result_columns (by-name %) :field_id)
(when-not <>
(throw (ex-info (str "Column " % " not found") {:column %}))))
result-field-id (->field-id "Average of Subtotal")]
(testing "new style tool call with query and query_id"
(execute-test! #(metabot-v3.tools.find-outliers/find-outliers
{:data-source {:query (:query query-details)
:query_id query-id
:result_field_id result-field-id}})))
(testing "new style tool call with just query"
(execute-test! #(metabot-v3.tools.find-outliers/find-outliers
{:data-source {:query (:query query-details)
:result_field_id result-field-id}})))))
(deftest ^:parallel metric-find-outliers-no-temporal-dimension-test
(mt/with-temp [:model/Card {metric-id :id} (-> (test-card)
(m/dissoc-in [:dataset_query :query :breakout]
[:dataset_query :query :breakout-idents])
(assoc :type :metric))]
(mt/with-current-user (mt/user->id :crowberto)
(is (= {:output "No temporal dimension found. Outliers can only be detected when a temporal dimension is available."}
(metabot-v3.tools.find-outliers/find-outliers
{:data-source {:metric_id metric-id}}))))))
(deftest ^:parallel metric-find-outliers-no-numeric-dimension-test
(mt/with-temp [:model/Card {metric-id :id} (-> (test-card)
(m/dissoc-in [:dataset_query :query :aggregation]
[:dataset_query :query :aggregation-idents])
(assoc :type :metric))]
(mt/with-current-user (mt/user->id :crowberto)
(is (= {:output "Could not determine result field."}
(metabot-v3.tools.find-outliers/find-outliers
{:data-source {:metric_id metric-id}}))))))
(deftest ^:parallel find-outliers-wrong-query-test
(let [query-id (u/generate-nano-id)
query-details (#'metabot-v3.dummy-tools/execute-query query-id (:dataset_query (test-card)))
->field-id #(u/prog1 (-> query-details :result_columns (by-name %) :field_id)
(when-not <>
(throw (ex-info (str "Column " % " not found") {:column %}))))
result-field-id (->field-id "Average of Subtotal")]
(mt/with-current-user (mt/user->id :crowberto)
(are [details output] (= {:output output}
(metabot-v3.tools.find-outliers/find-outliers
{:data-source {:query (:query details)
:result_field_id result-field-id}}))
(assoc-in query-details [:query :query :source-table] Integer/MAX_VALUE)
"Unexpected error running query"
(m/dissoc-in query-details [:query :query :breakout])
"No temporal dimension found. Outliers can only be detected when a temporal dimension is available.")
(let [wrong-result-field-id (str result-field-id "99999")]
(is (= {:output (str "Invalid result_field_id " wrong-result-field-id)}
(metabot-v3.tools.find-outliers/find-outliers
{:data-source {:query (:query query-details)
:result_field_id wrong-result-field-id}})))))))
(deftest ^:parallel invalid-ids-test
(are [data-source output] (= {:output output}
(metabot-v3.tools.find-outliers/find-outliers {:data-source data-source}))
{:metric_id "42"} "Invalid metric_id as data_source"
{:report_id "42"} "Invalid report_id as data_source"
{:table_id 42} "Invalid data_source"))

View File

@@ -0,0 +1,49 @@
(ns metabase-enterprise.metabot-v3.tools.generate-insights-test
(:require
[buddy.core.codecs :as codecs]
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.tools.generate-insights :as metabot-v3.tools.generate-insights]
[metabase.util.json :as json]))
(set! *warn-on-reflection* true)
(deftest ^:parallel generate-insights-test
(testing "Metrics"
(let [results-url "/auto/dashboard/question/42"]
(is (= {:output results-url
:reactions [{:type :metabot.reaction/redirect :url results-url}]}
(metabot-v3.tools.generate-insights/generate-insights {:for {:metric_id 42}})))))
(testing "Reports"
(let [results-url "/auto/dashboard/question/42"]
(is (= {:output results-url
:reactions [{:type :metabot.reaction/redirect :url results-url}]}
(metabot-v3.tools.generate-insights/generate-insights {:for {:report_id 42}})))))
(testing "Tables"
(let [results-url "/auto/dashboard/table/42"]
(are [id] (= {:output results-url
:reactions [{:type :metabot.reaction/redirect :url results-url}]}
(metabot-v3.tools.generate-insights/generate-insights {:for {:table_id id}}))
42
"42"))
(let [results-url "/auto/dashboard/table/card__42"]
(is (= {:output results-url
:reactions [{:type :metabot.reaction/redirect :url results-url}]}
(metabot-v3.tools.generate-insights/generate-insights {:for {:table_id "card__42"}}))))
(are [id] (= {:output "Invalid table_id"}
(metabot-v3.tools.generate-insights/generate-insights {:for {:table_id id}}))
:42
"table_name"))
(testing "Queries"
(let [query {:database 1
:type :query
:query {:source-table 3
:breakout [[:field 13 {:base-type :type/DateTime, :temporal-unit :month}]]
:aggregation [[:metric 123]]
:filter [:=
[:get-year [:field 13 {:base-type :type/DateTime, :temporal-unit :month}]]
2024]}}
entity-id (-> query json/encode .getBytes codecs/bytes->b64-str)
results-url (str "/auto/dashboard/adhoc/" entity-id)]
(is (= {:output results-url
:reactions [{:type :metabot.reaction/redirect :url results-url}]}
(metabot-v3.tools.generate-insights/generate-insights {:for {:query query}}))))))

View File

@@ -0,0 +1,22 @@
(ns metabase-enterprise.metabot-v3.tools.invite-user-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.tools.invite-user :as metabot-v3.tools.invite-user]
[metabase.test :as mt]))
(deftest invite-user-test
(mt/with-model-cleanup [:model/User]
(let [invoke-tool #(metabot-v3.tools.invite-user/invite-user {:email %})]
(testing "The user has to have permission to invite users."
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You don't have permissions to do that."
(invoke-tool "user@example.com"))))
(mt/with-current-user (mt/user->id :crowberto)
(testing "User can be invited"
(is (= {:output "user@example.com has been invited to Metabase."}
(invoke-tool "user@example.com"))))
(testing "Return an error message if the user already exists."
(is (= {:output "Email address already in use."}
(invoke-tool "crowberto@metabase.com"))))
(testing "Return an error message if email is invalid."
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Invalid input: .*"
(invoke-tool "total garbage, not an email address"))))))))

View File

@@ -0,0 +1,24 @@
(ns metabase-enterprise.metabot-v3.tools.show-results-to-user-test
(:require
[buddy.core.codecs :as codecs]
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.tools.show-results-to-user :as metabot-v3.tools.show-results-to-user]
[metabase.public-settings :as public-settings]
[metabase.util.json :as json]))
(set! *warn-on-reflection* true)
(deftest it-generates-a-reaction
(let [query {:database 1
:type :query
:query {:source-table 3
:breakout [[:field 13 {:base-type :type/DateTime, :temporal-unit :month}]]
:aggregation [[:metric 123]]
:filter [:=
[:get-year [:field 13 {:base-type :type/DateTime, :temporal-unit :month}]]
2024]}}
query-hash (-> {:dataset_query query} json/encode .getBytes codecs/bytes->b64-str)
results-url (str "/question#" query-hash)]
(is (= {:output (str "Results can be seen at: " (public-settings/site-url) results-url)
:reactions [{:type :metabot.reaction/redirect, :url results-url}]}
(metabot-v3.tools.show-results-to-user/show-results-to-user {:query query})))))

View File

@@ -0,0 +1,68 @@
import { P, match } from "ts-pattern";
import { t } from "ttag";
import { skipToken } from "metabase/api";
import type { FixSqlQueryButtonProps } from "metabase/plugins";
import { Button, Icon } from "metabase/ui";
import { useGetFixedSqlQueryQuery } from "metabase-enterprise/api";
import { getFixRequest, getFixedLineNumbers, getFixedQuery } from "./utils";
export function FixSqlQueryButton({
query,
queryError,
queryErrorType,
onQueryFix,
onHighlightLines,
}: FixSqlQueryButtonProps) {
const request = getFixRequest(query, queryError, queryErrorType);
const { data, error, isFetching } = useGetFixedSqlQueryQuery(
request ?? skipToken,
);
const handleClick = () => {
if (data) {
const fixedQuery = getFixedQuery(query, data.fixes);
const fixedLineNumbers = getFixedLineNumbers(data.fixes);
onQueryFix(fixedQuery, fixedLineNumbers);
}
};
const handleMouseEnter = () => {
if (data) {
onHighlightLines(getFixedLineNumbers(data.fixes));
}
};
const handleMouseLeave = () => {
onHighlightLines([]);
};
if (!request) {
return null;
}
const props = match({ data, error, isFetching })
.with({ isFetching: true }, () => ({
loading: true,
children: t`Trying to find a fix`,
}))
.with({ data: { fixes: [P._, ...P.array()] }, error: P.nullish }, () => ({
leftIcon: <Icon name="metabot" />,
children: t`Have Metabot fix it`,
}))
.otherwise(() => ({
disabled: true,
leftIcon: <Icon name="metabot_sad" />,
children: t`Metabot can't fix it`,
}));
return (
<Button
{...props}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
/>
);
}

View File

@@ -0,0 +1,184 @@
import userEvent from "@testing-library/user-event";
import { createMockMetadata } from "__support__/metadata";
import {
setupErrorFixNativeQueryEndpoint,
setupFixNativeQueryEndpoint,
} from "__support__/server-mocks";
import { renderWithProviders, screen } from "__support__/ui";
import * as Lib from "metabase-lib";
import { SAMPLE_METADATA, createQuery } from "metabase-lib/test-helpers";
import type {
DatasetError,
DatasetErrorType,
FixSqlQueryResponse,
} from "metabase-types/api";
import {
createMockFixSqlQueryResponse,
createMockSqlQueryFix,
} from "metabase-types/api/mocks";
import {
SAMPLE_DB_ID,
createSampleDatabase,
} from "metabase-types/api/mocks/presets";
import { FixSqlQueryButton } from "./FixSqlQueryButton";
type SetupOpts = {
query?: Lib.Query;
queryError?: DatasetError;
queryErrorType?: DatasetErrorType;
fixQueryResponse?: FixSqlQueryResponse;
};
function setup({
query = createNativeQuery("SELECT * FROM ORDERS"),
queryError = "Error",
queryErrorType = "invalid-query",
fixQueryResponse,
}: SetupOpts) {
const onQueryFix = jest.fn();
const onHighlightLines = jest.fn();
if (fixQueryResponse) {
setupFixNativeQueryEndpoint(fixQueryResponse);
} else {
setupErrorFixNativeQueryEndpoint();
}
renderWithProviders(
<FixSqlQueryButton
query={query}
queryError={queryError}
queryErrorType={queryErrorType}
onQueryFix={onQueryFix}
onHighlightLines={onHighlightLines}
/>,
);
return { onQueryFix, onHighlightLines };
}
function createNativeQuery(rawQuery: string) {
const metadataProvider = Lib.metadataProvider(SAMPLE_DB_ID, SAMPLE_METADATA);
return Lib.nativeQuery(SAMPLE_DB_ID, metadataProvider, rawQuery);
}
describe("FixSqlQueryButton", () => {
it("should allow to apply a single query fix", async () => {
const { onQueryFix } = setup({
query: createNativeQuery("SELECT1 * FROM ORDERS"),
fixQueryResponse: createMockFixSqlQueryResponse({
fixes: [
createMockSqlQueryFix({
fixed_sql: "SELECT * FROM ORDERS",
line_number: 1,
}),
],
}),
});
await userEvent.click(
await screen.findByRole("button", { name: /Have Metabot fix it/ }),
);
const [fixedQuery, lineNumbers] = onQueryFix.mock.lastCall;
expect(Lib.rawNativeQuery(fixedQuery)).toBe("SELECT * FROM ORDERS");
expect(lineNumbers).toEqual([1]);
});
it("should allow to apply multiple query fixes", async () => {
const { onQueryFix } = setup({
query: createNativeQuery("SELECT1 *\nFROM2 ORDERS\nWHERE 1=1"),
fixQueryResponse: createMockFixSqlQueryResponse({
fixes: [
createMockSqlQueryFix({
fixed_sql: "SELECT *",
line_number: 1,
}),
createMockSqlQueryFix({
fixed_sql: "FROM ORDERS",
line_number: 2,
}),
],
}),
});
await userEvent.click(
await screen.findByRole("button", { name: /Have Metabot fix it/ }),
);
const [fixedQuery, lineNumbers] = onQueryFix.mock.lastCall;
expect(Lib.rawNativeQuery(fixedQuery)).toBe(
"SELECT *\nFROM ORDERS\nWHERE 1=1",
);
expect(lineNumbers).toEqual([1, 2]);
});
it("should highlight affected lines on hover", async () => {
const { onHighlightLines } = setup({
query: createNativeQuery("SELECT1 * FROM ORDERS"),
fixQueryResponse: createMockFixSqlQueryResponse({
fixes: [
createMockSqlQueryFix({
fixed_sql: "SELECT * FROM ORDERS",
line_number: 1,
}),
],
}),
});
await userEvent.hover(
await screen.findByRole("button", { name: /Have Metabot fix it/ }),
);
expect(onHighlightLines).toHaveBeenLastCalledWith([1]);
});
it("should show an error message when fixes are empty", async () => {
setup({
query: createNativeQuery("SELECT1 * FROM ORDERS"),
fixQueryResponse: createMockFixSqlQueryResponse({
fixes: [],
}),
});
expect(
await screen.findByRole("button", { name: /Metabot can't fix it/ }),
).toBeInTheDocument();
});
it("should show an error message when the endpoint returns an error", async () => {
setup({
query: createNativeQuery("SELECT1 * FROM ORDERS"),
fixQueryResponse: undefined,
});
expect(
await screen.findByRole("button", { name: /Metabot can't fix it/ }),
).toBeInTheDocument();
});
it("should not prompt to a fix a non-native query", () => {
setup({ query: createQuery() });
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("should not prompt to fix a non-sql native query", () => {
const metadata = createMockMetadata({
databases: [createSampleDatabase({ engine: "mongo" })],
});
const metadataProvider = Lib.metadataProvider(SAMPLE_DB_ID, metadata);
const query = Lib.nativeQuery(SAMPLE_DB_ID, metadataProvider, "{}");
setup({ query });
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("should not prompt to a fix the query when there is an http error", () => {
setup({ queryError: { status: 500 } });
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("should not prompt to a fix the query for unrelated error types", () => {
setup({ queryErrorType: "missing-required-parameter" });
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1 @@
export * from "./FixSqlQueryButton";

View File

@@ -0,0 +1,50 @@
import { getEngineNativeType } from "metabase/lib/engine";
import * as Lib from "metabase-lib";
import type {
DatasetError,
DatasetErrorType,
FixSqlQueryRequest,
SqlQueryFix,
} from "metabase-types/api";
export function getFixRequest(
query: Lib.Query,
queryError: DatasetError,
queryErrorType: DatasetErrorType | undefined,
): FixSqlQueryRequest | undefined {
if (typeof queryError !== "string" || queryErrorType !== "invalid-query") {
return;
}
const queryInfo = Lib.queryDisplayInfo(query);
if (!queryInfo.isNative) {
return;
}
const engine = Lib.engine(query);
const engineType = engine && getEngineNativeType(engine);
if (engineType !== "sql") {
return;
}
return {
query: Lib.toLegacyQuery(query),
error_message: queryError,
};
}
export function getFixedQuery(query: Lib.Query, fixes: SqlQueryFix[]) {
const sql = Lib.rawNativeQuery(query);
const sqlLines = sql.split("\n");
const newSqlLines = fixes.reduce((result, fix) => {
const lineIndex = fix.line_number - 1;
result[lineIndex] = fix.fixed_sql;
return result;
}, sqlLines);
const newSql = newSqlLines.join("\n");
return Lib.withNativeQuery(query, newSql);
}
export function getFixedLineNumbers(fixes: SqlQueryFix[]) {
return fixes.map(fix => fix.line_number);
}

View File

@@ -0,0 +1,8 @@
import { PLUGIN_AI_SQL_FIXER } from "metabase/plugins";
import { hasPremiumFeature } from "metabase-enterprise/settings";
import { FixSqlQueryButton } from "./components/FixSqlQueryButton";
if (hasPremiumFeature("ai_sql_fixer")) {
PLUGIN_AI_SQL_FIXER.FixSqlQueryButton = FixSqlQueryButton;
}

View File

@@ -0,0 +1,20 @@
import type {
FixSqlQueryRequest,
FixSqlQueryResponse,
} from "metabase-types/api";
import { EnterpriseApi } from "./api";
export const aiSqlFixerApi = EnterpriseApi.injectEndpoints({
endpoints: builder => ({
getFixedSqlQuery: builder.query<FixSqlQueryResponse, FixSqlQueryRequest>({
query: body => ({
method: "POST",
url: "/api/ee/ai-sql-fixer/fix",
body,
}),
}),
}),
});
export const { useGetFixedSqlQueryQuery } = aiSqlFixerApi;

View File

@@ -1,6 +1,10 @@
export * from "./ai-sql-fixer";
export * from "./audit-info";
export * from "./api";
export * from "./billing-info";
export * from "./metabot";
export * from "./gsheets";
export * from "./query-validation";
export * from "./scim";
export * from "./tags";
export * from "./upload-management";

View File

@@ -0,0 +1,21 @@
import type {
MetabotAgentRequest,
MetabotAgentResponse,
} from "metabase-types/api";
import { EnterpriseApi } from "./api";
export const metabotApi = EnterpriseApi.injectEndpoints({
endpoints: builder => ({
metabotAgent: builder.mutation<MetabotAgentResponse, MetabotAgentRequest>({
query: body => ({
method: "POST",
url: "/api/ee/metabot-v3/v2/agent",
body,
}),
}),
}),
});
export const { metabotAgent } = metabotApi.endpoints;
export const { useMetabotAgentMutation } = metabotApi;

View File

@@ -1 +1,3 @@
export const ENTERPRISE_TAG_TYPES = ["scim"] as const;
export const METABOT_TAG = "metabot";
export const ENTERPRISE_TAG_TYPES = ["scim", METABOT_TAG] as const;

View File

@@ -0,0 +1,45 @@
# Metabot v3
## Sending requests to Metabot
### Request format
Every request is made up of three parts: `message`, `context` and `history`.
The `message` is usually the prompt typed in the Metabot chat bar by the user.
In the case of user confirmation, `message` is a predefined value that Metabot has asked to be sent in response to the user choosing a path.
The `context` is the "eyes" of Metabot, allowing it to "see" what the user sees.
Lastly `history` is the `history` value from the last Metabot response forwarded back in the next request to keep all requests stateless.
### Registering chat context
Chat context is currently managed via React context, which is perfect because I heard you liked context with your context.
`context.tsx` defines two methods `registerChatContextProvider` and `getChatContext`.
Registered provider functions will be called when `getChatContext` is executed and are passed the redux state at that point in time.
These functions return partial context objects which are then shallow merged to produce a final chat context object for the current view.
The functions may pick values off the redux state or pull in values available within their defined scope.
Default chat context required for all requests, like the current time, can be set in base context defined in `context.tsx`.
In OSS,`registerChatContextProvider` and `getChatContext` are no-ops, so any registered context functions will not be consumed.
A helper util has been provided to make registering context easier in React. The `useRegisterMetabotContextProvider(providerFn, providerFnDeps)`
hook will automatically register/deregister a provider function from a component as it is mounted and unmounted.
## Handling responses from Metabot
### Processing reactions
The backend sends reactions as a list, which are processed sequentially.
Each reaction type should have an associated handler which can either return a `Promise<void>` or `void`.
Async reaction handlers will be awaited before processing the next reaction.
### Adding a new reaction
Reactions are defined by the backend and their definition must be hand-copied into the frontend.
You can define new reaction types in `frontend/src/metabase-types/api/metabot.ts`.
To handle your new reaction, add your reaction's `type` to the `reactionHandlers` map in `reactions/index.ts` with a matching handler.
The reactions folder organizes handlers by similar functionality – you can either add your handler to an existing file or create a new one to handle a new domain of reactions.
### Handling errors in reactions
If a handler encounters an error, it can halt all subsequent reaction processing by setting Metabot's `isProcessing` flag to `false` and returning early.
The reaction processor will detect this change and stop further execution.
Some basic error handling utilities can be found in `reactions/errors.ts`, but generally you can still leverage the same tools for making Metabot say something to notify the user of the error.
Future updates will improve this system to allow sending errors to the backend for better user-facing error messages or even coach Metabot to make corrections.

View File

@@ -0,0 +1,41 @@
import { useCallback, useEffect } from "react";
import { tinykeys } from "tinykeys";
import ErrorBoundary from "metabase/ErrorBoundary";
import { getCurrentUser } from "metabase/admin/datamodel/selectors";
import { useSelector } from "metabase/lib/redux";
import { useMetabotAgent } from "../hooks";
import { MetabotChat } from "./MetabotChat";
export const Metabot = () => {
const currentUser = useSelector(getCurrentUser);
const { visible, setVisible } = useMetabotAgent();
const onClose = useCallback(() => setVisible(false), [setVisible]);
useEffect(() => {
if (!currentUser) {
return () => {};
}
return tinykeys(window, {
"$mod+b": e => {
e.preventDefault(); // prevent FF from opening bookmark menu
setVisible(!visible);
},
});
}, [visible, setVisible, currentUser]);
if (!visible) {
return null;
}
return (
<ErrorBoundary errorComponent={() => null}>
<MetabotChat onClose={onClose} />
</ErrorBoundary>
);
};

View File

@@ -0,0 +1,149 @@
/* TODO: rtl support */
@keyframes chat-bar-slide-in {
0% {
opacity: 0;
transform: translate(-50%, 2rem);
transform-origin: top;
}
100% {
opacity: 1;
transform: translate(-50%, 0);
transform-origin: top;
}
}
.container {
width: 30rem;
position: absolute;
left: 50%;
bottom: 1.5rem;
box-shadow: 0 2px 13.8px -1px #1871bf52;
border-radius: 22px;
z-index: 199; /* -1 of the default 200 for modals */
animation: chat-bar-slide-in 300ms ease forwards;
transition-property: transform, opacity;
}
.innerContainer {
padding: 0 1rem;
border-radius: 22px;
align-items: center;
box-sizing: border-box;
border: solid 1.5px transparent;
background: var(--mb-color-bg-white);
transition: background 150ms ease;
}
.innerContainer::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(to right, #2e96f1, #d932f4);
z-index: -1;
margin: -1.5px;
border-radius: 24px;
}
.innerContainerLoading {
background: var(--mb-color-bg-light);
}
.innerContainerExpanded {
padding: 1rem;
align-items: flex-start;
height: 8rem;
}
.input {
caret-color: var(--mb-color-brand);
}
.textarea {
textarea {
border: none;
border-radius: 0;
background: transparent;
outline: none;
width: 100%;
caret-color: var(--mb-color-brand);
padding: 0.5rem 0;
line-height: 1.7;
color: var(--mb-color-text-dark);
}
}
.textareaExpanded {
textarea {
padding: 0;
}
}
.textareaLoading {
textarea {
font-weight: bold;
&:disabled {
opacity: 1;
background: transparent;
}
}
}
/* nesting the style above keeps it from getting applied */
.textareaLoading textarea::placeholder,
.textareaLoading textarea:disabled::placeholder {
color: var(--mb-color-brand);
}
@keyframes responses-slide-in {
0% {
opacity: 0;
transform: translate(0, 0.5rem);
transform-origin: top;
}
100% {
opacity: 1;
transform: translate(0, 0);
transform-origin: top;
}
}
.responses {
position: absolute;
left: 0;
bottom: 100%;
margin-bottom: 0.25rem;
pointer-events: none; /* allow user to click through transparent area behind repsonses */
animation: responses-slide-in 150ms ease forwards;
transition-property: transform, opacity;
}
.response {
padding: 0.5rem 1rem;
display: flex;
background: var(--mb-color-brand);
font-size: 0.875rem;
font-weight: 700;
border-radius: 1rem;
color: white;
pointer-events: auto;
margin-bottom: 0.25rem;
position: relative;
}
.responseMessage {
flex-grow: 1;
}
button.responseDismissBtn {
color: #cbe2f7;
height: 1rem;
cursor: pointer;
margin-left: 0.5rem;
}

View File

@@ -0,0 +1,149 @@
import cx from "classnames";
import { useCallback, useRef, useState } from "react";
import { t } from "ttag";
import { Box, Flex, Icon, Textarea, UnstyledButton } from "metabase/ui";
import { useMetabotAgent } from "../../hooks";
import { MetabotIcon } from "../MetabotIcon";
import Styles from "./MetabotChat.module.css";
import { useAutoCloseMetabot } from "./useAutoCloseMetabot";
const MIN_INPUT_HEIGHT = 42;
const ANIMATION_DURATION_MS = 300;
export const MetabotChat = ({ onClose }: { onClose: () => void }) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [input, setMessage] = useState("");
const metabot = useMetabotAgent();
const resetInput = useCallback(() => {
setMessage("");
setInputExpanded(false);
}, []);
const handleSend = () => {
const trimmedInput = input.trim();
if (!trimmedInput.length || metabot.isDoingScience) {
return;
}
resetInput();
metabot
.submitInput(trimmedInput)
.catch(err => console.error(err))
.finally(() => textareaRef.current?.focus());
};
const handleClose = useCallback(() => {
resetInput();
onClose();
}, [resetInput, onClose]);
useAutoCloseMetabot({
hasUserInput: !!input,
});
const [inputExpanded, setInputExpanded] = useState(false);
const handleMaybeExpandInput = () => {
const textarea = textareaRef.current;
if (!textarea) {
return;
}
const isMultiRow = textarea.scrollHeight > MIN_INPUT_HEIGHT;
if (inputExpanded !== isMultiRow) {
setInputExpanded(isMultiRow);
}
// keep scrolled to bottom
textarea.scrollTop = Math.max(MIN_INPUT_HEIGHT, textarea.scrollHeight);
};
const handleInputChange = (value: string) => {
setMessage(value);
};
const inputPlaceholder = metabot.confirmationOptions
? Object.keys(metabot.confirmationOptions).join(" or ")
: t`Tell me to do something, or ask a question`;
const placeholder = metabot.isDoingScience
? t`Doing science...`
: inputPlaceholder;
return (
<Box
className={Styles.container}
style={{ animationDuration: `${ANIMATION_DURATION_MS}ms` }}
data-testid="metabot-chat"
>
{metabot.userMessages.length > 0 && (
<Box className={Styles.responses} data-testid="metabot-chat-messages">
{metabot.userMessages.map((msg, index) => (
<Box
className={Styles.response}
key={msg}
data-testid="metabot-chat-message"
>
<Box>{msg}</Box>
<UnstyledButton
className={Styles.responseDismissBtn}
onClick={() => metabot.dismissUserMessage(index)}
>
<Icon name="close" size="1rem" />
</UnstyledButton>
</Box>
))}
</Box>
)}
<Flex
className={cx(
Styles.innerContainer,
metabot.isDoingScience && Styles.innerContainerLoading,
inputExpanded && Styles.innerContainerExpanded,
)}
gap="sm"
>
<Box w="33px" h="24px">
<MetabotIcon isLoading={metabot.isDoingScience} />
</Box>
<Textarea
data-testid="metabot-chat-input"
w="100%"
autosize
minRows={1}
maxRows={4}
ref={textareaRef}
autoFocus
value={input}
disabled={metabot.isDoingScience}
className={cx(
Styles.textarea,
inputExpanded && Styles.textareaExpanded,
metabot.isDoingScience && Styles.textareaLoading,
)}
placeholder={placeholder}
onChange={e => handleInputChange(e.target.value)}
// @ts-expect-error - undocumented API for mantine Textarea - leverages the prop from react-textarea-autosize's TextareaAutosize component
onHeightChange={handleMaybeExpandInput}
onKeyDown={e => {
if (e.key === "Enter") {
// prevent event from inserting new line + interacting with other content
e.preventDefault();
e.stopPropagation();
handleSend();
}
}}
/>
<UnstyledButton
h="1rem"
onClick={handleClose}
data-testid="metabot-close-chat"
>
<Icon name="close" c="text-light" size="1rem" />
</UnstyledButton>
</Flex>
</Box>
);
};

View File

@@ -0,0 +1,2 @@
export * from "./MetabotChat";
export { METABOT_AUTO_CLOSE_DURATION_MS } from "./useAutoCloseMetabot";

View File

@@ -0,0 +1,51 @@
import { useEffect } from "react";
import { useLocation, usePrevious } from "react-use";
import { useMetabotAgent } from "metabase-enterprise/metabot/hooks";
export const METABOT_AUTO_CLOSE_DURATION_MS = 30 * 1000;
function basePathSegment(pathname: string) {
return pathname.split("/")?.[1] ?? "";
}
export function useAutoCloseMetabot({
hasUserInput,
}: {
hasUserInput: boolean;
}) {
const { visible, setVisible, isDoingScience } = useMetabotAgent();
const location = useLocation();
const { pathname } = location;
const prevPathname = usePrevious(pathname);
const isUiClosable = visible && !isDoingScience && !hasUserInput;
const isClosableRouteChange =
pathname !== undefined &&
prevPathname !== undefined &&
basePathSegment(pathname) !== basePathSegment(prevPathname);
// auto-close metabot on route change, unless:
// - route change happened during metabot processing
// - base segment of the route is the same (prevent hide if /question/1 => /question#ad-hoc-query-has)
// - user has some kind of value in the input
useEffect(() => {
if (isUiClosable && isClosableRouteChange) {
setVisible(false);
}
}, [isUiClosable, isClosableRouteChange, setVisible]);
// auto-close based when inactive for too long
const isInactive = !hasUserInput && !isDoingScience;
useEffect(() => {
if (isInactive) {
const timer = setTimeout(
() => setVisible(false),
METABOT_AUTO_CLOSE_DURATION_MS,
);
return () => clearTimeout(timer);
}
}, [isInactive, setVisible]);
}

View File

@@ -0,0 +1,33 @@
.dot {
transition: all 200ms ease;
}
@keyframes fade-in-out {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.dotLoading {
animation: fade-in-out 1800ms infinite linear alternate;
}
.dotOneLoading {
animation-delay: 0ms;
}
.dotTwoLoading {
animation-delay: 600ms;
}
.dotThreeLoading {
animation-delay: -600ms;
}

View File

@@ -0,0 +1,116 @@
import cx from "classnames";
import Styles from "./MetabotIcon.module.css";
const COLORS = {
brand: "var(--mb-color-brand)",
brandLighter: "var(--mb-color-brand-lighter)",
focus: "var(--mb-color-focus)",
summarize: "var(--mb-color-summarize)",
};
export const MetabotIcon = ({ isLoading }: { isLoading: boolean }) => {
const dotsStrokeColor = isLoading ? COLORS.summarize : COLORS.brand;
const dotsFillColor = isLoading ? COLORS.summarize : COLORS.focus;
return (
<svg
width="33"
height="24"
viewBox="0 0 33 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<rect
x="0.75"
y="0.75"
width="30.8721"
height="22.5"
rx="3.25"
fill={COLORS.focus}
stroke={COLORS.brand}
strokeWidth="1.5"
/>
<rect
x="3.90698"
y="3.34961"
width="24.5581"
height="14.5116"
rx="2"
fill={COLORS.brandLighter}
stroke={COLORS.brand}
strokeWidth="1.5"
/>
{isLoading && (
<>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18 13H15V12H18V13Z"
fill={COLORS.brand}
/>
<path
d="M10 8.5C10 9 10.8 10 12 10C13.2 10 13.8333 9 14 8.5"
stroke={COLORS.brand}
/>
<path
d="M19 8.5C19 9 19.8 10 21 10C22.2 10 22.8333 9 23 8.5"
stroke={COLORS.brand}
/>
</>
)}
{!isLoading && (
<>
<path
d="M17.3023 11.4414H17.8605C17.8605 12.4289 17.2754 13.1158 16.186 13.1158C15.0967 13.1158 14.5116 12.4289 14.5116 11.4414H15.0698C15.0698 11.9995 15.3488 12.5577 16.186 12.5577C17.0233 12.5577 17.3023 11.9995 17.3023 11.4414Z"
fill={COLORS.brand}
/>
<circle cx="11.5814" cy="8.7912" r="0.976744" fill={COLORS.brand} />
<ellipse
cx="20.5117"
cy="8.7912"
rx="0.976745"
ry="0.976744"
fill={COLORS.brand}
/>
</>
)}
{/* dots */}
{isLoading && (
<circle
cx="21.1163"
cy="20.6509"
r="1.11628"
fill={dotsFillColor}
className={cx(
Styles.dot,
isLoading && Styles.dotLoading,
isLoading && Styles.dotOneLoading,
)}
/>
)}
<path
d="M27.9651 20.6514C27.9651 20.9918 27.6892 21.2677 27.3488 21.2677C27.0085 21.2677 26.7325 20.9918 26.7325 20.6514C26.7325 20.3111 27.0085 20.0352 27.3488 20.0352C27.6892 20.0352 27.9651 20.3111 27.9651 20.6514Z"
fill={dotsFillColor}
stroke={dotsStrokeColor}
className={cx(
Styles.dot,
isLoading && Styles.dotLoading,
isLoading && Styles.dotTwoLoading,
)}
/>
<circle
cx="24.2791"
cy="20.6514"
r="0.61628"
fill={dotsFillColor}
stroke={dotsStrokeColor}
className={cx(
Styles.dot,
isLoading && Styles.dotLoading,
isLoading && Styles.dotThreeLoading,
)}
/>
</svg>
);
};

View File

@@ -0,0 +1 @@
export * from "./MetabotIcon";

View File

@@ -0,0 +1,63 @@
import dayjs from "dayjs";
import type React from "react";
import { createContext, useCallback, useRef } from "react";
import { useStore } from "metabase/lib/redux";
import type {
ChatContextProviderFn,
MetabotContext as MetabotCtx,
} from "metabase/metabot";
export const defaultContext = {
getChatContext: () => ({
user_is_viewing: [],
current_time_with_timezone: dayjs.tz(dayjs()).format(),
}),
registerChatContextProvider: () => () => {},
};
export const MetabotContext = createContext<MetabotCtx>(defaultContext);
export const MetabotProvider = ({
children,
}: {
children: React.ReactNode;
}) => {
const providerFnsRef = useRef<Set<ChatContextProviderFn>>(new Set());
const store = useStore();
const getChatContext = useCallback(() => {
const state = store.getState();
const baseContext = {
user_is_viewing: [],
current_time_with_timezone: dayjs.tz(dayjs()).format(),
};
return [...providerFnsRef.current].reduce((chatContext, providerFn) => {
try {
const partialContext = providerFn(state) || {};
return Object.assign(chatContext, partialContext);
} catch (err) {
console.error("A metabot chat context provider failed:", err);
return chatContext;
}
}, baseContext);
}, [store]);
const registerChatContextProvider = useCallback(
(providerFn: ChatContextProviderFn) => {
providerFnsRef.current.add(providerFn);
return () => providerFnsRef.current.delete(providerFn);
},
[],
);
return (
<MetabotContext.Provider
value={{ getChatContext, registerChatContextProvider }}
>
{children}
</MetabotContext.Provider>
);
};

View File

@@ -0,0 +1,64 @@
import { useCallback } from "react";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { useMetabotContext } from "metabase/metabot";
import { METABOT_TAG, useMetabotAgentMutation } from "metabase-enterprise/api";
import {
dismissUserMessage,
getConfirmationOptions,
getIsProcessing,
getMetabotVisisble,
getUserMessages,
setVisible,
submitInput,
} from "./state";
export const useMetabotAgent = () => {
const dispatch = useDispatch();
const { getChatContext } = useMetabotContext();
// TODO: create an enterprise useSelector
const userMessages = useSelector(getUserMessages as any) as ReturnType<
typeof getUserMessages
>;
const confirmationOptions = useSelector(
getConfirmationOptions as any,
) as ReturnType<typeof getConfirmationOptions>;
const isProcessing = useSelector(getIsProcessing as any) as ReturnType<
typeof getIsProcessing
>;
const [, sendMessageReq] = useMetabotAgentMutation({
fixedCacheKey: METABOT_TAG,
});
return {
visible: useSelector(getMetabotVisisble as any) as ReturnType<
typeof getMetabotVisisble
>,
setVisible: useCallback(
(isVisible: boolean) => dispatch(setVisible(isVisible)),
[dispatch],
),
userMessages,
confirmationOptions,
dismissUserMessage: (messageIndex: number) =>
dispatch(dismissUserMessage(messageIndex)),
submitInput: useCallback(
async (message: string) => {
const context = getChatContext();
const history = sendMessageReq.data?.history || [];
const state = sendMessageReq.data?.state || {};
await dispatch(submitInput({ message, context, history, state }));
},
[
dispatch,
getChatContext,
sendMessageReq.data?.history,
sendMessageReq.data?.state,
],
),
isDoingScience: sendMessageReq.isLoading || isProcessing,
};
};

View File

@@ -0,0 +1,66 @@
import { useMemo } from "react";
import { t } from "ttag";
import { METAKEY } from "metabase/lib/browser";
import type { PaletteAction } from "metabase/palette/types";
import {
PLUGIN_GO_MENU,
PLUGIN_METABOT,
PLUGIN_REDUCERS,
} from "metabase/plugins";
import { Flex, Text } from "metabase/ui";
import { hasPremiumFeature } from "metabase-enterprise/settings";
import { Metabot } from "./components/Metabot";
import { MetabotContext, MetabotProvider, defaultContext } from "./context";
import { useMetabotAgent } from "./hooks";
import { metabotReducer, setVisible } from "./state";
if (hasPremiumFeature("metabot_v3")) {
PLUGIN_METABOT.Metabot = Metabot;
PLUGIN_METABOT.defaultMetabotContextValue = defaultContext;
PLUGIN_METABOT.MetabotContext = MetabotContext;
PLUGIN_METABOT.getMetabotProvider = () => MetabotProvider;
PLUGIN_METABOT.useMetabotPalletteActions = (searchText: string) => {
const { submitInput, setVisible } = useMetabotAgent();
return useMemo(() => {
const ret: PaletteAction[] = [
{
id: "initialize_metabot",
name: searchText
? t`Ask Metabot, "${searchText}"`
: t`Ask me to do something, or ask me a question`,
section: "metabot",
keywords: searchText,
icon: "metabot",
perform: _currentActionImpl => {
setVisible(true);
if (searchText) {
submitInput(searchText);
}
},
},
];
return ret;
}, [searchText, submitInput, setVisible]);
};
PLUGIN_REDUCERS.metabotPlugin = metabotReducer;
PLUGIN_GO_MENU.getMenuItems = dispatch => [
{
title: (
<Flex align="center" justify="space-between" gap="md">
<div>{t`Metabot request`}</div>
<Text fz="sm" c="text-light">
{METAKEY} + B
</Text>
</Flex>
),
icon: "metabot",
action: () => dispatch(setVisible(true)),
},
];
}

View File

@@ -0,0 +1,434 @@
import { combineReducers } from "@reduxjs/toolkit";
import userEvent from "@testing-library/user-event";
import fetchMock from "fetch-mock";
import { P, isMatching } from "ts-pattern";
import { setupEnterprisePlugins } from "__support__/enterprise";
import { mockSettings } from "__support__/settings";
import { act, renderWithProviders, screen, waitFor } from "__support__/ui";
import { logout } from "metabase/auth/actions";
import * as domModule from "metabase/lib/dom";
import { isUuid, uuid } from "metabase/lib/uuid";
import { useRegisterMetabotContextProvider } from "metabase/metabot";
import type { User } from "metabase-types/api";
import {
createMockTokenFeatures,
createMockUser,
} from "metabase-types/api/mocks";
import { createMockState } from "metabase-types/store/mocks";
import { Metabot } from "./components/Metabot";
import { METABOT_AUTO_CLOSE_DURATION_MS } from "./components/MetabotChat";
import { MetabotProvider } from "./context";
import {
type MetabotState,
metabotInitialState,
metabotReducer,
setIsProcessing,
setVisible,
} from "./state";
function setup(
options: {
ui?: React.ReactElement;
metabotPluginInitialState?: MetabotState;
currentUser?: User | null | undefined;
} | void,
) {
const settings = mockSettings({
"token-features": createMockTokenFeatures({
metabot_v3: true,
}),
});
setupEnterprisePlugins();
const {
ui = <Metabot />,
currentUser = createMockUser(),
metabotPluginInitialState = {
...metabotInitialState,
conversationId: uuid(),
visible: true,
},
} = options || {};
return renderWithProviders(<MetabotProvider>{ui}</MetabotProvider>, {
storeInitialState: createMockState({
settings,
currentUser: currentUser ? currentUser : undefined,
plugins: {
metabotPlugin: metabotPluginInitialState,
},
} as any),
customReducers: {
plugins: combineReducers({
metabotPlugin: metabotReducer,
}),
},
});
}
const chat = () => screen.findByTestId("metabot-chat");
const input = () => screen.findByTestId("metabot-chat-input");
const enterChatMessage = async (message: string, send = true) =>
userEvent.type(await input(), `${message}${send ? "{Enter}" : ""}`);
const closeChatButton = () => screen.findByTestId("metabot-close-chat");
const assertVisible = async () =>
expect(await screen.findByTestId("metabot-chat")).toBeInTheDocument();
const assertNotVisible = async () =>
await waitFor(() => {
expect(screen.queryByTestId("metabot-chat")).not.toBeInTheDocument();
});
// NOTE: for some reason the keyboard shortcuts won't work with tinykeys while testing, using redux for now...
const hideMetabot = (dispatch: any) => act(() => dispatch(setVisible(false)));
const showMetabot = (dispatch: any) => act(() => dispatch(setVisible(true)));
const lastReqBody = async () => {
const lastCall = fetchMock.lastCall();
const bodyStr = String(await lastCall?.[1]?.body) || "";
return JSON.parse(bodyStr);
};
describe("metabot", () => {
describe("ui", () => {
// eslint-disable-next-line jest/expect-expect
it("should be able to render metabot", async () => {
setup();
await assertVisible();
});
it("should be able to toggle visibility", async () => {
const { store } = setup();
expect(await chat()).toBeInTheDocument();
await assertVisible();
hideMetabot(store.dispatch);
await assertNotVisible();
showMetabot(store.dispatch);
expect(await chat()).toBeInTheDocument();
await userEvent.click(await closeChatButton());
await assertNotVisible();
});
it("should start a new conversation each time metabot is opened", async () => {
const { store } = setup({
metabotPluginInitialState: metabotInitialState,
});
fetchMock.post(
`path:/api/ee/metabot-v3/v2/agent`,
whoIsYourFavoriteResponse,
);
showMetabot(store.dispatch);
await enterChatMessage("Who is your favorite?");
const firstConversationId = (await lastReqBody())?.conversation_id;
expect(isUuid(firstConversationId)).toBeTruthy();
hideMetabot(store.dispatch);
showMetabot(store.dispatch);
await enterChatMessage("Who is your favorite?");
const secondConversationId = (await lastReqBody())?.conversation_id;
expect(isUuid(secondConversationId)).toBeTruthy();
expect(secondConversationId).not.toBe(firstConversationId);
});
// eslint-disable-next-line jest/expect-expect
it("should auto-close metabot if inactive with no user input", async () => {
jest.useFakeTimers({ advanceTimers: true });
const { store } = setup();
// auto-hides
await assertVisible();
act(() => jest.advanceTimersByTime(METABOT_AUTO_CLOSE_DURATION_MS));
await assertNotVisible();
// does not auto-hide if there's user input
showMetabot(store.dispatch);
await userEvent.type(await input(), "Testing");
act(() => jest.advanceTimersByTime(METABOT_AUTO_CLOSE_DURATION_MS));
await assertVisible();
hideMetabot(store.dispatch);
// does not auto-close if metabot is processing a response
showMetabot(store.dispatch);
act(() => store.dispatch(setIsProcessing(true)));
act(() => jest.advanceTimersByTime(METABOT_AUTO_CLOSE_DURATION_MS));
await assertVisible();
act(() => store.dispatch(setIsProcessing(false)));
act(() => jest.advanceTimersByTime(METABOT_AUTO_CLOSE_DURATION_MS));
await assertNotVisible();
// does not auto-close if metabot is loading
showMetabot(store.dispatch);
fetchMock.post(
`path:/api/ee/metabot-v3/v2/agent`,
whoIsYourFavoriteResponse,
{ delay: METABOT_AUTO_CLOSE_DURATION_MS + 1000 }, // load longer than delay
);
await enterChatMessage("Who is your favorite?");
act(() => jest.advanceTimersByTime(METABOT_AUTO_CLOSE_DURATION_MS));
await assertVisible();
hideMetabot(store.dispatch);
jest.useRealTimers();
});
// eslint-disable-next-line jest/expect-expect
it("should properly auto-close metabot if route changes", async () => {
setup();
await assertVisible();
act(() => window.history?.pushState(null, "", "/some-route"));
await assertNotVisible();
});
// eslint-disable-next-line jest/expect-expect
it("should not close on route change if metabot is processing", async () => {
const { store } = setup();
showMetabot(store.dispatch);
act(() => store.dispatch(setIsProcessing(true)));
act(() => window.history?.pushState(null, "", "/some-route"));
await assertVisible();
act(() => store.dispatch(setIsProcessing(false)));
hideMetabot(store.dispatch);
await assertNotVisible();
});
// eslint-disable-next-line jest/expect-expect
it("should not close on route change if there is user input", async () => {
const { store } = setup();
// should not close if user has value in the input
showMetabot(store.dispatch);
await userEvent.type(await input(), "Some incomplete user prompt");
act(() => window.history?.pushState(null, "", "/some-route"));
await assertVisible();
});
// eslint-disable-next-line jest/expect-expect
it("should not close on route change if new location shares base bath segement", async () => {
const { store } = setup();
act(() => window.history?.pushState(null, "", "/base-path"));
showMetabot(store.dispatch);
await assertVisible();
act(() => window.history?.pushState(null, "", "/base-path/sub-path"));
await assertVisible();
act(() => window.history?.pushState(null, "", "/base-path"));
await assertVisible();
});
// eslint-disable-next-line jest/expect-expect
it("should hide metabot when the user logs out", async () => {
jest.spyOn(domModule, "reload").mockImplementation(() => {});
try {
const { store } = setup();
fetchMock.delete(`path:/api/session`, 200);
await assertVisible();
act(() => {
store.dispatch(logout(undefined) as any);
});
await assertNotVisible();
} finally {
(domModule.reload as any).mockRestore();
}
});
// eslint-disable-next-line jest/expect-expect
it("should not show metabot if the is not signed in user", async () => {
// suppress expected console error
const consoleErrorSpy = jest
.spyOn(console, "error")
.mockImplementation(message => {
if (
message ===
"Metabot can not be opened while there is no signed in user"
) {
return;
}
console.error(message);
});
try {
const { store } = setup({
metabotPluginInitialState: metabotInitialState,
currentUser: null,
});
await assertNotVisible();
showMetabot(store.dispatch);
await assertNotVisible();
} finally {
consoleErrorSpy.mockRestore();
}
});
});
describe("message", () => {
it("should properly send chat messages", async () => {
setup();
fetchMock.post(
`path:/api/ee/metabot-v3/v2/agent`,
whoIsYourFavoriteResponse,
{ delay: 50 }, // small delay to cause loading state
);
await enterChatMessage("Who is your favorite?", false);
expect(await input()).toHaveValue("Who is your favorite?");
await enterChatMessage("Who is your favorite?");
expect(
await screen.findByPlaceholderText("Doing science..."),
).toBeInTheDocument();
expect(
await screen.findByText("You are... but don't tell anyone!"),
).toBeInTheDocument();
// should auto-clear input + refocus
expect(await input()).toHaveValue("");
expect(await input()).toHaveFocus();
});
});
describe("context", () => {
it("should send along default context", async () => {
setup();
fetchMock.post(
`path:/api/ee/metabot-v3/v2/agent`,
whoIsYourFavoriteResponse,
);
await enterChatMessage("Who is your favorite?");
expect(
isMatching(
{ current_time_with_timezone: P.string },
(await lastReqBody())?.context,
),
).toEqual(true);
});
it("should allow components to register additional context", async () => {
fetchMock.post(
`path:/api/ee/metabot-v3/v2/agent`,
whoIsYourFavoriteResponse,
);
const TestComponent = () => {
useRegisterMetabotContextProvider(
() => ({ user_is_viewing: [{ type: "question", id: 1 }] }),
[],
);
return null;
};
setup({
ui: (
<>
<Metabot />
<TestComponent />
</>
),
});
await enterChatMessage("Who is your favorite?");
expect(
isMatching(
{
current_time_with_timezone: P.string,
user_is_viewing: [{ type: "question", id: 1 }],
},
(await lastReqBody())?.context,
),
).toBe(true);
});
});
describe("history", () => {
it("should send history from last response along with next message", async () => {
setup();
fetchMock.post(
`path:/api/ee/metabot-v3/v2/agent`,
whoIsYourFavoriteResponse,
);
// send a message to get some history back
await enterChatMessage("Who is your favorite?");
await waitFor(() => expect(fetchMock.calls()).toHaveLength(1));
// send another message and check that there is proper history
await enterChatMessage("Hi!");
const lastCall = fetchMock.lastCall();
const bodyStr = String(await lastCall?.[1]?.body) || "";
const sentHistory = JSON.parse(bodyStr)?.history;
expect(sentHistory).toEqual(whoIsYourFavoriteResponse.history);
});
it("should clear history if metabot is closed", async () => {
const { store } = setup();
fetchMock.post(
`path:/api/ee/metabot-v3/v2/agent`,
whoIsYourFavoriteResponse,
);
// send a message to get some history back
await enterChatMessage("Who is your favorite?");
await waitFor(() => expect(fetchMock.calls()).toHaveLength(1));
// close, open, and then send another message and check that there is no history
hideMetabot(store.dispatch);
showMetabot(store.dispatch);
await enterChatMessage("Hi!");
const lastCall = fetchMock.lastCall();
const bodyStr = String(await lastCall?.[1]?.body) || "";
const sentHistory = JSON.parse(bodyStr)?.history;
expect(sentHistory).toEqual([]);
});
});
});
const whoIsYourFavoriteResponse = {
reactions: [
{
type: "metabot.reaction/message",
message: "You are... but don't tell anyone!",
},
],
history: [
{
role: "user",
content: "Who is your favorite?",
},
{
content: "",
role: "assistant",
"tool-calls": [
{
id: "call_PVmnR8mcnYFF2AmqupSKzJDh",
name: "who-is-your-favorite",
arguments: {},
},
],
},
{
role: "tool",
"tool-call-id": "call_PVmnR8mcnYFF2AmqupSKzJDh",
content: "You are... but don't tell anyone!",
},
{
content: "You are... but don't tell anyone!",
role: "assistant",
},
],
state: {},
};

View File

@@ -0,0 +1,58 @@
import type {
ApiEndpointMutation,
ApiEndpointQuery,
} from "@reduxjs/toolkit/query";
import { t } from "ttag";
import { userApi } from "metabase/api";
import type { MetabotApiCallReaction } from "metabase-types/api";
import { sendWritebackMessageRequest, stopProcessingAndNotify } from "../state";
import type { ReactionHandler } from "./types";
const apiEndpointToRTKQueryMap: Record<
string,
ApiEndpointQuery<any, any> | ApiEndpointMutation<any, any>
> = {
"POST /api/user": userApi.endpoints.createUser,
};
export const apiCall: ReactionHandler<MetabotApiCallReaction> = reaction => {
return async ({ dispatch }) => {
const { method, url, body } = reaction.api_call;
const errorMsg = t`I can’t do that, unfortunately.`;
const rtkEndpoint = apiEndpointToRTKQueryMap[`${method} ${url}`];
if (!rtkEndpoint) {
console.error(
"Metabot does not have a matching endpoint to invoke",
reaction,
);
dispatch(stopProcessingAndNotify(errorMsg));
return;
}
try {
const result = (await dispatch(rtkEndpoint.initiate(body))) as any;
try {
if (result.error) {
await dispatch(
sendWritebackMessageRequest(
"Request failed for client for reason: " +
JSON.stringify(result.error),
),
);
return;
}
} catch (error) {
console.error("Metabot failed to write back: ", error);
dispatch(stopProcessingAndNotify(errorMsg));
}
} catch (error) {
console.error("Metabot failed to complete api request: ", error);
dispatch(stopProcessingAndNotify(errorMsg));
}
};
};

View File

@@ -0,0 +1,17 @@
import { t } from "ttag";
import { addUserMessage, clearUserMessages } from "../state";
import type { ReactionHandler } from "./types";
type UnknownReaction = { type: string } & Record<string, unknown>;
export const notifyUnknownReaction: ReactionHandler<
UnknownReaction
> = reaction => {
return ({ dispatch }) => {
console.error("Unknown reaction recieved", reaction);
dispatch(clearUserMessages());
dispatch(addUserMessage(t`Whoops, I actually can’t do this. Sorry.`));
};
};

View File

@@ -0,0 +1,37 @@
import type { MetabotReaction } from "metabase-types/api";
import { apiCall } from "./api";
import { requireUserConfirmation, showMessage } from "./messages";
import { redirect, writeBack } from "./metabot";
import { runQuery } from "./queries";
import type { ReactionHandler } from "./types";
import {
changeChartAppearance,
changeColumnSettings,
changeDisplayType,
changeSeriesSettings,
changeTableVisualizationSettings,
} from "./visualizations";
export * from "./errors";
type ReactionHandlers = {
[key in MetabotReaction["type"]]: ReactionHandler<
Extract<MetabotReaction, { type: key }>
>;
};
export const reactionHandlers: ReactionHandlers = {
"metabot.reaction/change-chart-appearance": changeChartAppearance,
"metabot.reaction/change-column-settings": changeColumnSettings,
"metabot.reaction/change-series-settings": changeSeriesSettings,
"metabot.reaction/change-display-type": changeDisplayType,
"metabot.reaction/change-table-visualization-settings":
changeTableVisualizationSettings,
"metabot.reaction/confirmation": requireUserConfirmation,
"metabot.reaction/message": showMessage,
"metabot.reaction/api-call": apiCall,
"metabot.reaction/writeback": writeBack,
"metabot.reaction/run-query": runQuery,
"metabot.reaction/redirect": redirect,
};

View File

@@ -0,0 +1,25 @@
import type {
MetabotConfirmationReaction,
MetabotMessageReaction,
} from "metabase-types/api";
import { addUserMessage, setConfirmationOptions } from "../state";
import type { ReactionHandler } from "./types";
export const showMessage: ReactionHandler<
MetabotMessageReaction
> = reaction => {
return ({ dispatch }) => {
dispatch(addUserMessage(reaction.message));
};
};
export const requireUserConfirmation: ReactionHandler<
MetabotConfirmationReaction
> = reaction => {
return ({ dispatch }) => {
dispatch(addUserMessage(reaction.description));
dispatch(setConfirmationOptions(reaction.options));
};
};

View File

@@ -0,0 +1,32 @@
import { push } from "react-router-redux";
import type {
MetabotRedirectReaction,
MetabotWriteBackReaction,
} from "metabase-types/api";
import { sendWritebackMessageRequest } from "../state";
import type { ReactionHandler } from "./types";
export const writeBack: ReactionHandler<
MetabotWriteBackReaction
> = reaction => {
return async ({ dispatch }) => {
await dispatch(sendWritebackMessageRequest(reaction.message));
};
};
export const redirect: ReactionHandler<MetabotRedirectReaction> = reaction => {
const redirectUrl = new URL(`${window.location.origin}${reaction.url}`);
return async ({ dispatch }) => {
await dispatch(
push({
pathname: redirectUrl.pathname,
hash: redirectUrl.hash,
search: redirectUrl.search,
}),
);
};
};

View File

@@ -0,0 +1,17 @@
import { updateQuestion } from "metabase/query_builder/actions";
import { getQuestion } from "metabase/query_builder/selectors";
import type { MetabotRunQueryReaction } from "metabase-types/api";
import type { ReactionHandler } from "./types";
export const runQuery: ReactionHandler<MetabotRunQueryReaction> =
reaction =>
async ({ dispatch, getState }) => {
const question = getQuestion(getState());
if (!question) {
return;
}
const newQuestion = question.setDatasetQuery(reaction.dataset_query);
await dispatch(updateQuestion(newQuestion, { run: true }));
};

View File

@@ -0,0 +1,8 @@
import type { Dispatch, GetState } from "metabase-types/store";
export type ReactionHandler<Reaction> = (
reaction: Reaction,
) => (reduxApis: {
dispatch: Dispatch;
getState: GetState;
}) => void | Promise<void>;

View File

@@ -0,0 +1,201 @@
import {
onUpdateVisualizationSettings,
setUIControls,
updateQuestion,
} from "metabase/query_builder/actions";
import { setQuestionDisplayType } from "metabase/query_builder/components/chart-type-selector";
import { getQueryResults, getQuestion } from "metabase/query_builder/selectors";
import { getColumnKey } from "metabase-lib/v1/queries/utils/column-key";
import type {
MetabotChangeChartAppearanceReaction,
MetabotChangeColumnSettingsReaction,
MetabotChangeDisplayTypeReaction,
MetabotChangeSeriesSettingsReaction,
MetabotChangeVisiualizationSettingsReaction,
VisualizationSettings,
} from "metabase-types/api";
import type { ReactionHandler } from "./types";
export const changeTableVisualizationSettings: ReactionHandler<
MetabotChangeVisiualizationSettingsReaction
> =
reaction =>
async ({ dispatch, getState }) => {
const queryResults = getQueryResults(getState());
const queryResultCols = queryResults?.[0]?.data?.cols ?? [];
const columnNames = queryResultCols.map((col: any) => col.name);
const visibleColumnNames = new Set(reaction.visible_columns);
const tableColumns = columnNames.map((name: string) => ({
name,
enabled: visibleColumnNames.has(name),
}));
await dispatch(
onUpdateVisualizationSettings({
"table.columns": tableColumns,
}),
);
};
export const changeDisplayType: ReactionHandler<
MetabotChangeDisplayTypeReaction
> =
reaction =>
async ({ dispatch, getState }) => {
{
const question = getQuestion(getState());
if (!question) {
return;
}
const newQuestion = setQuestionDisplayType(
question,
reaction.display_type,
);
await dispatch(updateQuestion(newQuestion, { run: true }));
dispatch(setUIControls({ isShowingRawTable: false }));
}
};
export const changeSeriesSettings: ReactionHandler<
MetabotChangeSeriesSettingsReaction
> =
reaction =>
async ({ dispatch, getState }) => {
const seriesSettings =
getQuestion(getState())?.settings().series_settings ?? {};
const newSeriesSettings = { ...seriesSettings };
reaction.series_settings.forEach(settings => {
const onlyIncludedSettings = Object.fromEntries(
Object.entries(settings).filter(([_key, value]) => value !== null),
);
newSeriesSettings[settings.key] = {
...newSeriesSettings[settings.key],
...onlyIncludedSettings,
};
});
await dispatch(
onUpdateVisualizationSettings({ series_settings: newSeriesSettings }),
);
};
export const changeColumnSettings: ReactionHandler<
MetabotChangeColumnSettingsReaction
> =
reaction =>
async ({ dispatch, getState }) => {
const columnSettings =
getQuestion(getState())?.settings().column_settings ?? {};
const newColumnSettings = { ...columnSettings };
reaction.column_settings.forEach(settings => {
const columnKey = getColumnKey({ name: settings.key });
const onlyIncludedSettings = Object.fromEntries(
Object.entries(settings).filter(([_key, value]) => value !== null),
);
newColumnSettings[columnKey] = {
...newColumnSettings[columnKey],
...onlyIncludedSettings,
};
});
await dispatch(
onUpdateVisualizationSettings({ column_settings: newColumnSettings }),
);
};
export const changeChartAppearance: ReactionHandler<
MetabotChangeChartAppearanceReaction
> =
({ type, ...payload }) =>
async ({ dispatch }) => {
const settingsUpdate: Partial<VisualizationSettings> = {};
if (payload.goal != null) {
const { goal_value, show_goal, goal_label } = payload.goal;
if (goal_value != null) {
settingsUpdate["graph.goal_value"] = goal_value;
}
if (show_goal != null) {
settingsUpdate["graph.show_goal"] = show_goal;
}
if (goal_label != null) {
settingsUpdate["graph.goal_label"] = goal_label;
}
}
if (payload.trend_line != null) {
settingsUpdate["graph.show_trendline"] = payload.trend_line;
}
if (payload.data_labels != null) {
const {
show_data_labels,
data_label_format,
pie_chart_percent_visibility,
} = payload.data_labels;
if (show_data_labels != null) {
settingsUpdate["graph.show_values"] = show_data_labels;
}
if (data_label_format != null) {
settingsUpdate["graph.label_value_formatting"] = data_label_format;
}
if (pie_chart_percent_visibility != null) {
settingsUpdate["pie.percent_visibility"] = pie_chart_percent_visibility;
}
}
if (payload.total != null) {
settingsUpdate["pie.show_total"] = payload.total;
}
if (payload.stack_type != null) {
// Metabase expects "none" to be null in the settings object
settingsUpdate["stackable.stack_type"] =
payload.stack_type === "none" ? null : payload.stack_type;
}
if (payload.max_series_count != null) {
if (typeof payload.max_series_count === "number") {
settingsUpdate["graph.max_categories"] = payload.max_series_count;
settingsUpdate["graph.max_categories_enabled"] = true;
} else if (payload.max_series_count === "all") {
settingsUpdate["graph.max_categories_enabled"] = false;
}
}
if (payload.y_axis_range != null) {
if (payload.y_axis_range.auto_range != null) {
settingsUpdate["graph.y_axis.auto_range"] =
payload.y_axis_range.auto_range;
}
if (payload.y_axis_range.min != null) {
settingsUpdate["graph.y_axis.min"] = payload.y_axis_range.min;
}
if (payload.y_axis_range.max != null) {
settingsUpdate["graph.y_axis.max"] = payload.y_axis_range.max;
}
}
if (payload.axes_labels != null) {
if (payload.axes_labels.x_axis_label != null) {
settingsUpdate["graph.x_axis.title_text"] =
payload.axes_labels.x_axis_label;
}
if (payload.axes_labels.y_axis_label != null) {
settingsUpdate["graph.y_axis.title_text"] =
payload.axes_labels.y_axis_label;
}
}
await dispatch(onUpdateVisualizationSettings(settingsUpdate));
};

View File

@@ -0,0 +1,215 @@
import { t } from "ttag";
import { getCurrentUser } from "metabase/admin/datamodel/selectors";
import { createAsyncThunk } from "metabase/lib/redux";
import { uuid } from "metabase/lib/uuid";
import {
EnterpriseApi,
METABOT_TAG,
metabotAgent,
} from "metabase-enterprise/api";
import type { MetabotChatContext, MetabotReaction } from "metabase-types/api";
import type { Dispatch } from "metabase-types/store";
import { notifyUnknownReaction, reactionHandlers } from "../reactions";
import { metabot } from "./reducer";
import {
getConfirmationOptions,
getIsProcessing,
getLastHistoryValue,
getLastSentContext,
getMetabotConversationId,
getMetabotState,
} from "./selectors";
export const {
addUserMessage,
dismissUserMessage,
clearUserMessages,
setIsProcessing,
setConfirmationOptions,
setConversationId,
} = metabot.actions;
export const setVisible =
(isVisible: boolean) => (dispatch: Dispatch, getState: any) => {
const currentUser = getCurrentUser(getState());
if (!currentUser) {
console.error(
"Metabot can not be opened while there is no signed in user",
);
return;
}
if (!isVisible) {
// reset the conversation history when closing metabot
dispatch(
EnterpriseApi.internalActions.removeMutationResult({
fixedCacheKey: METABOT_TAG,
}),
);
}
dispatch(metabot.actions.setVisible(isVisible));
};
export const submitInput = createAsyncThunk(
"metabase-enterprise/metabot/submitInput",
async (
data: {
message: string;
context: MetabotChatContext;
history: any[];
state: any;
},
{ dispatch, getState },
) => {
const isProcessing = getIsProcessing(getState() as any);
if (isProcessing) {
return console.error("Metabot is actively serving a request");
}
// handle if user is responding to a confirmation prompt
const requestingUserConfirmation = !!getConfirmationOptions(
getState() as any,
);
if (requestingUserConfirmation) {
await dispatch(selectUserConfirmationOption(data.message));
} else {
dispatch(clearUserMessages());
await dispatch(sendMessageRequest(data));
}
},
);
export const sendMessageRequest = createAsyncThunk(
"metabase-enterprise/metabot/sendMessageRequest",
async (
data: {
message: string;
context: MetabotChatContext;
history: any[];
state: any;
},
{ dispatch, getState },
) => {
// TODO: make enterprise store
let sessionId = getMetabotConversationId(getState() as any);
// should not be needed, but just in case the value got unset
if (!sessionId) {
console.warn(
"Metabot has no session id while open, this should never happen",
);
sessionId = uuid();
dispatch(setConversationId(sessionId));
}
const result = await dispatch(
metabotAgent.initiate(
{ ...data, conversation_id: sessionId },
{ fixedCacheKey: METABOT_TAG },
),
);
if (result.error) {
console.error("Metabot request returned error: ", result.error);
dispatch(clearUserMessages());
const message =
(result.error as any).status >= 500
? t`I'm currently offline, try again later.`
: undefined;
dispatch(stopProcessingAndNotify(message));
} else {
const reactions = result.data?.reactions || [];
await dispatch(processMetabotReactions(reactions));
return result;
}
},
);
export const selectUserConfirmationOption = createAsyncThunk(
"metabase-enterprise/metabot/selectUserConfirmationOption",
async (message: string, { dispatch, getState }) => {
const userConfirmationOptions = getConfirmationOptions(getState() as any);
if (!userConfirmationOptions) {
console.warn("Metabot has no user options to let user choose from");
return;
}
const confirmationOption = userConfirmationOptions[message];
if (!confirmationOption) {
const options = Object.keys(userConfirmationOptions);
const quotedOptions = options.map(option => `“${option}”`).join(" or ");
dispatch(addUserMessage(t`Sorry, could you give me a ${quotedOptions}?`));
} else {
dispatch(clearUserMessages());
dispatch(setConfirmationOptions(undefined));
await dispatch(processMetabotReactions(confirmationOption));
}
},
);
export const sendWritebackMessageRequest = createAsyncThunk(
"metabase-enterprise/metabot/sendWritebackMessageRequest",
async (message: string, { dispatch, getState }) => {
const lastSentContext = getLastSentContext(getState() as any);
const lastHistory = getLastHistoryValue(getState() as any);
const state = getMetabotState(getState() as any);
if (!lastSentContext) {
console.warn(
"Metabot expected to have a previously sent request before writing back to the server",
);
}
await dispatch(
sendMessageRequest({
message,
history: lastHistory,
context: lastSentContext ?? ({} as any),
state: state ?? ({} as any),
}),
);
},
);
export const processMetabotReactions = createAsyncThunk(
"metabase-enterprise/metabot/processMetabotReactions",
async (reactions: MetabotReaction[], { dispatch, getState }) => {
dispatch(setIsProcessing(true));
for (const reaction of reactions) {
try {
const reactionHandler =
reactionHandlers[reaction.type] ?? notifyUnknownReaction;
// TS isn't smart enough to know the reaction matches the handler
await reactionHandler(reaction as any)({ dispatch, getState });
} catch (error: any) {
console.error("Halting processing of reactions.", error);
dispatch(stopProcessingAndNotify());
break;
}
// TODO: make an EnterpriseStore
const isProcessing = getIsProcessing(getState() as any);
if (!isProcessing) {
console.warn(
"A handler has stopped further procesing of metabot reactions",
);
break;
}
}
dispatch(setIsProcessing(false));
},
);
export const stopProcessingAndNotify =
(message?: string) => (dispatch: Dispatch) => {
dispatch(setIsProcessing(false));
dispatch(clearUserMessages());
dispatch(addUserMessage(message || t`I can’t do that, unfortunately.`));
};

View File

@@ -0,0 +1,4 @@
export * from "./actions";
export * from "./reducer";
export * from "./selectors";
export * from "./types";

View File

@@ -0,0 +1,88 @@
import { type PayloadAction, createSlice } from "@reduxjs/toolkit";
import { logout } from "metabase/auth/actions";
import { uuid } from "metabase/lib/uuid";
import type {
MetabotChatContext,
MetabotHistory,
MetabotReaction,
} from "metabase-types/api";
import { sendMessageRequest } from "./actions";
type ConfirmationOptions =
| { [key: string]: MetabotReaction[] | undefined }
| undefined;
export interface MetabotState {
confirmationOptions: ConfirmationOptions | undefined;
isProcessing: boolean;
lastSentContext: MetabotChatContext | undefined;
lastHistoryValue: MetabotHistory | undefined;
conversationId: string | undefined;
userMessages: string[];
visible: boolean;
state: any;
}
export const metabotInitialState: MetabotState = {
confirmationOptions: undefined,
isProcessing: false,
lastSentContext: undefined,
lastHistoryValue: undefined,
conversationId: undefined,
userMessages: [],
visible: false,
state: {},
};
export const metabot = createSlice({
name: "metabase-enterprise/metabot",
initialState: metabotInitialState,
reducers: {
addUserMessage: (state, action: PayloadAction<string>) => {
state.userMessages.push(action.payload);
},
clearUserMessages: state => {
state.userMessages = [];
},
dismissUserMessage: (state, action: PayloadAction<number>) => {
state.userMessages.splice(action.payload, 1);
},
setIsProcessing: (state, action: PayloadAction<boolean>) => {
state.isProcessing = action.payload;
},
setConversationId: (state, action: PayloadAction<string | undefined>) => {
state.conversationId = action.payload;
},
setVisible: (state, { payload: visible }: PayloadAction<boolean>) => {
if (visible) {
state.visible = true;
state.conversationId = uuid();
} else {
return metabotInitialState;
}
},
setConfirmationOptions: (
state,
action: PayloadAction<ConfirmationOptions>,
) => {
// Type cast solves type error of "Type instantiation is excessively deep and possibly infinite."
// This approach will be removed in a follow up PR
state.confirmationOptions = action.payload as any;
},
},
extraReducers: builder => {
builder
.addCase(sendMessageRequest.pending, (state, action) => {
state.lastSentContext = action.meta.arg.context;
state.lastHistoryValue = action.meta.arg.history;
})
.addCase(sendMessageRequest.fulfilled, (state, action) => {
state.lastHistoryValue = action.payload?.data?.history;
})
.addCase(logout.pending, () => metabotInitialState);
},
});
export const metabotReducer = metabot.reducer;

View File

@@ -0,0 +1,46 @@
import { createSelector } from "@reduxjs/toolkit";
import type { MetabotStoreState } from "./types";
export const getMetabot = (state: MetabotStoreState) =>
state.plugins.metabotPlugin;
export const getMetabotVisisble = createSelector(
getMetabot,
metabot => metabot.visible,
);
export const getUserMessages = createSelector(
getMetabot,
metabot => metabot.userMessages,
);
export const getConfirmationOptions = createSelector(
getMetabot,
metabot => metabot.confirmationOptions,
);
export const getIsProcessing = createSelector(
getMetabot,
metabot => metabot.isProcessing,
);
export const getLastSentContext = createSelector(
getMetabot,
metabot => metabot.lastSentContext,
);
export const getLastHistoryValue = createSelector(
getMetabot,
metabot => metabot.lastHistoryValue,
);
export const getMetabotConversationId = createSelector(
getMetabot,
metabot => metabot.conversationId,
);
export const getMetabotState = createSelector(
getMetabot,
metabot => metabot.state,
);

View File

@@ -0,0 +1,11 @@
import type { EnterpriseSharedState } from "metabase-enterprise/shared/reducer";
import type { EnterpriseState } from "metabase-enterprise/shared/types";
import type { MetabotState } from "./reducer";
export interface MetabotStoreState extends EnterpriseState {
plugins: {
shared: EnterpriseSharedState;
metabotPlugin: MetabotState;
};
}

View File

@@ -23,6 +23,7 @@ import "./moderation";
import "./email_allow_list";
import "./email_restrict_recipients";
import "./advanced_permissions";
import "./ai-sql-fixer";
import "./audit_app";
import "./license";
import "./model_persistence";
@@ -35,3 +36,4 @@ import "./resource_downloads";
import "./user_provisioning";
import "./clean_up";
import "./troubleshooting";
import "./metabot";

View File

@@ -0,0 +1,15 @@
import type { DatasetQuery } from "metabase-types/api/query";
export type FixSqlQueryRequest = {
query: DatasetQuery;
error_message: string;
};
export type FixSqlQueryResponse = {
fixes: SqlQueryFix[];
};
export type SqlQueryFix = {
line_number: number;
fixed_sql: string;
};

View File

@@ -96,13 +96,8 @@ export interface Dataset {
row_count: number;
running_time: number;
json_query?: JsonQuery;
error?:
| string
| {
status: number; // HTTP status code
data?: string;
};
error_type?: string;
error?: DatasetError;
error_type?: DatasetErrorType;
error_is_curated?: boolean;
context?: string;
status?: string;
@@ -114,6 +109,18 @@ export interface Dataset {
started_at?: string;
}
export type DatasetError =
| string
| {
status: number; // HTTP status code
data?: string;
};
export type DatasetErrorType =
| "invalid-query"
| "missing-required-parameter"
| string;
export interface EmbedDatasetData {
rows: RowValues[];
cols: DatasetColumn[];
@@ -132,7 +139,7 @@ interface SuccessEmbedDataset {
}
export interface ErrorEmbedDataset {
error_type: string;
error_type: DatasetErrorType;
error: string;
status: string;
}

View File

@@ -1,6 +1,7 @@
export * from "./actions";
export * from "./activity";
export * from "./admin";
export * from "./ai-sql-fixer";
export * from "./alert";
export * from "./autocomplete";
export * from "./automagic-dashboards";

View File

@@ -1,5 +1,220 @@
import type { CardDisplayType } from "./visualization";
import type {
CardId,
CardType,
ColumnSettings,
DashboardId,
DatasetQuery,
SeriesSettings,
} from ".";
export type MetabotFeedbackType =
| "great"
| "wrong_data"
| "incorrect_result"
| "invalid_sql";
/* Metabot v3 - Base Types */
export type MetabotChatContext = {
user_is_viewing: MetabotEntityInfo[];
current_time_with_timezone: string;
};
export type MetabotTool = {
name: string; // TODO: make strictly typed - currently there's no tools
parameters: Record<string, any>;
};
export type MetabotHistoryUserMessageEntry = {
role: "user";
message: string;
context: MetabotChatContext;
};
export type MetabotHistoryToolEntry = {
role: "assistant";
assistant_response_type: "tools";
tools: MetabotTool[];
};
export type MetabotHistoryMessageEntry = {
role: "assistant";
assistant_response_type: "message";
message: string;
};
export type MetabotHistoryEntry =
| MetabotHistoryUserMessageEntry
| MetabotHistoryToolEntry
| MetabotHistoryMessageEntry;
export type MetabotHistory = any;
export type MetabotMessageReaction = {
type: "metabot.reaction/message";
message: string;
};
export type MetabotChangeDisplayTypeReaction = {
type: "metabot.reaction/change-display-type";
display_type: CardDisplayType;
};
export type MetabotChangeVisiualizationSettingsReaction = {
type: "metabot.reaction/change-table-visualization-settings";
visible_columns: string[];
};
export type MetabotConfirmationReaction = {
type: "metabot.reaction/confirmation";
description: string;
options: Record<string, MetabotReaction[]>;
};
export type MetabotWriteBackReaction = {
type: "metabot.reaction/writeback";
message: string;
};
export type MetabotApiCallReaction = {
type: "metabot.reaction/api-call";
api_call: {
method: string;
url: string;
body?: Record<string, any>;
};
};
export type MetabotRunQueryReaction = {
type: "metabot.reaction/run-query";
dataset_query: DatasetQuery;
};
type SeriesSettingsEntry = SeriesSettings & { key: string };
export type MetabotChangeSeriesSettingsReaction = {
type: "metabot.reaction/change-series-settings";
series_settings: SeriesSettingsEntry[];
};
type ColumnSettingsEntry = ColumnSettings & { key: string };
export type MetabotChangeColumnSettingsReaction = {
type: "metabot.reaction/change-column-settings";
column_settings: ColumnSettingsEntry[];
};
export type MetabotChangeChartAppearanceReaction = {
type: "metabot.reaction/change-chart-appearance";
goal: {
goal_value: number | null;
show_goal: boolean | null;
goal_label: string | null;
} | null;
trend_line: boolean | null;
data_labels: {
show_data_labels: boolean | null;
data_label_format: "auto" | "compact" | "full" | null;
pie_chart_percent_visibility: "off" | "legend" | "inside" | "both" | null;
};
total: boolean | null;
stack_type: "stacked" | "normalized" | "none" | null;
max_series_count: number | "all" | null;
axes_labels: {
x_axis_label: string | null;
y_axis_label: string | null;
} | null;
y_axis_range: {
auto_range: boolean | null;
min: number | null;
max: number | null;
} | null;
};
export type MetabotRedirectReaction = {
type: "metabot.reaction/redirect";
url: string;
};
export type MetabotReaction =
| MetabotChangeChartAppearanceReaction
| MetabotChangeColumnSettingsReaction
| MetabotChangeSeriesSettingsReaction
| MetabotMessageReaction
| MetabotChangeDisplayTypeReaction
| MetabotChangeVisiualizationSettingsReaction
| MetabotConfirmationReaction
| MetabotWriteBackReaction
| MetabotApiCallReaction
| MetabotRunQueryReaction
| MetabotRedirectReaction;
export type MetabotCardInfo = {
type: CardType;
id: CardId;
};
export type MetabotDashboardInfo = {
type: "dashboard";
id: DashboardId;
};
export type MetabotAdhocQueryInfo = {
type: "adhoc";
query: DatasetQuery;
};
export type MetabotEntityInfo =
| MetabotCardInfo
| MetabotDashboardInfo
| MetabotAdhocQueryInfo;
/* Metabot v3 - API Request Types */
export type MetabotAgentRequest = {
message: string;
context: MetabotChatContext;
history: MetabotHistory[];
conversation_id: string; // uuid
state: any;
};
export type MetabotAgentResponse = {
reactions: MetabotReaction[];
history: MetabotHistory[];
conversation_id: string;
state: any;
};
/* Metabot v3 - Type Guards */
export const isMetabotMessageReaction = (
reaction: MetabotReaction,
): reaction is MetabotMessageReaction => {
return reaction.type === "metabot.reaction/message";
};
export const isMetabotToolMessage = (
message: MetabotHistoryEntry,
): message is MetabotHistoryToolEntry => {
return (
message.role === "assistant" && message.assistant_response_type === "tools"
);
};
export const isMetabotHistoryMessage = (
message: MetabotHistoryEntry,
): message is MetabotHistoryMessageEntry => {
return (
message.role === "assistant" &&
message.assistant_response_type === "message"
);
};
export const isMetabotMessage = (
message: MetabotHistoryEntry,
): message is MetabotHistoryMessageEntry => {
return message.role === "assistant";
};

View File

@@ -0,0 +1,36 @@
import type {
FixSqlQueryRequest,
FixSqlQueryResponse,
SqlQueryFix,
} from "metabase-types/api";
import { createMockNativeDatasetQuery } from "./query";
export function createMockSqlQueryFix(
opts?: Partial<SqlQueryFix>,
): SqlQueryFix {
return {
line_number: 1,
fixed_sql: "",
...opts,
};
}
export function createMockFixSqlQueryRequest(
opts?: Partial<FixSqlQueryRequest>,
): FixSqlQueryRequest {
return {
query: createMockNativeDatasetQuery(),
error_message: "",
...opts,
};
}
export function createMockFixSqlQueryResponse(
opts?: Partial<FixSqlQueryResponse>,
): FixSqlQueryResponse {
return {
fixes: [],
...opts,
};
}

View File

@@ -1,5 +1,6 @@
export * from "./actions";
export * from "./activity";
export * from "./ai-sql-fixer";
export * from "./alert";
export * from "./automagic-dashboards";
export * from "./bookmark";

View File

@@ -131,6 +131,8 @@ export const createMockTokenFeatures = (
query_reference_validation: false,
serialization: false,
cache_preemptive: false,
metabot_v3: false,
ai_sql_fixer: false,
...opts,
});

View File

@@ -222,6 +222,8 @@ export const tokenFeatures = [
"collection_cleanup",
"query_reference_validation",
"cache_preemptive",
"metabot_v3",
"ai_sql_fixer",
] as const;
export type TokenFeature = (typeof tokenFeatures)[number];

View File

@@ -33,6 +33,8 @@ export const createMockQueryBuilderUIControlsState = (
modal: null,
modalContext: null,
dataReferenceStack: null,
isNativeQueryFixApplied: false,
highlightedNativeQueryLineNumbers: [],
...opts,
});

View File

@@ -50,6 +50,8 @@ export interface QueryBuilderUIControls {
modal: QueryModalType | null;
modalContext: TimelineEventId | null;
dataReferenceStack: null;
isNativeQueryFixApplied: boolean;
highlightedNativeQueryLineNumbers: number[];
}
export interface QueryBuilderLoadingControls {

View File

@@ -19,6 +19,7 @@ import { initializeIframeResizer } from "metabase/lib/dom";
import { connect } from "metabase/lib/redux";
import AppBar from "metabase/nav/containers/AppBar";
import Navbar from "metabase/nav/containers/Navbar";
import { PLUGIN_METABOT } from "metabase/plugins";
import { setErrorPage } from "metabase/redux/app";
import {
getErrorPage,
@@ -117,6 +118,7 @@ function App({
<UndoListing />
<StatusListing />
<NewModals />
<PLUGIN_METABOT.Metabot />
</AppContentContainer>
</AppContainer>
<Palette />

View File

@@ -5,11 +5,12 @@ import { usePrevious } from "react-use";
import { msgid, ngettext, t } from "ttag";
import _ from "underscore";
import { useListUsersQuery } from "metabase/api";
import { LoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper";
import { PaginationControls } from "metabase/components/PaginationControls";
import AdminS from "metabase/css/admin.module.css";
import CS from "metabase/css/core/index.css";
import Group from "metabase/entities/groups";
import Users from "metabase/entities/users";
import { useConfirmation } from "metabase/hooks/use-confirmation";
import { connect } from "metabase/lib/redux";
import { PLUGIN_GROUP_MANAGERS } from "metabase/plugins";
@@ -53,12 +54,12 @@ const mapDispatchToProps = {
),
};
const defaultUsersValue = [];
const PeopleList = ({
currentUser,
users,
groups,
query,
metadata,
membershipsByUser,
isAdmin,
loadMemberships,
@@ -67,12 +68,27 @@ const PeopleList = ({
updateMembership,
confirmDeleteMembershipAction,
confirmUpdateMembershipAction,
reloadUsers,
reloadGroups,
onNextPage,
onPreviousPage,
}) => {
const { modalContent, show } = useConfirmation();
const {
data,
isLoading,
error,
refetch: reloadUsers,
} = useListUsersQuery({
query: query.searchText,
status: query.status,
limit: query.pageSize,
offset: query.pageSize * query.page,
});
const users = data?.data || defaultUsersValue;
const total = data?.total ?? 0;
const prevUsers = usePrevious(users);
useEffect(() => {
@@ -103,8 +119,6 @@ const PeopleList = ({
}
}, [prevUsers, reloadUsers, users]);
const { total } = metadata;
const { page, pageSize, status } = query;
const isCurrentUser = u => currentUser?.id === u.id;
@@ -179,95 +193,97 @@ const PeopleList = ({
};
return (
<section className={CS.pb4}>
<table
data-testid="admin-people-list-table"
className={cx(AdminS.ContentTable, CS.borderBottom)}
>
<thead>
<tr>
<th>{t`Name`}</th>
<th />
<th>{t`Email`}</th>
{showDeactivated ? (
<Fragment>
<th>{t`Deactivated`}</th>
<th />
</Fragment>
) : (
<Fragment>
<th>{t`Groups`}</th>
<th>{t`Last Login`}</th>
<th />
</Fragment>
)}
</tr>
</thead>
<tbody>
{hasUsers &&
users.map(user => (
<PeopleListRow
key={user.id}
user={user}
showDeactivated={showDeactivated}
groups={groups}
userMemberships={membershipsByUser[user.id]}
isCurrentUser={isCurrentUser(user)}
isAdmin={isAdmin}
onAdd={groupId => handleAdd(groupId, user.id)}
onRemove={groupId => handleRemove(groupId, user.id)}
onChange={(groupId, membershipData) =>
handleChange(groupId, membershipData, user.id)
}
/>
))}
</tbody>
</table>
{hasUsers && (
<div
className={cx(CS.flex, CS.alignCenter, CS.justifyBetween, CS.p2)}
data-testid="people-list-footer"
<LoadingAndErrorWrapper loading={isLoading} error={error} noWrapper>
<section className={CS.pb4}>
<table
data-testid="admin-people-list-table"
className={cx(AdminS.ContentTable, CS.borderBottom)}
>
<div className={cx(CS.textMedium, CS.textBold)}>
{ngettext(
msgid`${total} person found`,
`${total} people found`,
total,
<thead>
<tr>
<th>{t`Name`}</th>
<th />
<th>{t`Email`}</th>
{showDeactivated ? (
<Fragment>
<th>{t`Deactivated`}</th>
<th />
</Fragment>
) : (
<Fragment>
<th>{t`Groups`}</th>
<th>{t`Last Login`}</th>
<th />
</Fragment>
)}
</tr>
</thead>
<tbody>
{hasUsers &&
users.map(user => (
<PeopleListRow
key={user.id}
user={user}
showDeactivated={showDeactivated}
groups={groups}
userMemberships={membershipsByUser[user.id]}
isCurrentUser={isCurrentUser(user)}
isAdmin={isAdmin}
onAdd={groupId => handleAdd(groupId, user.id)}
onRemove={groupId => handleRemove(groupId, user.id)}
onChange={(groupId, membershipData) =>
handleChange(groupId, membershipData, user.id)
}
/>
))}
</tbody>
</table>
{hasUsers && (
<div
className={cx(CS.flex, CS.alignCenter, CS.justifyBetween, CS.p2)}
data-testid="people-list-footer"
>
<div className={cx(CS.textMedium, CS.textBold)}>
{ngettext(
msgid`${total} person found`,
`${total} people found`,
total,
)}
</div>
<PaginationControls
page={page}
pageSize={pageSize}
total={total}
itemsLength={users.length}
onNextPage={onNextPage}
onPreviousPage={onPreviousPage}
/>
</div>
)}
{!hasUsers && (
<div
className={cx(
CS.flex,
CS.flexColumn,
CS.alignCenter,
CS.justifyCenter,
CS.p4,
CS.textMedium,
CS.textCentered,
)}
>
<div className={CS.my3}>
<Icon name="search" className={CS.mb1} size={32} />
<h3 className={CS.textLight}>{t`No results found`}</h3>
</div>
</div>
<PaginationControls
page={page}
pageSize={pageSize}
total={total}
itemsLength={users.length}
onNextPage={onNextPage}
onPreviousPage={onPreviousPage}
/>
</div>
)}
)}
{!hasUsers && (
<div
className={cx(
CS.flex,
CS.flexColumn,
CS.alignCenter,
CS.justifyCenter,
CS.p4,
CS.textMedium,
CS.textCentered,
)}
>
<div className={CS.my3}>
<Icon name="search" className={CS.mb1} size={32} />
<h3 className={CS.textLight}>{t`No results found`}</h3>
</div>
</div>
)}
{modalContent}
</section>
{modalContent}
</section>
</LoadingAndErrorWrapper>
);
};
@@ -291,25 +307,12 @@ PeopleList.propTypes = {
confirmUpdateMembershipAction: PropTypes.func.isRequired,
onNextPage: PropTypes.func,
onPreviousPage: PropTypes.func,
reloadUsers: PropTypes.func.isRequired,
reloadGroups: PropTypes.func.isRequired,
metadata: PropTypes.shape({
total: PropTypes.number.isRequired,
}).isRequired,
};
export default _.compose(
Group.loadList({
reload: true,
}),
Users.loadList({
reload: true,
query: (_, { query }) => ({
query: query.searchText,
status: query.status,
limit: query.pageSize,
offset: query.pageSize * query.page,
}),
}),
connect(mapStateToProps, mapDispatchToProps),
)(PeopleList);

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { usePagination } from "metabase/hooks/use-pagination";
import { SEARCH_DEBOUNCE_DURATION } from "metabase/lib/constants";
@@ -15,20 +15,28 @@ export const usePeopleQuery = pageSize => {
const { handleNextPage, handlePreviousPage, setPage, page } = usePagination();
const [status, setStatus] = useState(USER_STATUS.active);
const [searchInputValue, setSearchInputValue] = useState("");
const [searchInputValue, _setSearchInputValue] = useState("");
const dirtyRef = useRef(false);
const setSearchInputValue = useCallback(setter => {
_setSearchInputValue(setter);
dirtyRef.current = true;
}, []);
const [searchText, setSearchText] = useState("");
useEffect(() => {
const timerId = setTimeout(() => {
const searchText =
searchInputValue.length >= MIN_SEARCH_LENGTH ? searchInputValue : "";
// skip running if the user has not changed any input
if (dirtyRef.current === true) {
const timerId = setTimeout(() => {
const searchText =
searchInputValue.length >= MIN_SEARCH_LENGTH ? searchInputValue : "";
setPage(0);
setSearchText(searchText);
}, SEARCH_DEBOUNCE_DURATION);
setPage(0);
setSearchText(searchText);
}, SEARCH_DEBOUNCE_DURATION);
return () => clearTimeout(timerId);
return () => clearTimeout(timerId);
}
}, [searchInputValue, setPage]);
const updateStatus = status => {

Some files were not shown because too many files have changed in this diff Show More