Compare commits
254 Commits
python-run
...
pass-state
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bffebd03dd | ||
|
|
a7f60f8004 | ||
|
|
c4b2f29b0f | ||
|
|
44a15f3a77 | ||
|
|
6630e72403 | ||
|
|
318e3e6b6f | ||
|
|
91948e24ab | ||
|
|
6c20dc9c17 | ||
|
|
a6f8cc0919 | ||
|
|
3ebda589f5 | ||
|
|
e51023ca43 | ||
|
|
44da1f4d67 | ||
|
|
4a1c0c5029 | ||
|
|
f2792cdbfd | ||
|
|
10c042dbdc | ||
|
|
7badf9005d | ||
|
|
c3d157b9cd | ||
|
|
aeaa811281 | ||
|
|
d44226c7fa | ||
|
|
c4578261a8 | ||
|
|
ca0352d6ad | ||
|
|
32dc706a39 | ||
|
|
1b44cb8bb9 | ||
|
|
9fa2f59d64 | ||
|
|
e62bfc2004 | ||
|
|
95726e9d1b | ||
|
|
ec7595557a | ||
|
|
da64908493 | ||
|
|
9bd61f3557 | ||
|
|
49a83e9f41 | ||
|
|
bddc369c0d | ||
|
|
baa6baf893 | ||
|
|
c9fe612a50 | ||
|
|
6480f7b841 | ||
|
|
64fa2f2bc1 | ||
|
|
4c690a44dc | ||
|
|
d74631e48b | ||
|
|
c9f5a0e3b6 | ||
|
|
123ec0e340 | ||
|
|
3929342ac0 | ||
|
|
ed537dcbc7 | ||
|
|
60b85b61ac | ||
|
|
e3347d10a5 | ||
|
|
ca0cd2b5a5 | ||
|
|
10107fcf5c | ||
|
|
42aa717241 | ||
|
|
cb30ee8a4b | ||
|
|
dee6da84cc | ||
|
|
9d6db122ca | ||
|
|
bdccec2d1f | ||
|
|
653797622b | ||
|
|
77fb7c62fa | ||
|
|
c1eb2559ec | ||
|
|
8643fc3aef | ||
|
|
67abded9e8 | ||
|
|
dab935167b | ||
|
|
087ad1a2b1 | ||
|
|
c3cbeffc70 | ||
|
|
75b33df5bc | ||
|
|
b87ce054cb | ||
|
|
89490c9a16 | ||
|
|
9b78a0014f | ||
|
|
74c29161f3 | ||
|
|
59dcb7f115 | ||
|
|
2f817742e6 | ||
|
|
be840bb7bd | ||
|
|
086783dd68 | ||
|
|
3cd107d6d3 | ||
|
|
2d47254583 | ||
|
|
ec6033870c | ||
|
|
db9e5b1a49 | ||
|
|
96c9346eda | ||
|
|
57af6af23a | ||
|
|
8598fc59d5 | ||
|
|
6bcbe2f35a | ||
|
|
57af242b8f | ||
|
|
22a3a5fe44 | ||
|
|
8ddeee90f3 | ||
|
|
74574ed2aa | ||
|
|
d3804ab2bc | ||
|
|
c76e64407b | ||
|
|
7330ca489e | ||
|
|
7a0db83be5 | ||
|
|
abdfa33583 | ||
|
|
dee067ffff | ||
|
|
8cbea88330 | ||
|
|
d4944b478b | ||
|
|
5603565c7c | ||
|
|
fd44da515e | ||
|
|
56419f6be9 | ||
|
|
43dab70197 | ||
|
|
26bdc4ca18 | ||
|
|
c17bbb8ca3 | ||
|
|
d30c95d30b | ||
|
|
ce8558949a | ||
|
|
d027f538f1 | ||
|
|
13a73a6b9a | ||
|
|
80c322f8b1 | ||
|
|
acc331c5fb | ||
|
|
4edd922f10 | ||
|
|
6108f5d699 | ||
|
|
83af40a49d | ||
|
|
71d828be07 | ||
|
|
129f1ce7f4 | ||
|
|
0e0aac3f46 | ||
|
|
474ecbeec5 | ||
|
|
1b3ed70dec | ||
|
|
91abc51ec5 | ||
|
|
1c02aebe07 | ||
|
|
0af736a234 | ||
|
|
0c3b93131c | ||
|
|
ada0e86826 | ||
|
|
160be30f2e | ||
|
|
f6d87b174d | ||
|
|
1d8350e6b7 | ||
|
|
88605995e1 | ||
|
|
1516add0f2 | ||
|
|
751c628779 | ||
|
|
1c7d841199 | ||
|
|
73c8f0e87c | ||
|
|
5306cc9e37 | ||
|
|
1545b358dd | ||
|
|
2d68c5569e | ||
|
|
e0f0fac373 | ||
|
|
c98477e607 | ||
|
|
974424174c | ||
|
|
2df3d84fed | ||
|
|
f1d23fa5d9 | ||
|
|
de6b854244 | ||
|
|
b6af5b1a2e | ||
|
|
15adc8994c | ||
|
|
da69aaf7d0 | ||
|
|
e1c39f6b5e | ||
|
|
c76b952c5b | ||
|
|
0ad961096b | ||
|
|
8fac6159ec | ||
|
|
409d7a2251 | ||
|
|
325c6a17a1 | ||
|
|
6a6312c8e2 | ||
|
|
3a98e2d44f | ||
|
|
a604ab351b | ||
|
|
560f2e1455 | ||
|
|
a283463d05 | ||
|
|
ecc3e7ba0b | ||
|
|
57f58d6009 | ||
|
|
782d167f75 | ||
|
|
a90addd18c | ||
|
|
9c6a2571d4 | ||
|
|
2dc9749a64 | ||
|
|
aa4d5180da | ||
|
|
27a1329c00 | ||
|
|
6ed742c176 | ||
|
|
888333c654 | ||
|
|
b6a9a3d851 | ||
|
|
af08a7488c | ||
|
|
8933965e77 | ||
|
|
d4c0b24558 | ||
|
|
7b34e3c04f | ||
|
|
b498a5ad59 | ||
|
|
4530bb4472 | ||
|
|
f7bc32c37f | ||
|
|
b11ace9933 | ||
|
|
4ac8936dda | ||
|
|
da80d576fc | ||
|
|
75af27d1ef | ||
|
|
b71f81de3a | ||
|
|
8ae959af40 | ||
|
|
54bea92e33 | ||
|
|
c1cec51039 | ||
|
|
619faab76a | ||
|
|
fa679be3f5 | ||
|
|
3e151e39ce | ||
|
|
2086a5bcee | ||
|
|
94658fdbab | ||
|
|
c4d52fb574 | ||
|
|
1444a637e3 | ||
|
|
fdbbe3e246 | ||
|
|
4d722317bf | ||
|
|
07fb93c1dd | ||
|
|
3082c28ce6 | ||
|
|
c43ffdf1f6 | ||
|
|
30e1b63230 | ||
|
|
35f2719fc7 | ||
|
|
5d2b8b4192 | ||
|
|
dd305e34c2 | ||
|
|
7c47d4908f | ||
|
|
718602271b | ||
|
|
cbbc868d07 | ||
|
|
ca5aadc113 | ||
|
|
313f03307a | ||
|
|
e92eba2a34 | ||
|
|
eefe925be2 | ||
|
|
6ed9935bf0 | ||
|
|
7032631e79 | ||
|
|
b0e5c8f306 | ||
|
|
06b61e9d11 | ||
|
|
74c7533d9f | ||
|
|
eca6d725db | ||
|
|
1ee39a695f | ||
|
|
7d97203f80 | ||
|
|
53df1cc48f | ||
|
|
445689bd61 | ||
|
|
1afd8db757 | ||
|
|
f36b61d095 | ||
|
|
1320442e81 | ||
|
|
9a5c9f6b43 | ||
|
|
a77f8d3186 | ||
|
|
d18a41c615 | ||
|
|
23e07e333b | ||
|
|
c032343abb | ||
|
|
85454134b8 | ||
|
|
75a61b3ac1 | ||
|
|
3855c55a9a | ||
|
|
7023192f86 | ||
|
|
e52e7b9ca4 | ||
|
|
1917280df3 | ||
|
|
6a5ac5a8c5 | ||
|
|
54d966fb98 | ||
|
|
89e065a77a | ||
|
|
f46fb69020 | ||
|
|
e5d0c154e6 | ||
|
|
c937c135db | ||
|
|
0052fdfdec | ||
|
|
3847d4067c | ||
|
|
ba6d81f561 | ||
|
|
50f3c789b8 | ||
|
|
9c71ce697a | ||
|
|
ba2c9e9eca | ||
|
|
4a61e5a42c | ||
|
|
7007095b35 | ||
|
|
d32f8c5940 | ||
|
|
a1174e82b1 | ||
|
|
6b500e15f9 | ||
|
|
b02e3c9aa1 | ||
|
|
6abab13f62 | ||
|
|
f9919999ff | ||
|
|
22ae538b0c | ||
|
|
ae093274ff | ||
|
|
ac3a171308 | ||
|
|
de00816b7b | ||
|
|
2c647c0637 | ||
|
|
c4866381a3 | ||
|
|
749a5e6a56 | ||
|
|
556acb9b10 | ||
|
|
d60072bcc9 | ||
|
|
d478a79856 | ||
|
|
6067de5ba7 | ||
|
|
b98ed219e8 | ||
|
|
d6f4458fba | ||
|
|
4b5457848f | ||
|
|
acd4ed01c2 | ||
|
|
06212efee6 | ||
|
|
2c4c399e29 | ||
|
|
71ac4f02f1 |
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
33
.clj-kondo/test/hooks/metabase/models/setting_test.clj
Normal file
33
.clj-kondo/test/hooks/metabase/models/setting_test.clj
Normal 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)))))
|
||||
6
.github/workflows/uberjar.yml
vendored
6
.github/workflows/uberjar.yml
vendored
@@ -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
3
.gitignore
vendored
@@ -119,3 +119,6 @@ modules/drivers/*/.lsp/*
|
||||
.swc/
|
||||
|
||||
*storybook.log
|
||||
|
||||
# metabot
|
||||
llm-payloads
|
||||
|
||||
16
deps.edn
16
deps.edn
@@ -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.
|
||||
}
|
||||
|
||||
78
e2e/support/helpers/e2e-metabot-helpers.ts
Normal file
78
e2e/support/helpers/e2e-metabot-helpers.ts
Normal 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");
|
||||
};
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
120
e2e/test/scenarios/metabot/metabot.cy.spec.ts
Normal file
120
e2e/test/scenarios/metabot/metabot.cy.spec.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
};
|
||||
40
e2e/test/scenarios/native/ai-sql-fixer.cy.spec.ts
Normal file
40
e2e/test/scenarios/native/ai-sql-fixer.cy.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||

|
||||
265
enterprise/backend/src/metabase_enterprise/metabot_v3/api.clj
Normal file
265
enterprise/backend/src/metabase_enterprise/metabot_v3/api.clj
Normal 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))
|
||||
237
enterprise/backend/src/metabase_enterprise/metabot_v3/client.clj
Normal file
237
enterprise/backend/src/metabase_enterprise/metabot_v3/client.clj
Normal 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"}]
|
||||
[])
|
||||
@@ -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]]])
|
||||
@@ -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)))
|
||||
@@ -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 |
@@ -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
|
||||
@@ -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)))
|
||||
-)
|
||||
@@ -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))))
|
||||
@@ -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))))))
|
||||
@@ -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))
|
||||
127
enterprise/backend/src/metabase_enterprise/metabot_v3/repl.clj
Normal file
127
enterprise/backend/src/metabase_enterprise/metabot_v3/repl.clj
Normal 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}]})))
|
||||
100
enterprise/backend/src/metabase_enterprise/metabot_v3/tools.clj
Normal file
100
enterprise/backend/src/metabase_enterprise/metabot_v3/tools.clj
Normal 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))
|
||||
@@ -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}}"
|
||||
]
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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\"}]}}"
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}}}"
|
||||
]
|
||||
}
|
||||
@@ -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."})))
|
||||
@@ -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)))))
|
||||
@@ -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}}}"
|
||||
]
|
||||
}
|
||||
@@ -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)))
|
||||
@@ -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)})
|
||||
@@ -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\"}}"
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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}}"
|
||||
]
|
||||
}
|
||||
@@ -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)}]))
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)}))
|
||||
@@ -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))))
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "who-is-your-favorite",
|
||||
"description": "Tell me who's your favorite.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -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!"})
|
||||
@@ -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
|
||||
|
||||
@@ -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))))))))
|
||||
@@ -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!\"}"}]}}))))
|
||||
@@ -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)))))))
|
||||
@@ -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})))
|
||||
@@ -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 -)))))))))
|
||||
@@ -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}}
|
||||
{}))))))
|
||||
@@ -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"))
|
||||
@@ -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}}]}))))))
|
||||
@@ -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"))))))))
|
||||
@@ -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 {} {}))))
|
||||
@@ -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*)))))
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./FixSqlQueryButton";
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
|
||||
21
enterprise/frontend/src/metabase-enterprise/api/metabot.ts
Normal file
21
enterprise/frontend/src/metabase-enterprise/api/metabot.ts
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./MetabotChat";
|
||||
export { METABOT_AUTO_CLOSE_DURATION_MS } from "./useAutoCloseMetabot";
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./MetabotIcon";
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
56
enterprise/frontend/src/metabase-enterprise/metabot/hooks.ts
Normal file
56
enterprise/frontend/src/metabase-enterprise/metabot/hooks.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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)),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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.`));
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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));
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -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 }));
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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.`));
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./actions";
|
||||
export * from "./reducer";
|
||||
export * from "./selectors";
|
||||
export * from "./types";
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
15
frontend/src/metabase-types/api/ai-sql-fixer.ts
Normal file
15
frontend/src/metabase-types/api/ai-sql-fixer.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
36
frontend/src/metabase-types/api/mocks/ai-sql-fixer.ts
Normal file
36
frontend/src/metabase-types/api/mocks/ai-sql-fixer.ts
Normal 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
Reference in New Issue
Block a user