Compare commits

...

254 Commits

Author SHA1 Message Date
Thomas Schmidt
bffebd03dd update example flow 2025-02-12 10:59:56 +01:00
Thomas Schmidt
a7f60f8004 Pass state with constructed queries between FE - BE and AI-service 2025-02-12 10:56:34 +01: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
148 changed files with 7776 additions and 278 deletions

View File

@@ -324,7 +324,8 @@
;;
;; PRO TIP: Check out the `dev.deps-graph` namespace for helpful tools to see where a module is used externally.
:api-namespaces
{metabase.actions #{metabase.actions.api
{metabase-enterprise.metabot-v3 #{metabase-enterprise.metabot-v3.core}
metabase.actions #{metabase.actions.api
metabase.actions.core}
metabase.activity-feed #{metabase.activity-feed.api
metabase.activity-feed.init}
@@ -459,7 +460,8 @@
;; 2. A set of module symbols. This is the list of modules that are allowed to be referenced. An empty set means no
;; other modules are allowed to be referenced; this is the default for any modules that aren't listed here.
:allowed-modules
{metabase.actions :any
{metabase-enterprise.metabot-v3 :any
metabase.actions :any
metabase.activity-feed #{metabase.api
metabase.config
metabase.db

View File

@@ -169,7 +169,7 @@
version-info
version-info-last-checked})
(defn- defsetting-lint [node setting-name docstring options-list]
(defn- defsetting* [node setting-name docstring options-list]
(let [anon-binding (common/with-macro-meta (hooks/token-node '_) node)
;; (defn my-setting [] ...)
getter-node (-> (list
@@ -177,8 +177,7 @@
setting-name
(hooks/string-node "Docstring.")
(hooks/vector-node []))
hooks/list-node
(with-meta (meta node)))
hooks/list-node)
;; (defn my-setting! [_x] ...)
setter-node (-> (list
(hooks/token-node 'defn)
@@ -188,7 +187,6 @@
(hooks/string-node "Docstring.")
(hooks/vector-node [(hooks/token-node '_value-or-nil)]))
hooks/list-node
(with-meta (meta node))
common/add-lsp-ignore-unused-public-var-metadata)]
(when (nil? (second (drop-while (comp not #{[:k :export?]} first) options-list)))
@@ -197,16 +195,16 @@
:message "Setting definition must provide an explicit value for :export? indicating whether the setting should be exported or not with serialization."
:type :metabase/defsetting-must-specify-export))))
{:node (-> (list
(hooks/token-node 'let)
;; include description and the options map so they can get validated as well.
(hooks/vector-node
[anon-binding docstring
anon-binding (hooks/map-node options-list)])
getter-node
setter-node)
hooks/list-node
(with-meta (meta node)))}))
(-> (list
(hooks/token-node 'let)
;; include description and the options map so they can get validated as well.
(hooks/vector-node
[anon-binding docstring
anon-binding (hooks/map-node options-list)])
getter-node
setter-node)
hooks/list-node
(with-meta (meta node)))))
(defn defsetting
"Rewrite a [[metabase.models.defsetting]] form like
@@ -221,9 +219,11 @@
(defn my-setting! \"Docstring.\" [_value-or-nil]))
for linting purposes."
[{:keys [node]}]
(let [[setting-name docstring & options] (rest (:children node))]
(defsetting-lint node setting-name docstring options)))
[x]
(letfn [(update-node [node]
(let [[setting-name docstring & options] (rest (:children node))]
(defsetting* node setting-name docstring options)))]
(update x :node update-node)))
(defn define-multi-setting
"Rewrite a [[metabase.models.define-multi-setting]] form like
@@ -238,30 +238,8 @@
(defn my-setting! \"Docstring.\" [_value-or-nil]))
for linting purposes."
[{:keys [node]}]
(let [[setting-name docstring thunk & options] (rest (:children node))]
(defsetting-lint node setting-name docstring (concat options [(hooks/token-node :multi-thunk) thunk]))))
(comment
(defn- defsetting* [form]
(hooks/sexpr
(:node
(defsetting
{:node
(hooks/parse-string
(with-out-str
#_{:clj-kondo/ignore [:unresolved-namespace]}
(clojure.pprint/pprint
form)))}))))
(defn x []
(defsetting*
'(defsetting active-users-count
(deferred-tru "Cached number of active users. Refresh 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)))))))
[x]
(letfn [(update-node [node]
(let [[setting-name docstring thunk & options] (rest (:children node))]
(defsetting* node setting-name docstring (concat options [(hooks/token-node :multi-thunk) thunk]))))]
(update x :node update-node)))

View File

@@ -0,0 +1,33 @@
(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}]
(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
paths-ignore:
# config files
@@ -168,8 +168,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/.

3
.gitignore vendored
View File

@@ -119,3 +119,6 @@ modules/drivers/*/.lsp/*
.swc/
*storybook.log
# metabot
llm-payloads

View File

@@ -305,6 +305,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"
@@ -851,9 +852,18 @@
{com.nextjournal/beholder {:mvn/version "1.0.2"} ;; watcher
io.github.tonsky/clj-reload {:mvn/version "0.7.1"}} ;; reloader
:extra-paths "dev/src/watch"
:main-opts ["-m" "watch.watcher"]}}}
:main-opts ["-m" "watch.watceher"]}
;; For experimenting with MetaBot v3 from the CLI.
;;
;; clj -X:ee:metabot-v3/repl
:metabot-v3/repl
{:exec-fn metabase-enterprise.metabot-v3.repl/user-repl-cli}
;; clj -X:dev:ee:ee-dev:test:test/metabot-v3
:test/metabot-v3
{:exec-args {:only "enterprise/backend/test/metabase_enterprise/metabot_v3"}}}
;; TODO -- consider creating an alias that includes the `./bin` build-drivers & release code as well so we can work
;; on them all from a single REPL process.
;; TODO -- consider creating an alias that includes the `./bin` build-drivers & release code as well so we can work
;; on them all from a single REPL process.
}

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: "metabot.tool/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

@@ -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("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

@@ -0,0 +1,32 @@
(ns metabase-enterprise.ai-sql-fixer.api
"`/api/ee/ai-sql-fixer/` routes"
(:require
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.driver.util :as driver.u]
[metabase.models.persisted-info :as persisted-info]
[metabase.query-processor.middleware.permissions :as qp.perms]
[metabase.util.malli.schema :as ms]))
(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]]]
(binding [persisted-info/*allow-persisted-substitution* false]
(qp.perms/check-current-user-has-adhoc-native-query-perms query)
(let [sql (-> query :native :query)
driver (driver.u/database->driver (:database query))]
(-> (metabot-v3.client/fix-sql {:sql sql
:dialect driver
:error_message error_message
:schema_ddl ""})
(select-keys [:fixes])))))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/ai-sql-fixer` routes."
(api.macros/ns-handler *ns* +auth))

View File

@@ -7,11 +7,13 @@
(: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]
[metabase-enterprise.content-verification.api.routes]
[metabase-enterprise.llm.api]
[metabase-enterprise.metabot-v3.api]
[metabase-enterprise.query-reference-validation.api]
[metabase-enterprise.sandbox.api.routes]
[metabase-enterprise.scim.routes]
@@ -29,7 +31,9 @@
{:advanced-permissions (deferred-tru "Advanced Permissions")
:audit-app (deferred-tru "Audit app")
:collection-cleanup (deferred-tru "Collection Cleanup")
:ai-sql-fixer (deferred-tru "AI SQL Fixer")
: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")
@@ -55,10 +59,12 @@
"/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
"/logs" (premium-handler 'metabase-enterprise.advanced-config.api.logs :audit-app)
"/metabot-v3" (premium-handler metabase-enterprise.metabot-v3.api/routes :metabot-v3)
"/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,79 @@
# Metabot V3
Welcome to Metabot V3!
The main entrypoint is `metabot-v3.api`, which provides a single route, `POST api/ee/metabot-v3/agent`.
This takes three arguments:
- message (a non-blank string, the message from the user or possibly from another system that we want the LLM to
interpret)
- context:
``` json
"context": {
"current_time_with_timezone": <timestamp>,
"current_page": { // LLM understandable context of what this page is about (purpose)
"name": "Visualizer",
"description": "A page where query results are visualized."
},
"current_visualization_settings": {
"current_display_type": "table",
"visible_columns": [{"name": "foo"}],
"hidden_columns": [{"name": "bar"}]
}
}
```
- history, a collection of previous messages
Note that all requests are completely stateless: we don't maintain any internal history on the server, and the LLM
doesn't get any history that isn't sent in by the frontend.
When we receive a request, we build an `envelope`. The `envelope` is just a bundle of data, including the context and
history of the conversation, as well as some other details we need to handle the request correctly (for example, it
keeps track of the maximum number of round-trip requests to the LLM we'll do for a given API call, and errors if we
exceed that). The `metabot-v3.envelope` namespace provides utility functions for modifying or querying the `envelope`
to see what we should do, or to "modify" the envelope to include new results.
This brings us to `metabot-v3.handle-envelope/handle-envelope`. This contains the business logic that controls the
flow of a given `envelope`.
For background, when we invoke the LLM, it will either respond with a message for the user, or with "tool-calls" that
invoke external tools.
So, when we `handle-envelope`, we loop as follows:
- if we need to invoke the LLM, as when the most recent message is from the user, we do so and update the `envelope`
with the response
- if we need to invoke a tool, we do so
- if we don't need to invoke the LLM or a tool (the most recent message is from the LLM, and it's not a tool call), we
return the `envelope` unchanged
Why invoke the LLM multiple times for a single API request? This supports things like the following:
- the user asks Metabot to add some user to a group
- the LLM invokes two tools: one to look up the user, one to look up the group
- the tools are invoked and we respond to the LLM
- the LLM invokes a third tool to add the user to the group
- the tool is invoked, the user is added to the group, and we respond to the LLM
- the LLM writes a user-friendly message about what just happened, which is displayed to the user.
Each time we invoke a tool, it returns:
- an "output" - a string response that is sent to the LLM as a response to the tool call, and
- zero or more "reactions" - strictly typed data structures telling the *frontend* about things that happened, or
should happen.
As an example, the tool to change the data visualization type to a bar chart can just have an output of "success",
telling the LLM that the tool was invoked successfully. Then, we'll tell the frontend to actually change the viz type
with a reaction.
The overall flow looks like this:
![Sequence diagram](docs/sequence-diagram.png)

View File

@@ -0,0 +1,265 @@
(ns metabase-enterprise.metabot-v3.api
"`/api/ee/metabot-v3/` routes"
(:require
[clojure.walk :as walk]
[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.dummy-tools :as metabot-v3.dummy-tools]
[metabase-enterprise.metabot-v3.envelope :as metabot-v3.envelope]
[metabase-enterprise.metabot-v3.handle-envelope :as metabot-v3.handle-envelope]
[metabase-enterprise.metabot-v3.reactions :as 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.generate-insights :as metabot-v3.tools.generate-insights]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.legacy-mbql.schema :as mbql.s]
[metabase.util :as u]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]
[metabase.util.malli.schema :as ms]))
(defn- safe-case-updater
[f]
#(cond-> % (or (string? %) (keyword? %)) f))
(def ^:private safe->kebab-case-en
(safe-case-updater u/->kebab-case-en))
(def ^:private safe->snake_case_en
(safe-case-updater u/->snake_case_en))
(defn- recursive-update-keys
[form f]
(walk/walk #(cond-> % (coll? %) (recursive-update-keys f))
#(cond-> % (map? %) (update-keys f))
form))
(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 state session-id]
(let [env (-> (metabot-v3.envelope/create
(metabot-v3.context/create-context context)
history
state
session-id)
(metabot-v3.envelope/add-user-message message)
(metabot-v3.dummy-tools/invoke-dummy-tools)
(metabot-v3.handle-envelope/handle-envelope))]
{:reactions (encode-reactions (metabot-v3.envelope/reactions env))
:history (metabot-v3.envelope/history env)
:state (metabot-v3.envelope/get-state-for-response env)
:session_id session-id}))
(api.macros/defendpoint :post "/agent"
"Send a chat message to the LLM via the AI Proxy."
[_route-params
_query-params
{:keys [message context history state session_id] :as body} :- [:map
[:message ms/NonBlankString]
[:context [:map-of :keyword :any]]
[:history [:maybe [:sequential :map]]]
[:session_id ms/UUIDString]
[:state {:optional true} any?]]
]
(metabot-v3.context/log body :llm.log/fe->be)
(let [context (mc/decode ::metabot-v3.context/context
context (mtx/transformer {:name :api-request}))
history (mc/decode [:maybe ::metabot-v3.client.schema/messages]
history (mtx/transformer {:name :api-request}))
state (or state {})]
(doto (assoc
(request message context history state session_id)
:session_id session_id)
(metabot-v3.context/log :llm.log/be->fe))))
(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 ::temporal-filter
[:map
[:field_id :string]
[:operation [:enum
"date-equals" "date-not-equals"
"date-before" "date-on-or-before"
"date-after" "date-on-or-after"]]
[:value :string]])
(mr/def ::string-filter
[:map
[:field_id :string]
[:operation [:enum
"string-equals" "string-not-equals"
"string-contains" "string-not-contains"
"string-starts-with" "string-ends-with"]]
[:value :string]])
(mr/def ::numeric-filter
[:map
[:field_id :string]
[:operation [:enum
"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 ::filter
[:or ::existence-filter ::temporal-extraction-filter ::temporal-filter ::string-filter ::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/api-request #(update-keys % u/->kebab-case-en)
:encode/tool-api-request #(recursive-update-keys % safe->kebab-case-en)}
[:metric_id :int]
[:filters {:optional true} [:maybe [:sequential ::filter]]]
[:group_by {:optional true} [:maybe [:sequential ::group-by]]]])
(mr/def ::result-column
[:map
[:field_id :string]
[:name :string]
[:type :string]
[:description {:optional true} :string]])
(mr/def ::query-metric-result
[:or
[:map
{:decode/api-response #(update-keys % u/->snake_case_en)
:decode/tool-api-response #(recursive-update-keys % safe->snake_case_en)}
[:structured_output
[:map
[:type [:= :query]]
[:query_id :string]
[:query mbql.s/Query]
[:result_columns [:sequential ::result-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/api-request #(update-keys % u/->kebab-case-en)
:encode/tool-api-request #(recursive-update-keys % safe->kebab-case-en)}
[:dashboard_id :int]
[:email :string]
[:schedule ::subscription-schedule]])
(mr/def ::generate-insights-arguments
[:map
{:encode/api-request #(update-keys % u/->kebab-case-en)
:encode/tool-api-request #(recursive-update-keys % safe->kebab-case-en)}
[:for [:or
[:map [:metric_id :int]]
[:map [:table_id :string]]
[:map [:report_id :int]]
[:map [:query :map]]]]])
(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 body :llm.log/llm->be)
(let [arguments (mc/encode ::create-dashboard-subscription-arguments
arguments (mtx/transformer {:name :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 "/generate-insights" :- [:merge
[:map
[:output :string]
[:reactions [:sequential
[:map
[:type [:= :metabot.reaction/redirect]]
[:url :string]]]]]
::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 body :llm.log/llm->be)
(let [arguments (mc/encode ::generate-insights-arguments
arguments (mtx/transformer {:name :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 "/query-metric" :- [:merge ::query-metric-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 body :llm.log/llm->be)
(let [arguments (mc/encode ::query-metric-arguments
arguments (mtx/transformer {:name :api-request}))]
(doto (-> (mc/decode ::query-metric-result
(metabot-v3.tools.filters/query-metric arguments)
(mtx/transformer {:name :api-response}))
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm))))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/metabot-v3` routes."
(api.macros/ns-handler *ns* +auth))

View File

@@ -0,0 +1,237 @@
(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.api.common :as api]
[metabase.models.setting :refer [defsetting]]
[metabase.premium-features.core :as premium-features]
[metabase.util :as u]
[metabase.util.i18n :as 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- encode-request-body [body]
(mc/encode ::metabot-v3.client.schema/request
body
(mtx/transformer
(mtx/default-value-transformer)
{:name :api-request}
(mtx/key-transformer {:encode (fn [k]
(case k
(:additionalProperties :additional-properties) :additionalProperties
:anyOf :anyOf
(u/->snake_case_en k)))}))))
(mu/defn- build-request-body
[context :- [:maybe :map]
messages :- [:maybe ::metabot-v3.client.schema/messages]
session-id :- :string
tools :- [:sequential :any]
state :- [:maybe :map]]
(encode-request-body
{:messages messages
:context context
:tools tools
:session-id session-id
:state state
:user-id api/*current-user-id*}))
(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-endpoint-url []
(str (ai-proxy-base-url) "/v1/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- decode-response-body [response-body]
(mc/decode ::metabot-v3.client.schema/ai-proxy.response
response-body
(mtx/transformer
(mtx/json-transformer)
{:name :api-response}
(mtx/key-transformer {:decode u/->kebab-case-en}))))
(mu/defn ^:dynamic *request* :- ::metabot-v3.client.schema/ai-proxy.response
"Make a request to the AI Proxy."
[context :- [:maybe :map]
messages :- [:maybe ::metabot-v3.client.schema/messages]
session-id :- :string
tools :- [:sequential :any]
state :- [:maybe :map]]
;; TODO -- when `:metabot-v3` code goes live remove this check and check for the `:metabot-v3` feature specifically.
(assert (premium-features/has-any-features?) (i18n/tru "You must have a valid enterprise token to use MetaBot."))
#_(premium-features/assert-has-feature :metabot-v3 "MetaBot")
(try
(let [url (agent-endpoint-url)
body (doto (build-request-body context messages session-id tools state)
(metabot-v3.context/log :llm.log/be->llm))
_ (log/debugf "Request to AI Proxy:\n%s" (u/pprint-to-str body))
options (build-request-options body)
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 (decode-response-body (:body response))
(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)))))
(defn select-metric-request
"Make a request to AI Service to select a metric."
[metrics query]
(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 (decode-response-body (:body response))
(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 Service: %s" (ex-message e))
{}
e)))))
(defn find-outliers-request
"Make a request to AI Service to find outliers"
[values]
(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 (decode-response-body (:body response))
(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 service: %s" (ex-message e))
{}
e)))))
(defn fix-sql
"Ask the AI service to propose fixes a SQL query and a given error."
[body]
(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})))))
;;; Example flow. Copy this into the REPL to debug things
(comment
(defn- str->message [msg] {:role :user :content msg})
;; request 1
(let [session-id (str (random-uuid))
message-1 "Send an email to Cam"
response-1 (*request* {} [(str->message message-1)] state session-id [])]
;; response 1 looks something like:
(comment {:message {:content "Sorry I don't understand that. Could you please clarify what you would like to include in the email to Cam?"
:role :assistant
:tool-calls []}
:history [] ; Optional history from AI service
:metadata {:model "gpt-4o-mini", :usage {:total 439, :prompt 416, :completion 23}}})
(let [history [(str->message message-1)
(:message response-1)
(str->message "Cam's email is cam@metabase.com")]
response-2 (*request* {} history state session-id [])]
;; response 2 looks like:
(comment {:message
{:content "",
:role :assistant,
:tool-calls [{:id "call_DUTV9UW4s47fycBIjQ0Id0XM", :name :invite-user, :arguments {:email "cam@metabase.com"}}]}
:history [] ; Optional history from AI service
:metadata {:model "gpt-4o-mini", :usage {:total 497, :prompt 478, :completion 19}}})
(let [history (conj (vec history)
(:message response-2)
(str->message "Thank you!"))]
(*request* {} history state session-id [])))))
#_#_(*request* "Send an invite to Cam at cam@metabase.com" {} [] [])
(*request* "" {}
[{:content ""
:role :assistant
:tool-calls [{:id "call_e07zRoT7gH1L9dOOgCEIhql0"
:name :metabot.tool/invite-user
:arguments {:email "cam@metabase.com"}}]}
{:content "Sent the invite!"
:role :tool
:tool-call-id "call_e07zRoT7gH1L9dOOgCEIhql0"}]
[])

View File

@@ -0,0 +1,69 @@
(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.json :as json]
[metabase.util.malli.registry :as mr]))
(mr/def ::request.tools
[:sequential ::metabot-v3.tools.interface/metadata])
(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 [:map-of
{:decode/api-response (fn [x]
(cond-> x
(string? x) (json/decode true)))
:encode/api-request (fn [x]
(cond-> x
(map? x) json/encode))}
::metabot-v3.tools.interface/metadata.parameter.name
:any]]])
(mr/def ::message
[:map
[:role ::role]
[:content [:maybe :string]]
[:tool-calls {:optional true} [:maybe [:sequential ::message.tool-call]]]])
(mr/def ::messages
[:sequential ::message])
(mr/def ::request.tools
[:sequential ::metabot-v3.tools.interface/metadata])
(mr/def ::request
"Shape of the request we send to AI Proxy (before applying Malli encoding transformations)."
[:map
{:encode/api-request #(update-keys % u/->snake_case_en)}
[:messages ::messages]
[:tools ::request.tools]
[:context {:default {}} :map]
[:user-id integer?]])
(mr/def ::metric
"A metric as sent to the AI Service"
[:map
[:id integer?]
[:name :string]
[:description [:maybe :string]]])
(mr/def ::select-metric-request
"Shape of the request body we send to AI Service for Select Metric requests"
[:map
[:query :string]
[:metrics [:sequential ::metric]]])
(mr/def ::ai-proxy.response
"Shape of the response we get back from the AI Proxy."
[:map
[:message ::message]
[:state {:optional true} [:map-of :string :any]]])

View File

@@ -0,0 +1,66 @@
(ns metabase-enterprise.metabot-v3.context
(:require
[clojure.java.io :as io]
[metabase-enterprise.metabot-v3.tools.query :as metabot-v3.tools.query]
[metabase.config :as config]
[metabase.util :as u]
[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
;; TODO -- should this be recursive?
{:encode/api-request #(update-keys % u/->snake_case_en)}
:keyword
:any])
(def ^:private ^DateTimeFormatter current-user-time-format
(DateTimeFormatter/ofPattern "'Today is' EEEE, 'Year' yyyy, 'Date' yyyy-MM-dd, HH:mm:ss"))
(defn- set-user-time
[context]
(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 current-user-time-format offset-time)))))
(mu/defn create-context
"Create a tool context."
[context]
(set-user-time context))
(mu/defn create-reactions
"Extracts reactions based on the current context."
[context]
(vec (metabot-v3.tools.query/create-reactions context)))

View File

@@ -0,0 +1,12 @@
(ns metabase-enterprise.metabot-v3.core
"API namespace for the `metabase-enterprise.metabot-v3` module."
(:require
[metabase-enterprise.metabot-v3.api]
[potemkin :as p]))
(comment
metabase-enterprise.metabot-v3.api/keep-me)
(p/import-vars
[metabase-enterprise.metabot-v3.api
routes])

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

View File

@@ -0,0 +1,18 @@
title Metabot Sequence Diagram
Frontend->Backend: User sends request\n(message, context, history)
Backend->LLM: Forward request to LLM\n(message, tools)
LLM->Backend: LLM response\n(message or tool calls)
note over Backend: While tool calls are present:
Backend->Tool: Invoke tools specified by LLM
Tool->Backend: Return tool output\n(output string, reactions)
Backend->LLM: Send tool output to LLM
LLM->Backend: LLM response\n(message or tool calls)
note over Backend: Loop continues until LLM\nresponse has no tool calls
Backend->Frontend: Forward response to Frontend\n(reactions and/or message from the LLM)
note over Frontend:Frontend displays message and/or takes action based on reactions

View File

@@ -0,0 +1,326 @@
(ns metabase-enterprise.metabot-v3.dummy-tools
(:require
[medley.core :as m]
[metabase-enterprise.metabot-v3.envelope :as envelope]
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription]
[metabase-enterprise.metabot-v3.tools.filters]
[metabase-enterprise.metabot-v3.tools.interface :as metabot-v3.tools.interface]
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
[metabase-enterprise.metabot-v3.tools.who-is-your-favorite]
[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.util :as u]
[toucan2.core :as t2]))
(defn- get-current-user
[_tool-id _arguments _e]
(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
:name (str first_name " " last_name)
:email-address email}}
{:output "current user not found"}))
(defn- get-dashboard-details
[_tool-id {:keys [dashboard-id]} _e]
(if-let [dashboard (t2/select-one [:model/Dashboard :id :description :name] dashboard-id)]
{:structured-output dashboard}
{:output "dashboard not found"}))
(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)
filterable-cols (lib/filterable-columns 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
: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 :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)
field-id-prefix (metabot-v3.tools.u/table-field-id-prefix id)]
(-> {:id id
: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?
(not-empty (foreign-key-tables mp cols))))))))
(defn- card-details
[id]
(when-let [base (metabot-v3.tools.u/get-card id)]
(let [mp (lib.metadata.jvm/application-database-metadata-provider (:database_id base))
card-metadata (lib.metadata/card mp id)
card-query (lib/query mp (cond-> card-metadata
;; pivot questions have strange result-columns so we work with the dataset-query
(#{:question} (:type base)) (get :dataset-query)))
cols (lib/returned-columns card-query)
field-id-prefix (metabot-v3.tools.u/card-field-id-prefix id)]
(-> {:id id
:fields (into [] (map-indexed #(metabot-v3.tools.u/->result-column card-query %2 %1 field-id-prefix)) cols)
:name (lib/display-name card-query)}
(m/assoc-some :description (:description base)
:metrics (not-empty (mapv #(convert-metric % mp) (lib/available-metrics card-query)))
:queryable-foreign-key-tables (not-empty (foreign-key-tables mp cols)))))))
(defn- get-table-details
[_tool-id {:keys [table-id]} _e]
(let [details (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"))]
(if (map? details)
{:structured-output (assoc details :id table-id)}
{: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"]
(get-table-details :get-table-details {:table-id id} {})))
-)
(defn- get-metric-details
[_tool-id {:keys [metric-id]} _e]
(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
[_tool-id {:keys [report-id]} _e]
(let [details (if (int? report-id)
(let [details (card-details report-id)]
(some-> details
(select-keys [:id :description :name])
(assoc :result_columns (:fields details))))
"invalid report_id")]
(if (map? details)
{:structured-output details}
{:output (or details "report not found")})))
(comment
(binding [api/*current-user-permissions-set* (delay #{"/"})]
(let [id "card__90" #_"card__136" #_"27"]
(get-table-details :get-table-details {:table-id id} {})))
-)
(defn- dummy-tool-messages
[tool-id arguments {:keys [output structured-output]}]
(let [call-id (str "call_" (u/generate-nano-id))]
[{:content nil
:role :assistant
:tool-calls [{:id call-id
:name tool-id
:arguments arguments}]}
(-> {:role :tool
:tool-call-id call-id}
(m/assoc-some :content output)
(m/assoc-some :structured-content structured-output))]))
(defn- dummy-get-current-user
[env]
(let [content (get-current-user :get-current-user {} env)]
(reduce envelope/add-dummy-message env (dummy-tool-messages :get-current-user {} content))))
(def ^:private detail-getters
{:dashboard {:id :get-dashboard-details
:fn get-dashboard-details
:arg-fn (fn [id] {:dashboard-id id})}
:table {:id :get-table-details
:fn get-table-details
:arg-fn (fn [id] {:table-id (str id)})}
:model {:id :get-table-details
:fn get-table-details
:arg-fn (fn [id] {:table-id (str "card__" id)})}
:metric {:id :get-metric-details
:fn get-metric-details
:arg-fn (fn [id] {:metric-id id})}
:question {:id :get-report-details
:fn get-report-details
:arg-fn (fn [id] {:report-id id})}})
(defn- replace-with-queried-tables
[{:keys [query]}]
(let [ids (-> query :query mbql.normalize/normalize lib.util/collect-source-tables)
card-ids (keep lib.util/legacy-string-table-id->card-id ids)
table-ids (filter int? ids)]
(concat (map (fn [id]
{:type :table
:id id})
table-ids)
(when (seq card-ids)
(t2/select [:model/Card :type :id] :id [:in card-ids])))))
(defn- dummy-get-item-details
[{:keys [context] :as env}]
(transduce (comp (mapcat (fn [item]
(if (= (:type item) "adhoc")
(replace-with-queried-tables item)
[item])))
(distinct))
(completing
(fn [env viewed]
(if-let [{getter-id :id, getter-fn :fn, :keys [arg-fn]} (some-> viewed :type keyword detail-getters)]
(let [arguments (arg-fn (:id viewed))
tool-response (getter-fn getter-id arguments (envelope/context env))]
(reduce envelope/add-dummy-message env (dummy-tool-messages getter-id arguments tool-response)))
env)))
env
(:user_is_viewing context)))
(defn- execute-query
[query-id legacy-query]
(let [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- dummy-run-query
[{:keys [context] :as env}]
(transduce (filter (comp #{"adhoc"} :type))
(completing (fn [env {:keys [query]}]
(let [query-id (u/generate-nano-id)
arguments {:query_id query-id}
result (execute-query query-id (mbql.normalize/normalize query))]
(as-> env $env
(reduce envelope/add-dummy-message
$env
(dummy-tool-messages :run-query arguments {:structured-output result}))
(reduce envelope/add-dummy-message
$env
(dummy-tool-messages :show-results-to-user arguments
(metabot-v3.tools.interface/*invoke-tool*
:metabot.tool/show-results-to-user
{:query-id query-id}
$env)))))))
env
(:user_is_viewing context)))
(def ^:private dummy-tool-registry
[dummy-get-current-user
dummy-get-item-details
dummy-run-query])
(defn invoke-dummy-tools
"Invoke `tool` with `context` if applicable and return the resulting context."
[env]
(reduce (fn [env tool]
(tool env))
env
dummy-tool-registry))
(comment
(def test-query {:database 5
:type :query
:query
{:joins
[{:strategy :left-join
:alias "Products"
:condition
[:=
[:field "PRODUCT_ID" {:base-type :type/Integer}]
[:field 285 {:base-type :type/BigInteger, :join-alias "Products"}]]
:source-table 30}]
:breakout
[[:field 279 {:base-type :type/Float, :join-alias "Products", :binning {:strategy :default}}]
[:field "CREATED_AT" {:base-type :type/DateTime, :temporal-unit :month}]]
:aggregation
[[:min [:field "SUBTOTAL" {:base-type :type/Float}]]
[:avg [:field "SUBTOTAL" {:base-type :type/Float}]]
[:max [:field "SUBTOTAL" {:base-type :type/Float}]]]
:source-table "card__136"
:filter [:> [:field "SUBTOTAL" {:base-type :type/Float}] 50]}})
(def test-context
;; for testing purposes, pretend the user is viewing a bunch of things at once
{:user_is_viewing [{:type "dashboard"
:id 10
:parameters []
:is-embedded false}
{:type "table"
:id 27}
{:type "model"
:id 137}
{:type "metric"
:id 135}
{:type "report"
:id 89}
{:type "adhoc"
:query test-query}]})
(defn test-envelope []
{:context test-context
:dummy-history []
:history []})
(binding [api/*current-user-permissions-set* (delay #{"/"})
api/*current-user-id* 2
api/*is-superuser?* true]
(invoke-dummy-tools (test-envelope)))
-)

View File

@@ -0,0 +1,260 @@
(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."
(:require
[medley.core :as m]
[metabase-enterprise.metabot-v3.context :as metabot-v3.context]
[metabase-enterprise.metabot-v3.reactions :as reactions]
[metabase.util :as u]
[metabase.util.i18n :as i18n]
[metabase.util.json :as json]))
(def ^:constant max-round-trips
"The maximum number of times we'll make a request to the LLM before responding to the user. Currently we'll just throw
an exception if this limit is exceeded."
6)
(defn create
"Create a fresh envelope from a context and history. This envelope should be used for the lifetime of the request."
[context history state session-id]
{:context context
:history history
:state state
:session-id session-id
:max-round-trips max-round-trips
:round-trips-remaining max-round-trips
:dummy-history []})
(defn full-history
"History including the dummy tool invocations"
[e]
(concat (:dummy-history e) (:history e)))
(defn is-tool-call-response?
"Is this message a response to a tool call?"
[{:keys [role tool-call-id]}]
(and (= role :tool)
tool-call-id))
(defn- is-query? [content]
(and (map? content)
(contains? #{:query "query"} (:type content))))
(defn- stringified-content [{:keys [structured-content content]}]
(or content
(some-> structured-content json/encode)))
(defn- stringify-content [msg]
(-> msg
(dissoc :structured-content)
(assoc :content (stringified-content msg))))
(defn- maybe-remove-query [msg]
(cond-> msg
(and (is-tool-call-response? msg)
(is-query? (:structured-content msg)))
(update :structured-content dissoc :query)))
(defn- llm-message
"Formats a message for the LLM. Removes things we don't want the LLM to see (e.g. `query`) and stringifies structured
content."
[msg]
(-> msg
maybe-remove-query
stringify-content))
(defn llm-history
"History shaped for the LLM"
[e]
(mapv llm-message (full-history e)))
(defn session-id
"Get the session ID from the envelope"
[e]
(:session-id e))
(defn decrement-round-trips
"Decrement the remaining allowed round trips to the LLM, erroring if the maximum was exceeded."
[e]
(update e :round-trips-remaining (fn [v]
(u/prog1 (dec v)
(when (neg? <>)
(let [msg (i18n/tru "I can't answer your question.")]
(throw (ex-info "Error: too many round trips."
{:envelope e
:assistant-message msg}))))))))
(defn history
"Gets the history from the envelope."
[e]
(:history e))
(defn context
"Gets the context from the envelope."
[e]
(:context e))
(defn state
"Gets the state from the envelope."
[e]
(:state e))
(defn update-state
"Given a new state, update it in the envelope."
[e new-state]
(cond-> e
(some? new-state) (assoc :state new-state)))
(defn merge-state
"Merges new state with existing state in the envelope"
[e new-state]
(update e :state #(merge (or % {}) new-state)))
(defn- message->reactions
[msg]
(let [message-reaction {:type :metabot.reaction/message
:repl/message-color :green
:repl/message-emoji "🤖"
:message (:content msg)}
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 envelope. Includes messages from the LLM itself if applicable."
[e]
(let [last-user-msg-idx (->> (history e)
(map-indexed vector)
(filter #(= (:role (second %)) :user))
last
first)
llm-message-reactions (->> (history e)
(drop (inc last-user-msg-idx))
(filter #(= (:role %) :assistant))
(filter #(not-empty (:content %)))
(mapcat message->reactions)
(into []))]
(into [] (concat llm-message-reactions (:reactions e) (metabot-v3.context/create-reactions (:context e))))))
(defn add-user-message
"Given a user message (a string) adds it to the envelope."
[e msg]
(update e :history conj {:role :user :content msg}))
(defn add-message
"Given a raw message, add it to the envelope."
[e msg]
(update e :history conj msg))
(defn add-dummy-message
"Adds a message to the dummy history. This is sent to the LLM, but not part of the history we send to the frontend."
[e msg]
(update e :dummy-history conj msg))
(defn update-context
"Given a new context, set it in the envelope."
[e context]
(cond-> e
(some? context) (assoc :context context)))
(defn- update-reactions
"Given reactions, add them to the envelope"
[e reactions]
(update e :reactions (fnil into []) reactions))
(defn add-tool-response
"Given an output string and new context, adds them to the envelope."
[e tool-call-id {:keys [output structured-output context reactions]}]
(-> e
(add-message (-> {:role :tool
:tool-call-id tool-call-id}
(m/assoc-some :content output)
(m/assoc-some :structured-content structured-output)))
(update-context context)
(update-reactions reactions)))
(defn is-tool-call?
"Is this message a tool call?"
[{:keys [tool-calls]}]
(boolean (seq tool-calls)))
(defn find-query
"Given an envelope and a query-id, find the query in the history."
[e query-id]
(->> e
full-history
(filter #(and (is-tool-call-response? %)
(-> % :structured-content is-query?)))
(keep :structured-content)
(filter #(= (:query_id %) query-id))
first
:query))
(defn requires-tool-invocation?
"Does this envelope require tool call invocation?"
[e]
(->> e history peek is-tool-call?))
(defn is-user-message?
"Is this message from the user?"
[{:keys [role]}]
(= role :user))
(defn is-assistant-message?
"Is this message from the assistant (i.e. the LLM)?"
[{:keys [role]}]
(= role :assistant))
(defn requires-llm-response?
"Does this envelope require a new response from the LLM?"
[e]
(let [last-message (->> e history peek)]
(and (not (reactions/has-terminating-reaction? (:reactions e)))
(or (is-tool-call-response? last-message)
(is-user-message? last-message)))))
(defn tool-calls-requiring-invocation
"Gets a list of all the tool calls in the chat history that have not yet been responded to."
[e]
(let [tool-call-id->response (->> e
history
(filter is-tool-call-response?)
(map :tool-call-id)
(into #{}))]
(->> (history e)
(filter is-tool-call?)
(mapcat :tool-calls)
(remove #(tool-call-id->response (:id %))))))
(defn last-assistant-message->reaction
"This is a bit hacky. Right now we only respond to the user with reactions. So we take the last assistant message and
turn it into a reaction."
[e]
{:type :metabot.reaction/message
:repl/message-color :green
:repl/message-emoji "🤖"
:message (->> e
history
(filter is-assistant-message?)
last
:content)})
(defn get-state-for-response
"Gets the state that should be sent back to the frontend"
[e]
(let [state (:state e)]
;; Ensure queries from structured outputs are included in state
(->> e
history
(filter #(and (= (:role %) :tool)
(:structured-content %)))
(reduce (fn [acc msg]
(if-let [query-id (get-in msg [:structured-content :query_id])]
(assoc-in acc [:queries query-id]
(get-in msg [:structured-content :query]))
acc))
state))))

View File

@@ -0,0 +1,56 @@
(ns metabase-enterprise.metabot-v3.handle-envelope
"Code for handling responses from AI Proxy ([[metabase-enterprise.metabot-v3.client]])."
(:require
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
[metabase-enterprise.metabot-v3.envelope :as envelope]
[metabase-enterprise.metabot-v3.tools :as metabot-v3.tools]
[metabase-enterprise.metabot-v3.tools.interface :as metabot-v3.tools.interface]
[metabase.util.log :as log]
[metabase.util.o11y :as o11y]))
(defn- invoke-all-tool-calls! [e]
(reduce (fn [e {tool-name :name, tool-call-id :id, :keys [arguments]}]
(let [tool-invocation-result
(o11y/with-span :info {:name tool-name}
(try (metabot-v3.tools.interface/*invoke-tool* tool-name arguments e)
(catch Exception e
(log/errorf e "Error invoking tool: %s" tool-name)
{:output (format "An error occurred: %s" (ex-message e))})))]
(envelope/add-tool-response e tool-call-id tool-invocation-result)))
e
(envelope/tool-calls-requiring-invocation e)))
(defn- request-llm-response [e]
(let [context (envelope/context e)
state (envelope/state e)
response (metabot-v3.client/*request*
(select-keys context [:current_user_time])
(envelope/llm-history e)
(envelope/session-id e)
(metabot-v3.tools/applicable-tools (metabot-v3.tools/*tools-metadata*)
context)
state)]
(-> e
envelope/decrement-round-trips
(envelope/add-message (:message response))
(envelope/merge-state (:state response)))))
(defn handle-envelope
"Three possible states here:
1. *We* have updated the history with a tool call response, and require an LLM response.
2. We have received a response back from the LLM and need to respond to tool calls (if any)
3. We don't need to do anything at all."
[e]
(try
(loop [e e]
(cond
(envelope/requires-tool-invocation? e) (recur (invoke-all-tool-calls! e))
(envelope/requires-llm-response? e) (recur (request-llm-response e))
:else e))
(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))))))

View File

@@ -0,0 +1,100 @@
(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]])
(def ^:private terminating-reaction-types
"If one of these reactions is present in the reactions vector, stop the loop
and return the result to the user."
#{:metabot.reaction/redirect})
(defn- terminating-reaction?
"Checks if a reaction is a terminating reaction"
[reaction]
(contains? terminating-reaction-types (:type reaction)))
(defn has-terminating-reaction?
"Checks if the envelope reactions vector contains a terminating reaction"
[reactions]
(some terminating-reaction? reactions))

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.envelope :as metabot-v3.envelope]
[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 session-id]
(when-let [history' (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 [env (api/request input context history session-id)]
(handle-reactions (metabot-v3.envelope/reactions env))
(metabot-v3.envelope/history env))))
(catch Throwable e
#_{:clj-kondo/ignore [:discouraged-var]}
(println (u/pprint-to-str :red e))
history))]
(recur history' context session-id))))
(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))))
(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,100 @@
(ns metabase-enterprise.metabot-v3.tools
(:require
[clojure.string :as str]
[malli.core :as mc]
[malli.transform :as mtx]
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription]
[metabase-enterprise.metabot-v3.tools.filters]
[metabase-enterprise.metabot-v3.tools.find-metric]
[metabase-enterprise.metabot-v3.tools.find-outliers]
[metabase-enterprise.metabot-v3.tools.generate-insights]
[metabase-enterprise.metabot-v3.tools.interface :as metabot-v3.tools.interface]
[metabase-enterprise.metabot-v3.tools.invite-user]
[metabase-enterprise.metabot-v3.tools.show-results-to-user]
[metabase-enterprise.metabot-v3.tools.who-is-your-favorite]
[metabase.config :as config]
[metabase.lib.schema.common :as lib.schema.common]
[metabase.util :as u]
[metabase.util.files :as u.files]
[metabase.util.i18n :as i18n]
[metabase.util.json :as json]
[metabase.util.malli :as mu])
(:import
(java.nio.file Files OpenOption Path)))
(set! *warn-on-reflection* true)
(mu/defn- reducible-tool-metadata-file-paths :- (lib.schema.common/instance-of-class clojure.lang.IReduceInit)
[]
(reify
clojure.lang.IReduceInit
(reduce [_this rf init]
(u.files/with-open-path-to-resource [path "metabase_enterprise/metabot_v3/tools"]
(reduce rf
init
(eduction
(filter #(str/ends-with? (str %) ".json"))
(iterator-seq (.iterator (java.nio.file.Files/list path)))))))))
(defmulti ^:private read-tool-metadata
"Read tool metadata from a `place`... can be a tool keyword name, filename (string), or `Path`."
{:arglists '([place])}
class)
(defn- decode-tool-metadata [metadata]
(mc/decode ::metabot-v3.tools.interface/metadata
metadata
(mtx/transformer
(mtx/json-transformer)
(mtx/default-value-transformer)
{:name :metadata-file})))
(mu/defmethod read-tool-metadata Path :- ::metabot-v3.tools.interface/metadata
[^Path path :- (lib.schema.common/instance-of-class Path)]
(try
(with-open [is (Files/newInputStream path (u/varargs OpenOption))
r (java.io.InputStreamReader. is)]
(u/prog1 (decode-tool-metadata (json/decode r true))
(let [expected-filename (format "%s.json" (name (:name <>)))
actual-filename (str (.getFileName path))]
(assert (= expected-filename actual-filename)
(format "Tool filename should match tool name. Expected: '%s', got: '%s'" expected-filename actual-filename)))))
(catch Throwable e
(throw (ex-info (i18n/tru "Error reading tool metadata from file {0}: {1}"
(str path)
(ex-message e))
{:path (str path)}
e)))))
;;; These methods are mainly for REPL convenience
(defmethod read-tool-metadata :default
[filename]
(u.files/with-open-path-to-resource [path filename]
(read-tool-metadata path)))
(defmethod read-tool-metadata clojure.lang.Keyword
[tool-name]
(read-tool-metadata (format "metabase_enterprise/metabot_v3/tools/%s.json" (name tool-name))))
(mu/defn- tools-metadata* :- [:sequential ::metabot-v3.tools.interface/metadata]
[]
(into []
(map read-tool-metadata)
(reducible-tool-metadata-file-paths)))
(def ^{:arglists '([])} ^:dynamic *tools-metadata*
"Get metadata about the available tools. Metadata matches the `::metabot-v3.tools.interface/metadata` schema.
In prod model, tools metadata is only loaded once, the first time this function is called, and cached afterwards. In
dev mode, it is reloaded every time so we can pick up changes to tools."
(if config/is-prod?
(let [metadata (delay (tools-metadata*))]
(fn []
@metadata))
tools-metadata*))
(defn applicable-tools
"Given a list of tools and the relevant context, return the filtered list of tools that are applicable in this
context."
[tools context]
(filter #(metabot-v3.tools.interface/*tool-applicable?* (:name %) context) tools))

View File

@@ -0,0 +1,153 @@
{
"name": "create-dashboard-subscription",
"description": "This is helpful when a user wants to be kept up to date on the contents of a dashboard.\n## When to use the create-dashboard-subscription tool\nUse this when a user wants to receive regular updates on a dashboard or send regular updates to another user. The user must have provided a valid email address or you received their email in the conversation history. You also need a valid dashboard ID and schedule supported by the tool arguments.\n## When not to use the create-dashboard-subscription tool\n* The user hasn't provided a valid email address (never infer email addresses from usernames or other information).\n* The schedule the user provided does not exactly match the supported schedules (e.g. not a full hour, unsupported day of month, etc.).",
"parameters": {
"type": "object",
"properties": {
"dashboard_id": {
"type": "integer",
"description": "The ID of the dashboard to subscribe to."
},
"email": {
"type": "string",
"description": "A valid and complete email address of the user to whom the dashboard should be sent. Do not infer email addresses from usernames or other information. If the email address is incomplete or missing a part like the TLD, ask the user for clarification before proceeding."
},
"schedule": {
"description": "The schedule at which the dashboard should be sent.",
"anyOf": [
{
"type": "object",
"properties": {
"frequency": {
"type": "string",
"enum": [
"hourly"
],
"description": "Frequency at which the dashboard should be sent"
}
},
"required": [
"frequency"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"frequency": {
"type": "string",
"enum": [
"daily"
],
"description": "Frequency at which the dashboard should be sent"
},
"hour": {
"type": "integer",
"description": "Hour at which the dashboard should be sent. Must be between 0 and 23. Can only be a full hour."
}
},
"required": [
"frequency",
"hour"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"frequency": {
"type": "string",
"enum": [
"weekly"
],
"description": "Frequency at which the dashboard should be sent"
},
"day_of_week": {
"type": "string",
"enum": [
"sunday",
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday"
],
"description": "Day of the week at which the dashboard should be sent"
},
"hour": {
"type": "integer",
"description": "Hour at which the dashboard should be sent. Must be between 0 and 23. Can only be a full hour."
}
},
"required": [
"frequency",
"day_of_week",
"hour"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"frequency": {
"type": "string",
"enum": [
"monthly"
],
"description": "Frequency at which the dashboard should be sent"
},
"day_of_month": {
"type": "string",
"enum": [
"first-calendar-day",
"first-sunday",
"first-monday",
"first-tuesday",
"first-wednesday",
"first-thursday",
"first-friday",
"first-saturday",
"middle-of-month",
"last-calendar-day",
"last-sunday",
"last-monday",
"last-tuesday",
"last-wednesday",
"last-thursday",
"last-friday",
"last-saturday"
],
"description": "Day of the month at which the dashboard should be sent. Can be the first or last day of the month or the first or last day of the week (e.g. first Monday, first Tuesday, last Thursday, etc.) or the middle of the month (15th)."
},
"hour": {
"type": "integer",
"description": "Hour at which the dashboard should be sent. Must be between 0 and 23. Can only be a full hour."
}
},
"required": [
"frequency",
"day_of_month",
"hour"
],
"additionalProperties": false
}
]
}
},
"required": [
"dashboard_id",
"email",
"schedule"
],
"additionalProperties": false
},
"examples": [
"User: Send me updates on the dashboard every Wednesday at 8pm (user email: foo.bar@metabse.com)\nAssistant: {\"tool_name\": \"create-dashboard-subscription\", \"arguments\": {\"dashboard_id\": 1, \"email\": \"foo.bar@metabase.com\", \"schedule\": {\"frequency\": \"weekly\", \"day_of_week\": \"wednesday\", \"hour\": 20}}",
"User: Send this dashboard to my colleague Mark every month\nAssistant: Sure. Please provide a valid email address for Mark. When should the dashboard be sent?\nUser: mark@metabase.com every 17th of the month\nAssistant: 17th of the month is not a valid option. The closest I can do is the 15th (middle of month). Should I proceed?\nUser: yes\nAssistant: {\"tool_name\": \"create-dashboard-subscription\", \"arguments\": {\"dashboard_id\": 1, \"email\": \"mark@metabase.com\", \"schedule\": {\"frequency\": \"monthly\", \"day_of_month\": \"middle-of-month\", \"hour\": 0}}",
"User: Subscribe me to the dashboard every day at 14:15 (user email bar.foo@metabase.com)\nAssistant: Only full hours are supported. The closest I can do is every day at 14:00. Should I proceed?\nUser: yes\nAssistant: {\"tool_name\": \"create-dashboard-subscription\", \"arguments\": {\"dashboard_id\": 1, \"email\": \"bar.foo@metabase.com\", \"schedule\": {\"frequency\": \"daily\", \"hour\": 14}}",
"User: Send this dashboard to the #insights slack channel every first day of the month in the morning.\nAssistant: Sorry I only can send dashboard subscriptions to users of your Metabase instance.",
"User: Send this dashboard to Mike (mike@foobar.io) every hour\nAssistant: {\"tool_name\": \"create-dashboard-subscription\", \"arguments\": {\"dashboard_id\": 1, \"email\": \"mike@foobar.io\", \"schedule\": {\"frequency\": \"hourly\"}}\nTool Call Result: 'no user with this email found'\nAssistant: Seems that mike does not have an account in Metabase yet, should I invite him first?\nUser: No.",
"User: Subscribe me to the dashboard every last Thursday of the month at 8am (user email: foo@bar.com),\nAssistant: {\"tool_name\": \"create-dashboard-subscription\", \"arguments\": {\"dashboard_id\": 1, \"email\": \"foo@bar.com\", \"schedule\": {\"frequency\": \"monthly\", \"day_of_month\": \"last-thursday\", \"hour\": 8}}"
]
}

View File

@@ -0,0 +1,55 @@
(ns metabase-enterprise.metabot-v3.tools.create-dashboard-subscription
(:require
[metabase-enterprise.metabot-v3.tools.interface :as metabot-v3.tools.interface]
[metabase.api.common :as api]
[metabase.models.pulse :as models.pulse]
[metabase.util :as u]
[metabase.util.malli :as mu]
[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 (models.pulse/create-pulse! (map models.pulse/card->ref cards) [channel] pulse-data)
"success"))})
{:output "invalid dashboard_id"}))
(mu/defmethod metabot-v3.tools.interface/*invoke-tool* :metabot.tool/create-dashboard-subscription
[_tool-name arguments _env]
(create-dashboard-subscription arguments))

View File

@@ -0,0 +1,240 @@
{
"name": "filter-records",
"description": "Filter records of a table, or the results of a query by specified conditions. Multiple filters can be combined to create complex queries but note that the filters are applied in a logical AND fashion (cannot be combined with OR).\n## When to not use the filter-records tool\n-For querying and filtering existing metrics, you should only use the `query-metrics` tool instead (it also allows you to filter records).",
"parameters": {
"type": "object",
"properties": {
"data_source": {
"anyOf": [
{
"type": "object",
"description": "Filter the records of a table.",
"properties": {
"table_id": {
"type": "string",
"description": "The ID of the table to filter"
}
},
"required": [
"table_id"
],
"additionalProperties": false
},
{
"type": "object",
"description": "Filter the results of a query that you ran.",
"properties": {
"query_id": {
"type": "string",
"description": "The ID of the query to filter"
}
},
"required": [
"query_id"
],
"additionalProperties": false
},
{
"type": "object",
"description": "Filter the results of a saved report.",
"properties": {
"report_id": {
"type": "integer",
"description": "The ID of the report to filter"
}
},
"required": [
"report_id"
],
"additionalProperties": false
}
]
},
"filters": {
"type": [
"array",
"null"
],
"description": "An array of filter objects. Each filter specifies a field, an operation, and a value (if required). For single filters, pass one object. If you want to filter on multiple columns, pass multiple objects. If no filters are used, this **must** be set to `null`. Do not pass an empty array.",
"items": {
"anyOf": [
{
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": [
"is-null",
"is-not-null",
"string-is-empty",
"string-is-not-empty",
"is-true",
"is-false"
],
"description": "Operation to apply. `string-is-empty` and `string-is-not-empty` refer to both empty strings and NULL values. When filtering for empty/missing values, use `string-is-empty` and `string-is-not-empty` for categorical fields and `is-null` and `is-not-null` for others (e.g. date, number)."
},
"field_id": {
"type": "string",
"description": "ID of the field to filter. Needs to exactly match the `field_id` in the database. Provide numeric IDs as strings but never make up IDs."
}
},
"required": [
"field_id",
"operation"
],
"additionalProperties": false
},
{
"type": "object",
"description": "Filter records on parts of a date or datetime field. Use this to include / exclude records based on the year, quarter, month, day-of-week, hour, minute, or second of a date or datetime field.",
"properties": {
"operation": {
"type": "string",
"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"
],
"description": "Operation to apply"
},
"field_id": {
"type": "string",
"description": "ID of the date / datetime field to filter. Needs to exactly match the `field_id` in the database. Provide numeric IDs as strings but never make up IDs."
},
"value": {
"type": "integer",
"description": "Value to compare against. Needs to be integer: month (1-12), day-of-week (1-7 starting on Monday), hour (0-23), minute (0-59), second (0-59), quarter (1-4)."
}
},
"required": [
"operation",
"field_id",
"value"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": [
"date-equals",
"date-not-equals",
"date-before",
"date-on-or-before",
"date-after",
"date-on-or-after"
],
"description": "Operation to apply"
},
"field_id": {
"type": "string",
"description": "ID of the date / datetime field to filter. Needs to exactly match the `field_id` in the database. Provide numeric IDs as strings but never make up IDs."
},
"value": {
"type": "string",
"description": "Value to compare against. Date needs to be in the format 'YYYY-MM-DD' and date-time in the format 'YYYY-MM-DDTHH:MM:SS' (without timezone). Datetime fields can also be compared against date values (this will return all records where the datetime field is on the same day as the date value)."
}
},
"required": [
"operation",
"field_id",
"value"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": [
"string-equals",
"string-not-equals",
"string-contains",
"string-not-contains",
"string-starts-with",
"string-ends-with"
],
"description": "Operation to apply"
},
"field_id": {
"type": "string",
"description": "ID of the field to filter. Needs to exactly match the `field_id` in the database. Provide numeric IDs as strings but never make up IDs."
},
"value": {
"type": "string",
"description": "Value to compare against."
}
},
"required": [
"operation",
"field_id",
"value"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": [
"number-equals",
"number-not-equals",
"number-greater-than",
"number-greater-than-or-equal",
"number-less-than",
"number-less-than-or-equal"
],
"description": "Operation to apply"
},
"field_id": {
"type": "string",
"description": "ID of the field to filter. Needs to exactly match the `field_id` in the database. Provide numeric IDs as strings but never make up IDs."
},
"value": {
"type": [
"number",
"integer"
],
"description": "Value to compare against."
}
},
"required": [
"operation",
"field_id",
"value"
],
"additionalProperties": false
}
]
}
}
},
"required": [
"data_source",
"filters"
],
"additionalProperties": false
},
"examples": [
"User: Only show records after 1st January 2019 (User looking at report with id 2235, date field id 9384)\nAssistant: {\"tool_name\": \"filter-records\", \"arguments\": {\"data_source\": {\"report_id\": 2235}, \"filters\": [{\"field_id\": \"9384\", \"operation\": \"date-after\", \"value\": \"2019-01-01\"}]}}",
"User: Show me all products without a category (table id '5231', category field id 20234)\nAssistant: {\"tool_name\": \"filter-records\", \"arguments\": {\"data_source\": {\"table_id\": \"5231\"}, \"filters\": [{\"field_id\": \"20234\", \"operation\": \"string-is-empty\"}]}}",
"User: Only surveys with from 2021 (table_id 'card__285', creation field id 182).\nAssistant: {\"tool_name\": \"filter-records\", \"arguments\": {\"data_source\": {\"table_id\": \"card__285\"}, \"filters\": [{\"field_id\": \"182\", \"operation\": \"year-equals\", \"value\": 2021}]}}",
"User: Only show events where the amount is greater than 51 (user looking at query results with query id 'awfohewfbvwg', amount field id 9384)\nAssistant: {\"tool_name\": \"filter-records\", \"arguments\": {\"data_source\": {\"query_id\": \"awfohewfbvwg\"}, \"filters\": [{\"field_id\": \"9384\", \"operation\": \"number-greater-than\", \"value\": 51}]}}",
"User: Show users without a updated date (table id 'card__1043', updated date field id 20234)\nAssistant: {\"tool_name\": \"filter-records\", \"arguments\": {\"data_source\": {\"table_id\": \"card__1043\"}, \"filters\": [{\"field_id\": \"20234\", \"operation\": \"is-null\"}]}}"
]
}

View File

@@ -0,0 +1,218 @@
(ns metabase-enterprise.metabot-v3.tools.filters
(:require
[metabase-enterprise.metabot-v3.envelope :as metabot-v3.envelope]
[metabase-enterprise.metabot-v3.tools.interface :as metabot-v3.tools.interface]
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
[metabase.api.common :as api]
[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.lib.types.isa :as lib.types.isa]
[metabase.lib.util :as lib.util]
[metabase.util :as u]
[metabase.util.malli :as mu]))
(defn- date-filter?
"Tests if a date type filter is against a value without time component."
[{:keys [operation value]}]
(and (#{:date-equals :date-not-equals :date-before :date-on-or-before :date-after :date-on-or-after} operation)
(string? value)
(re-matches #"\d{4}-\d\d-\d\d" value)))
(defn- add-filter
[query llm-filter]
(let [llm-filter (update llm-filter :operation keyword)
{:keys [column operation value]} llm-filter
column (cond-> column
(date-filter? llm-filter) (lib/with-temporal-bucket :day))
filter
(case operation
:is-null (lib/is-null column)
:is-not-null (lib/not-null column)
:string-is-empty (lib/is-empty column)
:string-is-not-empty (lib/not-empty column)
:is-true (lib/= column true)
:is-false (lib/= column false)
:year-equals (lib/= (lib/get-year column) value)
:year-not-equals (lib/!= (lib/get-year column) value)
:quarter-equals (lib/= (lib/get-quarter column) value)
:quarter-not-equals (lib/!= (lib/get-quarter column) value)
:month-equals (lib/= (lib/get-month column) value)
:month-not-equals (lib/!= (lib/get-month column) value)
:day-of-week-equals (lib/= (lib/get-day-of-week column :iso) value)
:day-of-week-not-equals (lib/!= (lib/get-day-of-week column :iso) value)
:hour-equals (lib/= (lib/get-hour column) value)
:hour-not-equals (lib/!= (lib/get-hour column) value)
:minute-equals (lib/= (lib/get-minute column) value)
:minute-not-equals (lib/!= (lib/get-minute column) value)
:second-equals (lib/= (lib/get-second column) value)
:second-not-equals (lib/!= (lib/get-second column) value)
:date-equals (lib/= column value)
:date-not-equals (lib/!= column value)
:date-before (lib/< column value)
:date-on-or-before (lib/<= column value)
:date-after (lib/> column value)
:date-on-or-after (lib/>= column value)
:string-equals (lib/= column value)
:string-not-equals (lib/!= column value)
:string-contains (lib/contains column value)
:string-not-contains (lib/not (lib/contains column value))
:string-starts-with (lib/starts-with column value)
:string-ends-with (lib/ends-with column value)
:number-equals (lib/= column value)
:number-not-equals (lib/!= column value)
:number-greater-than (lib/> column value)
:number-greater-than-or-equal (lib/>= column value)
:number-less-than (lib/< column value)
:number-less-than-or-equal (lib/<= column 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.convert/->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)))))
(mu/defmethod metabot-v3.tools.interface/*invoke-tool* :metabot.tool/query-metric
[_tool-name arguments _e]
(query-metric arguments))
(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- base-query
[data-source e]
(let [{:keys [table_id query_id report_id]} data-source
model-id (lib.util/legacy-string-table-id->card-id table_id)]
(cond
(some? 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})))
(some? 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}))))
(some? 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})))
(some? query_id)
(if-let [query (metabot-v3.envelope/find-query e query_id)]
(let [mp (lib.metadata.jvm/application-database-metadata-provider (:database query))]
[(metabot-v3.tools.u/query-field-id-prefix query_id)
(lib/query mp query)])
(throw (ex-info (str "No query found with query_id " query_id) {:agent-error? true
:data_source data-source})))
:else
(throw (ex-info "Invalid data_source" {:agent-error? true
:data_source data-source})))))
(defn- filter-records
[{:keys [data-source filters] :as _arguments} e]
(let [[filter-field-id-prefix base] (base-query data-source e)
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)]
{:type :query
:query_id query-id
:query (lib.convert/->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))}))
(comment
(binding [api/*current-user-permissions-set* (delay #{"/"})
api/*current-user-id* 2
api/*is-superuser?* true]
(filter-records {:data-source {:table_id 27}
:filters [{:operation "number-greater-than"
:field_id "field_[27]_[:field {:base-type :type/Float, :effective-type :type/Float} 257]"
:value 50}]}
nil))
-)
(mu/defmethod metabot-v3.tools.interface/*invoke-tool* :metabot.tool/filter-records
[_tool-name arguments e]
(try
{:structured-output (filter-records arguments e)}
(catch Exception e
(metabot-v3.tools.u/handle-agent-error e))))
(mu/defmethod metabot-v3.tools.interface/*tool-applicable?* :metabot.tool/filter-records
[_tool-name _context]
true)

View File

@@ -0,0 +1,17 @@
{
"name": "find-metric",
"description": "Search for a curated and trustworthy business metric in the Metabase instance to use for your analysis. You will receive metric details like its metric_id that can be used in other tools. Provide a detailed description of the metric, including both its full name and any known acronyms or abbreviations. This ensures that the metric can be accurately identified.",
"parameters": {
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "A comprehensive description of the metric you are searching for. Include the full name and any relevant acronyms or abbreviations"
}
},
"required": [
"message"
],
"additionalProperties": false
}
}

View File

@@ -0,0 +1,74 @@
{
"name": "find-outliers",
"description": "Find outliers for a metric, or the numeric column of a query- or report result. Use this if the user explicitly asks for outliers, unusual mins/maxes, whether values are within an normal range or records that stands out in results.",
"parameters": {
"type": "object",
"properties": {
"data_source": {
"anyOf": [
{
"type": "object",
"description": "Get outliers for a query result column.",
"properties": {
"query_id": {
"type": "string",
"description": "The ID of the query to analyze."
},
"result_field_id": {
"type": "string",
"description": "`field_id` of the column in the query result to calculate outliers on provided via `result_columns`."
}
},
"required": [
"query_id",
"result_field_id"
],
"additionalProperties": false
},
{
"type": "object",
"description": "Get outliers for a report result column.",
"properties": {
"report_id": {
"type": "integer",
"description": "The ID of the report to analyze."
},
"result_field_id": {
"type": "string",
"description": "`field_id` of the column in the query result to calculate outliers on provided via `result_columns`."
}
},
"required": [
"report_id",
"result_field_id"
],
"additionalProperties": false
},
{
"type": "object",
"description": "Get outliers for a metric",
"properties": {
"metric_id": {
"type": "integer",
"description": "The ID of the report to analyze."
}
},
"required": [
"metric_id"
],
"additionalProperties": false
}
]
}
},
"required": [
"data_source"
],
"additionalProperties": false
},
"examples": [
"User: Can you find outliers? (user is looking at a query result with id 'awergasdvoihw2' with 3 numeric columns (field_ids 'aggregation_0', 'aggregation_3', 'aggregation_4' and other columns)\nAssistant: {\"tool_name\": \"find-outliers\", \"arguments\": {\"data_source\": {\"query_id\": \"awergasdvoihw2\", \"result_field_id\": \"aggregation_0\"}}}, {\"tool_name\": \"find-outliers\", \"arguments\": {\"data_source\": {\"query_id\": \"awergasdvoihw2\", \"result_field_id\": \"aggregation_3\"}}}, {\"tool_name\": \"find-outliers\", \"arguments\": {\"data_source\": {\"query_id\": \"awergasdvoihw2\", \"result_field_id\": \"aggregation_4\"}}}",
"User: Any unusual values in the monthly page views? (user is looking at a report with id 'card__6231', page view count field_id='aggregation_0')\nAssistant: {\"tool_name\": \"find-outliers\", \"arguments\": {\"data_source\": {\"report_id\": 6231, \"result_field_id\": \"aggregation_0\"}}}",
"User: Is the revenue of the last months within normal range? (user is looking at a metric with id 522)\nAssistant: {\"tool_name\": \"find-outliers\", \"arguments\": {\"data_source\": {\"metric_id\": 522}}}"
]
}

View File

@@ -0,0 +1,18 @@
(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]
[metabase-enterprise.metabot-v3.tools.interface :as tools.interface]
[toucan2.core :as t2]))
(defmethod tools.interface/*invoke-tool* :metabot.tool/find-metric
[_ {:keys [message]} _env]
(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.envelope :as metabot-v3.envelope]
[metabase-enterprise.metabot-v3.tools.interface :as metabot-v3.tools.interface]
[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]
[metabase.util.malli :as mu]
[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_id report_id metric_id] :as data-source} env]
(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_id (if-let [query (metabot-v3.envelope/find-query env query_id)]
(do
(api/read-check :model/Database (:database query))
[(metabot-v3.tools.u/query-field-id-prefix query_id)
query])
(throw (ex-info (str "No query found with query_id " query_id) {:agent-error? true
:data_source data-source})))
:else (throw (ex-info "Invalid data_source" {:agent-error? true
:data_source data-source}))))
(mu/defmethod metabot-v3.tools.interface/*invoke-tool* :metabot.tool/find-outliers
[_tool-name {:keys [data-source]} env]
(let [{:keys [metric_id result_field_id]} data-source]
(try
(let [[field-id-prefix dataset-query] (find-dataset-query data-source env)
{:keys [data]} (u/prog1 (qp/process-query (qp/userland-query-with-default-constraints dataset-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,79 @@
{
"name": "generate-insights",
"description": "Use this tool for exploratory data analysis, initial data exploration, or when you need a broad overview of your table, report_card, query result or curated metric, including key metrics and general trends. This is a good starting point for unspecific data analysis or exploration.",
"parameters": {
"type": "object",
"properties": {
"for": {
"anyOf": [
{
"type": "object",
"description": "Surface interesting patterns and insights for a pre-defined metric.",
"properties": {
"metric_id": {
"type": "integer",
"description": "The ID of the metric to analyze."
}
},
"required": [
"metric_id"
],
"additionalProperties": false
},
{
"type": "object",
"description": "Generate insights for a table.",
"properties": {
"table_id": {
"type": "string",
"description": "The ID of the table to analyze."
}
},
"required": [
"table_id"
],
"additionalProperties": false
},
{
"type": "object",
"description": "Generate insights for the results of a saved report.",
"properties": {
"report_id": {
"type": "integer",
"description": "The ID of the report to analyze."
}
},
"required": [
"report_id"
],
"additionalProperties": false
},
{
"type": "object",
"description": "Generate insights for the results of a query that recently has been run.",
"properties": {
"query_id": {
"type": "string",
"description": "The ID of the query to analyze."
}
},
"required": [
"query_id"
],
"additionalProperties": false
}
]
}
},
"required": [
"for"
],
"additionalProperties": false
},
"examples": [
"User: Explore the user acquisition costs (metric with id 928)\nAssistant: {\"tool_name\": \"generate-insights\", \"arguments\": {\"for\": {\"metric_id\": 928}}}",
"User: Anything interesting in the orders table (order table id card_25)?\nAssistant: {\"tool_name\": \"generate-insights\", \"arguments\": {\"for\": {\"table_id\": \"card_25\"}}}",
"User: Give me an overview of the results (query id \"qwerboiahsdvcvhuowef\")\nAssistant: {\"tool_name\": \"generate-insights\", \"arguments\": {\"for\": {\"query_id\": \"qwerboiahsdvcvhuowef\"}}}",
"User: Any insights on the sales report (report id 1234)?\nAssistant: {\"tool_name\": \"generate-insights\", \"arguments\": {\"for\": {\"report_id\": 1234}}}"
]
}

View File

@@ -0,0 +1,51 @@
(ns metabase-enterprise.metabot-v3.tools.generate-insights
(:require
[buddy.core.codecs :as codecs]
[metabase-enterprise.metabot-v3.envelope :as env]
[metabase-enterprise.metabot-v3.tools.interface :as metabot-v3.tools.interface]
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
[metabase.public-settings :as public-settings]
[metabase.util.json :as json]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(defn generate-insights
"Generate insights."
[{what-for :for, env ::env}]
(try
(let [[k id] (some #(find what-for %) [:metric_id :table_id :report_id :query_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
(-> env
(env/find-query id)
(or (throw (ex-info (str "No query found with query_id " id) {:agent-error? true})))
json/encode
.getBytes
codecs/bytes->b64-str)
: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 (str (public-settings/site-url) results-url)
:reactions [{:type :metabot.reaction/redirect :url results-url}]})
(catch Exception e
(metabot-v3.tools.u/handle-agent-error e))))
(mu/defmethod metabot-v3.tools.interface/*invoke-tool* :metabot.tool/generate-insights
[_tool-name arguments env]
(generate-insights (assoc arguments ::env env)))

View File

@@ -0,0 +1,108 @@
(ns metabase-enterprise.metabot-v3.tools.interface
(:require
[metabase.lib.schema.common :as lib.schema.common]
[metabase.util :as u]
[metabase.util.malli :as mu]
[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.parameters.properties
[:map-of ::metadata.parameter.name ::metadata.parameter])
(mr/def ::metadata.parameters
[:map
{:decode/metadata-file #(update-keys % (comp keyword u/->kebab-case-en))
:decode/api-response #(update-keys % (comp keyword u/->kebab-case-en))
:encode/api-request #(update-keys % u/->camelCaseEn)}
[:type [:= {:decode/metadata-file keyword} :object]]
[:properties ::metadata.parameters.properties]
[:required {:optional true, :default []} [:sequential ::metadata.parameter.name]]
[:additional-properties {:optional true, :default false} :boolean]])
(mr/def ::metadata.name
[:and
{:encode/api-request (fn [x]
(u/->snake_case_en (name x)))
:decode/metadata-file (fn [x]
(let [kw (keyword x)]
(if (namespace kw)
kw
(keyword "metabot.tool" (name kw)))))
:decode/api-request (fn [x]
(let [kw (keyword x)]
(if (namespace kw)
kw
(keyword "metabot.tool" (name kw)))))
:decode/api-response (fn [x]
(keyword "metabot.tool" (name (u/->kebab-case-en x))))}
:keyword
[:fn
{:error/message "Tool names should be kebab-case (both parsed and in YAML files)"}
#(= (u/->kebab-case-en %) %)]])
(mr/def ::metadata
[:map
[:name ::metadata.name]
[:description ::lib.schema.common/non-blank-string]
[:parameters ::metadata.parameters]])
(defmulti ^:dynamic *tool-applicable?*
"Whether or not the current tool can possibly be applicable and passed to the AI Proxy. For example, an `invite-user`
tool cannot be applicable if the current user does not have user invite permissions. We should error on the side of
letting the LLM decide whether a tool is appropriate or not in cases where y
it's in any way ambiguous -- we should
not be parsing the message string or making these sorts of decisions ourselves."
{:arglists '([tool-name context])}
(fn [tool-name _context]
(keyword tool-name)))
(defmethod *tool-applicable?* :default
[_tool-name _context]
true)
(defmulti ^:dynamic *invoke-tool*
"Invoke a Metabot v3 tool, e.g. send an email to someone. This should return a map with the following keys:
- `output`, the string value to return to the LLM
- `reactions`, a vector of frontend reactions with a shape like
[{:type :reaction/display-message
:message \"I need more info.\"}]
`reactions` is optional - in general, we let the LLM react instead. For example, when inviting a user, we tell the
LLM the user was invited and let it generate the message to send to the user rather than hardcoding anything.
Different reactions should get bundled with different keys, for example `:metabot.reaction/message` should include
`:message`. This should match what the frontend expects."
{:arglists '([tool-name argument-map context full-history])}
(fn [tool-name _argument-map _env]
(keyword tool-name)))
(mu/defmethod *invoke-tool* :default
[tool-name _argument-map _env]
{:output (format "Tool '%s' is not yet implemented." tool-name)})

View File

@@ -0,0 +1,21 @@
{
"name": "invite-user",
"description": "Invite a user to Metabase. Can only be used with valid and complete email address (username + @ + domain + TLD) like 'peter.pan@neverland.com'. This will fail if the email address is incomplete or missing a part like the TLD (e.g. 'peter.pan@neverland'). Only use this tool when explicitly asked by the user to invite someone.",
"parameters": {
"type": "object",
"properties": {
"email": {
"type": "string",
"description": "A valid and complete email address (username + @ + domain + TLD) like 'peter.pan@neverland.com'. Cannot be used with incomplete emails (e.g. 'peter.pan@neverland'). If the email address is incomplete or missing a part like the TLD, ask the user for clarification before proceeding."
}
},
"required": [
"email"
],
"additionalProperties": false
},
"examples": [
"User: Invite my colleague Peter (user email: metabot@metabase.com)\nAssistant: Please provide the email address of Peter.\nUser: peter.p@metabase.com\nAssistant: {\"tool_name\": \"invite-user\", \"arguments\": {\"email\": \"peter.p@metabase.com\"}}",
"User: Invite a.f@coorp to Metabase\nAssistant: 'a.f@coorp is not a valid email address. Please provide a valid email address.\nUser: a.f@corp.net\nAssistant: {\"tool_name\": \"invite-user\", \"arguments\": {\"email\": \"a.f@corp.net\"}}"
]
}

View File

@@ -0,0 +1,21 @@
(ns metabase-enterprise.metabot-v3.tools.invite-user
(:require
[metabase-enterprise.metabot-v3.tools.interface :as metabot-v3.tools.interface]
[metabase.api.user :as api.user]
[metabase.util.malli :as mu]))
(mu/defmethod metabot-v3.tools.interface/*invoke-tool* :metabot.tool/invite-user
[_tool-name {:keys [email]} _env]
(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))))))
(mu/defmethod metabot-v3.tools.interface/*tool-applicable?* :metabot.tool/invite-user
[_tool-name _context]
false)

View File

@@ -0,0 +1,234 @@
{
"name": "query-metric",
"description": "Get the value of a curated and trustworthy business metric with optional filters or breakouts by dimension (e.g. over time or by a category). You can either choose the dimensions provided by the metric or the fields (also foreign key table fields) from the table the metric is based on. Filters can be applied without grouping and vice versa. If users ask for unspecific insights or interesting things, use the 'generate-insights' tool instead. Note that the query-metric tool already includes filtering, so you should not chain it with the 'filter-data' tool.",
"parameters": {
"type": "object",
"properties": {
"metric_id": {
"type": "integer",
"description": "The ID of the metric to calculate. Needs to exactly match the `metric_id` in the database and cannot be made up."
},
"filters": {
"type": [
"array",
"null"
],
"description": "Filter the records before calculating the metric. Each filter specifies a field, an operation, and a value (if required). For single filters, pass one object. If you want to filter on multiple columns, pass multiple objects. If the user doesn't specify any filters, set this parameter to null.",
"items": {
"anyOf": [
{
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": [
"is-null",
"is-not-null",
"string-is-empty",
"string-is-not-empty",
"is-true",
"is-false"
],
"description": "Operation to apply. `string-is-empty` and `string-is-not-empty` refer to both empty strings and NULL values."
},
"field_id": {
"type": "string",
"description": "ID of the field to filter. Needs to exactly match the `field_id` in the database. Provide numeric IDs as strings but never make up IDs."
}
},
"required": [
"field_id",
"operation"
],
"additionalProperties": false
},
{
"type": "object",
"description": "Filter records on parts of a date or datetime field. Use this to include / exclude records based on the year, quarter, month, day-of-week, hour, minute, or second of a date or datetime field.",
"properties": {
"operation": {
"type": "string",
"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"
],
"description": "Operation to apply"
},
"field_id": {
"type": "string",
"description": "ID of the date / datetime field to filter. Needs to exactly match the `field_id` in the database. Provide numeric IDs as strings but never make up IDs."
},
"value": {
"type": "integer",
"description": "Value to compare against. Needs to be integer: month (1-12), day-of-week (1-7 starting on Monday), hour (0-23), minute (0-59), second (0-59), quarter (1-4)."
}
},
"required": [
"operation",
"field_id",
"value"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": [
"date-equals",
"date-not-equals",
"date-before",
"date-on-or-before",
"date-after",
"date-on-or-after"
],
"description": "Operation to apply"
},
"field_id": {
"type": "string",
"description": "ID of the date / datetime field to filter. Needs to exactly match the `field_id` in the database. Provide numeric IDs as strings but never make up IDs."
},
"value": {
"type": "string",
"description": "Value to compare against. Date needs to be in the format 'YYYY-MM-DD' and date-time in the format 'YYYY-MM-DDTHH:MM:SS' (without timezone). Datetime fields can also be compared against date values (this will return all records where the datetime field is on the same day as the date value)."
}
},
"required": [
"operation",
"field_id",
"value"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": [
"string-equals",
"string-not-equals",
"string-contains",
"string-not-contains",
"string-starts-with",
"string-ends-with"
],
"description": "Operation to apply"
},
"field_id": {
"type": "string",
"description": "ID of the field to filter. Needs to exactly match the `field_id` in the database. Provide numeric IDs as strings but never make up IDs."
},
"value": {
"type": "string",
"description": "Value to compare against."
}
},
"required": [
"operation",
"field_id",
"value"
],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": [
"number-equals",
"number-not-equals",
"number-greater-than",
"number-greater-than-or-equal",
"number-less-than",
"number-less-than-or-equal"
],
"description": "Operation to apply"
},
"field_id": {
"type": "string",
"description": "ID of the field to filter. Needs to exactly match the `field_id` in the database. Provide numeric IDs as strings but never make up IDs."
},
"value": {
"type": [
"number",
"integer"
],
"description": "Value to compare against."
}
},
"required": [
"operation",
"field_id",
"value"
],
"additionalProperties": false
}
]
}
},
"group_by": {
"type": [
"array",
"null"
],
"description": "The dimensions to group by when calculating the metric. For a single dimension breakout, pass one object. If you want to group by multiple columns (e.g. one time dimension and one categorical), pass multiple objects. If the user doesn't specify a breakout explicitly, set this parameter to null to get the overall value. Do not add any groupings if the user only wants to filter the data or see the overall value. You can truncate date or datetime fields to a specific granularity (e.g. day, week, month, quarter, year) by setting the `field_granularity` to the desired value. If the field is not a date or datetime field, set `field_granularity` to null.",
"items": {
"type": "object",
"properties": {
"field_id": {
"type": "string",
"description": "ID of the field to group by. Needs to exactly match the `field_id` in the database. Provide numeric IDs as strings but never make up IDs."
},
"field_granularity": {
"type": [
"string",
"null"
],
"enum": [
null,
"day",
"week",
"month",
"quarter",
"year"
],
"description": "The time granularity of the field to group by. Only applicable for date or datetime fields. If the field is not a date or datetime field, set this to null."
}
},
"required": [
"field_id",
"field_granularity"
],
"additionalProperties": false
}
}
},
"required": [
"metric_id",
"filters",
"group_by"
],
"additionalProperties": false
},
"examples": [
"User: Show me the number of new customers per country (metric id 255, country field id 9384\nAssistant: {\"tool_name\": \"query-metric\", \"arguments\": {\"metric_id\": 255, \"group_by\": [{\"field_id\": \"9384\", \"field_granularity\": null}], \"filters\": null}}",
"User: How many orders did we have this year (metric id 255, date field id 9384, current year 2019)\nAssistant: {\"tool_name\": \"query-metric\", \"arguments\": {\"metric_id\": 255, \"filters\": [{\"field_id\": \"9384\", \"operation\": \"year-equals\", \"value\": 2019}], \"group_by\": null}}",
"User: Show me the number of new customers per country and month (metric id 255, country field id 9384, month field id 9385)\nAssistant: {\"tool_name\": \"query-metric\", \"arguments\": {\"metric_id\": 255, \"group_by\": [{\"field_id\": \"9384\", \"field_granularity\": null}, {\"field_id\": \"9385\", \"field_granularity\": \"month\"}], \"filters\": null}}",
"User: Daily revenue (metric id 255, date field id 9384)\nAssistant: {\"tool_name\": \"query-metric\", \"arguments\": {\"metric_id\": 255, \"group_by\": [{\"field_id\": \"9384\", \"field_granularity\": \"day\"}], \"filters\": null}}"
]
}

View File

@@ -0,0 +1,10 @@
(ns metabase-enterprise.metabot-v3.tools.query
(:require
[metabase.lib.query :as lib.query]))
(defn create-reactions
"Extracts reactions based on the current context."
[{:keys [query run-query?]}]
(when (and query run-query?)
[{:type :metabot.reaction/run-query
:dataset_query (lib.query/->legacy-MBQL query)}]))

View File

@@ -0,0 +1,17 @@
{
"name": "show-results-to-user",
"description": "Show the results of a query you ran to the user.",
"parameters": {
"type": "object",
"properties": {
"query_id": {
"type": "string",
"description": "The ID of the query you want to display to the user."
}
},
"required": [
"query_id"
],
"additionalProperties": false
}
}

View File

@@ -0,0 +1,22 @@
(ns metabase-enterprise.metabot-v3.tools.show-results-to-user
(:require
[buddy.core.codecs :as codecs]
[metabase-enterprise.metabot-v3.envelope :as env]
[metabase-enterprise.metabot-v3.tools.interface :as metabot-v3.tools.interface]
[metabase.public-settings :as public-settings]
[metabase.util.json :as json]
[metabase.util.malli :as mu]))
(set! *warn-on-reflection* true)
(mu/defmethod metabot-v3.tools.interface/*invoke-tool* :metabot.tool/show-results-to-user
[_tool-name {:keys [query-id] :as _arguments} env]
(if-let [query (env/find-query env query-id)]
(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}]})
{:output (str "No query found with query_id " query-id)}))

View File

@@ -0,0 +1,93 @@
(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"
(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 "/"))
(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)]
(-> {:field_id (str field-id-prefix pos)
:name (lib/display-name query -1 column :long)
:type (convert-field-type column)}
(m/assoc-some :description (get column :description)))))
(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]
(when-not (str/starts-with? field_id field-id-prefix)
(throw (ex-info (str "field " field_id " not found") {:agent-error? true
:expected-prefix field-id-prefix})))
(-> field_id (subs (count field-id-prefix)) parse-long))
(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))))

View File

@@ -0,0 +1,10 @@
{
"name": "who-is-your-favorite",
"description": "Tell me who's your favorite.",
"parameters": {
"type": "object",
"properties": {},
"required": [],
"additionalProperties": false
}
}

View File

@@ -0,0 +1,8 @@
(ns metabase-enterprise.metabot-v3.tools.who-is-your-favorite
(:require
[metabase-enterprise.metabot-v3.tools.interface :as metabot-v3.tools.interface]
[metabase.util.malli :as mu]))
(mu/defmethod metabot-v3.tools.interface/*invoke-tool* :metabot.tool/who-is-your-favorite
[_tool-name _arg-map _env]
{:output "You are... but don't tell anyone!"})

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,114 @@
(ns metabase-enterprise.metabot-v3.api-test
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.api :as metabot-v3.api]
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
[metabase-enterprise.metabot-v3.tools :as metabot-v3.tools]
[metabase-enterprise.metabot-v3.tools.interface :as metabot-v3.tools.interface]
[metabase.test :as mt]))
(deftest ^:parallel encode-reactions-test
(testing "FE should see snake_case keys in reactions"
(is (= [{:type :metabot.reaction/collection-updated
:collection_id 1}]
(#'metabot-v3.api/encode-reactions
[{:type :metabot.reaction/collection-updated
:collection-id 1}])))))
(deftest ^:parallel e2e-test
(mt/with-test-user :rasta
(let [calls (atom [])
session-id (str (random-uuid))]
(binding [metabot-v3.tools/*tools-metadata* (constantly
[{:name :invite-user
:description "Invite a user to Metabase. Requires a valid email address."
:parameters
{:type :object
:properties {"email" {:type :string, :description "A valid email address of the user to invite"}}
:required ["email"]
:additionalProperties false}}])
metabot-v3.client/*request*
(fn [_context history _session-id _tools]
(swap! calls conj [:api-request (last history)])
(swap! calls conj [:ai-proxy-request (last history)])
(let [response (cond
(some-> history last :content (str/starts-with? "Send an email"))
{:message {:content "Sorry I don't understand that. Could you please clarify what you would like to include in the email to Cam?"
:role "assistant"
:tool_calls []}}
(some-> history last :content (str/starts-with? "Cam's email is"))
{:message {:content nil
:role "assistant"
:tool_calls [{:id "call_1drvrXfHb6q9Doxh8leujqKB"
:name "invite-user"
:arguments "{\"email\": \"cam@metabase.com\"}"}]}}
(= (:role (last history)) :tool)
{:message {:content "Cool, I invited him"
:role "assistant"
:tool_calls []}}
:else
{:message {:content "Unknown!", :role "assistant"}})]
(swap! calls conj [:ai-proxy-response response])
(#'metabot-v3.client/decode-response-body response)))
metabot-v3.tools.interface/*invoke-tool*
(fn [tool-name arguments _env]
[{:type :metabot.reaction/invoke-tool, :tool-name tool-name, :arguments arguments}])]
(let [response-1 (#'metabot-v3.api/request "Send an email to Cam" {} [] session-id)]
(swap! calls conj [:api-response response-1])
(let [response-2 (#'metabot-v3.api/request "Cam's email is cam@metabase.com" {} (:history response-1) session-id)]
(swap! calls conj [:api-response response-2])
(is (=? [;; first FE => BE request
[:api-request {:role :user, :content "Send an email to Cam"}]
;; first BE => AIP request
[:ai-proxy-request {:role :user, :content "Send an email to Cam"}]
;; first BE <= AIP response
[:ai-proxy-response
{:message
{:content "Sorry I don't understand that. Could you please clarify what you would like to include in the email to Cam?"
:role "assistant"
:tool_calls []}}]
;; first FE <= BE response
[:api-response
{:reactions [{:type :metabot.reaction/message
:message "Sorry I don't understand that. Could you please clarify what you would like to include in the email to Cam?"}]
:history [{:role :user, :content "Send an email to Cam"}
{:content "Sorry I don't understand that. Could you please clarify what you would like to include in the email to Cam?"
:role :assistant
:tool-calls []}]}]
;; follow-up FE => BE request
[:api-request {:content "Cam's email is cam@metabase.com"
:role :user}]
;; follow-up BE => AIP request
[:ai-proxy-request {:role :user, :content "Cam's email is cam@metabase.com"}]
;; follow-up BE <= AIP request
[:ai-proxy-response
{:message
{:content nil
:role "assistant"
:tool_calls
[{:id "call_1drvrXfHb6q9Doxh8leujqKB", :name "invite-user", :arguments "{\"email\": \"cam@metabase.com\"}"}]}}]
[:api-request {:role :tool, :tool-call-id "call_1drvrXfHb6q9Doxh8leujqKB", :content nil}]
[:ai-proxy-request {:role :tool, :tool-call-id "call_1drvrXfHb6q9Doxh8leujqKB", :content nil}]
[:ai-proxy-response {:message {:content "Cool, I invited him", :role "assistant", :tool_calls []}}]
;; follow-up FE <= BE request
[:api-response
{:reactions [{:type :metabot.reaction/message
:message "Cool, I invited him"}]
:history [{:role :user, :content "Send an email to Cam"}
{:content "Sorry I don't understand that. Could you please clarify what you would like to include in the email to Cam?"
:role :assistant
:tool-calls []}
{:role :user, :content "Cam's email is cam@metabase.com"}
{:content nil,
:role :assistant,
:tool-calls
[{:id "call_1drvrXfHb6q9Doxh8leujqKB", :name :metabot.tool/invite-user, :arguments {:email "cam@metabase.com"}}]}
{:role :tool, :tool-call-id "call_1drvrXfHb6q9Doxh8leujqKB"}
{:content "Cool, I invited him"
:role :assistant}]}]]
@calls))))))))

View File

@@ -0,0 +1,55 @@
(ns metabase-enterprise.metabot-v3.client-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]))
(deftest ^:parallel build-request-body-test
(let [session-id (str (random-uuid))]
(is (=? {:messages [{:role :user, :content "Hello"}]
:context {}
:tools [{:name "invite_user"
:description "Invite a user to Metabase. Requires a valid email address."
:parameters {:type :object
:properties {"email" {:type :string
:description "A valid email address of the user to invite"}}
:required ["email"]
:additionalProperties false}}]
:session_id session-id}
(#'metabot-v3.client/build-request-body {}
[{:role :user :content "Hello"}]
session-id
[{:name "invite_user"
:description "Invite a user to Metabase. Requires a valid email address."
:parameters {:type :object
:properties {:email {:type :string
:description "A valid email address of the user to invite"}}
:required [:email]
:additional-properties false}}])))))
(deftest ^:parallel encode-request-body-test
(is (= {:messages [{:content nil
:role :assistant
:tool_calls [{:id "call_xsI6ygzaTnANYVxcmoAiRLRL"
:name "say_hello"
:arguments "{\"name\":\"User\",\"greeting\":\"Hello!\"}"}]}]
:context {}}
(#'metabot-v3.client/encode-request-body
{:messages [{:content nil
:role :assistant
:tool-calls [{:id "call_xsI6ygzaTnANYVxcmoAiRLRL"
:name :say-hello
:arguments {:name "User", :greeting "Hello!"}}]}]}))))
(deftest ^:parallel decode-response-body-test
(is (= {:message {:content nil
:role :assistant
:tool-calls [{:id "call_1drvrXfHb6q9Doxh8leujqKB"
:name :metabot.tool/say-hello
:arguments {:name "User"
:greeting "Hello!"}}]}}
(#'metabot-v3.client/decode-response-body
{:message {:content nil
:role "assistant"
:tool_calls [{:id "call_1drvrXfHb6q9Doxh8leujqKB"
:name "say-hello"
:arguments "{\"name\":\"User\",\"greeting\":\"Hello!\"}"}]}}))))

View File

@@ -0,0 +1,51 @@
(ns metabase-enterprise.metabot-v3.handle-envelope-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
[metabase-enterprise.metabot-v3.envelope :as envelope]
[metabase-enterprise.metabot-v3.handle-envelope :as metabot-v3.handle-envelope]
[metabase-enterprise.metabot-v3.tools.interface :as metabot-v3.tools.interface]))
(defn- create-env [& responses]
(envelope/create {} (into [] responses) (random-uuid)))
(deftest ^:parallel single-reaction-test
(let [response {:content "Sorry I don't understand that."
:role :assistant
:tool-calls []}
e (create-env response)
e* (#'metabot-v3.handle-envelope/handle-envelope e)]
(testing "No reactions were created"
(is (empty? (:reactions e*))))
(testing "The history is unchanged"
(is (= [response] (:history e*))))
(testing "We can get a reaction from the last message in the history"
(is (= {:type :metabot.reaction/message
:message "Sorry I don't understand that."
:repl/message-color :green
:repl/message-emoji "🤖"}
(envelope/last-assistant-message->reaction e*))))))
(deftest ^:parallel handle-tools-response-test
(let [response {:content nil
:role :assistant
:tool-calls [{:id "call_1drvrXfHb6q9Doxh8leujqKB"
:name :say-hello
:arguments {:name "User"
:greeting "Hello!"}}]}
invocations (atom [])]
(binding [metabot-v3.tools.interface/*tool-applicable?* (constantly true)
metabot-v3.tools.interface/*invoke-tool* (fn [tool-name args _env]
(swap! invocations conj [tool-name args])
(let [msg (format "Invoked tool %s" tool-name)]
{:output msg
:reactions [{:type :metabot.reaction/message, :message msg}]}))
metabot-v3.client/*request* (fn [& _] {:message "niiiice, you invoked the tool. thanks."})]
(let [e (metabot-v3.handle-envelope/handle-envelope (create-env response))]
(testing "response forwarded to FE"
(is (=? {:reactions [{:type :metabot.reaction/message
:message "Invoked tool :say-hello"}]}
e)))
(testing "invocations"
(is (= [[:say-hello {:name "User", :greeting "Hello!"}]]
@invocations)))))))

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,55 @@
(ns metabase-enterprise.metabot-v3.tools.create-dashboard-subscription-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription]
[metabase-enterprise.metabot-v3.tools.interface :as metabot-v3.tools.interface]
[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 [base-data {:dashboard-id dashboard-id
:email email
:schedule {:frequency "monthly"
:day_of_month "last-sunday"
:hour 7}}
invoke-tool #(binding [api/*current-user-id* user-id]
(metabot-v3.tools.interface/*invoke-tool*
:metabot.tool/create-dashboard-subscription
%
{}))]
(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,324 @@
(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.create-dashboard-subscription]
[metabase-enterprise.metabot-v3.tools.interface :as metabot-v3.tools.interface]
[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))
(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.interface/*invoke-tool*
:metabot.tool/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.interface/*invoke-tool*
:metabot.tool/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.interface/*invoke-tool*
:metabot.tool/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]]
:breakout [[:field (mt/id :orders :created_at)
{:base-type :type/DateTimeWithLocalTZ
:temporal-unit :week}]]}}}}
(metabot-v3.tools.interface/*invoke-tool*
:metabot.tool/query-metric
{:metric-id metric-id
:group-by [{:field_id (->field-id "Created At")
:field_granularity "week"}]}
{})))))
(testing "Missing metric results in an error."
(is (= {:output (str "No metric found with metric_id " Integer/MAX_VALUE)}
(metabot-v3.tools.interface/*invoke-tool*
:metabot.tool/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.interface/*invoke-tool*
:metabot.tool/query-metric
{:metric-id (str metric-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.interface/*invoke-tool*
:metabot.tool/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.interface/*invoke-tool*
:metabot.tool/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
[:> [:field (mt/id :orders :discount)
{:base-type :type/Float}]
3]}}}}
(metabot-v3.tools.interface/*invoke-tool*
:metabot.tool/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 (= {:output (str "No table found with table_id " Integer/MAX_VALUE)}
(metabot-v3.tools.interface/*invoke-tool*
:metabot.tool/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.interface/*invoke-tool*
:metabot.tool/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.interface/*invoke-tool*
:metabot.tool/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.interface/*invoke-tool*
:metabot.tool/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.interface/*invoke-tool*
:metabot.tool/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.interface/*invoke-tool*
:metabot.tool/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.interface/*invoke-tool*
:metabot.tool/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.interface/*invoke-tool*
:metabot.tool/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.interface/*invoke-tool*
:metabot.tool/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 %}))))
env {:history [{:role :tool
:tool-call-id "some tool call ID"
:structured-content query-details}]}]
(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.interface/*invoke-tool*
:metabot.tool/filter-records
{:data-source {:query_id query-id}}
env))))
(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.interface/*invoke-tool*
:metabot.tool/filter-records
{:data-source {:query_id query-id}
:filters [{:field_id (->field-id "Discount")
:operation "number-greater-than"
:value 3}]}
env))))
(testing "Missing query results in an error."
(is (= {:output (str "No query found with query_id " query-id)}
(metabot-v3.tools.interface/*invoke-tool*
:metabot.tool/filter-records
{:data-source {:query_id query-id}}
{}))))))

View File

@@ -0,0 +1,165 @@
(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.create-dashboard-subscription]
[metabase-enterprise.metabot-v3.tools.interface :as metabot-v3.tools.interface]
[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.interface/*invoke-tool*
:metabot.tool/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.interface/*invoke-tool*
:metabot.tool/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")]
(execute-test! #(metabot-v3.tools.interface/*invoke-tool*
:metabot.tool/find-outliers
{:data-source {:query_id query-id
:result_field_id result-field-id}}
{:history [{:role :tool
:tool-call-id "some tool call ID"
:structured-content query-details}]}))))
(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.interface/*invoke-tool*
:metabot.tool/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.interface/*invoke-tool*
:metabot.tool/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.interface/*invoke-tool*
:metabot.tool/find-outliers
{:data-source {:query_id query-id
:result_field_id result-field-id}}
{:history [{:role :tool
:tool-call-id "some tool call ID"
:structured-content details}]}))
(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.interface/*invoke-tool*
:metabot.tool/find-outliers
{:data-source {:query_id query-id
:result_field_id wrong-result-field-id}}
{:history [{:role :tool
:tool-call-id "some tool call ID"
:structured-content query-details}]})))))))
(deftest ^:parallel invalid-ids-test
(are [data-source output] (= {:output output}
(metabot-v3.tools.interface/*invoke-tool*
:metabot.tool/find-outliers {:data-source data-source} {}))
{:metric_id "42"} "Invalid metric_id as data_source"
{:report_id "42"} "Invalid report_id as data_source"
{:query_id "42"} "No query found with query_id 42"
{:table_id 42} "Invalid data_source"))

View File

@@ -0,0 +1,75 @@
(ns metabase-enterprise.metabot-v3.tools.generate-insights-test
(:require
[buddy.core.codecs :as codecs]
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription]
[metabase-enterprise.metabot-v3.tools.interface :as metabot-v3.tools.interface]
[metabase.public-settings :as public-settings]
[metabase.util :as u]
[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 (str (public-settings/site-url) results-url)
:reactions [{:type :metabot.reaction/redirect :url results-url}]}
(metabot-v3.tools.interface/*invoke-tool*
:metabot.tool/generate-insights
{:for {:metric_id 42}}
{})))))
(testing "Reports"
(let [results-url "/auto/dashboard/question/42"]
(is (= {:output (str (public-settings/site-url) results-url)
:reactions [{:type :metabot.reaction/redirect :url results-url}]}
(metabot-v3.tools.interface/*invoke-tool*
:metabot.tool/generate-insights
{:for {:report_id 42}}
{})))))
(testing "Tables"
(let [results-url "/auto/dashboard/table/42"]
(are [id] (= {:output (str (public-settings/site-url) results-url)
:reactions [{:type :metabot.reaction/redirect :url results-url}]}
(metabot-v3.tools.interface/*invoke-tool*
:metabot.tool/generate-insights
{:for {:table_id id}}
{}))
42
"42"))
(let [results-url "/auto/dashboard/table/card__42"]
(is (= {:output (str (public-settings/site-url) results-url)
:reactions [{:type :metabot.reaction/redirect :url results-url}]}
(metabot-v3.tools.interface/*invoke-tool*
:metabot.tool/generate-insights
{:for {:table_id "card__42"}}
{}))))
(are [id] (= {:output "Invalid table_id"}
(metabot-v3.tools.interface/*invoke-tool*
:metabot.tool/generate-insights
{:for {:table_id id}}
{}))
:42
"table_name"))
(testing "Queries"
(let [query-id (u/generate-nano-id)
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 (str (public-settings/site-url) results-url)
:reactions [{:type :metabot.reaction/redirect :url results-url}]}
(metabot-v3.tools.interface/*invoke-tool*
:metabot.tool/generate-insights
{:for {:query_id query-id}}
{:history [{:role :tool
:tool-call-id "some tool call ID"
:structured-content {:type :query
:query_id query-id
:query query}}]}))))))

View File

@@ -0,0 +1,23 @@
(ns metabase-enterprise.metabot-v3.tools.invite-user-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription]
[metabase-enterprise.metabot-v3.tools.interface :as metabot-v3.tools.interface]
[metabase.test :as mt]))
(deftest invite-user-test
(mt/with-model-cleanup [:model/User]
(let [invoke-tool #(metabot-v3.tools.interface/*invoke-tool* :metabot.tool/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,8 @@
(ns metabase-enterprise.metabot-v3.tools.who-is-your-favorite-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.tools.interface :as tools.interface]))
(deftest it-generates-a-reaction
(is (= {:output "You are... but don't tell anyone!"}
(tools.interface/*invoke-tool* :metabot.tool/who-is-your-favorite {} {}))))

View File

@@ -0,0 +1,8 @@
(ns metabase-enterprise.metabot-v3.tools-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.metabot-v3.tools :as metabot-v3.tools]))
(deftest ^:parallel validate-tools-test
(testing "Check that all tools are valid."
(is (seq (metabot-v3.tools/*tools-metadata*)))))

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,5 +1,9 @@
export * from "./ai-sql-fixer";
export * from "./audit-info";
export * from "./api";
export * from "./billing-info";
export * from "./metabot";
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/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,109 @@
import cx from "classnames";
import Styles from "./MetabotIcon.module.css";
export const MetabotIcon = ({ isLoading }: { isLoading: boolean }) => {
const dotsStrokeColor = isLoading ? "#88BF4D" : "#509EE3";
const dotsFillColor = isLoading ? "#88BF4D" : "#CBE2F7";
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="#CBE2F7"
stroke="#509EE3"
strokeWidth="1.5"
/>
<rect
x="3.90698"
y="3.34961"
width="24.5581"
height="14.5116"
rx="2"
fill="#EEF6FD"
stroke="#509EE3"
strokeWidth="1.5"
/>
{isLoading && (
<>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M18 13H15V12H18V13Z"
fill="#509EE3"
/>
<path
d="M10 8.5C10 9 10.8 10 12 10C13.2 10 13.8333 9 14 8.5"
stroke="#509EE3"
/>
<path
d="M19 8.5C19 9 19.8 10 21 10C22.2 10 22.8333 9 23 8.5"
stroke="#509EE3"
/>
</>
)}
{!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="#509EE3"
/>
<circle cx="11.5814" cy="8.7912" r="0.976744" fill="#509EE3" />
<ellipse
cx="20.5117"
cy="8.7912"
rx="0.976745"
ry="0.976744"
fill="#509EE3"
/>
</>
)}
{/* 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,56 @@
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: async (message: string) => {
const context = getChatContext();
const history = sendMessageReq.data?.history || [];
const state = sendMessageReq.data?.state || {};
await dispatch(submitInput({ message, context, history, state }));
},
isDoingScience: sendMessageReq.isLoading || isProcessing,
};
};

View File

@@ -0,0 +1,39 @@
import { t } from "ttag";
import { METAKEY } from "metabase/lib/browser";
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 { 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_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,433 @@
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,
sessionId: 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 session each time metabot is opened", async () => {
const { store } = setup({
metabotPluginInitialState: metabotInitialState,
});
fetchMock.post(
`path:/api/ee/metabot-v3/agent`,
whoIsYourFavoriteResponse,
);
showMetabot(store.dispatch);
await enterChatMessage("Who is your favorite?");
const firstSessionId = (await lastReqBody())?.session_id;
expect(isUuid(firstSessionId)).toBeTruthy();
hideMetabot(store.dispatch);
showMetabot(store.dispatch);
await enterChatMessage("Who is your favorite?");
const secondSessionId = (await lastReqBody())?.session_id;
expect(isUuid(secondSessionId)).toBeTruthy();
expect(secondSessionId).not.toBe(firstSessionId);
});
// 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/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/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/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/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/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/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: "metabot.tool/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

@@ -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(([_, 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(([_, 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 _ from "underscore";
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,
getMetabotSessionId,
} from "./selectors";
export const {
addUserMessage,
dismissUserMessage,
clearUserMessages,
setIsProcessing,
setConfirmationOptions,
setSessionId,
} = 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: Record<string, 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: Record<string, any>;
},
{ dispatch, getState },
) => {
// TODO: make enterprise store
let sessionId = getMetabotSessionId(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(setSessionId(sessionId));
}
const result = await dispatch(
metabotAgent.initiate(
{ ...data, session_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 lastState = (getState() as any).metabot?.lastResponse?.state || {};
if (!lastSentContext) {
console.warn(
"Metabot expected to have a previously sent request before writing back to the server",
);
}
await dispatch(
sendMessageRequest({
message,
history: lastHistory,
state: lastState,
context: lastSentContext ?? ({} 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,86 @@
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;
sessionId: string | undefined;
userMessages: string[];
visible: boolean;
}
export const metabotInitialState: MetabotState = {
confirmationOptions: undefined,
isProcessing: false,
lastSentContext: undefined,
lastHistoryValue: undefined,
sessionId: undefined,
userMessages: [],
visible: false,
};
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;
},
setSessionId: (state, action: PayloadAction<string | undefined>) => {
state.sessionId = action.payload;
},
setVisible: (state, { payload: visible }: PayloadAction<boolean>) => {
if (visible) {
state.visible = true;
state.sessionId = 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,42 @@
import { createSelector } from "@reduxjs/toolkit";
import _ from "underscore";
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 getMetabotSessionId = createSelector(
getMetabot,
metabot => metabot.sessionId,
);

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

@@ -95,13 +95,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;
@@ -113,6 +108,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[];
@@ -131,7 +138,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,219 @@
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[];
state: Record<string, any>;
session_id: string; // uuid
};
export type MetabotAgentResponse = {
reactions: MetabotReaction[];
history: MetabotHistory[];
state: Record<string, 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,
};
}

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