Compare commits
354 Commits
proj-pytho
...
sql-genera
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
349f48ce44 | ||
|
|
b08b714b0e | ||
|
|
523cd3812b | ||
|
|
d67c024717 | ||
|
|
e52be87e3a | ||
|
|
4f83b5eff3 | ||
|
|
02ef883215 | ||
|
|
a654c4025b | ||
|
|
8c236418de | ||
|
|
ffb7af68c8 | ||
|
|
5333eeda6b | ||
|
|
9981e3db93 | ||
|
|
93ab435a9d | ||
|
|
805f066b8c | ||
|
|
8af934fecd | ||
|
|
79a24d5800 | ||
|
|
ccc58d6578 | ||
|
|
c06e62576b | ||
|
|
2aec76218d | ||
|
|
c14d51b22e | ||
|
|
ef69ec3129 | ||
|
|
4fee5cd3c4 | ||
|
|
644f935654 | ||
|
|
2e6734d105 | ||
|
|
1db581dd1b | ||
|
|
71d913d8d1 | ||
|
|
51ac49727e | ||
|
|
806d623462 | ||
|
|
8f62da32cc | ||
|
|
edd7bc6a9c | ||
|
|
8f62fb33a5 | ||
|
|
15f333d81f | ||
|
|
2543a6af72 | ||
|
|
961a73ff4c | ||
|
|
383591f75b | ||
|
|
2e1c2d7205 | ||
|
|
57ca9f0aac | ||
|
|
4c1fe3d767 | ||
|
|
8c758e3674 | ||
|
|
2bf18a43ec | ||
|
|
4846e8a94b | ||
|
|
a62e4e10ef | ||
|
|
9778f8c12b | ||
|
|
486d792c9a | ||
|
|
4975f725a9 | ||
|
|
b5e38e84b5 | ||
|
|
ce62f30ffc | ||
|
|
8374ed8450 | ||
|
|
e2660bf3ef | ||
|
|
08ebf5a3e3 | ||
|
|
485df761d0 | ||
|
|
f2f4f57866 | ||
|
|
3abeeb9ab2 | ||
|
|
cb22a6bcf4 | ||
|
|
ad34b73d94 | ||
|
|
351daa8851 | ||
|
|
61b153a918 | ||
|
|
d1b0bf48fd | ||
|
|
1671d222cd | ||
|
|
c0ef616c6d | ||
|
|
78743ba645 | ||
|
|
01e98249a2 | ||
|
|
1dabc6ccbb | ||
|
|
752287a56d | ||
|
|
865d8a0d81 | ||
|
|
26dc17b31d | ||
|
|
2f6a39b40e | ||
|
|
dfab957510 | ||
|
|
ea3b49aa0a | ||
|
|
5d81a60a64 | ||
|
|
10bd22a3b9 | ||
|
|
1e75309a10 | ||
|
|
68d24b95b4 | ||
|
|
0574137a29 | ||
|
|
455713606e | ||
|
|
fcf86ea731 | ||
|
|
168c055c8f | ||
|
|
1e715f418b | ||
|
|
5aca4ec11d | ||
|
|
27045cf9a7 | ||
|
|
d358286383 | ||
|
|
c19b8310dd | ||
|
|
3b535a542c | ||
|
|
291d9e12e1 | ||
|
|
bd1da7cdb9 | ||
|
|
96cd8ba5b7 | ||
|
|
b5e2e66b27 | ||
|
|
5b8a479fe4 | ||
|
|
bf5df2cd38 | ||
|
|
98332749de | ||
|
|
827c0c2eab | ||
|
|
e74c254612 | ||
|
|
04b7020aef | ||
|
|
edb6b83feb | ||
|
|
10550112f5 | ||
|
|
5c99127943 | ||
|
|
5b5ab1fa16 | ||
|
|
76d904ba3a | ||
|
|
ca3698711b | ||
|
|
271fab9a30 | ||
|
|
ff0ed9be47 | ||
|
|
a69afcb929 | ||
|
|
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 |
@@ -626,6 +626,10 @@
|
||||
metabase-enterprise.advanced-permissions.models.permissions.group-manager}
|
||||
:uses #{api audit driver enterprise/impersonation lib models permissions premium-features query-processor util enterprise/sandbox}}
|
||||
|
||||
enterprise/ai-sql-fixer
|
||||
{:api #{metabase-enterprise.ai-sql-fixer.api}
|
||||
:uses #{api driver enterprise/metabot-v3 query-analysis query-processor util}}
|
||||
|
||||
enterprise/airgap
|
||||
{:api #{}
|
||||
:uses #{premium-features util}}
|
||||
@@ -696,6 +700,11 @@
|
||||
{:api #{metabase-enterprise.llm.api}
|
||||
:uses #{analytics analyze api models query-processor util}}
|
||||
|
||||
enterprise/metabot-v3
|
||||
{:api #{metabase-enterprise.metabot-v3.api metabase-enterprise.metabot-v3.core
|
||||
metabase-enterprise.metabot-v3.tools.api}
|
||||
:uses #{api config db legacy-mbql lib models premium-features public-settings pulse query-processor request util}}
|
||||
|
||||
enterprise/query-reference-validation
|
||||
{:api #{metabase-enterprise.query-reference-validation.api}
|
||||
:uses #{api models public-settings request util}}
|
||||
|
||||
34
.clj-kondo/test/hooks/metabase/models/setting_test.clj
Normal file
34
.clj-kondo/test/hooks/metabase/models/setting_test.clj
Normal file
@@ -0,0 +1,34 @@
|
||||
(ns hooks.metabase.models.setting-test
|
||||
(:require
|
||||
[clj-kondo.hooks-api :as hooks]
|
||||
[clojure.test :refer :all]
|
||||
[hooks.metabase.models.setting]))
|
||||
|
||||
#_{:clj-kondo/ignore [:metabase/validate-deftest]}
|
||||
(deftest ^:parallel defsetting-test
|
||||
(let [node (-> '(defsetting active-users-count
|
||||
(deferred-tru "Cached number of active users. Refreshed every 5 minutes.")
|
||||
:visibility :admin
|
||||
:type :integer
|
||||
:default 0
|
||||
:getter (fn []
|
||||
(if-not ((requiring-resolve 'metabase.db/db-is-set-up?))
|
||||
0
|
||||
(cached-active-users-count))))
|
||||
pr-str
|
||||
hooks/parse-string)]
|
||||
(is (= '(let [_ (deferred-tru "Cached number of active users. Refreshed every 5 minutes.")
|
||||
_ {:default 0
|
||||
:type :integer
|
||||
:getter (fn []
|
||||
(if-not ((requiring-resolve 'metabase.db/db-is-set-up?))
|
||||
0
|
||||
(cached-active-users-count)))
|
||||
:visibility :admin}]
|
||||
:active-users-count
|
||||
(defn active-users-count "Docstring." [])
|
||||
(defn active-users-count! "Docstring." [_value-or-nil]))
|
||||
(-> {:node node}
|
||||
hooks.metabase.models.setting/defsetting
|
||||
:node
|
||||
hooks/sexpr)))))
|
||||
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
|
||||
- "internal-tools"
|
||||
paths-ignore:
|
||||
@@ -170,8 +170,8 @@ jobs:
|
||||
push: true
|
||||
|
||||
# TODO: remove when we're done testing metabot
|
||||
- name: Retag and push images if metabot-v3-second-try (ee) - should be cached at this point
|
||||
if: ${{ (github.ref_name == 'metabot-v3-second-try') && matrix.edition == 'ee' }}
|
||||
- name: Retag and push images if metabot-v3-main (ee) - should be cached at this point
|
||||
if: ${{ (github.ref_name == 'metabot-v3-main') && matrix.edition == 'ee' }}
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: bin/docker/.
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -125,5 +125,9 @@ modules/drivers/*/.lsp/*
|
||||
|
||||
# jar downloader
|
||||
/jars/
|
||||
|
||||
# metabot
|
||||
llm-payloads
|
||||
|
||||
/bin/bb
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 162 KiB |
1
deps.edn
1
deps.edn
@@ -309,6 +309,7 @@
|
||||
:test
|
||||
{:extra-paths ["test_config"]
|
||||
:exec-fn metabase.test-runner/find-and-run-tests-cli
|
||||
:exec-args {:exclude-tags [:metabot-v3/e2e]}
|
||||
:jvm-opts ["-Dmb.run.mode=test"
|
||||
"-Dmacaw.run.mode=test"
|
||||
"-Dmb.db.in.memory=true"
|
||||
|
||||
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: "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",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -47,7 +47,9 @@ describe("issue 19180", () => {
|
||||
cy.wait("@cardQuery");
|
||||
cy.button("Cancel").click();
|
||||
H.tableInteractive();
|
||||
cy.findByText("Query results will appear here.").should("not.exist");
|
||||
cy.findByText("Here's where your results will appear").should(
|
||||
"not.exist",
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1134,7 +1134,9 @@ describe("issue 34514", () => {
|
||||
H.tableInteractive().should("not.exist");
|
||||
cy.findByTestId("query-visualization-root").within(() => {
|
||||
cy.findByText("We're experiencing server issues").should("not.exist");
|
||||
cy.findByText("Query results will appear here.").should("be.visible");
|
||||
cy.findByText("Here's where your results will appear").should(
|
||||
"be.visible",
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1434,3 +1436,18 @@ describe("issue 51925", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("issue 53649", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
});
|
||||
|
||||
it("should not get caught in an infinite loop when opening the native editor (metabase#53649)", () => {
|
||||
H.startNewNativeModel();
|
||||
|
||||
// If the app freezes, this won't work
|
||||
H.NativeEditor.type("select 1");
|
||||
H.NativeEditor.get().should("contain", "select 1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -948,7 +948,7 @@ describe("issue 27257", () => {
|
||||
it("should not drop numeric filter widget value on refresh even if it's zero (metabase#27257)", () => {
|
||||
cy.reload();
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Query results will appear here.");
|
||||
cy.findByText("Here's where your results will appear");
|
||||
cy.findByDisplayValue("0");
|
||||
});
|
||||
});
|
||||
|
||||
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.skip("scenarios > native > ai sql fixer", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.setTokenFeatures("all");
|
||||
cy.intercept("POST", "/api/ee/ai-sql-fixer/fix").as("fixSql");
|
||||
});
|
||||
|
||||
it("should be able to fix SQL with AI", () => {
|
||||
H.visitQuestionAdhoc({
|
||||
display: "table",
|
||||
dataset_query: {
|
||||
database: SAMPLE_DB_ID,
|
||||
type: "native",
|
||||
native: {
|
||||
query: "SELECT1 * FROM ORDERS",
|
||||
},
|
||||
},
|
||||
});
|
||||
cy.wait("@fixSql");
|
||||
cy.findByTestId("query-visualization-root").within(() => {
|
||||
cy.button(/Have Metabot fix it/).click();
|
||||
cy.findByText(FIX_MESSAGE).should("be.visible");
|
||||
});
|
||||
H.runNativeQuery();
|
||||
H.NativeEditor.get()
|
||||
.should("be.visible")
|
||||
.and("contain", "SELECT * FROM ORDERS");
|
||||
cy.findByTestId("query-visualization-root").within(() => {
|
||||
H.tableInteractive().should("be.visible");
|
||||
cy.findByText(FIX_MESSAGE).should("not.exist");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -668,7 +668,7 @@ describe("issue 34330", () => {
|
||||
|
||||
it("should only call the autocompleter with all text typed (metabase#34330)", () => {
|
||||
cy.findByTestId("query-visualization-root")
|
||||
.findByText("Query results will appear here.")
|
||||
.findByText("Here's where your results will appear")
|
||||
.should("be.visible");
|
||||
|
||||
H.NativeEditor.type("SEAT", { delay: 10 });
|
||||
|
||||
@@ -33,7 +33,9 @@ describe("issue 11727", { tags: "@external" }, () => {
|
||||
H.runNativeQuery({ wait: false });
|
||||
cy.findByText("Doing science...").should("be.visible");
|
||||
cy.get("body").type("{cmd}{enter}");
|
||||
cy.findByText("Query results will appear here.").should("be.visible");
|
||||
cy.findByText("Here's where your results will appear").should(
|
||||
"be.visible",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -300,7 +300,7 @@ describe("scenarios > question > native", () => {
|
||||
);
|
||||
|
||||
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
|
||||
cy.findByText("Query results will appear here.").should("be.visible");
|
||||
cy.findByText("Here's where your results will appear").should("be.visible");
|
||||
});
|
||||
|
||||
it("should allow to preview a fully parameterized query", () => {
|
||||
|
||||
@@ -500,7 +500,7 @@ describe("issue 30165", () => {
|
||||
cy.get("@dataset.all").should("have.length", 0);
|
||||
cy.get("@cardQuery.all").should("have.length", 0);
|
||||
cy.findByTestId("query-builder-main")
|
||||
.findByText("Query results will appear here.")
|
||||
.findByText("Here's where your results will appear")
|
||||
.should("be.visible");
|
||||
});
|
||||
});
|
||||
|
||||
210
enterprise/backend/src/metabase_enterprise/ai_sql_fixer/api.clj
Normal file
210
enterprise/backend/src/metabase_enterprise/ai_sql_fixer/api.clj
Normal file
@@ -0,0 +1,210 @@
|
||||
(ns metabase-enterprise.ai-sql-fixer.api
|
||||
"`/api/ee/ai-sql-fixer/` routes"
|
||||
(:require
|
||||
[clojure.set :as set]
|
||||
[metabase-enterprise.metabot-v3.core :as metabot-v3]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.driver.util :as driver.u]
|
||||
[metabase.query-analysis.core :as query-analyzer]
|
||||
[metabase.query-processor.middleware.permissions :as qp.perms]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[toucan2.core :as t2]
|
||||
[toucan2.realize :as t2.realize])
|
||||
(:import
|
||||
(java.io StringWriter Writer)
|
||||
(org.apache.commons.text.similarity LevenshteinDistance)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;; some arbitrary limits
|
||||
(def ^:private max-schema-sample-tables
|
||||
"If the number of tables in the database doesn't exceed this number, we send them all to the agent."
|
||||
10)
|
||||
|
||||
(def ^:private max-schema-candidate-tables
|
||||
"The maximum number of tables we process to find the ones that might be relevant for the SQL query to fix."
|
||||
10000)
|
||||
|
||||
(def ^:private max-distance
|
||||
"The maximal edit distance at which we consider two table (or schema) names similar."
|
||||
4)
|
||||
|
||||
(def ^:private ^LevenshteinDistance matcher (LevenshteinDistance. (int max-distance)))
|
||||
|
||||
(defn- similar?
|
||||
[left right]
|
||||
(nat-int? (.apply matcher (u/lower-case-en left) (u/lower-case-en right))))
|
||||
|
||||
(defn- matching-tables?
|
||||
[{tschema :schema, tname :name} {uschema :schema, uname :name} {:keys [match-schema?]}]
|
||||
(and (similar? uname tname)
|
||||
(or (not match-schema?)
|
||||
(nil? uschema)
|
||||
(nil? tschema)
|
||||
(similar? uschema tschema))))
|
||||
|
||||
(defn- find-matching-tables
|
||||
[database-id unrecognized-tables used-ids]
|
||||
(into []
|
||||
(keep (fn [table]
|
||||
(when (some #(matching-tables? table % {:match-schema? false}) unrecognized-tables)
|
||||
(t2.realize/realize table))))
|
||||
(t2/reducible-select [:model/Table :id :name :schema]
|
||||
:db_id database-id
|
||||
:active true
|
||||
:visibility_type nil
|
||||
(cond-> {:limit max-schema-candidate-tables}
|
||||
(seq used-ids) (assoc :where [:not-in :id used-ids])))))
|
||||
|
||||
(defn- used-tables
|
||||
[{:keys [database] :as query}]
|
||||
(let [queried-tables (->> (query-analyzer/tables-for-native query :all-drivers-trusted? true)
|
||||
:tables
|
||||
(map #(set/rename-keys % {:table :name, :table-id :id})))
|
||||
{recognized-tables true, unrecognized-tables false} (group-by t2/instance? queried-tables)]
|
||||
(concat recognized-tables
|
||||
(find-matching-tables database unrecognized-tables (map :id recognized-tables)))))
|
||||
|
||||
(defn- format-escaped
|
||||
[^String identifier ^Writer writer]
|
||||
(if (re-matches #"[\p{Alpha}_][\p{Alnum}_]*" identifier)
|
||||
(.append writer identifier)
|
||||
(do
|
||||
(.append writer \")
|
||||
(doseq [^Character c identifier]
|
||||
(when (= c \")
|
||||
(.append writer c))
|
||||
(.append writer c))
|
||||
(.append writer \"))))
|
||||
|
||||
(defn- format-field-ddl
|
||||
[{:keys [^String database_type], fname :name} ^Writer writer]
|
||||
(format-escaped fname writer)
|
||||
(.append writer " ")
|
||||
(.append writer database_type))
|
||||
|
||||
(defn- format-table-ddl
|
||||
[{:keys [schema fields], tname :name} ^Writer writer]
|
||||
(.append writer "CREATE TABLE ")
|
||||
(when (seq schema)
|
||||
(format-escaped schema writer)
|
||||
(.append writer \.))
|
||||
(format-escaped tname writer)
|
||||
(.append writer " (")
|
||||
(when (seq fields)
|
||||
(.append writer "\n ")
|
||||
(format-field-ddl (first fields) writer)
|
||||
(doseq [field (rest fields)]
|
||||
(.append writer ",\n ")
|
||||
(format-field-ddl field writer)))
|
||||
(.append writer "\n);"))
|
||||
|
||||
(defn- format-schema-ddl
|
||||
[tables]
|
||||
(let [sw (StringWriter.)]
|
||||
(doseq [table tables]
|
||||
(format-table-ddl table sw)
|
||||
(.append sw \newline))
|
||||
(str sw)))
|
||||
|
||||
(defn- schema-sample
|
||||
([query]
|
||||
(schema-sample query nil))
|
||||
([{:keys [database] :as query} {:keys [all-tables-limit] :or {all-tables-limit max-schema-sample-tables}}]
|
||||
(let [tables (t2/select [:model/Table :id :name :schema]
|
||||
:db_id database
|
||||
:active true
|
||||
:visibility_type nil
|
||||
{:limit (inc all-tables-limit)})
|
||||
tables (if (> (count tables) all-tables-limit)
|
||||
(used-tables query)
|
||||
tables)
|
||||
tables (t2/hydrate tables :fields)]
|
||||
(format-schema-ddl tables))))
|
||||
|
||||
(defn- extract-table-names-from-sql
|
||||
"Extract table names from SQL text for table relevance matching"
|
||||
[sql-text]
|
||||
(when sql-text
|
||||
(let [words (re-seq #"[a-zA-Z_][a-zA-Z0-9_]*" sql-text)]
|
||||
(set words))))
|
||||
|
||||
(defn- table-relevance-score
|
||||
"Calculate a relevance score for a table based on presence of its name in the query/instructions"
|
||||
[table-name table-words]
|
||||
(if (contains? table-words table-name)
|
||||
2 ; Direct match
|
||||
0)) ; No match
|
||||
|
||||
(defn- select-relevant-tables
|
||||
"Select most relevant tables based on SQL query content or instructions"
|
||||
[tables text max-tables]
|
||||
(let [table-words (extract-table-names-from-sql text)]
|
||||
(->> tables
|
||||
(map #(assoc % :relevance-score (table-relevance-score (:name %) table-words)))
|
||||
(sort-by :relevance-score >)
|
||||
(take max-tables)
|
||||
(map #(dissoc % :relevance-score)))))
|
||||
|
||||
(defn- structured-tables
|
||||
"Return table data in the new AI-service format, filtered by relevance if needed"
|
||||
[query & [instructions]]
|
||||
(let [tables (t2/select [:model/Table :id :name :schema :description]
|
||||
:db_id (:database query)
|
||||
:active true
|
||||
:visibility_type nil)
|
||||
tables (t2/hydrate tables :fields)
|
||||
sql-text (-> query :native :query)
|
||||
relevance-text (or instructions sql-text "")
|
||||
tables-to-use (if (> (count tables) max-schema-sample-tables)
|
||||
(select-relevant-tables tables relevance-text max-schema-sample-tables)
|
||||
tables)]
|
||||
(map (fn [t]
|
||||
{:name (:name t)
|
||||
:schema (:schema t)
|
||||
:description (or (:description t) "")
|
||||
:columns (map (fn [f]
|
||||
{:name (:name f)
|
||||
:data_type (:database_type f)
|
||||
:description (or (:description f) "")})
|
||||
(:fields t))})
|
||||
tables-to-use)))
|
||||
|
||||
(api.macros/defendpoint :post "/fix"
|
||||
"Suggest fixes for a SQL query."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [query error_message]} :- [:map
|
||||
[:query [:map
|
||||
[:database ms/PositiveInt]]]
|
||||
[:error_message :string]]]
|
||||
(qp.perms/check-current-user-has-adhoc-native-query-perms query)
|
||||
(let [driver (-> query :database driver.u/database->driver)]
|
||||
(-> (metabot-v3/fix-sql
|
||||
{:sql (-> query :native :query)
|
||||
:dialect driver
|
||||
:error_message error_message
|
||||
:schema_ddl (schema-sample query)})
|
||||
(select-keys [:fixes]))))
|
||||
|
||||
(api.macros/defendpoint :post "/generate"
|
||||
"Generate a SQL statement from instructions."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [query instructions]} :- [:map
|
||||
[:query [:map
|
||||
[:database ms/PositiveInt]]]
|
||||
[:instructions :string]]]
|
||||
(qp.perms/check-current-user-has-adhoc-native-query-perms query)
|
||||
(let [driver (-> query :database driver.u/database->driver)]
|
||||
(-> (metabot-v3/generate-sql
|
||||
{:instructions instructions
|
||||
:dialect driver
|
||||
:tables (structured-tables query instructions)})
|
||||
(select-keys [:generated_sql]))))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/ai-sql-fixer` routes."
|
||||
(api.macros/ns-handler *ns* +auth))
|
||||
@@ -7,6 +7,7 @@
|
||||
(:require
|
||||
[metabase-enterprise.advanced-config.api.logs]
|
||||
[metabase-enterprise.advanced-permissions.api.routes]
|
||||
[metabase-enterprise.ai-sql-fixer.api]
|
||||
[metabase-enterprise.api.routes.common :as ee.api.common]
|
||||
[metabase-enterprise.audit-app.api.routes]
|
||||
[metabase-enterprise.billing.api.routes]
|
||||
@@ -14,6 +15,8 @@
|
||||
[metabase-enterprise.data-editing.api]
|
||||
[metabase-enterprise.gsheets.api :as gsheets.api]
|
||||
[metabase-enterprise.llm.api]
|
||||
[metabase-enterprise.metabot-v3.api]
|
||||
[metabase-enterprise.metabot-v3.tools.api]
|
||||
[metabase-enterprise.query-reference-validation.api]
|
||||
[metabase-enterprise.sandbox.api.routes]
|
||||
[metabase-enterprise.scim.routes]
|
||||
@@ -32,9 +35,11 @@
|
||||
:attached-dwh (deferred-tru "Attached DWH")
|
||||
:audit-app (deferred-tru "Audit app")
|
||||
:collection-cleanup (deferred-tru "Collection Cleanup")
|
||||
:ai-sql-fixer (deferred-tru "AI SQL Fixer")
|
||||
:etl-connections (deferred-tru "ETL Connections")
|
||||
:table-data-editing (deferred-tru "Editing Table Data")
|
||||
:llm-autodescription (deferred-tru "LLM Auto-description")
|
||||
:metabot-v3 (deferred-tru "MetaBot")
|
||||
:query-reference-validation (deferred-tru "Query Reference Validation")
|
||||
:scim (deferred-tru "SCIM configuration")
|
||||
:serialization (deferred-tru "Serialization")
|
||||
@@ -60,6 +65,7 @@
|
||||
"/api/ee routes. The following routes are NICE and do follow the `/ee/<feature>/` naming convention. Please add new
|
||||
routes here and follow the convention."
|
||||
{"/advanced-permissions" (premium-handler metabase-enterprise.advanced-permissions.api.routes/routes :advanced-permissions)
|
||||
"/ai-sql-fixer" (premium-handler metabase-enterprise.ai-sql-fixer.api/routes :ai-sql-fixer)
|
||||
"/audit-app" (premium-handler metabase-enterprise.audit-app.api.routes/routes :audit-app)
|
||||
"/autodescribe" (premium-handler 'metabase-enterprise.llm.api :llm-autodescription)
|
||||
"/billing" metabase-enterprise.billing.api.routes/routes
|
||||
@@ -68,6 +74,8 @@
|
||||
(premium-handler :attached-dwh)
|
||||
(premium-handler :etl-connections))
|
||||
"/logs" (premium-handler 'metabase-enterprise.advanced-config.api.logs :audit-app)
|
||||
"/metabot-v3" (premium-handler metabase-enterprise.metabot-v3.api/routes :metabot-v3)
|
||||
"/metabot-tools" metabase-enterprise.metabot-v3.tools.api/routes
|
||||
"/query-reference-validation" (premium-handler metabase-enterprise.query-reference-validation.api/routes :query-reference-validation)
|
||||
"/scim" (premium-handler metabase-enterprise.scim.routes/routes :scim)
|
||||
"/serialization" (premium-handler metabase-enterprise.serialization.api/routes :serialization)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
(ns metabase-enterprise.metabot-v3.api
|
||||
"`/api/ee/metabot-v3/` routes"
|
||||
(:require
|
||||
[malli.core :as mc]
|
||||
[malli.transform :as mtx]
|
||||
[metabase-enterprise.metabot-v3.client.schema :as metabot-v3.client.schema]
|
||||
[metabase-enterprise.metabot-v3.context :as metabot-v3.context]
|
||||
[metabase-enterprise.metabot-v3.envelope :as metabot-v3.envelope]
|
||||
[metabase-enterprise.metabot-v3.reactions :as metabot-v3.reactions]
|
||||
[metabase-enterprise.metabot-v3.tools.api :as metabot-v3.tools.api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.schema :as ms]))
|
||||
|
||||
(mu/defn ^:private encode-reactions [reactions :- [:sequential ::metabot-v3.reactions/reaction]]
|
||||
(mc/encode [:sequential ::metabot-v3.reactions/reaction]
|
||||
reactions
|
||||
(mtx/transformer
|
||||
{:name :api-response}
|
||||
(mtx/key-transformer {:encode u/->snake_case_en}))))
|
||||
|
||||
(defn request
|
||||
"Handles an incoming request, making all required tool invocation, LLM call loops, etc."
|
||||
[message context history conversation_id state]
|
||||
(let [initial-message (metabot-v3.envelope/user-message message)
|
||||
history (conj (vec history) initial-message)
|
||||
env (-> {:context (metabot-v3.context/create-context context)
|
||||
:conversation-id conversation_id
|
||||
:messages history
|
||||
:state state}
|
||||
metabot-v3.tools.api/handle-envelope)
|
||||
messages (:messages env)]
|
||||
{:reactions (-> messages metabot-v3.envelope/reactions encode-reactions)
|
||||
:history (into history messages)
|
||||
:state (metabot-v3.envelope/state env)}))
|
||||
|
||||
(api.macros/defendpoint :post "/v2/agent"
|
||||
"Send a chat message to the LLM via the AI Proxy."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [message context conversation_id history state] :as body}
|
||||
:- [:map
|
||||
[:message ms/NonBlankString]
|
||||
[:context ::metabot-v3.context/context]
|
||||
[:conversation_id ms/UUIDString]
|
||||
[:history [:maybe ::metabot-v3.client.schema/messages]]
|
||||
[:state :map]]]
|
||||
(metabot-v3.context/log body :llm.log/fe->be)
|
||||
(doto (assoc
|
||||
(request message context history conversation_id state)
|
||||
:conversation_id conversation_id)
|
||||
(metabot-v3.context/log :llm.log/be->fe)))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/metabot-v3` routes."
|
||||
(api.macros/ns-handler *ns* +auth))
|
||||
202
enterprise/backend/src/metabase_enterprise/metabot_v3/client.clj
Normal file
202
enterprise/backend/src/metabase_enterprise/metabot_v3/client.clj
Normal file
@@ -0,0 +1,202 @@
|
||||
(ns metabase-enterprise.metabot-v3.client
|
||||
(:require
|
||||
[clj-http.client :as http]
|
||||
[clojure.string :as str]
|
||||
[malli.core :as mc]
|
||||
[malli.transform :as mtx]
|
||||
[metabase-enterprise.metabot-v3.client.schema :as metabot-v3.client.schema]
|
||||
[metabase-enterprise.metabot-v3.context :as metabot-v3.context]
|
||||
[metabase-enterprise.metabot-v3.util :as metabot-v3.u]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.models.setting :refer [defsetting]]
|
||||
[metabase.premium-features.core :as premium-features]
|
||||
[metabase.public-settings :as public-settings]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.i18n :refer [deferred-tru]]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.o11y :refer [with-span]]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defsetting ai-proxy-base-url
|
||||
(deferred-tru "URL for the a AI Proxy service")
|
||||
:type :string
|
||||
:encryption :no
|
||||
:default "http://localhost:8000"
|
||||
:visibility :internal
|
||||
:export? false)
|
||||
|
||||
(def ^:dynamic ^:private *debug* false)
|
||||
|
||||
(defn- ->json-bytes ^bytes [x]
|
||||
(with-open [os (java.io.ByteArrayOutputStream.)
|
||||
w (java.io.OutputStreamWriter. os)]
|
||||
(json/encode-to x w nil)
|
||||
(.toByteArray os)))
|
||||
|
||||
(defn- request-headers
|
||||
[]
|
||||
{"Accept" "application/json"
|
||||
"Content-Type" "application/json;charset=UTF-8"
|
||||
"x-metabase-instance-token" (premium-features/premium-embedding-token)})
|
||||
|
||||
(defn- build-request-options [body]
|
||||
(merge
|
||||
{:headers (request-headers)
|
||||
:body (->json-bytes body)
|
||||
:follow-redirects true
|
||||
:throw-exceptions false}
|
||||
(when *debug*
|
||||
{:debug true})))
|
||||
|
||||
(mu/defn- maybe-parse-response-body-as-json :- :map
|
||||
[response :- :map]
|
||||
(let [json? (some-> (get-in response [:headers "Content-Type"]) (str/starts-with? "application/json"))]
|
||||
(cond-> response
|
||||
json? (update :body #(json/decode % true)))))
|
||||
|
||||
(defn- post! [url options]
|
||||
(let [response-metadata (promise)
|
||||
response-status (promise)]
|
||||
(with-span :info {:name :metabot-v3.client/request
|
||||
:url url
|
||||
:metadata response-metadata
|
||||
:status response-status}
|
||||
(u/prog1 (maybe-parse-response-body-as-json (http/post url options))
|
||||
(deliver response-metadata (some-> <> :body :metadata))
|
||||
(deliver response-status (some-> <> :status))))))
|
||||
|
||||
(defn- agent-v2-endpoint-url []
|
||||
(str (ai-proxy-base-url) "/v2/agent"))
|
||||
|
||||
(defn- metric-selection-endpoint-url []
|
||||
(str (ai-proxy-base-url) "/v1/select-metric"))
|
||||
|
||||
(defn- find-outliers-endpoint-url []
|
||||
(str (ai-proxy-base-url) "/v1/find-outliers"))
|
||||
|
||||
(defn- fix-sql-endpoint []
|
||||
(str (ai-proxy-base-url) "/v1/sql/fix"))
|
||||
|
||||
(defn- generate-sql-endpoint []
|
||||
(str (ai-proxy-base-url) "/v1/sql/generate"))
|
||||
|
||||
(mu/defn request :- ::metabot-v3.client.schema/ai-proxy.response-v2
|
||||
"Make a V2 request to the AI Proxy."
|
||||
[{:keys [context messages conversation-id session-id state]}
|
||||
:- [:map
|
||||
[:context :map]
|
||||
[:messages ::metabot-v3.client.schema/messages]
|
||||
[:conversation-id :string]
|
||||
[:session-id :string]
|
||||
[:state :map]]]
|
||||
(premium-features/assert-has-feature :metabot-v3 "MetaBot")
|
||||
(try
|
||||
(let [url (agent-v2-endpoint-url)
|
||||
body (-> {:messages messages
|
||||
:context context}
|
||||
(metabot-v3.u/recursive-update-keys metabot-v3.u/safe->snake_case_en)
|
||||
(assoc :conversation_id conversation-id
|
||||
:state state
|
||||
:user_id api/*current-user-id*))
|
||||
_ (metabot-v3.context/log body :llm.log/be->llm)
|
||||
_ (log/debugf "V2 request to AI Proxy:\n%s" (u/pprint-to-str body))
|
||||
options (cond-> {:headers {"Accept" "application/json"
|
||||
"Content-Type" "application/json;charset=UTF-8"
|
||||
"x-metabase-instance-token" (premium-features/premium-embedding-token)
|
||||
"x-metabase-session-token" session-id
|
||||
"x-metabase-url" (public-settings/site-url)}
|
||||
:body (->json-bytes body)
|
||||
:throw-exceptions false}
|
||||
*debug* (assoc :debug true))
|
||||
response (post! url options)]
|
||||
(metabot-v3.context/log (:body response) :llm.log/llm->be)
|
||||
(log/debugf "Response from AI Proxy:\n%s" (u/pprint-to-str (select-keys response #{:body :status :headers})))
|
||||
(if (= (:status response) 200)
|
||||
(u/prog1 (mc/decode ::metabot-v3.client.schema/ai-proxy.response-v2
|
||||
(:body response)
|
||||
(mtx/transformer
|
||||
(mtx/json-transformer)
|
||||
{:name :api-response}
|
||||
(mtx/key-transformer {:decode u/->kebab-case-en})))
|
||||
(log/debugf "Response (decoded):\n%s" (u/pprint-to-str <>)))
|
||||
(throw (ex-info (format "Error: unexpected status code: %d %s" (:status response) (:reason-phrase response))
|
||||
{:request (assoc options :body body)
|
||||
:response response}))))
|
||||
(catch Throwable e
|
||||
(throw (ex-info (format "Error in request to AI Proxy: %s" (ex-message e)) {} e)))))
|
||||
|
||||
(mu/defn select-metric-request
|
||||
"Make a request to AI Service to select a metric."
|
||||
[metrics :- [:sequential ::metabot-v3.client.schema/metric]
|
||||
query :- :string]
|
||||
(try
|
||||
(let [url (metric-selection-endpoint-url)
|
||||
body {:metrics metrics
|
||||
:query query}
|
||||
options (build-request-options body)
|
||||
response (post! url options)]
|
||||
(if (= (:status response) 200)
|
||||
(u/prog1 (:body response)
|
||||
(log/debugf "Response:\n%s" (u/pprint-to-str <>)))
|
||||
(throw (ex-info (format "Error: unexpected status code: %d %s" (:status response) (:reason-phrase response))
|
||||
{:request (assoc options :body body)
|
||||
:response response}))))
|
||||
(catch Throwable e
|
||||
(throw (ex-info (format "Error in request to AI Service: %s" (ex-message e))
|
||||
{}
|
||||
e)))))
|
||||
|
||||
(mu/defn find-outliers-request
|
||||
"Make a request to AI Service to find outliers"
|
||||
[values :- [:sequential [:map [:dimension :any] [:value :any]]]]
|
||||
(try
|
||||
(let [url (find-outliers-endpoint-url)
|
||||
body {:values values}
|
||||
options (build-request-options body)
|
||||
response (post! url options)]
|
||||
(if (= (:status response) 200)
|
||||
(u/prog1 (:body response)
|
||||
(log/debugf "Response:\n%s" (u/pprint-to-str <>)))
|
||||
(throw (ex-info (format "Error: unexpected status code: %d %s" (:status response) (:reason-phrase response))
|
||||
{:request (assoc options :body body)
|
||||
:response response}))))
|
||||
(catch Throwable e
|
||||
(throw (ex-info (format "Error in request to AI service: %s" (ex-message e))
|
||||
{}
|
||||
e)))))
|
||||
|
||||
(mu/defn fix-sql
|
||||
"Ask the AI service to propose fixes a SQL query and a given error."
|
||||
[body :- [:map
|
||||
[:sql :string]
|
||||
[:dialect :keyword]
|
||||
[:error_message :string]
|
||||
[:schema_ddl {:optional true} :string]]]
|
||||
(let [url (fix-sql-endpoint)
|
||||
options (build-request-options body)
|
||||
response (post! url options)]
|
||||
(if (= (:status response) 200)
|
||||
(:body response)
|
||||
(throw (ex-info (format "Error in request to AI service: unexpected status code: %d %s"
|
||||
(:status response) (:reason-phrase response))
|
||||
{:request (assoc options :body body)
|
||||
:response response})))))
|
||||
|
||||
(mu/defn generate-sql
|
||||
"Ask the AI service to generate SQL given instructions, dialect, and an optional schema_ddl."
|
||||
[body :- [:map
|
||||
[:instructions :string]
|
||||
[:dialect :keyword]
|
||||
[:schema_ddl {:optional true} :string]]]
|
||||
(let [url (generate-sql-endpoint)
|
||||
options (build-request-options body)
|
||||
response (post! url options)]
|
||||
(if (= (:status response) 200)
|
||||
(:body response)
|
||||
(throw (ex-info (format "Error calling AI service for SQL generation: %d %s"
|
||||
(:status response) (:reason-phrase response))
|
||||
{:request (assoc options :body body)
|
||||
:response response})))))
|
||||
@@ -0,0 +1,41 @@
|
||||
(ns metabase-enterprise.metabot-v3.client.schema
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.tools.interface :as metabot-v3.tools.interface]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.malli.registry :as mr]))
|
||||
|
||||
(mr/def ::role
|
||||
[:enum
|
||||
{:encode/api-request u/->snake_case_en
|
||||
:decode/api-request keyword}
|
||||
:system :user :assistant :tool])
|
||||
|
||||
(mr/def ::message.tool-call
|
||||
[:map
|
||||
[:id {:description "Internal ID used by the LLM."} :string]
|
||||
[:name ::metabot-v3.tools.interface/metadata.name]
|
||||
[:arguments [:or :string [:map-of ::metabot-v3.tools.interface/metadata.parameter.name :any]]]])
|
||||
|
||||
(mr/def ::message
|
||||
[:map
|
||||
#_{:decode/api-response #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->kebab-case-en)
|
||||
:encode/api-request #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:role ::role]
|
||||
[:content {:optional true} [:maybe :string]]
|
||||
[:tool-calls {:optional true} [:maybe [:sequential ::message.tool-call]]]])
|
||||
|
||||
(mr/def ::messages
|
||||
[:sequential ::message])
|
||||
|
||||
(mr/def ::metric
|
||||
"A metric as sent to the AI Service"
|
||||
[:map
|
||||
[:id integer?]
|
||||
[:name :string]
|
||||
[:description [:maybe :string]]])
|
||||
|
||||
(mr/def ::ai-proxy.response-v2
|
||||
"Schema of the V2 AI agent response."
|
||||
[:map
|
||||
[:messages ::messages]
|
||||
[:state :map]])
|
||||
@@ -0,0 +1,56 @@
|
||||
(ns metabase-enterprise.metabot-v3.context
|
||||
(:require
|
||||
[clojure.java.io :as io]
|
||||
[metabase.config :as config]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.registry :as mr])
|
||||
(:import
|
||||
(java.time OffsetDateTime)
|
||||
(java.time.format DateTimeFormatter)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;; todo: remove this before shipping. This is quick and dirty
|
||||
(defn log
|
||||
"Log a payload. Direction should be `:llm.log/fe->be` or similar. This should not be shipping in this form. This is
|
||||
not a rolling log, or logging to the console. This pretty prints to the file `llm-payloads`. It is explicitly useful
|
||||
for dev work and not production.
|
||||
|
||||
Examples calls are ;; using doto
|
||||
(doto (request message context history)
|
||||
(metabot-v3.context/log :llm.log/be->fe))
|
||||
;; or just regularly
|
||||
(metabot-v3.context/log _body :llm.log/fe->be)"
|
||||
[payload direction]
|
||||
(when config/is-dev?
|
||||
(let [directions {:llm.log/fe->be "\"FE -----------------> BE\""
|
||||
:llm.log/be->llm "\"BE -----------------> LLM\""
|
||||
:llm.log/llm->be "\"LLM -----------------> BE\""
|
||||
:llm.log/be->fe "\"BE -----------------> FE\""}]
|
||||
(with-open [^java.io.BufferedWriter w (io/writer "llm-payloads" :append true)]
|
||||
(io/copy (directions direction (name direction)) w)
|
||||
(.newLine w)
|
||||
(let [payload' (json/encode payload {:pretty true})]
|
||||
(io/copy payload' w))
|
||||
(.newLine w)
|
||||
(.newLine w)))))
|
||||
|
||||
(mr/def ::context
|
||||
[:map-of :keyword :any])
|
||||
|
||||
(defn- set-user-time
|
||||
[context {:keys [date-format] :or {date-format DateTimeFormatter/ISO_INSTANT}}]
|
||||
(let [offset-time (or (some-> context :current_time_with_timezone OffsetDateTime/parse)
|
||||
(OffsetDateTime/now))]
|
||||
(-> context
|
||||
(dissoc :current_time_with_timezone)
|
||||
(assoc :current_user_time (.format ^DateTimeFormatter date-format offset-time)))))
|
||||
|
||||
(mu/defn create-context :- ::context
|
||||
"Create a tool context."
|
||||
([context :- ::context]
|
||||
(create-context context nil))
|
||||
([context :- ::context
|
||||
opts :- [:maybe [:map-of :keyword :any]]]
|
||||
(set-user-time context opts)))
|
||||
@@ -0,0 +1,13 @@
|
||||
(ns metabase-enterprise.metabot-v3.core
|
||||
"API namespace for the `metabase-enterprise.metabot-v3` module."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.api]
|
||||
[metabase-enterprise.metabot-v3.client]
|
||||
[potemkin :as p]))
|
||||
|
||||
(p/import-vars
|
||||
[metabase-enterprise.metabot-v3.api
|
||||
routes]
|
||||
[metabase-enterprise.metabot-v3.client
|
||||
fix-sql
|
||||
generate-sql])
|
||||
@@ -0,0 +1,253 @@
|
||||
(ns metabase-enterprise.metabot-v3.dummy-tools
|
||||
(:require
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription]
|
||||
[metabase-enterprise.metabot-v3.tools.filters]
|
||||
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.legacy-mbql.normalize :as mbql.normalize]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.lib.metadata.jvm :as lib.metadata.jvm]
|
||||
[metabase.lib.types.isa :as lib.types.isa]
|
||||
[metabase.lib.util :as lib.util]
|
||||
[metabase.models.field-values :as field-values]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn get-current-user
|
||||
"Get information about the current user."
|
||||
[]
|
||||
(if-let [{:keys [id email first_name last_name]}
|
||||
(or (some-> api/*current-user* deref)
|
||||
(t2/select-one [:model/User :id :email :first_name :last_name] api/*current-user-id*))]
|
||||
{:structured-output {:id id
|
||||
:type :user
|
||||
:name (str first_name " " last_name)
|
||||
:email-address email}}
|
||||
{:output "current user not found"}))
|
||||
|
||||
(defn get-dashboard-details
|
||||
"Get information about the dashboard with ID `dashboard-id`."
|
||||
[{:keys [dashboard-id]}]
|
||||
(if-let [dashboard (t2/select-one [:model/Dashboard :id :description :name] dashboard-id)]
|
||||
{:structured-output (assoc dashboard :type :dashboard)}
|
||||
{:output "dashboard not found"}))
|
||||
|
||||
(defn- add-field-values
|
||||
[cols]
|
||||
(if-let [field-ids (seq (keep :id cols))]
|
||||
(let [id->values (field-values/batched-get-latest-full-field-values field-ids)]
|
||||
(map #(m/assoc-some % :field-values (-> % :id id->values :values)) cols))
|
||||
cols))
|
||||
|
||||
(defn- add-table-reference
|
||||
[query col]
|
||||
(cond-> col
|
||||
(and (:fk-field-id col)
|
||||
(:table-id col))
|
||||
(assoc :table-reference (-> (lib/display-name query (lib.metadata/field query (:fk-field-id col)))
|
||||
lib.util/strip-id))))
|
||||
|
||||
(defn metric-details
|
||||
"Get metric details as returned by tools."
|
||||
([id]
|
||||
(when-let [card (metabot-v3.tools.u/get-card id)]
|
||||
(metric-details card (lib.metadata.jvm/application-database-metadata-provider (:database_id card)))))
|
||||
([card metadata-provider]
|
||||
(let [id (:id card)
|
||||
metric-query (lib/query metadata-provider (lib.metadata/card metadata-provider id))
|
||||
breakouts (lib/breakouts metric-query)
|
||||
base-query (lib/remove-all-breakouts metric-query)
|
||||
visible-cols (->> (lib/visible-columns base-query)
|
||||
(map #(add-table-reference base-query %)))
|
||||
filterable-cols (->> (lib/filterable-columns base-query)
|
||||
add-field-values
|
||||
(map #(add-table-reference base-query %)))
|
||||
default-temporal-breakout (->> breakouts
|
||||
(map #(lib/find-matching-column % visible-cols))
|
||||
(m/find-first lib.types.isa/temporal?))
|
||||
field-id-prefix (metabot-v3.tools.u/card-field-id-prefix id)]
|
||||
{:id id
|
||||
:type :metric
|
||||
:name (:name card)
|
||||
:description (:description card)
|
||||
:default_time_dimension_field_id (when default-temporal-breakout
|
||||
(-> (metabot-v3.tools.u/->result-column
|
||||
metric-query default-temporal-breakout visible-cols field-id-prefix)
|
||||
:field_id))
|
||||
:queryable_dimensions (mapv #(metabot-v3.tools.u/->result-column metric-query % visible-cols field-id-prefix)
|
||||
filterable-cols)})))
|
||||
|
||||
(comment
|
||||
(binding [api/*current-user-permissions-set* (delay #{"/"})]
|
||||
(metric-details 135))
|
||||
-)
|
||||
|
||||
(defn- convert-metric
|
||||
[db-metric metadata-provider]
|
||||
(-> db-metric (metric-details metadata-provider)
|
||||
(select-keys [:id :type :name :description :default_time_dimension_field_id])))
|
||||
|
||||
(declare ^:private table-details)
|
||||
|
||||
(defn- foreign-key-tables
|
||||
[metadata-provider fields]
|
||||
(when-let [target-field-ids (->> fields
|
||||
(into #{} (keep :fk-target-field-id))
|
||||
not-empty)]
|
||||
(let [table-ids (t2/select-fn-set :table_id :model/Field :id [:in target-field-ids])]
|
||||
(lib.metadata/bulk-metadata metadata-provider :metadata/table table-ids)
|
||||
(->> table-ids
|
||||
(into [] (keep #(table-details % {:include-foreign-key-tables? false
|
||||
:metadata-provider metadata-provider})))
|
||||
not-empty))))
|
||||
|
||||
(defn- table-details
|
||||
[id {:keys [include-foreign-key-tables? metadata-provider]}]
|
||||
(when-let [base (if metadata-provider
|
||||
(lib.metadata/table metadata-provider id)
|
||||
(metabot-v3.tools.u/get-table id :db_id :description))]
|
||||
(let [mp (or metadata-provider
|
||||
(lib.metadata.jvm/application-database-metadata-provider (:db_id base)))
|
||||
table-query (lib/query mp (lib.metadata/table mp id))
|
||||
cols (-> (lib/returned-columns table-query)
|
||||
add-field-values)
|
||||
field-id-prefix (metabot-v3.tools.u/table-field-id-prefix id)]
|
||||
(-> {:id id
|
||||
:type :table
|
||||
:fields (into [] (map-indexed #(metabot-v3.tools.u/->result-column table-query %2 %1 field-id-prefix)) cols)
|
||||
:name (lib/display-name table-query)}
|
||||
(m/assoc-some :description (:description base)
|
||||
:metrics (not-empty (mapv #(convert-metric % mp) (lib/available-metrics table-query)))
|
||||
:queryable-foreign-key-tables (when include-foreign-key-tables?
|
||||
(foreign-key-tables mp cols)))))))
|
||||
|
||||
(defn- card-details
|
||||
"Get details for a card."
|
||||
([id]
|
||||
(when-let [card (metabot-v3.tools.u/get-card id)]
|
||||
(card-details card (lib.metadata.jvm/application-database-metadata-provider (:database_id card)))))
|
||||
([base metadata-provider]
|
||||
(let [id (:id base)
|
||||
card-metadata (lib.metadata/card metadata-provider id)
|
||||
dataset-query (get card-metadata :dataset-query)
|
||||
;; pivot questions have strange result-columns so we work with the dataset-query
|
||||
card-type (:type base)
|
||||
card-query (lib/query metadata-provider (if (and (#{:question} card-type)
|
||||
(#{:pivot} (:display base))
|
||||
(#{:query} (:type dataset-query)))
|
||||
dataset-query
|
||||
card-metadata))
|
||||
cols (-> (lib/returned-columns card-query)
|
||||
add-field-values)
|
||||
field-id-prefix (metabot-v3.tools.u/card-field-id-prefix id)]
|
||||
(-> {:id id
|
||||
:type card-type
|
||||
:fields (into [] (map-indexed #(metabot-v3.tools.u/->result-column card-query %2 %1 field-id-prefix)) cols)
|
||||
:name (lib/display-name card-query)
|
||||
:queryable-foreign-key-tables (vec (foreign-key-tables metadata-provider cols))}
|
||||
(m/assoc-some :description (:description base)
|
||||
:metrics (not-empty (mapv #(convert-metric % metadata-provider) (lib/available-metrics card-query))))))))
|
||||
|
||||
(defn- cards-details
|
||||
[card-type database-id cards]
|
||||
(let [mp (lib.metadata.jvm/application-database-metadata-provider database-id)
|
||||
detail-fn (case card-type
|
||||
:metric metric-details
|
||||
:model card-details)]
|
||||
(lib.metadata/bulk-metadata mp :metadata/card (map :id cards))
|
||||
(map #(-> % (detail-fn mp) (assoc :type card-type)) cards)))
|
||||
|
||||
(defn answer-sources
|
||||
"Get the details metrics and models from the collection with name `collection-name`."
|
||||
[metabot-collection]
|
||||
(let [metrics-and-models (metabot-v3.tools.u/get-metrics-and-models metabot-collection)
|
||||
{metrics :metric, models :model}
|
||||
(->> (for [[[card-type database-id] cards] (group-by (juxt :type :database_id) metrics-and-models)
|
||||
detail (cards-details card-type database-id cards)]
|
||||
detail)
|
||||
(group-by :type))]
|
||||
{:structured-output {:metrics (vec metrics)
|
||||
:models (vec models)}}))
|
||||
|
||||
(comment
|
||||
(binding [api/*current-user-permissions-set* (delay #{"/"})
|
||||
api/*current-user-id* 2
|
||||
api/*is-superuser?* true]
|
||||
#_(table-details 30 nil)
|
||||
(card-details 110)
|
||||
#_(metric-details 108)
|
||||
#_(answer-sources "__METABOT__"))
|
||||
-)
|
||||
|
||||
(defn get-table-details
|
||||
"Get information about the table or model with ID `table-id`.
|
||||
`table-id` is string either encoding an integer that is the ID of a table
|
||||
or a string containing the prefix card__ and the ID of a model (card) as suffix.
|
||||
Alternatively, `table-id` can be an integer ID of a table.
|
||||
`model-id` is an integer ID of a model (card). Exactly one of `table-id` or `model-id`
|
||||
should be supplied."
|
||||
[{:keys [model-id table-id]}]
|
||||
(let [details (cond
|
||||
(int? model-id) (card-details model-id)
|
||||
(int? table-id) (table-details table-id {:include-foreign-key-tables? true})
|
||||
(string? table-id) (if-let [[_ card-id] (re-matches #"card__(\d+)" table-id)]
|
||||
(card-details (parse-long card-id))
|
||||
(if (re-matches #"\d+" table-id)
|
||||
(table-details (parse-long table-id) {:include-foreign-key-tables? true})
|
||||
"invalid table_id"))
|
||||
:else "invalid arguments")]
|
||||
(if (map? details)
|
||||
{:structured-output details}
|
||||
{:output (or details "table not found")})))
|
||||
|
||||
(comment
|
||||
(binding [api/*current-user-permissions-set* (delay #{"/"})
|
||||
api/*current-user-id* 2
|
||||
api/*is-superuser?* true]
|
||||
(let [id #_"card__137" #_"card__136" #_27 "27"]
|
||||
(get-table-details {:table-id id})))
|
||||
-)
|
||||
|
||||
(defn get-metric-details
|
||||
"Get information about the metric with ID `metric-id`."
|
||||
[{:keys [metric-id]}]
|
||||
(let [details (if (int? metric-id)
|
||||
(metric-details metric-id)
|
||||
"invalid metric_id")]
|
||||
(if (map? details)
|
||||
{:structured-output details}
|
||||
{:output (or details "metric not found")})))
|
||||
|
||||
(defn get-report-details
|
||||
"Get information about the report (card) with ID `report-id`."
|
||||
[{:keys [report-id]}]
|
||||
(let [details (if (int? report-id)
|
||||
(let [details (card-details report-id)]
|
||||
(some-> details
|
||||
(select-keys [:id :type :description :name])
|
||||
(assoc :result_columns (:fields details))))
|
||||
"invalid report_id")]
|
||||
(if (map? details)
|
||||
{:structured-output details}
|
||||
{:output (or details "report not found")})))
|
||||
|
||||
(defn- execute-query
|
||||
[query-id legacy-query]
|
||||
(let [legacy-query (mbql.normalize/normalize legacy-query)
|
||||
field-id-prefix (metabot-v3.tools.u/query-field-id-prefix query-id)
|
||||
mp (lib.metadata.jvm/application-database-metadata-provider (:database legacy-query))
|
||||
query (lib/query mp legacy-query)
|
||||
returned-cols (lib/returned-columns query)]
|
||||
{:type :query
|
||||
:query_id query-id
|
||||
:query legacy-query
|
||||
:result_columns (into []
|
||||
(map-indexed #(metabot-v3.tools.u/->result-column query %2 %1 field-id-prefix))
|
||||
returned-cols)}))
|
||||
|
||||
(defn get-query-details
|
||||
"Get the details of a (legacy) query."
|
||||
[{:keys [query]}]
|
||||
{:structured-output (execute-query (u/generate-nano-id) query)})
|
||||
@@ -0,0 +1,45 @@
|
||||
(ns metabase-enterprise.metabot-v3.envelope
|
||||
"(Because 'context' was already taken.)
|
||||
|
||||
The 'envelope' holds the context for our conversation with the LLM. Specifically, it bundles up the history, and the
|
||||
context into one convenient location, with a simple API for querying and modifying.")
|
||||
|
||||
(defn history
|
||||
"Gets the history from the envelope."
|
||||
[e]
|
||||
(:history e))
|
||||
|
||||
(defn state
|
||||
"Gets the state from the envelope."
|
||||
[e]
|
||||
(:state e))
|
||||
|
||||
(defn- message->reactions
|
||||
[msg]
|
||||
(let [message-reaction (when-let [message (not-empty (:content msg))]
|
||||
{:type :metabot.reaction/message
|
||||
:repl/message-color :green
|
||||
:repl/message-emoji "🤖"
|
||||
:message message})
|
||||
navigate-reaction (when-let [nav-path (:navigate-to msg)]
|
||||
{:type :metabot.reaction/redirect
|
||||
:url nav-path})]
|
||||
(filter some? [message-reaction navigate-reaction])))
|
||||
|
||||
(defn reactions
|
||||
"Gets the reactions from the LLM."
|
||||
[messages]
|
||||
(->> messages
|
||||
(filter #(= (:role %) :assistant))
|
||||
(mapcat message->reactions)
|
||||
(into [])))
|
||||
|
||||
(defn user-message
|
||||
"Create a user message structure with `msg` as content."
|
||||
[msg]
|
||||
{:role :user :content msg})
|
||||
|
||||
(defn add-message
|
||||
"Given a raw message, add it to the envelope."
|
||||
[e msg]
|
||||
(update e :history conj msg))
|
||||
@@ -0,0 +1,85 @@
|
||||
(ns metabase-enterprise.metabot-v3.reactions
|
||||
"Schemas for various reactions.
|
||||
|
||||
All reactions must have a `:type` with the namespace `metabot.reaction`, but declaring a schema for it is optional.
|
||||
If you want to declare a schema, you can use [[defreaction]]."
|
||||
(:require
|
||||
[malli.core :as mc]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.registry :as mr]))
|
||||
|
||||
(mr/def ::reaction-type
|
||||
"A MetaBot v3 reaction type keyword e.g. `:metabot.reaction/message`"
|
||||
[:fn
|
||||
{:error/message "Reaction type must be a kebab-case keyword starting whose namespace is `metabot.reaction`."}
|
||||
(fn [x]
|
||||
(and (qualified-keyword? x)
|
||||
(= (u/->kebab-case-en (u/qualified-name x)) (u/qualified-name x))
|
||||
(= (namespace x) "metabot.reaction")))])
|
||||
|
||||
;;; TODO -- need Kondo hook that registers the keyword
|
||||
(mu/defn defreaction
|
||||
"Declare a new reaction type and the schema for it.
|
||||
|
||||
The schema name matches the reaction-name."
|
||||
[reaction-name :- ::reaction-type
|
||||
schema]
|
||||
(derive reaction-name :metabot/registered-action)
|
||||
(mr/register! reaction-name schema))
|
||||
|
||||
(defn- known-reaction-types
|
||||
"Reaction types with schemas that were declared with [[defreaction]]."
|
||||
[]
|
||||
(descendants :metabot/registered-action))
|
||||
|
||||
;;; this is just a placeholder so LSP can register the place it lives for jump-to-definition functionality. Actual
|
||||
;;; schema gets created below by [[reaction-schema]] and [[update-reaction-schema!]]
|
||||
(mr/def ::reaction any?)
|
||||
|
||||
(defn- reaction-schema
|
||||
"Build the schema for `::reaction`."
|
||||
[]
|
||||
[:and
|
||||
[:map
|
||||
[:type ::reaction-type]]
|
||||
(into [:multi
|
||||
{:dispatch :type}
|
||||
[::mc/default :any]]
|
||||
(map (fn [reaction-type]
|
||||
[reaction-type reaction-type]))
|
||||
(known-reaction-types))])
|
||||
|
||||
(defn- update-reaction-schema! []
|
||||
(log/debug "Updating reaction schema")
|
||||
(mr/register! :metabase-enterprise.metabot-v3.reactions/reaction (reaction-schema)))
|
||||
|
||||
(update-reaction-schema!)
|
||||
|
||||
;;; when the descendants of `:metabot/registered-action` change update the `::reaction` schema
|
||||
(add-watch #'clojure.core/global-hierarchy
|
||||
::update-reaction-schema
|
||||
(fn [_key _ref old-hierarchy new-hierarchy]
|
||||
(when-not (= (descendants old-hierarchy :metabot/registered-action)
|
||||
(descendants new-hierarchy :metabot/registered-action))
|
||||
(update-reaction-schema!))))
|
||||
|
||||
;;;;
|
||||
;;;; Reaction definitions
|
||||
;;;;
|
||||
|
||||
(defreaction :metabot.reaction/message
|
||||
[:map
|
||||
[:type [:= :metabot.reaction/message]]
|
||||
[:message :string]])
|
||||
|
||||
(defreaction :metabot.reaction/run-query
|
||||
[:map
|
||||
[:type [:= :metabot.reaction/run-query]]
|
||||
[:dataset_query :map]])
|
||||
|
||||
(defreaction :metabot.reaction/redirect
|
||||
[:map
|
||||
[:type [:= :metabot.reaction/redirect]]
|
||||
[:url :string]])
|
||||
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.reactions :as metabot-v3.reactions]
|
||||
[metabase.db :as mdb]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defmulti ^:private handle-reaction
|
||||
{:arglists '([reaction])}
|
||||
:type)
|
||||
|
||||
(defmethod handle-reaction :metabot.reaction/message
|
||||
[{:keys [message], repl-emoji :repl/emoji, repl-message-color :repl/message-color, :as _reaction}]
|
||||
(let [message (cond->> message
|
||||
repl-emoji (str repl-emoji " ")
|
||||
repl-message-color (u/colorize repl-message-color))]
|
||||
#_{:clj-kondo/ignore [:discouraged-var]}
|
||||
(println message)))
|
||||
|
||||
(defmethod handle-reaction :default
|
||||
[reaction]
|
||||
#_{:clj-kondo/ignore [:discouraged-var]}
|
||||
(println (u/format-color :magenta "<REACTION>\n%s" (u/pprint-to-str reaction))))
|
||||
|
||||
(mu/defn- handle-reactions
|
||||
[reactions :- [:sequential ::metabot-v3.reactions/reaction]]
|
||||
(doseq [reaction reactions]
|
||||
(handle-reaction reaction)))
|
||||
|
||||
(defn user-repl
|
||||
"REPL for interacting with MetaBot."
|
||||
([history context conversation-id] (user-repl history context conversation-id {}))
|
||||
([history context conversation-id state]
|
||||
(when-let [{history' :history, state' :state} (try
|
||||
(when-let [input (try
|
||||
#_{:clj-kondo/ignore [:discouraged-var]}
|
||||
(print "\n> ")
|
||||
(flush)
|
||||
(read-line)
|
||||
(catch Throwable _))]
|
||||
#_{:clj-kondo/ignore [:discouraged-var]}
|
||||
(println "🗨 " (u/colorize :blue input))
|
||||
(when (and (not (#{"quit" "exit" "bye" "goodbye" "\\q"} input))
|
||||
(not (str/blank? input)))
|
||||
(let [result (api/request input context history conversation-id state)]
|
||||
(handle-reactions (:reactions result))
|
||||
result)))
|
||||
(catch Throwable e
|
||||
#_{:clj-kondo/ignore [:discouraged-var]}
|
||||
(println (u/pprint-to-str :red e))
|
||||
history))]
|
||||
(recur history' context conversation-id state'))))
|
||||
|
||||
(defn user-repl-cli
|
||||
"CLI entrypoint for using the MetaBot REPL.
|
||||
|
||||
clj -X:ee:metabot-v3/repl"
|
||||
[_options]
|
||||
(mdb/setup-db! :create-sample-content? false)
|
||||
#_{:clj-kondo/ignore [:discouraged-var]}
|
||||
(println "Starting MetaBot REPL... 🤖")
|
||||
(user-repl [] {} (str (random-uuid)))
|
||||
(System/exit 0))
|
||||
|
||||
(comment
|
||||
(require 'metabase.api.common)
|
||||
(binding [metabase.api.common/*current-user-permissions-set* (delay #{"/"})
|
||||
metabase.api.common/*current-user-id* 2
|
||||
metabase.api.common/*is-superuser?* true]
|
||||
(user-repl [] {} (str (random-uuid))))
|
||||
|
||||
(defn test-context
|
||||
[]
|
||||
(let [test-query {:dataset_query {:database 1, :type "query", :query {:source-table 27}},
|
||||
:display "table",
|
||||
:visualization_settings {},
|
||||
:type "question"}]
|
||||
{:user_is_viewing [{:type :dashboard
|
||||
:id 5
|
||||
:parameters []
|
||||
:is-embedded false}
|
||||
{:type :table
|
||||
:id 27}
|
||||
{:type :model
|
||||
:id 18}
|
||||
{:type :metric
|
||||
:id 135}
|
||||
{:type :report
|
||||
:id 43}
|
||||
{:type :adhoc
|
||||
:query test-query}]}))
|
||||
|
||||
(defn- user-repl-with-context [context]
|
||||
(user-repl [] context (str (random-uuid))))
|
||||
|
||||
#_{:clj-kondo/ignore [:metabase/modules]}
|
||||
(require '[metabase.test :as mt])
|
||||
|
||||
;; Discounted orders only
|
||||
(mt/with-test-user :crowberto
|
||||
(user-repl-with-context
|
||||
{:user_is_viewing [{:type :table :ref 5}]}))
|
||||
|
||||
;; subscribe me to this dashboard every week on monday
|
||||
(mt/with-test-user :crowberto
|
||||
(user-repl-with-context
|
||||
{:user_is_viewing [{:type :dashboard :ref 11}]}))
|
||||
|
||||
;; filter on the last 6 months
|
||||
;; actually, only filter for current year
|
||||
(mt/with-test-user :crowberto
|
||||
(user-repl-with-context
|
||||
{:user_is_viewing [{:type :report :ref 111}]}))
|
||||
|
||||
;; any outliers?
|
||||
;; breakout by plan
|
||||
(mt/with-test-user :crowberto
|
||||
(user-repl-with-context
|
||||
{:user_is_viewing [{:type :metric :id 122}]})))
|
||||
@@ -0,0 +1,689 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.api
|
||||
"Code for handling tool requests from the AI service."
|
||||
(:require
|
||||
[buddy.core.hash :as buddy-hash]
|
||||
[buddy.sign.jwt :as jwt]
|
||||
[clj-time.core :as time]
|
||||
[malli.core :as mc]
|
||||
[malli.transform :as mtx]
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
|
||||
[metabase-enterprise.metabot-v3.context :as metabot-v3.context]
|
||||
[metabase-enterprise.metabot-v3.dummy-tools :as metabot-v3.dummy-tools]
|
||||
[metabase-enterprise.metabot-v3.envelope :as envelope]
|
||||
[metabase-enterprise.metabot-v3.reactions]
|
||||
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription :as metabot-v3.tools.create-dashboard-subscription]
|
||||
[metabase-enterprise.metabot-v3.tools.filters :as metabot-v3.tools.filters]
|
||||
[metabase-enterprise.metabot-v3.tools.find-metric :as metabot-v3.tools.find-metric]
|
||||
[metabase-enterprise.metabot-v3.tools.find-outliers :as metabot-v3.tools.find-outliers]
|
||||
[metabase-enterprise.metabot-v3.tools.generate-insights :as metabot-v3.tools.generate-insights]
|
||||
[metabase-enterprise.metabot-v3.util :as metabot-v3.u]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :as api.routes.common]
|
||||
[metabase.legacy-mbql.schema :as mbql.s]
|
||||
[metabase.lib.schema.temporal-bucketing :as lib.schema.temporal-bucketing]
|
||||
[metabase.models.setting :as setting :refer [defsetting]]
|
||||
[metabase.request.core :as request]
|
||||
[metabase.util.i18n :refer [deferred-tru]]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[metabase.util.malli.schema :as ms]))
|
||||
|
||||
(defsetting site-uuid-for-metabot-tools
|
||||
"UUID that we use for encrypting JWT tokens given to the AI service to make callbacks with."
|
||||
:encryption :when-encryption-key-set
|
||||
:visibility :internal
|
||||
:sensitive? true
|
||||
:feature :metabot-v3
|
||||
:doc false
|
||||
:export? false
|
||||
:base setting/uuid-nonce-base)
|
||||
|
||||
(defsetting metabot-ai-service-token-ttl
|
||||
(deferred-tru "The number of seconds the tokens passed to AI service should be valid.")
|
||||
:type :integer
|
||||
:visibility :settings-manager
|
||||
:default 180
|
||||
:feature :metabot-v3
|
||||
:doc false
|
||||
:export? true
|
||||
:audit :never)
|
||||
|
||||
(defn- get-ai-service-token
|
||||
[user-id]
|
||||
(let [secret (buddy-hash/sha256 (site-uuid-for-metabot-tools))
|
||||
claims {:user user-id, :exp (time/plus (time/now) (time/seconds (metabot-ai-service-token-ttl)))}]
|
||||
(jwt/encrypt claims secret {:alg :dir, :enc :a128cbc-hs256})))
|
||||
|
||||
(defn- decode-ai-service-token
|
||||
[token]
|
||||
(try
|
||||
(when (string? token)
|
||||
(-> token
|
||||
(jwt/decrypt (buddy-hash/sha256 (site-uuid-for-metabot-tools)))
|
||||
:user))
|
||||
(catch Exception e
|
||||
(log/error e "Bad AI service token")
|
||||
nil)))
|
||||
|
||||
(defn handle-envelope
|
||||
"Executes the AI loop in the context of a new session. Returns the response of the AI service."
|
||||
[e]
|
||||
(let [session-id (get-ai-service-token api/*current-user-id*)]
|
||||
(try
|
||||
(metabot-v3.client/request (assoc e :session-id session-id))
|
||||
(catch Exception ex
|
||||
(let [d (ex-data ex)]
|
||||
(if-let [assistant-message (:assistant-message d)]
|
||||
(envelope/add-message (or (:envelope d) e)
|
||||
{:role :assistant
|
||||
:content assistant-message})
|
||||
(throw ex)))))))
|
||||
|
||||
(mr/def ::bucket
|
||||
(into [:enum {:error/message "Valid bucket"}]
|
||||
(map name)
|
||||
lib.schema.temporal-bucketing/ordered-datetime-bucketing-units))
|
||||
|
||||
(mr/def ::existence-filter
|
||||
[:map
|
||||
[:field_id :string]
|
||||
[:operation [:enum
|
||||
"is-null" "is-not-null"
|
||||
"string-is-empty" "string-is-not-empty"
|
||||
"is-true" "is-false"]]])
|
||||
|
||||
(mr/def ::temporal-extraction-filter
|
||||
[:map
|
||||
[:field_id :string]
|
||||
[:operation [:enum
|
||||
"year-equals" "year-not-equals"
|
||||
"quarter-equals" "quarter-not-equals"
|
||||
"month-equals" "month-not-equals"
|
||||
"day-of-week-equals" "day-of-week-not-equals"
|
||||
"hour-equals" "hour-not-equals"
|
||||
"minute-equals" "minute-not-equals"
|
||||
"second-equals" "second-not-equals"]]
|
||||
[:value :int]])
|
||||
|
||||
(mr/def ::disjunctive-temporal-extraction-filter
|
||||
[:map
|
||||
[:field_id :string]
|
||||
[:operation [:enum
|
||||
"year-equals" "year-not-equals"
|
||||
"quarter-equals" "quarter-not-equals"
|
||||
"month-equals" "month-not-equals"
|
||||
"day-of-week-equals" "day-of-week-not-equals"
|
||||
"hour-equals" "hour-not-equals"
|
||||
"minute-equals" "minute-not-equals"
|
||||
"second-equals" "second-not-equals"]]
|
||||
[:values [:sequential :int]]])
|
||||
|
||||
(mr/def ::temporal-filter
|
||||
[:map
|
||||
[:field_id :string]
|
||||
[:bucket {:optional true} ::bucket]
|
||||
[:operation [:enum
|
||||
"equals" "not-equals"
|
||||
"greater-than" "greater-than-or-equal"
|
||||
"less-than" "less-than-or-equal"
|
||||
"date-equals" "date-not-equals"
|
||||
"date-before" "date-on-or-before"
|
||||
"date-after" "date-on-or-after"]]
|
||||
[:value [:or :string :int]]])
|
||||
|
||||
(mr/def ::disjunctive-temporal-filter
|
||||
[:map
|
||||
[:field_id :string]
|
||||
[:bucket {:optional true} ::bucket]
|
||||
[:operation [:enum
|
||||
"equals" "not-equals"
|
||||
"greater-than" "greater-than-or-equal"
|
||||
"less-than" "less-than-or-equal"]]
|
||||
[:values [:sequential [:or :string :int]]]])
|
||||
|
||||
(mr/def ::string-filter
|
||||
[:map
|
||||
[:field_id :string]
|
||||
[:operation [:enum
|
||||
"equals" "not-equals"
|
||||
"string-equals" "string-not-equals"
|
||||
"string-contains" "string-not-contains"
|
||||
"string-starts-with" "string-ends-with"]]
|
||||
[:value :string]])
|
||||
|
||||
(mr/def ::disjunctive-string-date-filter
|
||||
[:map
|
||||
[:field_id :string]
|
||||
[:operation [:enum
|
||||
"equals" "not-equals"
|
||||
"string-contains" "string-not-contains"
|
||||
"string-starts-with" "string-ends-with"]]
|
||||
[:values [:sequential :string]]])
|
||||
|
||||
(mr/def ::numeric-filter
|
||||
[:map
|
||||
[:field_id :string]
|
||||
[:operation [:enum
|
||||
"equals" "not-equals"
|
||||
"greater-than" "greater-than-or-equal"
|
||||
"less-than" "less-than-or-equal"
|
||||
"number-equals" "number-not-equals"
|
||||
"number-greater-than" "number-greater-than-or-equal"
|
||||
"number-less-than" "number-less-than-or-equal"]]
|
||||
[:value [:or :int :double]]])
|
||||
|
||||
(mr/def ::disjunctive-numeric-filter
|
||||
[:map
|
||||
[:field_id :string]
|
||||
[:operation [:enum
|
||||
"equals" "not-equals"
|
||||
"number-equals" "number-not-equals"]]
|
||||
[:values [:sequential [:or :int :double]]]])
|
||||
|
||||
(mr/def ::filter
|
||||
[:or
|
||||
::existence-filter
|
||||
::temporal-extraction-filter ::disjunctive-temporal-extraction-filter
|
||||
::temporal-filter ::disjunctive-temporal-filter
|
||||
::string-filter ::disjunctive-string-date-filter
|
||||
::numeric-filter ::disjunctive-numeric-filter])
|
||||
|
||||
(mr/def ::group-by
|
||||
[:map
|
||||
[:field_id :string]
|
||||
[:field_granularity {:optional true} [:maybe [:enum "day" "week" "month" "quarter" "year"]]]])
|
||||
|
||||
(mr/def ::query-metric-arguments
|
||||
[:map
|
||||
{:encode/tool-api-request #(#_metabot-v3.u/recursive-update-keys update-keys % metabot-v3.u/safe->kebab-case-en)}
|
||||
[:metric_id :int]
|
||||
[:filters {:optional true} [:maybe [:sequential ::filter]]]
|
||||
[:group_by {:optional true} [:maybe [:sequential ::group-by]]]])
|
||||
|
||||
(mr/def ::aggregation
|
||||
[:map
|
||||
[:field_id :string]
|
||||
[:bucket {:optional true} ::bucket]
|
||||
[:function [:enum "avg" "count" "count-distinct" "max" "min" "sum"]]])
|
||||
|
||||
(mr/def ::field
|
||||
[:map
|
||||
[:field_id :string]
|
||||
[:bucket {:optional true} ::bucket]])
|
||||
|
||||
(mr/def ::query-model-arguments
|
||||
[:map
|
||||
{:encode/tool-api-request #(#_metabot-v3.u/recursive-update-keys update-keys % metabot-v3.u/safe->kebab-case-en)}
|
||||
[:model_id :int]
|
||||
[:fields {:optional true} [:maybe [:sequential {:min 1} ::field]]]
|
||||
[:filters {:optional true} [:maybe [:sequential ::filter]]]
|
||||
[:aggregations {:optional true} [:maybe [:sequential ::aggregation]]]
|
||||
[:group_by {:optional true} [:maybe [:sequential ::group-by]]]])
|
||||
|
||||
(mr/def ::field-type
|
||||
[:enum "boolean" "date" "datetime" "time" "number" "string"])
|
||||
|
||||
(mr/def ::column
|
||||
[:map
|
||||
[:field_id :string]
|
||||
[:name :string]
|
||||
[:type [:maybe ::field-type]]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:semantic_type {:optional true} [:maybe :string]]
|
||||
[:field_values {:optional true} [:or
|
||||
[:sequential :boolean]
|
||||
[:sequential :double]
|
||||
[:sequential :int]
|
||||
[:sequential :string]]]])
|
||||
|
||||
(mr/def ::columns
|
||||
[:sequential ::column])
|
||||
|
||||
(mr/def ::filtering-result
|
||||
[:or
|
||||
[:map
|
||||
{:decode/tool-api-response #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:structured_output
|
||||
[:map
|
||||
[:type [:= :query]]
|
||||
[:query_id :string]
|
||||
[:query mbql.s/Query]
|
||||
[:result_columns [:sequential ::column]]]]]
|
||||
[:map
|
||||
[:output :string]]])
|
||||
|
||||
(mr/def ::tool-request [:map [:conversation_id ms/UUIDString]])
|
||||
|
||||
(mr/def ::subscription-schedule
|
||||
(let [days ["sunday" "monday" "tuesday" "wednesday" "thursday" "friday" "saturday"]]
|
||||
[:or
|
||||
[:map
|
||||
[:frequency [:= "hourly"]]]
|
||||
[:map
|
||||
[:frequency [:= "daily"]]
|
||||
[:hour :int]]
|
||||
[:map
|
||||
[:frequency [:= "weekly"]]
|
||||
[:hour :int]
|
||||
[:day_of_week (into [:enum] days)]]
|
||||
[:map
|
||||
[:frequency [:= "monthly"]]
|
||||
[:hour :int]
|
||||
[:day_of_month (into [:enum "first-calendar-day" "middle-of-month" "last-calendar-day"]
|
||||
(for [fl ["first" "last"]
|
||||
day days]
|
||||
(str fl "-" day)))]]]))
|
||||
|
||||
(mr/def ::create-dashboard-subscription-arguments
|
||||
[:map
|
||||
{:encode/tool-api-request #(#_metabot-v3.u/recursive-update-keys update-keys % metabot-v3.u/safe->kebab-case-en)}
|
||||
[:dashboard_id :int]
|
||||
[:email :string]
|
||||
[:schedule ::subscription-schedule]])
|
||||
|
||||
(mr/def ::filter-records-arguments
|
||||
[:map
|
||||
{:encode/tool-api-request #(#_metabot-v3.u/recursive-update-keys update-keys % metabot-v3.u/safe->kebab-case-en)}
|
||||
[:data_source [:or
|
||||
[:map
|
||||
[:query [:map
|
||||
[:database :int]]]
|
||||
[:query_id {:optional true} :string]]
|
||||
[:map [:report_id :int]]
|
||||
[:map [:table_id :string]]]]
|
||||
[:filters [:sequential ::filter]]])
|
||||
|
||||
(mr/def ::basic-metric
|
||||
[:map
|
||||
[:id :int]
|
||||
[:type [:= :metric]]
|
||||
[:name :string]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:default_time_dimension_field_id {:optional true} [:maybe :string]]])
|
||||
|
||||
(mr/def ::full-metric
|
||||
[:merge ::basic-metric [:map [:queryable_dimensions ::columns]]])
|
||||
|
||||
(mr/def ::find-metric-result
|
||||
[:or
|
||||
[:map
|
||||
{:decode/tool-api-response #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:structured_output [:map
|
||||
[:id :int]
|
||||
[:type [:= :metric]]
|
||||
[:name :string]
|
||||
[:description [:maybe :string]]
|
||||
[:default_time_dimension_field_id [:maybe ::column]]
|
||||
[:queryable_dimensions [:sequential ::column]]]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::find-outliers-arguments
|
||||
[:map
|
||||
{:encode/tool-api-request #(#_metabot-v3.u/recursive-update-keys update-keys % metabot-v3.u/safe->kebab-case-en)}
|
||||
[:data_source [:or
|
||||
[:map
|
||||
[:query [:map
|
||||
[:database :int]]]
|
||||
[:query_id {:optional true} :string]
|
||||
[:result_field_id :string]]
|
||||
[:map
|
||||
[:metric_id :int]]
|
||||
[:map
|
||||
[:report_id :int]
|
||||
[:result_field_id :string]]
|
||||
[:map
|
||||
[:table_id :string]
|
||||
[:result_field_id :string]]]]])
|
||||
|
||||
(mr/def ::find-outliers-result
|
||||
[:or
|
||||
[:map
|
||||
{:decode/tool-api-response #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:structured_output [:sequential
|
||||
[:map
|
||||
[:dimension :any]
|
||||
[:value [:or :int :double]]]]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::generate-insights-arguments
|
||||
[:map
|
||||
{:encode/tool-api-request #(#_metabot-v3.u/recursive-update-keys update-keys % metabot-v3.u/safe->kebab-case-en)}
|
||||
[:for [:or
|
||||
[:map [:metric_id :int]]
|
||||
[:map [:table_id :string]]
|
||||
[:map [:report_id :int]]
|
||||
[:map [:query :map]]]]])
|
||||
|
||||
(mr/def ::get-current-user-result
|
||||
[:or
|
||||
[:map
|
||||
{:decode/tool-api-response #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:structured_output [:map
|
||||
[:id :int]
|
||||
[:type [:= :user]]
|
||||
[:name :string]
|
||||
[:email_address :string]]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::get-dashboard-details-result
|
||||
[:or
|
||||
[:map
|
||||
{:decode/tool-api-response #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:structured_output [:map
|
||||
[:id :int]
|
||||
[:type [:= :dashboard]]
|
||||
[:name :string]
|
||||
[:description {:optional true} :string]]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::get-metric-details-result
|
||||
[:or
|
||||
[:map
|
||||
{:decode/tool-api-response #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:structured_output ::full-metric]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::get-query-details-result
|
||||
[:or
|
||||
[:map
|
||||
{:decode/tool-api-response
|
||||
(fn [result]
|
||||
(let [query (:query ((some-fn :structured-output :structured_output) result))]
|
||||
(-> result
|
||||
(m/dissoc-in [:structured-output :query] [:structured_output :query])
|
||||
(metabot-v3.u/recursive-update-keys metabot-v3.u/safe->snake_case_en)
|
||||
(assoc-in [:structured_output :query] query))))}
|
||||
[:structured_output [:map
|
||||
[:type [:= :query]]
|
||||
[:query_id :string]
|
||||
[:query :map]
|
||||
[:result_columns ::columns]]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::get-report-details-result
|
||||
[:or
|
||||
[:map
|
||||
{:decode/tool-api-response #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:structured_output [:map
|
||||
[:id :int]
|
||||
[:type [:= :question]]
|
||||
[:name :string]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:result_columns ::columns]]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::get-table-details-arguments
|
||||
[:and
|
||||
[:map
|
||||
{:encode/tool-api-request #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->kebab-case-en)}
|
||||
[:model_id {:optional true} :int]
|
||||
[:table_id {:optional true} [:or :int :string]]]
|
||||
[:fn {:error/message "Exactly one of model_id and table_id required"}
|
||||
#(= (count (select-keys % [:model_id :table_id])) 1)]])
|
||||
|
||||
(mr/def ::basic-table
|
||||
[:map
|
||||
[:id :int]
|
||||
[:type [:enum :model :table]]
|
||||
[:name :string]
|
||||
[:fields ::columns]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:metrics {:optional true} [:sequential ::basic-metric]]])
|
||||
|
||||
(mr/def ::full-table
|
||||
[:merge
|
||||
::basic-table
|
||||
[:map [:queryable_foreign_key_tables {:optional true} [:sequential ::basic-table]]]])
|
||||
|
||||
(mr/def ::get-table-details-result
|
||||
[:or
|
||||
[:map
|
||||
{:decode/tool-api-response #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:structured_output ::full-table]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::answer-sources-result
|
||||
[:or
|
||||
[:map
|
||||
{:decode/tool-api-response #(metabot-v3.u/recursive-update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:structured_output [:map
|
||||
[:metrics [:sequential ::full-metric]]
|
||||
[:models [:sequential ::full-table]]]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(def metabot-collection-name
|
||||
"The name of the collection exposed by the answer-sources tool."
|
||||
"__METABOT__")
|
||||
|
||||
(api.macros/defendpoint :post "/answer-sources" :- [:merge ::answer-sources-result ::tool-request]
|
||||
"Create a dashboard subscription."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [conversation_id] :as body} :- ::tool-request]
|
||||
(metabot-v3.context/log (assoc body :api :answer-sources) :llm.log/llm->be)
|
||||
(doto (-> (mc/decode ::answer-sources-result
|
||||
(metabot-v3.dummy-tools/answer-sources metabot-collection-name)
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm)))
|
||||
|
||||
(api.macros/defendpoint :post "/create-dashboard-subscription" :- [:merge
|
||||
[:map [:output :string]]
|
||||
::tool-request]
|
||||
"Create a dashboard subscription."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments ::create-dashboard-subscription-arguments]]
|
||||
::tool-request]]
|
||||
(metabot-v3.context/log (assoc body :api :create-dashboard-subscription) :llm.log/llm->be)
|
||||
(let [arguments (mc/encode ::create-dashboard-subscription-arguments
|
||||
arguments (mtx/transformer {:name :tool-api-request}))]
|
||||
(doto (-> (metabot-v3.tools.create-dashboard-subscription/create-dashboard-subscription arguments)
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm))))
|
||||
|
||||
(api.macros/defendpoint :post "/filter-records" :- [:merge ::filtering-result ::tool-request]
|
||||
"Construct a query from a metric."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments ::filter-records-arguments]]
|
||||
::tool-request]]
|
||||
(metabot-v3.context/log (assoc body :api :filter-records) :llm.log/llm->be)
|
||||
(let [arguments (mc/encode ::filter-records-arguments
|
||||
arguments (mtx/transformer {:name :tool-api-request}))]
|
||||
(doto (-> (mc/decode ::filtering-result
|
||||
(metabot-v3.tools.filters/filter-records arguments)
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm))))
|
||||
|
||||
(api.macros/defendpoint :post "/find-metric" :- [:merge ::find-metric-result ::tool-request]
|
||||
"Find a metric matching a description."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments [:map [:message :string]]]]
|
||||
::tool-request]]
|
||||
(metabot-v3.context/log (assoc body :api :find-metric) :llm.log/llm->be)
|
||||
(doto (-> (mc/decode ::find-metric-result
|
||||
(metabot-v3.tools.find-metric/find-metric arguments)
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm)))
|
||||
|
||||
(api.macros/defendpoint :post "/find-outliers" :- [:merge ::find-outliers-result ::tool-request]
|
||||
"Find outliers in the values provided by a data source for a given column."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments ::find-outliers-arguments]]
|
||||
::tool-request]]
|
||||
(metabot-v3.context/log (assoc body :api :find-outliers) :llm.log/llm->be)
|
||||
(let [arguments (mc/encode ::find-outliers-arguments
|
||||
arguments (mtx/transformer {:name :tool-api-request}))]
|
||||
(doto (-> (mc/decode ::find-outliers-result
|
||||
(metabot-v3.tools.find-outliers/find-outliers arguments)
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm))))
|
||||
|
||||
(api.macros/defendpoint :post "/generate-insights" :- [:merge
|
||||
[:map
|
||||
[:output :string]
|
||||
[:reactions [:sequential :metabot.reaction/redirect]]]
|
||||
::tool-request]
|
||||
"Generate insights."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments ::generate-insights-arguments]]
|
||||
::tool-request]]
|
||||
(metabot-v3.context/log (assoc body :api :generate-insights) :llm.log/llm->be)
|
||||
(let [arguments (mc/encode ::generate-insights-arguments
|
||||
arguments (mtx/transformer {:name :tool-api-request}))]
|
||||
(doto (-> (metabot-v3.tools.generate-insights/generate-insights arguments)
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm))))
|
||||
|
||||
(api.macros/defendpoint :post "/get-current-user" :- [:merge ::get-current-user-result ::tool-request]
|
||||
"Get information about user that started the conversation."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [conversation_id] :as body} :- ::tool-request]
|
||||
(metabot-v3.context/log (assoc body :api :get-current-user) :llm.log/llm->be)
|
||||
(doto (-> (mc/decode ::get-current-user-result
|
||||
(metabot-v3.dummy-tools/get-current-user)
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm)))
|
||||
|
||||
(api.macros/defendpoint :post "/get-dashboard-details" :- [:merge ::get-dashboard-details-result ::tool-request]
|
||||
"Get information about a given dashboard."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments [:map [:dashboard_id :int]]]]
|
||||
::tool-request]]
|
||||
(metabot-v3.context/log (assoc body :api :get-dashboard-details) :llm.log/llm->be)
|
||||
(doto (-> (mc/decode ::get-dashboard-details-result
|
||||
(metabot-v3.dummy-tools/get-dashboard-details
|
||||
(metabot-v3.u/recursive-update-keys arguments metabot-v3.u/safe->kebab-case-en))
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm)))
|
||||
|
||||
(api.macros/defendpoint :post "/get-metric-details" :- [:merge ::get-metric-details-result ::tool-request]
|
||||
"Get information about a given metric."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments [:map [:metric_id :int]]]]
|
||||
::tool-request]]
|
||||
(metabot-v3.context/log (assoc body :api :get-metric-details) :llm.log/llm->be)
|
||||
(doto (-> (mc/decode ::get-metric-details-result
|
||||
(metabot-v3.dummy-tools/get-metric-details
|
||||
(metabot-v3.u/recursive-update-keys arguments metabot-v3.u/safe->kebab-case-en))
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm)))
|
||||
|
||||
(api.macros/defendpoint :post "/get-query-details" :- [:merge ::get-query-details-result ::tool-request]
|
||||
"Get information about a given query."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments [:map [:query :map]]]]
|
||||
::tool-request]]
|
||||
(metabot-v3.context/log (assoc body :api :get-query-details) :llm.log/llm->be)
|
||||
(doto (-> (mc/decode ::get-query-details-result
|
||||
(metabot-v3.dummy-tools/get-query-details arguments)
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm)))
|
||||
|
||||
(api.macros/defendpoint :post "/get-report-details" :- [:merge ::get-report-details-result ::tool-request]
|
||||
"Get information about a given report."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments [:map [:report_id :int]]]]
|
||||
::tool-request]]
|
||||
(metabot-v3.context/log (assoc body :api :get-report-details) :llm.log/llm->be)
|
||||
(doto (-> (mc/decode ::get-report-details-result
|
||||
(metabot-v3.dummy-tools/get-report-details
|
||||
(metabot-v3.u/recursive-update-keys arguments metabot-v3.u/safe->kebab-case-en))
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm)))
|
||||
|
||||
(api.macros/defendpoint :post "/get-table-details" :- [:merge ::get-table-details-result ::tool-request]
|
||||
"Get information about a given table or model."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments ::get-table-details-arguments]]
|
||||
::tool-request]]
|
||||
(metabot-v3.context/log (assoc body :api :get-table-details) :llm.log/llm->be)
|
||||
(doto (-> (mc/decode ::get-table-details-result
|
||||
(metabot-v3.dummy-tools/get-table-details
|
||||
(metabot-v3.u/recursive-update-keys arguments metabot-v3.u/safe->kebab-case-en))
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm)))
|
||||
|
||||
(api.macros/defendpoint :post "/query-metric" :- [:merge ::filtering-result ::tool-request]
|
||||
"Construct a query from a metric."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments ::query-metric-arguments]]
|
||||
::tool-request]]
|
||||
(metabot-v3.context/log (assoc body :api :query-metric) :llm.log/llm->be)
|
||||
(let [arguments (mc/encode ::query-metric-arguments
|
||||
arguments (mtx/transformer {:name :tool-api-request}))]
|
||||
(doto (-> (mc/decode ::filtering-result
|
||||
(metabot-v3.tools.filters/query-metric arguments)
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm))))
|
||||
|
||||
(api.macros/defendpoint :post "/query-model" :- [:merge ::filtering-result ::tool-request]
|
||||
"Construct a query from a model."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments ::query-model-arguments]]
|
||||
::tool-request]]
|
||||
(metabot-v3.context/log (assoc body :api :query-model) :llm.log/llm->be)
|
||||
(let [arguments (mc/encode ::query-model-arguments
|
||||
arguments (mtx/transformer {:name :tool-api-request}))]
|
||||
(doto (-> (mc/decode ::filtering-result
|
||||
(metabot-v3.tools.filters/query-model arguments)
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm))))
|
||||
|
||||
(defn- enforce-authentication
|
||||
"Middleware that returns a 401 response if no `ai-session` can be found for `request`."
|
||||
[handler]
|
||||
(with-meta
|
||||
(fn [{:keys [headers] :as request} respond raise]
|
||||
(if-let [user-id (decode-ai-service-token (get headers "x-metabase-session"))]
|
||||
(request/with-current-user user-id
|
||||
(handler request respond raise))
|
||||
(if (:metabase-user-id request)
|
||||
(handler request respond raise)
|
||||
(respond request/response-unauthentic))))
|
||||
(meta handler)))
|
||||
|
||||
(def ^{:arglists '([handler])} +tool-session
|
||||
"Wrap `routes` so they may only be accessed with proper authentication credentials."
|
||||
(api.routes.common/wrap-middleware-for-open-api-spec-generation enforce-authentication))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/metabot-tools` routes."
|
||||
(api.macros/ns-handler *ns* +tool-session))
|
||||
@@ -0,0 +1,50 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.create-dashboard-subscription
|
||||
(:require
|
||||
[metabase.api.common :as api]
|
||||
^{:clj-kondo/ignore [:deprecated-namespace]}
|
||||
[metabase.pulse.core :as pulse]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn create-dashboard-subscription
|
||||
"Create a dashboard subscription."
|
||||
[{:keys [dashboard-id email schedule]}]
|
||||
(if (int? dashboard-id)
|
||||
(let [dashboard (-> (t2/select-one :model/Dashboard :id dashboard-id)
|
||||
(t2/hydrate [:dashcards :card]))
|
||||
cards (for [{:keys [id card]} (:dashcards dashboard)
|
||||
:when (-> card :id int?)]
|
||||
(-> card
|
||||
(select-keys [:id :name :collection_id :description :display :parameter_mappings])
|
||||
(assoc :dashboard_card_id id :dashboard_id dashboard-id)))
|
||||
recipient-id (t2/select-one-fn :id :model/User :email email)
|
||||
recipient {:id recipient-id}
|
||||
{:keys [frequency hour day_of_week day_of_month]} schedule
|
||||
channel {:channel_type :email
|
||||
:enabled true
|
||||
:recipients [recipient]
|
||||
:schedule_day (or (some-> day_of_week (subs 0 3) u/lower-case-en)
|
||||
(some->> day_of_month
|
||||
u/lower-case-en
|
||||
(re-find #"^(?:first|last)-(mon|tue|wed|thu|fri|sat|sun)")
|
||||
second))
|
||||
:schedule_frame (some->> day_of_month (re-find #"^(?:first|mid|last)"))
|
||||
:schedule_hour hour
|
||||
:schedule_type frequency}
|
||||
pulse-data (-> dashboard
|
||||
(select-keys [:collection_id :collection_position :name :parameters])
|
||||
(assoc :dashboard_id dashboard-id
|
||||
:creator_id api/*current-user-id*
|
||||
:skip_if_empty false))]
|
||||
{:output
|
||||
(cond
|
||||
(nil? recipient-id)
|
||||
"no user with this email found"
|
||||
|
||||
(nil? dashboard)
|
||||
"no dashboard with this dashboard_id found"
|
||||
|
||||
:else
|
||||
(do (pulse/create-pulse! (map pulse/card->ref cards) [channel] pulse-data)
|
||||
"success"))})
|
||||
{:output "invalid dashboard_id"}))
|
||||
@@ -0,0 +1,348 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.filters
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.lib.metadata.jvm :as lib.metadata.jvm]
|
||||
[metabase.lib.types.isa :as lib.types.isa]
|
||||
[metabase.lib.util :as lib.util]
|
||||
[metabase.util :as u]))
|
||||
|
||||
(defn- apply-bucket
|
||||
[column bucket]
|
||||
(let [bucket (keyword bucket)]
|
||||
(case bucket
|
||||
:week-of-year (lib/get-week column :iso)
|
||||
:day-of-week (lib/get-day-of-week column :iso)
|
||||
(lib/with-temporal-bucket column bucket))))
|
||||
|
||||
(defn- bucketed-column
|
||||
[{:keys [column bucket]}]
|
||||
(cond-> column
|
||||
(and bucket
|
||||
(lib.types.isa/temporal? column))
|
||||
(apply-bucket bucket)))
|
||||
|
||||
(defn- add-filter
|
||||
[query llm-filter]
|
||||
(let [llm-filter (update llm-filter :operation keyword)
|
||||
{:keys [operation value values]} llm-filter
|
||||
expr (bucketed-column llm-filter)
|
||||
filter
|
||||
(case operation
|
||||
:is-null (lib/is-null expr)
|
||||
:is-not-null (lib/not-null expr)
|
||||
:string-is-empty (lib/is-empty expr)
|
||||
:string-is-not-empty (lib/not-empty expr)
|
||||
:is-true (lib/= expr true)
|
||||
:is-false (lib/= expr false)
|
||||
:equals (if values
|
||||
(apply lib/= expr values)
|
||||
(lib/= expr value))
|
||||
:not-equals (if values
|
||||
(apply lib/!= expr values)
|
||||
(lib/!= expr value))
|
||||
:greater-than (lib/> expr value)
|
||||
:greater-than-or-equal (lib/>= expr value)
|
||||
:less-than (lib/< expr value)
|
||||
:less-than-or-equal (lib/<= expr value)
|
||||
:year-equals (if values
|
||||
(apply lib/= (lib/get-year expr) values)
|
||||
(lib/= (lib/get-year expr) value))
|
||||
:year-not-equals (if values
|
||||
(apply lib/!= (lib/get-year expr) values)
|
||||
(lib/!= (lib/get-year expr) value))
|
||||
:quarter-equals (if values
|
||||
(apply lib/= (lib/get-quarter expr) values)
|
||||
(lib/= (lib/get-quarter expr) value))
|
||||
:quarter-not-equals (if values
|
||||
(apply lib/!= (lib/get-quarter expr) values)
|
||||
(lib/!= (lib/get-quarter expr) value))
|
||||
:month-equals (if values
|
||||
(apply lib/= (lib/get-month expr) values)
|
||||
(lib/= (lib/get-month expr) value))
|
||||
:month-not-equals (if values
|
||||
(apply lib/!= (lib/get-month expr) values)
|
||||
(lib/!= (lib/get-month expr) value))
|
||||
:day-of-week-equals (if values
|
||||
(apply lib/= (lib/get-day-of-week expr :iso) values)
|
||||
(lib/= (lib/get-day-of-week expr :iso) value))
|
||||
:day-of-week-not-equals (if values
|
||||
(apply lib/!= (lib/get-day-of-week expr :iso) values)
|
||||
(lib/!= (lib/get-day-of-week expr :iso) value))
|
||||
:hour-equals (if values
|
||||
(apply lib/= (lib/get-hour expr) values)
|
||||
(lib/= (lib/get-hour expr) value))
|
||||
:hour-not-equals (if values
|
||||
(apply lib/!= (lib/get-hour expr) values)
|
||||
(lib/!= (lib/get-hour expr) value))
|
||||
:minute-equals (if values
|
||||
(apply lib/= (lib/get-minute expr) values)
|
||||
(lib/= (lib/get-minute expr) value))
|
||||
:minute-not-equals (if values
|
||||
(apply lib/!= (lib/get-minute expr) values)
|
||||
(lib/!= (lib/get-minute expr) value))
|
||||
:second-equals (if values
|
||||
(apply lib/= (lib/get-second expr) values)
|
||||
(lib/= (lib/get-second expr) value))
|
||||
:second-not-equals (if values
|
||||
(apply lib/!= (lib/get-second expr) values)
|
||||
(lib/!= (lib/get-second expr) value))
|
||||
:date-equals (if values
|
||||
(apply lib/= expr values)
|
||||
(lib/= expr value))
|
||||
:date-not-equals (if values
|
||||
(apply lib/!= expr values)
|
||||
(lib/!= expr value))
|
||||
:date-before (lib/< expr value)
|
||||
:date-on-or-before (lib/<= expr value)
|
||||
:date-after (lib/> expr value)
|
||||
:date-on-or-after (lib/>= expr value)
|
||||
:string-equals (if values
|
||||
(apply lib/= expr values)
|
||||
(lib/= expr value))
|
||||
:string-not-equals (if values
|
||||
(apply lib/!= expr values)
|
||||
(lib/!= expr value))
|
||||
:string-contains (if values
|
||||
(apply lib/contains expr values)
|
||||
(lib/contains expr value))
|
||||
:string-not-contains (if values
|
||||
(apply lib/not (lib/contains expr values))
|
||||
(lib/not (lib/contains expr value)))
|
||||
:string-starts-with (if values
|
||||
(apply lib/starts-with expr values)
|
||||
(lib/starts-with expr value))
|
||||
:string-ends-with (if values
|
||||
(apply lib/ends-with expr values)
|
||||
(lib/ends-with expr value))
|
||||
:number-equals (if values
|
||||
(apply lib/= expr values)
|
||||
(lib/= expr value))
|
||||
:number-not-equals (if values
|
||||
(apply lib/!= expr values)
|
||||
(lib/!= expr value))
|
||||
:number-greater-than (lib/> expr value)
|
||||
:number-greater-than-or-equal (lib/>= expr value)
|
||||
:number-less-than (lib/< expr value)
|
||||
:number-less-than-or-equal (lib/<= expr value)
|
||||
(throw (ex-info (str "unknown filter operation " operation) {:agent-error? true})))]
|
||||
(lib/filter query filter)))
|
||||
|
||||
(defn- add-breakout
|
||||
[query {:keys [column field_granularity]}]
|
||||
(let [expr (cond-> column
|
||||
(and field_granularity
|
||||
(lib.types.isa/temporal? column))
|
||||
(lib/with-temporal-bucket (keyword field_granularity)))]
|
||||
(lib/breakout query expr)))
|
||||
|
||||
(defn- query-metric*
|
||||
[{:keys [metric-id filters group-by] :as _arguments}]
|
||||
(let [card (metabot-v3.tools.u/get-card metric-id)
|
||||
mp (lib.metadata.jvm/application-database-metadata-provider (:database_id card))
|
||||
base-query (->> (lib/query mp (lib.metadata/card mp metric-id))
|
||||
lib/remove-all-breakouts)
|
||||
visible-cols (lib/visible-columns base-query)
|
||||
filter-field-id-prefix (metabot-v3.tools.u/card-field-id-prefix metric-id)
|
||||
query (as-> base-query $q
|
||||
(reduce add-filter
|
||||
$q
|
||||
(map #(metabot-v3.tools.u/resolve-column % filter-field-id-prefix visible-cols) filters))
|
||||
(reduce add-breakout
|
||||
$q
|
||||
(map #(metabot-v3.tools.u/resolve-column % filter-field-id-prefix visible-cols) group-by)))
|
||||
query-id (u/generate-nano-id)
|
||||
query-field-id-prefix (metabot-v3.tools.u/query-field-id-prefix query-id)
|
||||
returned-cols (lib/returned-columns query)]
|
||||
{:type :query
|
||||
:query_id query-id
|
||||
:query (lib/->legacy-MBQL query)
|
||||
:result_columns (into []
|
||||
(map-indexed #(metabot-v3.tools.u/->result-column query %2 %1 query-field-id-prefix))
|
||||
returned-cols)}))
|
||||
|
||||
(comment
|
||||
(binding [api/*current-user-permissions-set* (delay #{"/"})]
|
||||
(let [id 135]
|
||||
(query-metric* {:metric-id id})))
|
||||
-)
|
||||
|
||||
(defn query-metric
|
||||
"Create a query based on a metric."
|
||||
[{:keys [metric-id] :as arguments}]
|
||||
(try
|
||||
(if (int? metric-id)
|
||||
{:structured-output (query-metric* arguments)}
|
||||
{:output (str "Invalid metric_id " metric-id)})
|
||||
(catch Exception e
|
||||
(if (= (:status-code (ex-data e)) 404)
|
||||
{:output (str "No metric found with metric_id " metric-id)}
|
||||
(metabot-v3.tools.u/handle-agent-error e)))))
|
||||
|
||||
(comment
|
||||
(binding [api/*current-user-permissions-set* (delay #{"/"})
|
||||
api/*current-user-id* 2
|
||||
api/*is-superuser?* true]
|
||||
(query-metric* {:metric-id 135
|
||||
:filters
|
||||
[{:operation "number-greater-than",
|
||||
:field_id "field_[27]_[:field {:base-type :type/Float, :effective-type :type/Float} 257]",
|
||||
:value 35}],
|
||||
:group-by nil}))
|
||||
-)
|
||||
|
||||
(defn- add-aggregation
|
||||
[query aggregation]
|
||||
(let [expr (bucketed-column aggregation)
|
||||
agg-expr (case (-> aggregation :function keyword)
|
||||
:count (lib/count)
|
||||
:count-distinct (lib/distinct expr)
|
||||
:sum (lib/sum expr)
|
||||
:min (lib/min expr)
|
||||
:max (lib/max expr)
|
||||
:avg (lib/avg expr))]
|
||||
(lib/aggregate query agg-expr)))
|
||||
|
||||
(defn- expression?
|
||||
[expr-or-column]
|
||||
(vector? expr-or-column))
|
||||
|
||||
(defn- add-fields
|
||||
[query projection]
|
||||
(->> projection
|
||||
(map (fn [[expr-or-column expr-name]]
|
||||
(if (expression? expr-or-column)
|
||||
(lib/expression-ref query expr-name)
|
||||
expr-or-column)))
|
||||
(lib/with-fields query)))
|
||||
|
||||
(defn- query-model*
|
||||
[{:keys [model-id fields filters aggregations group-by] :as _arguments}]
|
||||
(let [card (metabot-v3.tools.u/get-card model-id)
|
||||
mp (lib.metadata.jvm/application-database-metadata-provider (:database_id card))
|
||||
base-query (lib/query mp (lib.metadata/card mp model-id))
|
||||
visible-cols (lib/visible-columns base-query)
|
||||
filter-field-id-prefix (metabot-v3.tools.u/card-field-id-prefix model-id)
|
||||
resolve-visible-column #(metabot-v3.tools.u/resolve-column % filter-field-id-prefix visible-cols)
|
||||
projection (map (comp (juxt bucketed-column (fn [{:keys [column bucket]}]
|
||||
(let [column (cond-> column
|
||||
bucket (assoc :unit (keyword bucket)))]
|
||||
(lib/display-name base-query -1 column :long))))
|
||||
resolve-visible-column)
|
||||
fields)
|
||||
reduce-query (fn [query f coll] (reduce f query coll))
|
||||
query (-> base-query
|
||||
(reduce-query (fn [query [expr-or-column expr-name]]
|
||||
(lib/expression query expr-name expr-or-column))
|
||||
(filter (comp expression? first) projection))
|
||||
(add-fields projection)
|
||||
(reduce-query add-filter (map resolve-visible-column filters))
|
||||
(reduce-query add-aggregation (map resolve-visible-column aggregations))
|
||||
(reduce-query add-breakout (map resolve-visible-column group-by)))
|
||||
query-id (u/generate-nano-id)
|
||||
query-field-id-prefix (metabot-v3.tools.u/query-field-id-prefix query-id)
|
||||
returned-cols (lib/returned-columns query)]
|
||||
{:type :query
|
||||
:query_id query-id
|
||||
:query (lib/->legacy-MBQL query)
|
||||
:result_columns (into []
|
||||
(map-indexed #(metabot-v3.tools.u/->result-column query %2 %1 query-field-id-prefix))
|
||||
returned-cols)}))
|
||||
|
||||
(defn query-model
|
||||
"Create a query based on a model."
|
||||
[{:keys [model-id] :as arguments}]
|
||||
(try
|
||||
(if (int? model-id)
|
||||
{:structured-output (query-model* arguments)}
|
||||
{:output (str "Invalid model_id " model-id)})
|
||||
(catch Exception e
|
||||
(if (= (:status-code (ex-data e)) 404)
|
||||
{:output (str "No model found with model_id " model-id)}
|
||||
(metabot-v3.tools.u/handle-agent-error e)))))
|
||||
|
||||
(defn- base-query
|
||||
[data-source]
|
||||
(let [{:keys [table_id query query_id report_id]} data-source
|
||||
model-id (lib.util/legacy-string-table-id->card-id table_id)
|
||||
handle-query (fn [query query_id]
|
||||
(let [mp (lib.metadata.jvm/application-database-metadata-provider (:database query))]
|
||||
[(if query_id
|
||||
(metabot-v3.tools.u/query-field-id-prefix query_id)
|
||||
metabot-v3.tools.u/any-prefix-pattern)
|
||||
(-> (lib/query mp query) lib/append-stage)]))]
|
||||
(cond
|
||||
model-id
|
||||
(if-let [model (metabot-v3.tools.u/get-card model-id)]
|
||||
(let [mp (lib.metadata.jvm/application-database-metadata-provider (:database_id model))]
|
||||
[(metabot-v3.tools.u/card-field-id-prefix model-id)
|
||||
(lib/query mp (lib.metadata/card mp model-id))])
|
||||
(throw (ex-info (str "No table found with table_id " table_id) {:agent-error? true
|
||||
:data_source data-source})))
|
||||
|
||||
table_id
|
||||
(let [table_id (cond-> table_id
|
||||
(string? table_id) parse-long)]
|
||||
(if-let [table (metabot-v3.tools.u/get-table table_id :db_id)]
|
||||
(let [mp (lib.metadata.jvm/application-database-metadata-provider (:db_id table))]
|
||||
[(metabot-v3.tools.u/table-field-id-prefix table_id)
|
||||
(lib/query mp (lib.metadata/table mp table_id))])
|
||||
(throw (ex-info (str "No table found with table_id " table_id) {:agent-error? true
|
||||
:data_source data-source}))))
|
||||
|
||||
report_id
|
||||
(if-let [card (metabot-v3.tools.u/get-card report_id)]
|
||||
(let [mp (lib.metadata.jvm/application-database-metadata-provider (:database_id card))]
|
||||
[(metabot-v3.tools.u/card-field-id-prefix report_id)
|
||||
(lib/query mp (cond-> (lib.metadata/card mp report_id)
|
||||
;; pivot questions have strange result-columns so we work with the dataset-query
|
||||
(#{:question} (:type card)) (get :dataset-query)))])
|
||||
(throw (ex-info (str "No report found with report_id " report_id) {:agent-error? true
|
||||
:data_source data-source})))
|
||||
|
||||
query
|
||||
(handle-query query query_id)
|
||||
|
||||
:else
|
||||
(throw (ex-info "Invalid data_source" {:agent-error? true
|
||||
:data_source data-source})))))
|
||||
|
||||
(defn filter-records
|
||||
"Add `filters` to the query referenced by `data-source`"
|
||||
[{:keys [data-source filters] :as _arguments}]
|
||||
(try
|
||||
(let [[filter-field-id-prefix base] (base-query data-source)
|
||||
returned-cols (lib/returned-columns base)
|
||||
query (reduce add-filter base (map #(metabot-v3.tools.u/resolve-column % filter-field-id-prefix returned-cols) filters))
|
||||
query-id (u/generate-nano-id)
|
||||
query-field-id-prefix (metabot-v3.tools.u/query-field-id-prefix query-id)]
|
||||
{:structured-output
|
||||
{:type :query
|
||||
:query_id query-id
|
||||
:query (lib/->legacy-MBQL query)
|
||||
:result_columns (into []
|
||||
(map-indexed #(metabot-v3.tools.u/->result-column query %2 %1 query-field-id-prefix))
|
||||
(lib/returned-columns query))}})
|
||||
(catch Exception ex
|
||||
(metabot-v3.tools.u/handle-agent-error ex))))
|
||||
|
||||
(comment
|
||||
(require '[metabase.query-processor :as qp]
|
||||
'[toucan2.core :as t2])
|
||||
(t2/select :model/Field)
|
||||
(binding [api/*current-user-permissions-set* (delay #{"/"})
|
||||
api/*current-user-id* 2
|
||||
api/*is-superuser?* true]
|
||||
(-> (filter-records #_{:data-source {:table_id 3}
|
||||
:filters [{:operation "number-greater-than"
|
||||
:field_id "t3/6"
|
||||
:value 50}]}
|
||||
{:data-source {:table_id 1}
|
||||
:filters [{:operation "greater-than"
|
||||
:bucket "month-of-year"
|
||||
:field_id "t1/3"
|
||||
:value #_"2020-01-01" 1}]})
|
||||
:structured-output :query qp/process-query :data :native_form :query))
|
||||
-)
|
||||
@@ -0,0 +1,20 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.find-metric
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.client :as client]
|
||||
[metabase-enterprise.metabot-v3.dummy-tools :as dummy-tools]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn find-metric
|
||||
"Find a metric described by `message`.
|
||||
|
||||
See [[metabase-enterprise.metabot-v3.dummy-tools/metric-details]] for the output if a metric can be found."
|
||||
[{:keys [message]}]
|
||||
(let [{:keys [id]} (client/select-metric-request
|
||||
(t2/select [:model/Card :id :name :description]
|
||||
:type [:= "metric"]
|
||||
:archived [:= false])
|
||||
message)]
|
||||
(if-let [result (when id
|
||||
(dummy-tools/metric-details id))]
|
||||
{:structured-output result}
|
||||
{:output "Metric not found."})))
|
||||
@@ -0,0 +1,81 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.find-outliers
|
||||
(:require
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
|
||||
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.lib.types.isa :as lib.types.isa]
|
||||
[metabase.query-processor :as qp]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- checked-card-dataset-query
|
||||
[card-id]
|
||||
(-> (t2/select-one [:model/Card :collection_id :dataset_query] card-id)
|
||||
api/read-check
|
||||
:dataset_query))
|
||||
|
||||
(defn- find-dataset-query
|
||||
[{:keys [query query_id report_id metric_id] :as data-source}]
|
||||
(letfn [(handle-query [query query_id]
|
||||
(api/read-check :model/Database (:database query))
|
||||
[(if query_id
|
||||
(metabot-v3.tools.u/query-field-id-prefix query_id)
|
||||
metabot-v3.tools.u/any-prefix-pattern)
|
||||
query])]
|
||||
(cond
|
||||
metric_id (if (int? metric_id)
|
||||
[(metabot-v3.tools.u/card-field-id-prefix metric_id)
|
||||
(checked-card-dataset-query metric_id)]
|
||||
(throw (ex-info "Invalid metric_id as data_source" {:agent-error? true
|
||||
:data_source data-source})))
|
||||
report_id (if (int? report_id)
|
||||
[(metabot-v3.tools.u/card-field-id-prefix report_id)
|
||||
(checked-card-dataset-query report_id)]
|
||||
(throw (ex-info "Invalid report_id as data_source" {:agent-error? true
|
||||
:data_source data-source})))
|
||||
query (handle-query query query_id)
|
||||
:else (throw (ex-info "Invalid data_source" {:agent-error? true
|
||||
:data_source data-source})))))
|
||||
|
||||
(defn find-outliers
|
||||
"Find outliers in the values provided by `data-source` for a given column."
|
||||
[{:keys [data-source]}]
|
||||
(let [{:keys [metric_id result_field_id]} data-source]
|
||||
(try
|
||||
(let [[field-id-prefix dataset-query] (find-dataset-query data-source)
|
||||
{:keys [data]} (u/prog1 (-> dataset-query
|
||||
(qp/userland-query-with-default-constraints {:context :ad-hoc})
|
||||
qp/process-query)
|
||||
(when-not (= :completed (:status <>))
|
||||
(throw (ex-info "Unexpected error running query" {:agent-error? true
|
||||
:status (:status <>)}))))
|
||||
dimension-col-idx (or (->> data
|
||||
:cols
|
||||
(map-indexed vector)
|
||||
(m/find-first (fn [[_i col]]
|
||||
(lib.types.isa/temporal? (u/normalize-map col))))
|
||||
first)
|
||||
(throw (ex-info "No temporal dimension found. Outliers can only be detected when a temporal dimension is available."
|
||||
{:agent-error? true})))
|
||||
value-col-idx (if metric_id
|
||||
(or (->> data
|
||||
:cols
|
||||
(map-indexed vector)
|
||||
(m/find-first (fn [[_i col]]
|
||||
(lib.types.isa/numeric? (u/normalize-map col))))
|
||||
first)
|
||||
(throw (ex-info "Could not determine result field."
|
||||
{:agent-error? true})))
|
||||
(metabot-v3.tools.u/resolve-column-index result_field_id field-id-prefix))]
|
||||
(when-not (< -1 value-col-idx (-> data :rows first count))
|
||||
(throw (ex-info (str "Invalid result_field_id " result_field_id)
|
||||
{:agent-error? true})))
|
||||
{:structured-output (->> data
|
||||
:rows
|
||||
(map (fn [row]
|
||||
{:dimension (nth row dimension-col-idx)
|
||||
:value (nth row value-col-idx)}))
|
||||
(metabot-v3.client/find-outliers-request))})
|
||||
(catch Exception e
|
||||
(metabot-v3.tools.u/handle-agent-error e)))))
|
||||
@@ -0,0 +1,32 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.generate-insights
|
||||
(:require
|
||||
[buddy.core.codecs :as codecs]
|
||||
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
|
||||
[metabase.util.json :as json]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn generate-insights
|
||||
"Generate insights."
|
||||
[{what-for :for}]
|
||||
(try
|
||||
(let [[k id] (some #(find what-for %) [:metric_id :table_id :report_id :query])
|
||||
entity-type (case k
|
||||
(:metric_id :report_id) "question"
|
||||
:table_id "table"
|
||||
(:query :query_id) "adhoc"
|
||||
(throw (ex-info (str "Cannot generate insights for " what-for) {:agent-error? true})))
|
||||
entity-id (case k
|
||||
:query (-> id json/encode .getBytes codecs/bytes->b64-str)
|
||||
id)
|
||||
results-url (str "/auto/dashboard/" entity-type "/" entity-id)]
|
||||
(when (and (= k :table_id)
|
||||
(not (int? id))
|
||||
(not (and (string? id)
|
||||
(re-matches #"(?:card__)?\d+" id))))
|
||||
(throw (ex-info "Invalid table_id" {:agent-error? true
|
||||
:table_id id})))
|
||||
{:output results-url
|
||||
:reactions [{:type :metabot.reaction/redirect :url results-url}]})
|
||||
(catch Exception e
|
||||
(metabot-v3.tools.u/handle-agent-error e))))
|
||||
@@ -0,0 +1,41 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.interface
|
||||
(:require
|
||||
[metabase.lib.schema.common :as lib.schema.common]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.malli.registry :as mr]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(mr/def ::metadata.parameter.type
|
||||
:keyword)
|
||||
|
||||
(mr/def ::metadata.parameter.name
|
||||
[:and
|
||||
{:decode/metadata-file (fn [x]
|
||||
(keyword (u/->kebab-case-en x)))
|
||||
:encode/api-request (fn [x]
|
||||
(u/->snake_case_en (name x)))
|
||||
:decode/api-response (fn [x]
|
||||
(keyword (u/->kebab-case-en x)))}
|
||||
:keyword])
|
||||
|
||||
(mr/def ::metadata.parameter
|
||||
[:and
|
||||
[:map
|
||||
[:type {:optional true} [:or
|
||||
::metadata.parameter.type
|
||||
[:set ::metadata.parameter.type]]]
|
||||
[:anyOf {:optional true} [:sequential [:ref ::metadata.parameter]]]
|
||||
[:description {:optional true} [:maybe ::lib.schema.common/non-blank-string]]]
|
||||
[:fn
|
||||
{:error/message "metadata.parameter must specify either `:type`` or `:anyOf`, but not both."}
|
||||
(fn [query]
|
||||
(= (count (select-keys query [:type :anyOf])) 1))]])
|
||||
|
||||
(mr/def ::metadata.name
|
||||
[:and
|
||||
{:encode/api-request (fn [x] (u/->snake_case_en (name x)))
|
||||
:decode/metadata-file keyword
|
||||
:decode/api-request keyword
|
||||
:decode/api-response keyword}
|
||||
:keyword])
|
||||
@@ -0,0 +1,16 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.invite-user
|
||||
(:require
|
||||
[metabase.api.user :as api.user]))
|
||||
|
||||
(defn invite-user
|
||||
"Send a Metabase invitation to the address `email`."
|
||||
[{:keys [email]}]
|
||||
(try
|
||||
(api.user/invite-user {:email email})
|
||||
{:output (format "%s has been invited to Metabase." email)}
|
||||
(catch clojure.lang.ExceptionInfo e
|
||||
(let [d (ex-data e)]
|
||||
(if-let [message (when (= (:status-code d) 400)
|
||||
(-> d :errors :email))]
|
||||
{:output message}
|
||||
(throw e))))))
|
||||
@@ -0,0 +1,18 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.show-results-to-user
|
||||
(:require
|
||||
[buddy.core.codecs :as codecs]
|
||||
[metabase.public-settings :as public-settings]
|
||||
[metabase.util.json :as json]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn show-results-to-user
|
||||
"Generate link where the query can be seen."
|
||||
[{:keys [query] :as _arguments}]
|
||||
(let [query-hash (-> {:dataset_query query}
|
||||
json/encode
|
||||
(.getBytes "UTF-8")
|
||||
codecs/bytes->b64-str)
|
||||
results-url (str "/question#" query-hash)]
|
||||
{:output (str "Results can be seen at: " (public-settings/site-url) results-url)
|
||||
:reactions [{:type :metabot.reaction/redirect, :url results-url}]}))
|
||||
@@ -0,0 +1,129 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.util
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[medley.core :as m]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.types.isa :as lib.types.isa]
|
||||
[metabase.models.collection :as collection]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn handle-agent-error
|
||||
"Return an agent output for agent errors, re-throw `e` otherwise."
|
||||
[e]
|
||||
(if (-> e ex-data :agent-error?)
|
||||
{:output (ex-message e)}
|
||||
(throw e)))
|
||||
|
||||
(defn convert-field-type
|
||||
"Return tool type for `column`."
|
||||
[column]
|
||||
(let [column (u/normalize-map column)]
|
||||
(cond
|
||||
(lib.types.isa/boolean? column) "boolean"
|
||||
(lib.types.isa/string-or-string-like? column) "string"
|
||||
(lib.types.isa/numeric? column) "number"
|
||||
(isa? (:effective-type column) :type/DateTime) "datetime"
|
||||
(isa? (:effective-type column) :type/Time) "time"
|
||||
(lib.types.isa/temporal? column) "date")))
|
||||
|
||||
(defn table-field-id-prefix
|
||||
"Return the field ID prefix for `table-id`."
|
||||
[table-id]
|
||||
(str "t" table-id "/"))
|
||||
|
||||
(defn card-field-id-prefix
|
||||
"Return the field ID prefix for a model or a metric with ID `card-id`."
|
||||
[card-id]
|
||||
(str "c" card-id "/"))
|
||||
|
||||
(defn query-field-id-prefix
|
||||
"Return the field ID prefix for `query-id`."
|
||||
[query-id]
|
||||
(str "q" query-id "/"))
|
||||
|
||||
(def any-prefix-pattern
|
||||
"A prefix pattern accepting columns from any entity."
|
||||
#"^.*/(\d+)")
|
||||
|
||||
(defn- find-column-indexes
|
||||
[col-to-find cols]
|
||||
(keep-indexed (fn [i col]
|
||||
(when (= col (select-keys col-to-find (keys col)))
|
||||
i))
|
||||
cols))
|
||||
|
||||
(defn ->result-column
|
||||
"Return tool result columns for `column` of `query`. The position of `column` is determined by `index-or-columns`
|
||||
unless the latter is a sequence of columns, in which case the index of `column` in `index-or-columns` is used.
|
||||
The ID is then generated by prefixing the position with `field-id-prefix`."
|
||||
[query column index-or-columns field-id-prefix]
|
||||
(let [pos (if (sequential? index-or-columns)
|
||||
(first (find-column-indexes column index-or-columns))
|
||||
index-or-columns)
|
||||
semantic-type (:semantic-type column)]
|
||||
(-> {:field_id (str field-id-prefix pos)
|
||||
:name (lib/display-name query column)
|
||||
:type (convert-field-type column)}
|
||||
(m/assoc-some :description (:description column)
|
||||
:semantic-type (some-> semantic-type name u/->snake_case_en)
|
||||
:field-values (:field-values column)
|
||||
:table-reference (:table-reference column)))))
|
||||
|
||||
(defn resolve-column-index
|
||||
"Resolve the reference `field_id` to the index of the result columns in the entity with `field-id-prefix`."
|
||||
[field_id field-id-prefix]
|
||||
(if (string? field-id-prefix)
|
||||
(if (str/starts-with? field_id field-id-prefix)
|
||||
(-> field_id (subs (count field-id-prefix)) parse-long)
|
||||
(throw (ex-info (str "field " field_id " not found") {:agent-error? true
|
||||
:expected-prefix field-id-prefix})))
|
||||
(if-let [id-str (when (instance? java.util.regex.Pattern field-id-prefix)
|
||||
(-> (re-matches field-id-prefix field_id)
|
||||
second))]
|
||||
(parse-long id-str)
|
||||
(throw (ex-info (str "invalid field_id " field_id " for prefix " field-id-prefix)
|
||||
{:agent-error? true
|
||||
:expected-prefix (str field-id-prefix)
|
||||
:field_id field_id})))))
|
||||
|
||||
(defn resolve-column
|
||||
"Resolve the reference `field_id` in filter `item` by finding the column in `columns` specified by `field_id`.
|
||||
`field-id-prefix` is used to check if the filter refers to a column from the right entity."
|
||||
[{:keys [field_id] :as item} field-id-prefix columns]
|
||||
(let [index (resolve-column-index field_id field-id-prefix)]
|
||||
(assoc item :column (nth columns index))))
|
||||
|
||||
(defn get-table
|
||||
"Get the `fields` of the table with ID `id`."
|
||||
[id & fields]
|
||||
(when-let [table (t2/select-one (into [:model/Table :id] fields) id)]
|
||||
(when (mi/can-read? table)
|
||||
table)))
|
||||
|
||||
(defn get-card
|
||||
"Retrieve the card with `id` from the app DB."
|
||||
[id]
|
||||
(-> (t2/select-one :model/Card :id id)
|
||||
api/read-check
|
||||
(t2/hydrate :based_on_upload :creator :can_write :can_run_adhoc_query [:collection :is_personal])
|
||||
(api/present-in-trash-if-archived-directly (collection/trash-collection-id))))
|
||||
|
||||
(defn get-metrics-and-models
|
||||
"Retrieve the metric and model cards from the collection with name `collection-name` from the app DB."
|
||||
[collection-name]
|
||||
(let [collection-query {:select [:id]
|
||||
:from :collection
|
||||
:where [:= :name collection-name]}]
|
||||
(-> (t2/select :model/Card :type [:in [:metric :model]] :collection_id [:in collection-query] {:order-by [:id]})
|
||||
(->> (filter mi/can-read?))
|
||||
(t2/hydrate :based_on_upload :creator :can_write :can_run_adhoc_query [:collection :is_personal])
|
||||
(api/present-in-trash-if-archived-directly (collection/trash-collection-id)))))
|
||||
|
||||
(comment
|
||||
(binding [api/*current-user-permissions-set* (delay #{"/"})]
|
||||
(let [collection-name "Usage analytics"]
|
||||
(get-metrics-and-models collection-name)))
|
||||
-)
|
||||
@@ -0,0 +1,23 @@
|
||||
(ns metabase-enterprise.metabot-v3.util
|
||||
(:require
|
||||
[clojure.walk :as walk]
|
||||
[metabase.util :as u]))
|
||||
|
||||
(defn- safe-case-updater
|
||||
[f]
|
||||
#(cond-> % (or (string? %) (keyword? %)) f))
|
||||
|
||||
(def safe->kebab-case-en
|
||||
"Convert strings or keywords to kebab-case."
|
||||
(safe-case-updater u/->kebab-case-en))
|
||||
|
||||
(def safe->snake_case_en
|
||||
"Convert strings or keywords to snake_case"
|
||||
(safe-case-updater u/->snake_case_en))
|
||||
|
||||
(defn recursive-update-keys
|
||||
"Recursively convert map keys in `form` with `f`."
|
||||
[form f]
|
||||
(walk/walk #(cond-> % (coll? %) (recursive-update-keys f))
|
||||
#(cond-> % (map? %) (update-keys f))
|
||||
form))
|
||||
@@ -0,0 +1,77 @@
|
||||
(ns metabase-enterprise.ai-sql-fixer.api-test
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.ai-sql-fixer.api :as ai-sql-fixer.api]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(deftest ^:parallel format-escpaed-test
|
||||
(are [in out] (= out
|
||||
(with-out-str (#'ai-sql-fixer.api/format-escaped in *out*)))
|
||||
"almallama" "almallama"
|
||||
"alma llama" "\"alma llama\""
|
||||
"\"alma\" llama" "\"\"\"alma\"\" llama\""))
|
||||
|
||||
(deftest ^:parallel schema-sample-test
|
||||
(mt/test-driver :h2
|
||||
(let [query {:database (mt/id)
|
||||
:native {:query (str "SELECT * FROM x.orders1"
|
||||
" INNER JOIN products ON orders1.product_id = products.id"
|
||||
" INNER JOIN ANALYTIC_EVENT ON true")}}
|
||||
normalize #(into #{} (str/split % #"(?<=;)\n"))]
|
||||
(is (= (-> (str "CREATE TABLE PUBLIC.PRODUCTS (\n"
|
||||
" ID BIGINT,\n"
|
||||
" EAN CHARACTER VARYING,\n"
|
||||
" TITLE CHARACTER VARYING,\n"
|
||||
" CATEGORY CHARACTER VARYING,\n"
|
||||
" VENDOR CHARACTER VARYING,\n"
|
||||
" PRICE DOUBLE PRECISION,\n"
|
||||
" RATING DOUBLE PRECISION,\n"
|
||||
" CREATED_AT TIMESTAMP WITH TIME ZONE\n"
|
||||
");\n"
|
||||
"CREATE TABLE PUBLIC.ORDERS (\n"
|
||||
" ID BIGINT,\n"
|
||||
" USER_ID INTEGER,\n"
|
||||
" PRODUCT_ID INTEGER,\n"
|
||||
" SUBTOTAL DOUBLE PRECISION,\n"
|
||||
" TAX DOUBLE PRECISION,\n"
|
||||
" TOTAL DOUBLE PRECISION,\n"
|
||||
" DISCOUNT DOUBLE PRECISION,\n"
|
||||
" CREATED_AT TIMESTAMP WITH TIME ZONE,\n"
|
||||
" QUANTITY INTEGER\n"
|
||||
");\n"
|
||||
"CREATE TABLE PUBLIC.USERS (\n"
|
||||
" ID BIGINT,\n"
|
||||
" NAME CHARACTER VARYING,\n"
|
||||
" LAST_LOGIN TIMESTAMP,\n"
|
||||
" PASSWORD CHARACTER VARYING\n"
|
||||
");\n")
|
||||
normalize)
|
||||
(-> (#'ai-sql-fixer.api/schema-sample query {:all-tables-limit 5})
|
||||
normalize))))))
|
||||
|
||||
(deftest ^:parallel no-used-table-test
|
||||
(mt/test-driver :postgres
|
||||
(let [query {:database (mt/id)
|
||||
:native {:query "SELECT * FROM x.orders1"}}
|
||||
normalize #(into #{} (str/split % #"(?<=;)\n"))]
|
||||
(is (= (-> (str "CREATE TABLE public.orders (\n"
|
||||
" id int4,\n"
|
||||
" user_id int4,\n"
|
||||
" product_id int4,\n"
|
||||
" subtotal float8,\n"
|
||||
" tax float8,\n"
|
||||
" total float8,\n"
|
||||
" discount float8,\n"
|
||||
" created_at timestamptz,\n"
|
||||
" quantity int4\n"
|
||||
");\n"
|
||||
"CREATE TABLE public.users (\n"
|
||||
" id int4,\n"
|
||||
" name text,\n"
|
||||
" last_login timestamp,\n"
|
||||
" password text\n"
|
||||
");\n")
|
||||
normalize)
|
||||
(-> (#'ai-sql-fixer.api/schema-sample query {:all-tables-limit 5})
|
||||
normalize))))))
|
||||
@@ -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,42 @@
|
||||
(ns metabase-enterprise.metabot-v3.api-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
|
||||
[metabase-enterprise.metabot-v3.tools.api :as metabot-v3.tools.api]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(deftest agent-test
|
||||
(let [ai-requests (atom [])
|
||||
conversation-id (str (random-uuid))
|
||||
question "what can you do?"
|
||||
navigation-target "url"
|
||||
historical-message {:role "user", :content "hello?"}
|
||||
agent-message {:role :assistant, :navigate-to navigation-target}
|
||||
agent-state {:key "value"}]
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(testing "Trivial request"
|
||||
(with-redefs [metabot-v3.client/request (fn [e]
|
||||
(swap! ai-requests conj e)
|
||||
{:messages [agent-message]
|
||||
:state agent-state})]
|
||||
(let [response (mt/user-http-request :rasta :post 200 "ee/metabot-v3/v2/agent"
|
||||
{:message question
|
||||
:context {}
|
||||
:conversation_id conversation-id
|
||||
:history [historical-message]
|
||||
:state {}})]
|
||||
(is (=? [{:context {:current_user_time (every-pred string? java.time.Instant/parse)}
|
||||
:messages [(update historical-message :role keyword) {:role :user, :content question}]
|
||||
:state {}
|
||||
:conversation-id conversation-id
|
||||
:session-id #(#'metabot-v3.tools.api/decode-ai-service-token %)}]
|
||||
@ai-requests))
|
||||
(is (=? {:reactions [{:type "metabot.reaction/redirect", :url navigation-target}]
|
||||
:history [historical-message
|
||||
{:role "user", :content question}
|
||||
(update agent-message :role name)]
|
||||
:state agent-state
|
||||
:conversation_id conversation-id}
|
||||
response))))))))
|
||||
@@ -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,509 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.api-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.metabot-v3.tools.api :as metabot-v3.tools.api]
|
||||
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription :as metabot-v3.tools.create-dashboard-subscription]
|
||||
[metabase-enterprise.metabot-v3.tools.filters :as metabot-v3.tools.filters]
|
||||
[metabase-enterprise.metabot-v3.tools.find-metric :as metabot-v3.tools.find-metric]
|
||||
[metabase-enterprise.metabot-v3.tools.find-outliers :as metabot-v3.tools.find-outliers]
|
||||
[metabase-enterprise.metabot-v3.tools.generate-insights :as metabot-v3.tools.generate-insights]
|
||||
[metabase.legacy-mbql.normalize :as mbql.normalize]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.lib.metadata.jvm :as lib.metadata.jvm]
|
||||
[metabase.models.field-values :as field-values]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- ai-session-token
|
||||
([] (ai-session-token :rasta))
|
||||
([user]
|
||||
(-> user mt/user->id (#'metabot-v3.tools.api/get-ai-service-token))))
|
||||
|
||||
(deftest create-dashboard-subscription-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [tool-requests (atom [])
|
||||
conversation-id (str (random-uuid))
|
||||
output (str (random-uuid))
|
||||
ai-token (ai-session-token)]
|
||||
(with-redefs [metabot-v3.tools.create-dashboard-subscription/create-dashboard-subscription
|
||||
(fn [arguments]
|
||||
(swap! tool-requests conj arguments)
|
||||
{:output output})]
|
||||
(let [response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/create-dashboard-subscription"
|
||||
{:request-options {:headers {"x-metabase-session" ai-token}}}
|
||||
{:arguments {:dashboard_id 1
|
||||
:email "user@example.com"
|
||||
:schedule {:frequency "hourly"}}
|
||||
:conversation_id conversation-id})]
|
||||
(is (=? {:output output
|
||||
:conversation_id conversation-id}
|
||||
response)))))))
|
||||
|
||||
(deftest filter-records-test
|
||||
(doseq [data-source [{:query {:database 1}, :query_id "query ID"}
|
||||
{:query {:database 1}}
|
||||
{:report_id 1}
|
||||
{:table_id "1"}]]
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [tool-requests (atom [])
|
||||
conversation-id (str (random-uuid))
|
||||
output (str (random-uuid))
|
||||
ai-token (ai-session-token)]
|
||||
(with-redefs [metabot-v3.tools.filters/filter-records
|
||||
(fn [arguments]
|
||||
(swap! tool-requests conj arguments)
|
||||
{:structured-output output})]
|
||||
(let [filters [{:field_id "q2a/1", :operation "is-not-null"}
|
||||
{:field_id "q2a/2", :operation "equals", :value "3"}
|
||||
{:field_id "q2a/3", :operation "equals", :values ["3" "4"]}
|
||||
{:field_id "q2a/5", :operation "not-equals", :values [3 4]}
|
||||
{:field_id "q2a/6", :operation "month-equals", :values [4 5 9]}
|
||||
{:field_id "c2a/6", :bucket "week-of-year" :operation "not-equals", :values [14 15 19]}
|
||||
{:field_id "q2a/6", :operation "year-equals", :value 2008}]
|
||||
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/filter-records"
|
||||
{:request-options {:headers {"x-metabase-session" ai-token}}}
|
||||
{:arguments {:data_source data-source
|
||||
:filters filters}
|
||||
:conversation_id conversation-id})]
|
||||
(is (=? {:structured_output output
|
||||
:conversation_id conversation-id}
|
||||
response))))))))
|
||||
|
||||
(deftest find-metric-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [tool-requests (atom [])
|
||||
conversation-id (str (random-uuid))
|
||||
output (str (random-uuid))
|
||||
ai-token (ai-session-token)]
|
||||
(with-redefs [metabot-v3.tools.find-metric/find-metric
|
||||
(fn [arguments]
|
||||
(swap! tool-requests conj arguments)
|
||||
{:structured-output output})]
|
||||
(let [response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/find-metric"
|
||||
{:request-options {:headers {"x-metabase-session" ai-token}}}
|
||||
{:arguments {:message "search text"}
|
||||
:conversation_id conversation-id})]
|
||||
(is (=? {:structured_output output
|
||||
:conversation_id conversation-id}
|
||||
response)))))))
|
||||
|
||||
(deftest find-outliers-test
|
||||
(doseq [data-source [{:query {:database 1}, :query_id "query ID", :result_field_id "q1233/2"}
|
||||
{:query {:database 1}, :result_field_id "q1233/2"}
|
||||
{:metric_id 1}
|
||||
{:report_id 1, :result_field_id "c131/3"}
|
||||
{:table_id "card__1", :result_field_id "t42/7"}]]
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [tool-requests (atom [])
|
||||
conversation-id (str (random-uuid))
|
||||
output (str (random-uuid))
|
||||
ai-token (ai-session-token)]
|
||||
(with-redefs [metabot-v3.tools.find-outliers/find-outliers
|
||||
(fn [arguments]
|
||||
(swap! tool-requests conj arguments)
|
||||
{:structured-output output})]
|
||||
(let [response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/find-outliers"
|
||||
{:request-options {:headers {"x-metabase-session" ai-token}}}
|
||||
{:arguments {:data_source data-source}
|
||||
:conversation_id conversation-id})]
|
||||
(is (=? {:structured_output output
|
||||
:conversation_id conversation-id}
|
||||
response))))))))
|
||||
|
||||
(deftest generate-insights-test
|
||||
(doseq [data-source [{:query {:database 1}}
|
||||
{:metric_id 1}
|
||||
{:report_id 1}
|
||||
{:table_id "card__1"}]]
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [tool-requests (atom [])
|
||||
conversation-id (str (random-uuid))
|
||||
output (str (random-uuid))
|
||||
redirect-url "https://example.com/redirect-target"
|
||||
ai-token (ai-session-token)]
|
||||
(with-redefs [metabot-v3.tools.generate-insights/generate-insights
|
||||
(fn [arguments]
|
||||
(swap! tool-requests conj arguments)
|
||||
{:output output
|
||||
:reactions [{:type :metabot.reaction/redirect
|
||||
:url redirect-url}]})]
|
||||
(let [response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/generate-insights"
|
||||
{:request-options {:headers {"x-metabase-session" ai-token}}}
|
||||
{:arguments {:for data-source}
|
||||
:conversation_id conversation-id})]
|
||||
(is (=? {:output output
|
||||
:reactions [{:type "metabot.reaction/redirect"
|
||||
:url redirect-url}]
|
||||
:conversation_id conversation-id}
|
||||
response))))))))
|
||||
|
||||
(deftest query-metric-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [tool-requests (atom [])
|
||||
conversation-id (str (random-uuid))
|
||||
output (str (random-uuid))
|
||||
ai-token (ai-session-token)]
|
||||
(with-redefs [metabot-v3.tools.filters/query-metric
|
||||
(fn [arguments]
|
||||
(swap! tool-requests conj arguments)
|
||||
{:structured-output output})]
|
||||
(let [filters [{:field_id "c2/7", :operation "number-greater-than", :value 50}
|
||||
{:field_id "c2/3", :operation "equals", :values ["3" "4"]}
|
||||
{:field_id "c2/5", :operation "not-equals", :values [3 4]}
|
||||
{:field_id "c2/6", :operation "month-equals", :values [4 5 9]}
|
||||
{:field_id "c2/6", :bucket "day-of-month" :operation "not-equals", :values [14 15 19]}
|
||||
{:field_id "c2/6", :bucket "day-of-week" :operation "equals", :values [1 7]}
|
||||
{:field_id "c2/6", :operation "year-equals", :value 2008}]
|
||||
breakouts [{:field_id "c2/4", :field_granularity "week"}
|
||||
{:field_id "c2/6", :field_granularity "day"}]
|
||||
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/query-metric"
|
||||
{:request-options {:headers {"x-metabase-session" ai-token}}}
|
||||
{:arguments {:metric_id 1
|
||||
:filters filters
|
||||
:group_by breakouts}
|
||||
:conversation_id conversation-id})]
|
||||
(is (= {:structured_output output
|
||||
:conversation_id conversation-id}
|
||||
response)))))))
|
||||
|
||||
(deftest query-model-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [tool-requests (atom [])
|
||||
conversation-id (str (random-uuid))
|
||||
output (str (random-uuid))
|
||||
ai-token (ai-session-token)]
|
||||
(with-redefs [metabot-v3.tools.filters/query-model
|
||||
(fn [arguments]
|
||||
(swap! tool-requests conj arguments)
|
||||
{:structured-output output})]
|
||||
(let [fields [{:field_id "c2/8", :bucket "year-of-era"}
|
||||
{:field_id "c2/9"}]
|
||||
filters [{:field_id "c2/7", :operation "number-greater-than", :value 50}
|
||||
{:field_id "c2/3", :operation "equals", :values ["3" "4"]}
|
||||
{:field_id "c2/5", :operation "not-equals", :values [3 4]}
|
||||
{:field_id "c2/6", :operation "month-equals", :values [4 5 9]}
|
||||
{:field_id "c2/6", :bucket "day-of-month" :operation "not-equals", :values [14 15 19]}
|
||||
{:field_id "c2/6", :bucket "day-of-week" :operation "equals", :values [1 7]}
|
||||
{:field_id "c2/6", :operation "year-equals", :value 2008}]
|
||||
aggregations [{:field_id "c2/10", :bucket "week", :function "count-distinct"}
|
||||
{:field_id "c2/11", :function "sum"}]
|
||||
breakouts [{:field_id "c2/4", :field_granularity "week"}
|
||||
{:field_id "c2/6", :field_granularity "day"}]
|
||||
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/query-model"
|
||||
{:request-options {:headers {"x-metabase-session" ai-token}}}
|
||||
{:arguments {:model_id 1
|
||||
:fields fields
|
||||
:filters filters
|
||||
:aggregations aggregations
|
||||
:group_by breakouts}
|
||||
:conversation_id conversation-id})]
|
||||
(is (= {:structured_output output
|
||||
:conversation_id conversation-id}
|
||||
response)))))))
|
||||
|
||||
(defn- int-sequence?
|
||||
[coll]
|
||||
(boolean (and (seqable? coll) (seq coll) (every? int? coll))))
|
||||
|
||||
(defn- string-sequence?
|
||||
[coll]
|
||||
(boolean (and (seqable? coll) (seq coll) (every? string? coll))))
|
||||
|
||||
(defn- ensure-field-values!
|
||||
[table-name-or-id]
|
||||
(run! field-values/create-or-update-full-field-values!
|
||||
(t2/reducible-select :model/Field :table_id (cond-> table-name-or-id (keyword? table-name-or-id) mt/id))))
|
||||
|
||||
(deftest answer-sources-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
|
||||
model-source-query (lib/query mp (lib.metadata/table mp (mt/id :products)))
|
||||
metric-source-query (-> model-source-query
|
||||
(lib/aggregate (lib/avg (lib.metadata/field mp (mt/id :products :rating))))
|
||||
(lib/breakout (lib/with-temporal-bucket
|
||||
(lib.metadata/field mp (mt/id :products :created_at)) :week)))
|
||||
metric-data {:name "Metrica"
|
||||
:description "Metric description"
|
||||
:dataset_query (lib/->legacy-MBQL metric-source-query)
|
||||
:type :metric}
|
||||
model-data {:name "Model model"
|
||||
:description "Model desc"
|
||||
:dataset_query (lib/->legacy-MBQL model-source-query)
|
||||
:type :model}]
|
||||
(with-redefs [metabot-v3.tools.api/metabot-collection-name (str (random-uuid))]
|
||||
(mt/with-temp [:model/Collection {collection-id :id} {:name metabot-v3.tools.api/metabot-collection-name}
|
||||
:model/Card {metric-id :id} (assoc metric-data :collection_id collection-id)
|
||||
:model/Card _ignored metric-data
|
||||
:model/Card _ignored model-data
|
||||
:model/Card {model-id :id} (assoc model-data :collection_id collection-id)]
|
||||
(let [model-metric-base-query (lib/query mp (lib.metadata/card mp model-id))
|
||||
rating-column (m/find-first (comp #{"RATING"} :name) (lib/visible-columns model-metric-base-query))
|
||||
created-at-column (m/find-first (comp #{"CREATED_AT"} :name) (lib/breakoutable-columns model-metric-base-query))
|
||||
model-metric-source-query (-> model-metric-base-query
|
||||
(lib/aggregate (lib/avg rating-column))
|
||||
(lib/breakout (lib/with-temporal-bucket created-at-column :week)))
|
||||
model-metric-data {:name "Model metric"
|
||||
:description "Model metric desc"
|
||||
:dataset_query (lib/->legacy-MBQL model-metric-source-query)
|
||||
:type :metric}]
|
||||
(mt/with-temp [:model/Card {model-metric-id :id} (assoc model-metric-data :collection_id collection-id)]
|
||||
(ensure-field-values! :products)
|
||||
(let [conversation-id (str (random-uuid))
|
||||
ai-token (ai-session-token)
|
||||
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/answer-sources"
|
||||
{:request-options {:headers {"x-metabase-session" ai-token}}}
|
||||
{:conversation_id conversation-id})
|
||||
expected-fields
|
||||
[{:name "ID", :type "number", :semantic_type "pk"}
|
||||
{:name "Ean", :type "string", :field_values string-sequence?}
|
||||
{:name "Title", :type "string", :semantic_type "title", :field_values string-sequence?}
|
||||
{:name "Category", :type "string", :semantic_type "category", :field_values string-sequence?}
|
||||
{:name "Vendor", :type "string", :semantic_type "company", :field_values string-sequence?}
|
||||
{:name "Price", :type "number"}
|
||||
{:name "Rating", :type "number", :semantic_type "score"}
|
||||
{:name "Created At", :type "datetime", :semantic_type "creation_timestamp"}]]
|
||||
(is (=? {:structured_output
|
||||
{:metrics [(-> metric-data
|
||||
(select-keys [:name :description])
|
||||
(assoc :id metric-id
|
||||
:type "metric"
|
||||
:default_time_dimension_field_id (format "c%d/%d" metric-id 7)
|
||||
:queryable_dimensions
|
||||
(map-indexed #(assoc %2 :field_id (format "c%d/%d" metric-id %1))
|
||||
expected-fields)))
|
||||
(-> model-metric-data
|
||||
(select-keys [:name :description])
|
||||
(assoc :id model-metric-id
|
||||
:type "metric"
|
||||
:default_time_dimension_field_id (format "c%d/%d" model-metric-id 7)
|
||||
:queryable_dimensions
|
||||
(map-indexed #(assoc %2 :field_id (format "c%d/%d" model-metric-id %1))
|
||||
expected-fields)))]
|
||||
:models [(-> model-data
|
||||
(select-keys [:name :description])
|
||||
(assoc :id model-id
|
||||
:type "model"
|
||||
:fields (map-indexed #(assoc %2 :field_id (format "c%d/%d" model-id %1))
|
||||
expected-fields)
|
||||
:metrics
|
||||
[{:id model-metric-id
|
||||
:name "Model metric"
|
||||
:description "Model metric desc"
|
||||
:default_time_dimension_field_id (format "c%d/%d" model-metric-id 7)}]))]}
|
||||
:conversation_id conversation-id}
|
||||
response))))))))))
|
||||
|
||||
(deftest ^:parallel get-current-user-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [conversation-id (str (random-uuid))
|
||||
ai-token (ai-session-token)
|
||||
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-current-user"
|
||||
{:request-options {:headers {"x-metabase-session" ai-token}}}
|
||||
{:conversation_id conversation-id})]
|
||||
(is (=? {:structured_output {:id (mt/user->id :rasta)
|
||||
:type "user"
|
||||
:name "Rasta Toucan"
|
||||
:email_address "rasta@metabase.com"}
|
||||
:conversation_id conversation-id}
|
||||
response)))))
|
||||
|
||||
(deftest ^:parallel get-dashboard-details-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [dash-data {:name "dashing dash", :description "dash description"}]
|
||||
(mt/with-temp [:model/Dashboard {dash-id :id} dash-data]
|
||||
(let [conversation-id (str (random-uuid))
|
||||
ai-token (ai-session-token)
|
||||
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-dashboard-details"
|
||||
{:request-options {:headers {"x-metabase-session" ai-token}}}
|
||||
{:arguments {:dashboard_id dash-id}
|
||||
:conversation_id conversation-id})]
|
||||
(is (=? {:structured_output (assoc dash-data :id dash-id, :type "dashboard")
|
||||
:conversation_id conversation-id}
|
||||
response)))))))
|
||||
|
||||
(deftest get-metric-details-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
|
||||
source-query (-> (lib/query mp (lib.metadata/table mp (mt/id :orders)))
|
||||
(lib/aggregate (lib/avg (lib.metadata/field mp (mt/id :orders :subtotal))))
|
||||
(lib/breakout (lib/with-temporal-bucket
|
||||
(lib.metadata/field mp (mt/id :orders :created_at)) :week)))
|
||||
metric-data {:name "Metrica"
|
||||
:description "Metric description"
|
||||
:dataset_query (lib/->legacy-MBQL source-query)
|
||||
:type :metric}]
|
||||
(mt/with-temp [:model/Card {metric-id :id} metric-data]
|
||||
(ensure-field-values! :orders)
|
||||
(ensure-field-values! :products)
|
||||
(let [conversation-id (str (random-uuid))
|
||||
ai-token (ai-session-token)
|
||||
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-metric-details"
|
||||
{:request-options {:headers {"x-metabase-session" ai-token}}}
|
||||
{:arguments {:metric_id metric-id}
|
||||
:conversation_id conversation-id})
|
||||
expected-fields
|
||||
[{:name "ID", :type "number", :semantic_type "pk"}
|
||||
{:name "User ID", :type "number", :semantic_type "fk"}
|
||||
{:name "Product ID", :type "number", :semantic_type "fk"}
|
||||
{:name "Subtotal", :type "number"}
|
||||
{:name "Tax", :type "number"}
|
||||
{:name "Total", :type "number"}
|
||||
{:name "Discount", :type "number", :semantic_type "discount"}
|
||||
{:name "Created At", :type "datetime", :semantic_type "creation_timestamp"}
|
||||
{:name "Quantity", :type "number", :semantic_type "quantity", :field_values int-sequence?}
|
||||
{:name "ID", :type "number", :semantic_type "pk", :table_reference "User"}
|
||||
{:name "Address", :type "string", :table_reference "User"}
|
||||
{:name "Email", :type "string", :semantic_type "email", :table_reference "User"}
|
||||
{:name "Password", :type "string", :table_reference "User"}
|
||||
{:name "Name", :type "string", :semantic_type "name", :table_reference "User"}
|
||||
{:name "City", :type "string", :semantic_type "city", :table_reference "User"}
|
||||
{:name "Longitude", :type "number", :semantic_type "longitude", :table_reference "User"}
|
||||
{:name "State", :type "string", :semantic_type "state", :table_reference "User"}
|
||||
{:name "Source", :type "string", :semantic_type "source", :table_reference "User"}
|
||||
{:name "Birth Date", :type "date", :table_reference "User"}
|
||||
{:name "Zip", :type "string", :table_reference "User"}
|
||||
{:name "Latitude", :type "number", :semantic_type "latitude", :table_reference "User"}
|
||||
{:name "Created At", :type "datetime", :semantic_type "creation_timestamp", :table_reference "User"}
|
||||
{:name "ID", :type "number", :semantic_type "pk", :table_reference "Product"}
|
||||
{:name "Ean", :type "string", :field_values string-sequence?, :table_reference "Product"}
|
||||
{:name "Title", :type "string"
|
||||
:semantic_type "title"
|
||||
:field_values string-sequence?
|
||||
:table_reference "Product"}
|
||||
{:name "Category"
|
||||
:type "string"
|
||||
:semantic_type "category"
|
||||
:field_values string-sequence?
|
||||
:table_reference "Product"}
|
||||
{:name "Vendor", :type "string"
|
||||
:semantic_type "company"
|
||||
:field_values string-sequence?
|
||||
:table_reference "Product"}
|
||||
{:name "Price", :type "number", :table_reference "Product"}
|
||||
{:name "Rating", :type "number", :semantic_type "score", :table_reference "Product"}
|
||||
{:name "Created At", :type "datetime", :semantic_type "creation_timestamp", :table_reference "Product"}]]
|
||||
(is (=? {:structured_output (-> metric-data
|
||||
(select-keys [:name :description])
|
||||
(assoc :id metric-id
|
||||
:type "metric"
|
||||
:default_time_dimension_field_id (format "c%d/%d" metric-id 7)
|
||||
:queryable_dimensions
|
||||
(map-indexed #(assoc %2 :field_id (format "c%d/%d" metric-id %1))
|
||||
expected-fields)))
|
||||
:conversation_id conversation-id}
|
||||
response)))))))
|
||||
|
||||
(deftest ^:parallel get-query-details-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
|
||||
source-query (-> (lib/query mp (lib.metadata/table mp (mt/id :products)))
|
||||
(lib/aggregate (lib/avg (lib.metadata/field mp (mt/id :products :rating))))
|
||||
(lib/breakout (lib/with-temporal-bucket
|
||||
(lib.metadata/field mp (mt/id :products :created_at)) :week)))
|
||||
query (lib/->legacy-MBQL source-query)
|
||||
conversation-id (str (random-uuid))
|
||||
ai-token (ai-session-token)
|
||||
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-query-details"
|
||||
{:request-options {:headers {"x-metabase-session" ai-token}}}
|
||||
{:arguments {:query query}
|
||||
:conversation_id conversation-id})
|
||||
generated-id (-> response :structured_output :query_id)]
|
||||
(is (=? {:structured_output {:type "query"
|
||||
:query_id string?
|
||||
:query query
|
||||
:result_columns
|
||||
[{:field_id (str "q" generated-id "/0"), :name "Created At: Week", :type "datetime"}
|
||||
{:field_id (str "q" generated-id "/1"), :name "Average of Rating", :type "number"}]}
|
||||
:conversation_id conversation-id}
|
||||
;; normalize query to convert strings like "field" to keywords
|
||||
(update-in response [:structured_output :query] mbql.normalize/normalize))))))
|
||||
|
||||
(deftest ^:parallel get-report-details-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
|
||||
source-query (-> (lib/query mp (lib.metadata/table mp (mt/id :products)))
|
||||
(lib/aggregate (lib/avg (lib.metadata/field mp (mt/id :products :rating))))
|
||||
(lib/breakout (lib/with-temporal-bucket
|
||||
(lib.metadata/field mp (mt/id :products :created_at)) :week)))
|
||||
question-data {:name "Question?"
|
||||
:description "Descriptive?"
|
||||
:dataset_query (lib/->legacy-MBQL source-query)
|
||||
:type :question}]
|
||||
(mt/with-temp [:model/Card {question-id :id} question-data]
|
||||
(let [conversation-id (str (random-uuid))
|
||||
ai-token (ai-session-token)
|
||||
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-report-details"
|
||||
{:request-options {:headers {"x-metabase-session" ai-token}}}
|
||||
{:arguments {:report_id question-id}
|
||||
:conversation_id conversation-id})]
|
||||
(is (=? {:structured_output (-> question-data
|
||||
(select-keys [:name :description])
|
||||
(assoc :id question-id
|
||||
:type "question"
|
||||
:result_columns
|
||||
(map-indexed #(assoc %2 :field_id (format "c%d/%d" question-id %1))
|
||||
[{:name "Created At: Week", :type "datetime"}
|
||||
{:name "Average of Rating", :type "number"}])))
|
||||
:conversation_id conversation-id}
|
||||
response)))))))
|
||||
|
||||
(deftest ^:parallel get-model-details-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
|
||||
source-query (-> (lib/query mp (lib.metadata/table mp (mt/id :products)))
|
||||
(lib/aggregate (lib/avg (lib.metadata/field mp (mt/id :products :rating))))
|
||||
(lib/breakout (lib/with-temporal-bucket
|
||||
(lib.metadata/field mp (mt/id :products :created_at)) :week)))
|
||||
model-data {:name "Model model"
|
||||
:description "Model desc"
|
||||
:dataset_query (lib/->legacy-MBQL source-query)
|
||||
:type :model}]
|
||||
(mt/with-temp [:model/Card {model-id :id} model-data]
|
||||
(doseq [arguments [{:model_id model-id}
|
||||
{:table_id (str "card__" model-id)}]]
|
||||
(let [conversation-id (str (random-uuid))
|
||||
ai-token (ai-session-token)
|
||||
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-table-details"
|
||||
{:request-options {:headers {"x-metabase-session" ai-token}}}
|
||||
{:arguments arguments
|
||||
:conversation_id conversation-id})]
|
||||
(is (=? {:structured_output (-> model-data
|
||||
(select-keys [:name :description])
|
||||
(assoc :id model-id
|
||||
:type "model"
|
||||
:fields
|
||||
(map-indexed #(assoc %2 :field_id (format "c%d/%d" model-id %1))
|
||||
[{:name "Created At: Week", :type "datetime"}
|
||||
{:name "Average of Rating", :type "number"}])))
|
||||
:conversation_id conversation-id}
|
||||
response))))))))
|
||||
|
||||
(deftest get-table-details-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
(let [table-id (mt/id :products)]
|
||||
(ensure-field-values! table-id)
|
||||
(doseq [arg-id [table-id (str table-id)]]
|
||||
(let [conversation-id (str (random-uuid))
|
||||
ai-token (ai-session-token)
|
||||
response (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-table-details"
|
||||
{:request-options {:headers {"x-metabase-session" ai-token}}}
|
||||
{:arguments {:table_id arg-id}
|
||||
:conversation_id conversation-id})
|
||||
expected-fields
|
||||
[{:name "ID", :type "number", :semantic_type "pk"}
|
||||
{:name "Ean", :type "string", :field_values string-sequence?}
|
||||
{:name "Title", :type "string", :semantic_type "title", :field_values string-sequence?}
|
||||
{:name "Category", :type "string", :semantic_type "category", :field_values string-sequence?}
|
||||
{:name "Vendor", :type "string", :semantic_type "company", :field_values string-sequence?}
|
||||
{:name "Price", :type "number"}
|
||||
{:name "Rating", :type "number", :semantic_type "score"}
|
||||
{:name "Created At", :type "datetime", :semantic_type "creation_timestamp"}]]
|
||||
(is (=? {:structured_output {:name "Products"
|
||||
:id table-id
|
||||
:type "table"
|
||||
:fields (map-indexed #(assoc %2 :field_id (format "t%d/%d" table-id %1))
|
||||
expected-fields)}
|
||||
:conversation_id conversation-id}
|
||||
response)))))))
|
||||
@@ -0,0 +1,52 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.create-dashboard-subscription-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription
|
||||
:as metabot-v3.tools.create-dashboard-subscription]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(deftest create-dashboard-subscription-test
|
||||
(mt/with-model-cleanup [:model/Pulse]
|
||||
(mt/with-temp [:model/User {:keys [email] user-id :id} {:email "email@example.com"}
|
||||
:model/Collection {collection-id :id} {}
|
||||
:model/Dashboard {dashboard-id :id} {:collection_id collection-id}
|
||||
:model/Card {card-id-0 :id} {}
|
||||
:model/DashboardTab {dashboard-tab-id-0 :id} {:dashboard_id dashboard-id
|
||||
:position 0}
|
||||
:model/DashboardCard _normal-dashcard-0 {:dashboard_id dashboard-id
|
||||
:card_id card-id-0
|
||||
:dashboard_tab_id dashboard-tab-id-0
|
||||
:row 1
|
||||
:col 0}
|
||||
:model/Card {card-id-1 :id} {}
|
||||
:model/DashboardTab {dashboard-tab-id-1 :id} {:dashboard_id dashboard-id
|
||||
:position 1}
|
||||
:model/DashboardCard _normal-dashcard-1 {:dashboard_id dashboard-id
|
||||
:card_id card-id-1
|
||||
:dashboard_tab_id dashboard-tab-id-1
|
||||
:row 2
|
||||
:col 3}
|
||||
:model/DashboardCard _cardless-dashcard {:dashboard_id dashboard-id
|
||||
:dashboard_tab_id dashboard-tab-id-0
|
||||
:row 0
|
||||
:col 0}]
|
||||
(let [invoke-tool #(binding [api/*current-user-id* user-id]
|
||||
(metabot-v3.tools.create-dashboard-subscription/create-dashboard-subscription %))
|
||||
base-data {:dashboard-id dashboard-id
|
||||
:email email
|
||||
:schedule {:frequency "monthly"
|
||||
:day_of_month "last-sunday"
|
||||
:hour 7}}]
|
||||
(testing "Subscription can be created"
|
||||
(is (= {:output "success"}
|
||||
(invoke-tool base-data))))
|
||||
(testing "Return an error message if the user cannot be found"
|
||||
(is (= {:output "no user with this email found"}
|
||||
(invoke-tool (update base-data :email str ".hu")))))
|
||||
(testing "Return an error message if the dashboard-id is invalid"
|
||||
(is (= {:output "invalid dashboard_id"}
|
||||
(invoke-tool (update base-data :dashboard-id str)))))
|
||||
(testing "Return an error message if the dashboard cannot be found"
|
||||
(is (= {:output "no dashboard with this dashboard_id found"}
|
||||
(invoke-tool (update base-data :dashboard-id -)))))))))
|
||||
@@ -0,0 +1,453 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.filters-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.metabot-v3.dummy-tools :as metabot-v3.dummy-tools]
|
||||
[metabase-enterprise.metabot-v3.tools.filters :as metabot-v3.tools.filters]
|
||||
[metabase.lib.convert :as lib.convert]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.lib.metadata.jvm :as lib.metadata.jvm]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util :as u]))
|
||||
|
||||
(defn- by-name
|
||||
[dimensions dimension-name]
|
||||
(let [pred (if (string? dimension-name)
|
||||
(comp #{dimension-name} :name)
|
||||
(every-pred (comp #{(first dimension-name)} :table-reference)
|
||||
(comp #{(second dimension-name)} :name)))]
|
||||
(m/find-first pred dimensions)))
|
||||
|
||||
(deftest ^:parallel query-metric-test
|
||||
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
|
||||
created-at-meta (lib.metadata/field mp (mt/id :orders :created_at))
|
||||
metric-query (-> (lib/query mp (lib.metadata/table mp (mt/id :orders)))
|
||||
(lib/aggregate (lib/avg (lib.metadata/field mp (mt/id :orders :subtotal))))
|
||||
(lib/breakout (lib/with-temporal-bucket created-at-meta :month)))
|
||||
legacy-metric-query (lib.convert/->legacy-MBQL metric-query)]
|
||||
(mt/with-temp [:model/Card {metric-id :id} {:dataset_query legacy-metric-query
|
||||
:database_id (mt/id)
|
||||
:name "Average Order Value"
|
||||
:description "The average subtotal of orders."
|
||||
:type :metric}]
|
||||
(testing "User has to have execution rights."
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You don't have permissions to do that."
|
||||
(metabot-v3.tools.filters/query-metric
|
||||
{:metric-id metric-id
|
||||
:filters []
|
||||
:group-by []}))))
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(let [metric-details (metabot-v3.dummy-tools/metric-details metric-id)
|
||||
->field-id #(u/prog1 (-> metric-details :queryable_dimensions (by-name %) :field_id)
|
||||
(when-not <>
|
||||
(throw (ex-info (str "Column " % " not found") {:column %}))))]
|
||||
(testing "Trivial query works."
|
||||
(is (=? {:structured-output {:type :query,
|
||||
:query_id string?
|
||||
:query {:database (mt/id)
|
||||
:type :query
|
||||
:query {:source-table (mt/id :orders)
|
||||
:aggregation [[:metric metric-id]]}}}}
|
||||
(metabot-v3.tools.filters/query-metric
|
||||
{:metric-id metric-id
|
||||
:filters []
|
||||
:group-by []}))))
|
||||
(testing "Filtering and grouping works and ignores bucketing for non-temporal columns."
|
||||
(is (=? {:structured-output {:type :query,
|
||||
:query_id string?
|
||||
:query {:database (mt/id)
|
||||
:type :query
|
||||
:query {:source-table (mt/id :orders)
|
||||
:aggregation [[:metric metric-id]]
|
||||
:breakout [[:field (mt/id :products :category)
|
||||
{:base-type :type/Text
|
||||
:source-field (mt/id :orders :product_id)}]]
|
||||
:filter
|
||||
[:and
|
||||
[:= [:field (mt/id :people :state)
|
||||
{:base-type :type/Text
|
||||
:source-field (mt/id :orders :user_id)}]
|
||||
"TX"]
|
||||
[:> [:field (mt/id :orders :discount)
|
||||
{:base-type :type/Float}]
|
||||
3]]}}}}
|
||||
(metabot-v3.tools.filters/query-metric
|
||||
{:metric-id metric-id
|
||||
:filters [{:field_id (->field-id ["User" "State"])
|
||||
:operation "string-equals"
|
||||
:value "TX"}
|
||||
{:field_id (->field-id "Discount")
|
||||
:operation "number-greater-than"
|
||||
:value 3}]
|
||||
:group-by [{:field_id (->field-id ["Product" "Category"])
|
||||
:field_granularity "year"}]}))))
|
||||
(testing "Temporal bucketing works for temporal columns."
|
||||
(is (=? {:structured-output {:type :query,
|
||||
:query_id string?
|
||||
:query {:database (mt/id)
|
||||
:type :query
|
||||
:query {:source-table (mt/id :orders)
|
||||
:aggregation [[:metric metric-id]]
|
||||
:filter [:and
|
||||
[:!=
|
||||
[:get-week
|
||||
[:field (mt/id :orders :created_at)
|
||||
{:base-type :type/DateTimeWithLocalTZ}]
|
||||
:iso]
|
||||
1 2 3]
|
||||
[:=
|
||||
[:field (mt/id :orders :created_at)
|
||||
{:base-type :type/DateTimeWithLocalTZ
|
||||
:temporal-unit :month-of-year}]
|
||||
6 7 8]]
|
||||
:breakout [[:field (mt/id :orders :created_at)
|
||||
{:base-type :type/DateTimeWithLocalTZ
|
||||
:temporal-unit :week}]]}}}}
|
||||
(metabot-v3.tools.filters/query-metric
|
||||
{:metric-id metric-id
|
||||
:filters [{:field_id (->field-id "Created At")
|
||||
:bucket "week-of-year"
|
||||
:operation "not-equals"
|
||||
:values [1 2 3]}
|
||||
{:field_id (->field-id "Created At")
|
||||
:bucket "month-of-year"
|
||||
:operation "equals"
|
||||
:values [6 7 8]}]
|
||||
:group-by [{:field_id (->field-id "Created At")
|
||||
:field_granularity "week"}]}))))
|
||||
(testing "Multi-value filtering works"
|
||||
(is (=? {:structured-output {:type :query,
|
||||
:query_id string?
|
||||
:query {:database (mt/id)
|
||||
:type :query
|
||||
:query {:source-table (mt/id :orders)
|
||||
:aggregation [[:metric metric-id]]
|
||||
:filter
|
||||
[:and
|
||||
[:starts-with {}
|
||||
[:field (mt/id :people :state)
|
||||
{:base-type :type/Text
|
||||
:source-field (mt/id :orders :user_id)}]
|
||||
"A" "G"]
|
||||
[:!=
|
||||
[:field (mt/id :orders :discount) {:base-type :type/Float}]
|
||||
3 42]]}}}}
|
||||
(metabot-v3.tools.filters/query-metric
|
||||
{:metric-id metric-id
|
||||
:filters [{:field_id (->field-id ["User" "State"])
|
||||
:operation "string-starts-with"
|
||||
:values ["A" "G"]}
|
||||
{:field_id (->field-id "Discount")
|
||||
:operation "not-equals"
|
||||
:values [3 42]}]})))))
|
||||
(testing "Missing metric results in an error."
|
||||
(is (= {:output (str "No metric found with metric_id " Integer/MAX_VALUE)}
|
||||
(metabot-v3.tools.filters/query-metric {:metric-id Integer/MAX_VALUE}))))
|
||||
(testing "Invalid metric-id results in an error."
|
||||
(is (= {:output (str "Invalid metric_id " metric-id)}
|
||||
(metabot-v3.tools.filters/query-metric {:metric-id (str metric-id)}))))))))
|
||||
|
||||
(deftest ^:parallel query-model-test
|
||||
(mt/with-temp [:model/Card {model-id :id} {:dataset_query (mt/mbql-query orders {})
|
||||
:database_id (mt/id)
|
||||
:name "Orders Model"
|
||||
:description "The _real_ orders."
|
||||
:type :model}]
|
||||
(testing "User has to have execution rights."
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You don't have permissions to do that."
|
||||
(metabot-v3.tools.filters/query-model
|
||||
{:model-id model-id
|
||||
:filters []
|
||||
:group-by []}))))
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(let [model-details (-> (metabot-v3.dummy-tools/get-table-details {:model-id model-id})
|
||||
:structured-output)
|
||||
model-card-id (str "card__" model-id)
|
||||
->field-id #(u/prog1 (-> model-details :fields (by-name %) :field_id)
|
||||
(when-not <>
|
||||
(throw (ex-info (str "Column " % " not found") {:column %}))))
|
||||
order-created-at-field-id (->field-id "Created At")]
|
||||
(testing "Trivial query works."
|
||||
(is (=? {:structured-output {:type :query,
|
||||
:query_id string?
|
||||
:query (mt/mbql-query orders {:source-table model-card-id})}}
|
||||
(metabot-v3.tools.filters/query-model
|
||||
{:model-id model-id
|
||||
:filters []
|
||||
:group-by []}))))
|
||||
(testing "Filtering, aggregation and grouping works and ignores bucketing for non-temporal columns."
|
||||
(is (=? {:structured-output {:type :query,
|
||||
:query_id string?
|
||||
:query (mt/mbql-query orders
|
||||
{:source-table model-card-id
|
||||
:aggregation [[:sum [:field "SUBTOTAL" {}]]]
|
||||
:breakout [[:field "PRODUCT_ID" {}]]
|
||||
:filter [:> [:field "DISCOUNT" {}] 3]})}}
|
||||
(metabot-v3.tools.filters/query-model
|
||||
{:model-id model-id
|
||||
:filters [{:field_id (->field-id "Discount")
|
||||
:operation "number-greater-than"
|
||||
:value 3}]
|
||||
:aggregations [{:field_id (->field-id "Subtotal")
|
||||
:bucket "day-of-month" ; ignored
|
||||
:function "sum"}]
|
||||
:group-by [{:field_id (->field-id "Product ID")
|
||||
:field_granularity "year"}]}))))
|
||||
(testing "Temporal bucketing works for temporal columns."
|
||||
(is (=? {:structured-output {:type :query,
|
||||
:query_id string?
|
||||
:query (mt/mbql-query orders
|
||||
{:source-table model-card-id
|
||||
:aggregation [[:min [:field "CREATED_AT"
|
||||
{:base-type :type/DateTimeWithLocalTZ
|
||||
:temporal-unit :hour-of-day}]]
|
||||
[:avg [:field "CREATED_AT"
|
||||
{:base-type :type/DateTimeWithLocalTZ
|
||||
:temporal-unit :hour-of-day}]]
|
||||
[:max [:field "CREATED_AT"
|
||||
{:base-type :type/DateTimeWithLocalTZ
|
||||
:temporal-unit :hour-of-day}]]]
|
||||
:filter [:and
|
||||
[:!= [:get-week [:field "CREATED_AT" {}] :iso] 1 2 3]
|
||||
[:=
|
||||
[:field "CREATED_AT"
|
||||
{:base-type :type/DateTimeWithLocalTZ
|
||||
:temporal-unit :month-of-year}]
|
||||
6 7 8]]
|
||||
:breakout [[:field "PRODUCT_ID" {}]
|
||||
[:field "CREATED_AT"
|
||||
{:base-type :type/DateTimeWithLocalTZ
|
||||
:temporal-unit :week}]]})}}
|
||||
(metabot-v3.tools.filters/query-model
|
||||
{:model-id model-id
|
||||
:filters [{:field_id order-created-at-field-id
|
||||
:bucket "week-of-year"
|
||||
:operation "not-equals"
|
||||
:values [1 2 3]}
|
||||
{:field_id order-created-at-field-id
|
||||
:bucket "month-of-year"
|
||||
:operation "equals"
|
||||
:values [6 7 8]}]
|
||||
:aggregations [{:field_id order-created-at-field-id
|
||||
:bucket "hour-of-day"
|
||||
:function "min"}
|
||||
{:field_id order-created-at-field-id
|
||||
:bucket "hour-of-day"
|
||||
:function "avg"}
|
||||
{:field_id order-created-at-field-id
|
||||
:bucket "hour-of-day"
|
||||
:function "max"}]
|
||||
:group-by [{:field_id (->field-id "Product ID")}
|
||||
{:field_id order-created-at-field-id
|
||||
:field_granularity "week"}]}))))
|
||||
(testing "Fields can be selected"
|
||||
(is (=? {:structured-output
|
||||
{:type :query,
|
||||
:query_id string?
|
||||
:query (mt/mbql-query orders
|
||||
{:source-table model-card-id
|
||||
:expressions {"Created At: Day of week" [:get-day-of-week [:field "CREATED_AT" {}] :iso]}
|
||||
:fields [[:field "CREATED_AT" {:base-type :type/DateTimeWithLocalTZ, :temporal-unit :day-of-month}]
|
||||
[:expression "Created At: Day of week" {:base-type :type/Integer}]
|
||||
[:field "TOTAL" {:base-type :type/Float}]]
|
||||
:filter [:!= [:field "USER_ID" {}] 3 42]})}}
|
||||
(metabot-v3.tools.filters/query-model
|
||||
{:model-id model-id
|
||||
:fields [{:field_id order-created-at-field-id
|
||||
:bucket "day-of-month"}
|
||||
{:field_id order-created-at-field-id
|
||||
:bucket "day-of-week"}
|
||||
{:field_id (->field-id "Total")}]
|
||||
:filters [{:field_id (->field-id "User ID")
|
||||
:operation "not-equals"
|
||||
:values [3 42]}]})))))
|
||||
(testing "Missing model results in an error."
|
||||
(is (= {:output (str "No model found with model_id " Integer/MAX_VALUE)}
|
||||
(metabot-v3.tools.filters/query-model {:model-id Integer/MAX_VALUE}))))
|
||||
(testing "Invalid model-id results in an error."
|
||||
(is (= {:output (str "Invalid model_id " model-id)}
|
||||
(metabot-v3.tools.filters/query-model {:model-id (str model-id)})))))))
|
||||
|
||||
(deftest ^:parallel filter-records-table-test
|
||||
(testing "User has to have execution rights, otherwise the table should be invisible."
|
||||
(is (= {:output (str "No table found with table_id " (mt/id :orders))}
|
||||
(metabot-v3.tools.filters/filter-records
|
||||
{:data-source {:table_id (mt/id :orders)}
|
||||
:filters []}))))
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
|
||||
table-id (mt/id :orders)
|
||||
table-details (#'metabot-v3.dummy-tools/table-details table-id {:metadata-provider mp})
|
||||
->field-id #(u/prog1 (-> table-details :fields (by-name %) :field_id)
|
||||
(when-not <>
|
||||
(throw (ex-info (str "Column " % " not found") {:column %}))))]
|
||||
(testing "Trivial query works."
|
||||
(is (=? {:structured-output {:type :query,
|
||||
:query_id string?
|
||||
:query {:database (mt/id)
|
||||
:type :query
|
||||
:query {:source-table table-id}}}}
|
||||
(metabot-v3.tools.filters/filter-records
|
||||
{:data-source {:table_id table-id}
|
||||
:filters []}))))
|
||||
(testing "Filtering works."
|
||||
(is (=? {:structured-output {:type :query,
|
||||
:query_id string?
|
||||
:query {:database (mt/id)
|
||||
:type :query
|
||||
:query {:source-table (mt/id :orders)
|
||||
:filter
|
||||
[:and
|
||||
[:=
|
||||
[:get-day-of-week
|
||||
[:field (mt/id :orders :created_at)
|
||||
{:base-type :type/DateTimeWithLocalTZ}]
|
||||
:iso]
|
||||
1 7]
|
||||
[:>
|
||||
[:field (mt/id :orders :discount) {:base-type :type/Float}]
|
||||
3]]}}}}
|
||||
(metabot-v3.tools.filters/filter-records
|
||||
{:data-source {:table_id table-id}
|
||||
:filters [{:field_id (->field-id "Created At")
|
||||
:bucket "day-of-week"
|
||||
:operation "equals"
|
||||
:values [1 7]}
|
||||
{:field_id (->field-id "Discount")
|
||||
:operation "number-greater-than"
|
||||
:value 3}]})))))
|
||||
(testing "Missing table results in an error."
|
||||
(is (= {:output (str "No table found with table_id " Integer/MAX_VALUE)}
|
||||
(metabot-v3.tools.filters/filter-records
|
||||
{:data-source {:table_id Integer/MAX_VALUE}}))))))
|
||||
|
||||
(deftest ^:parallel filter-records-model-test
|
||||
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
|
||||
query (lib/query mp (lib.metadata/table mp (mt/id :orders)))
|
||||
legacy-query (lib.convert/->legacy-MBQL query)]
|
||||
(mt/with-temp [:model/Card {model-id :id} {:dataset_query legacy-query
|
||||
:database_id (mt/id)
|
||||
:name "Orders"
|
||||
:description "The orders."
|
||||
:type :model}]
|
||||
(testing "User has to have execution rights."
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You don't have permissions to do that."
|
||||
(metabot-v3.tools.filters/filter-records
|
||||
{:data-source {:table_id (str "card__" model-id)}
|
||||
:filters []}))))
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(let [model-details (#'metabot-v3.dummy-tools/card-details model-id)
|
||||
table-id (str "card__" model-id)
|
||||
->field-id #(u/prog1 (-> model-details :fields (by-name %) :field_id)
|
||||
(when-not <>
|
||||
(throw (ex-info (str "Column " % " not found") {:column %}))))]
|
||||
(testing "Trivial query works."
|
||||
(is (=? {:structured-output {:type :query,
|
||||
:query_id string?
|
||||
:query {:database (mt/id)
|
||||
:type :query
|
||||
:query {:source-table table-id}}}}
|
||||
(metabot-v3.tools.filters/filter-records
|
||||
{:data-source {:table_id table-id}
|
||||
:filters []}))))
|
||||
(testing "Filtering works."
|
||||
(is (=? {:structured-output {:type :query,
|
||||
:query_id string?
|
||||
:query {:database (mt/id)
|
||||
:type :query
|
||||
:query {:source-table table-id
|
||||
:filter [:> [:field "DISCOUNT" {:base-type :type/Float}] 3]}}}}
|
||||
(metabot-v3.tools.filters/filter-records
|
||||
{:data-source {:table_id table-id}
|
||||
:filters [{:field_id (->field-id "Discount")
|
||||
:operation "number-greater-than"
|
||||
:value 3}]})))))
|
||||
(testing "Missing table results in an error."
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Not found."
|
||||
(metabot-v3.tools.filters/filter-records
|
||||
{:data-source {:table_id (str "card__" Integer/MAX_VALUE)}}))))))))
|
||||
|
||||
(deftest ^:parallel filter-records-report-test
|
||||
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
|
||||
table-id (mt/id :orders)
|
||||
query (lib/query mp (lib.metadata/table mp table-id))
|
||||
legacy-query (lib.convert/->legacy-MBQL query)]
|
||||
(mt/with-temp [:model/Card {card-id :id} {:dataset_query legacy-query
|
||||
:database_id (mt/id)
|
||||
:name "Orders"
|
||||
:description "The orders."
|
||||
:type :question}]
|
||||
(testing "User has to have execution rights."
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You don't have permissions to do that."
|
||||
(metabot-v3.tools.filters/filter-records
|
||||
{:data-source {:report_id card-id}
|
||||
:filters []}))))
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(let [report-details (#'metabot-v3.dummy-tools/card-details card-id)
|
||||
->field-id #(u/prog1 (-> report-details :fields (by-name %) :field_id)
|
||||
(when-not <>
|
||||
(throw (ex-info (str "Column " % " not found") {:column %}))))]
|
||||
(testing "Trivial query works."
|
||||
(is (=? {:structured-output {:type :query,
|
||||
:query_id string?
|
||||
:query {:database (mt/id)
|
||||
:type :query
|
||||
:query {:source-table table-id}}}}
|
||||
(metabot-v3.tools.filters/filter-records
|
||||
{:data-source {:report_id card-id}
|
||||
:filters []}))))
|
||||
(testing "Filtering works."
|
||||
(is (=? {:structured-output {:type :query,
|
||||
:query_id string?
|
||||
:query {:database (mt/id)
|
||||
:type :query
|
||||
:query {:source-table table-id
|
||||
:filter [:>
|
||||
[:field
|
||||
(mt/id :orders :discount)
|
||||
{:base-type :type/Float}]
|
||||
3]}}}}
|
||||
(metabot-v3.tools.filters/filter-records
|
||||
{:data-source {:report_id card-id}
|
||||
:filters [{:field_id (->field-id "Discount")
|
||||
:operation "number-greater-than"
|
||||
:value 3}]})))))
|
||||
(testing "Missing table results in an error."
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Not found."
|
||||
(metabot-v3.tools.filters/filter-records
|
||||
{:data-source {:report_id Integer/MAX_VALUE}}))))))))
|
||||
|
||||
(deftest ^:parallel filter-records-query-test
|
||||
(let [query-id (u/generate-nano-id)
|
||||
mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
|
||||
table-id (mt/id :orders)
|
||||
query (lib/query mp (lib.metadata/table mp table-id))
|
||||
legacy-query (lib.convert/->legacy-MBQL query)
|
||||
query-details (#'metabot-v3.dummy-tools/execute-query query-id legacy-query)
|
||||
->field-id #(u/prog1 (-> query-details :result_columns (by-name %) :field_id)
|
||||
(when-not <>
|
||||
(throw (ex-info (str "Column " % " not found") {:column %}))))
|
||||
input {:data-source {:query_id query-id}
|
||||
:filters [{:field_id (->field-id "Discount")
|
||||
:operation "number-greater-than"
|
||||
:value 3}]}
|
||||
expected {:structured-output {:type :query,
|
||||
:query_id string?
|
||||
:query {:database (mt/id)
|
||||
:type :query
|
||||
:query {:source-query {:source-table table-id}
|
||||
:filter [:>
|
||||
[:field
|
||||
"DISCOUNT"
|
||||
{:base-type :type/Float}]
|
||||
3]}}}}]
|
||||
(testing "Filtering works."
|
||||
(testing "new tool call with query and query_id"
|
||||
(is (=? expected
|
||||
(metabot-v3.tools.filters/filter-records
|
||||
(assoc input :data-source (select-keys query-details [:query :query_id]))))))
|
||||
(testing "new tool call with just query"
|
||||
(is (=? expected
|
||||
(metabot-v3.tools.filters/filter-records
|
||||
(assoc input :data-source (select-keys query-details [:query])))))))))
|
||||
@@ -0,0 +1,148 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.find-outliers-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
|
||||
[metabase-enterprise.metabot-v3.dummy-tools :as metabot-v3.dummy-tools]
|
||||
[metabase-enterprise.metabot-v3.tools.find-outliers :as metabot-v3.tools.find-outliers]
|
||||
[metabase.lib.convert :as lib.convert]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.lib.metadata.jvm :as lib.metadata.jvm]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util :as u]))
|
||||
|
||||
(defn- by-name
|
||||
[dimensions dimension-name]
|
||||
(m/find-first (comp #{dimension-name} :name) dimensions))
|
||||
|
||||
(defn- test-card
|
||||
[]
|
||||
(let [mp (lib.metadata.jvm/application-database-metadata-provider (mt/id))
|
||||
created-at-meta (lib.metadata/field mp (mt/id :orders :created_at))
|
||||
query (-> (lib/query mp (lib.metadata/table mp (mt/id :orders)))
|
||||
(lib/aggregate (lib/avg (lib.metadata/field mp (mt/id :orders :subtotal))))
|
||||
(lib/breakout (lib/with-temporal-bucket created-at-meta :year)))
|
||||
legacy-query (lib.convert/->legacy-MBQL query)]
|
||||
{:dataset_query legacy-query
|
||||
:database_id (mt/id)
|
||||
:name "Average Order Value"
|
||||
:description "The average subtotal of orders."}))
|
||||
|
||||
(def ^:private test-dimensions
|
||||
[{:dimension "2016-01-01T00:00:00Z", :value "54.43"}
|
||||
{:dimension "2017-01-01T00:00:00Z", :value "54.66"}
|
||||
{:dimension "2018-01-01T00:00:00Z", :value "83.72"}
|
||||
{:dimension "2019-01-01T00:00:00Z", :value "84.07"}
|
||||
{:dimension "2020-01-01T00:00:00Z", :value "84.68"}])
|
||||
|
||||
(def ^:private normalize-value-xf
|
||||
(map (fn [dimension] (update dimension :value #(-> % str (subs 0 5))))))
|
||||
|
||||
(defn- ai-find-outliers
|
||||
"Simulates the AI service finding the outliers in `dimensions`."
|
||||
[dimensions]
|
||||
(into [] (comp (take 2) normalize-value-xf) dimensions))
|
||||
|
||||
(def ^:private test-outliers
|
||||
(ai-find-outliers test-dimensions))
|
||||
|
||||
(defn- execute-test!
|
||||
[call-tool]
|
||||
(testing "User has to have execution rights."
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You don't have permissions to do that."
|
||||
(call-tool))))
|
||||
(testing "Request gets forwarded to AI service."
|
||||
(let [input-dimensions (atom nil)]
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(with-redefs [metabot-v3.client/find-outliers-request
|
||||
(fn [dimensions] (ai-find-outliers (reset! input-dimensions dimensions)))]
|
||||
(is (= {:structured-output test-outliers}
|
||||
(call-tool)))
|
||||
(is (= test-dimensions
|
||||
(into [] normalize-value-xf @input-dimensions))))))))
|
||||
|
||||
(deftest metric-find-outliers-test
|
||||
(mt/with-temp [:model/Card {metric-id :id} (assoc (test-card) :type :metric)]
|
||||
(execute-test! #(metabot-v3.tools.find-outliers/find-outliers
|
||||
{:data-source {:metric_id metric-id}}))))
|
||||
|
||||
(deftest report-find-outliers-test
|
||||
(mt/with-temp [:model/Card {report-id :id} (assoc (test-card) :type :question)]
|
||||
(let [report-details (mt/with-current-user (mt/user->id :crowberto)
|
||||
(#'metabot-v3.dummy-tools/card-details report-id))
|
||||
->field-id #(u/prog1 (-> report-details :fields (by-name %) :field_id)
|
||||
(when-not <>
|
||||
(throw (ex-info (str "Column " % " not found") {:column %}))))
|
||||
result-field-id (->field-id "Average of Subtotal")]
|
||||
(execute-test! #(metabot-v3.tools.find-outliers/find-outliers
|
||||
{:data-source {:report_id report-id
|
||||
:result_field_id result-field-id}})))))
|
||||
|
||||
(deftest query-find-outliers-test
|
||||
(let [query-id (u/generate-nano-id)
|
||||
query-details (#'metabot-v3.dummy-tools/execute-query query-id (:dataset_query (test-card)))
|
||||
->field-id #(u/prog1 (-> query-details :result_columns (by-name %) :field_id)
|
||||
(when-not <>
|
||||
(throw (ex-info (str "Column " % " not found") {:column %}))))
|
||||
result-field-id (->field-id "Average of Subtotal")]
|
||||
(testing "new style tool call with query and query_id"
|
||||
(execute-test! #(metabot-v3.tools.find-outliers/find-outliers
|
||||
{:data-source {:query (:query query-details)
|
||||
:query_id query-id
|
||||
:result_field_id result-field-id}})))
|
||||
(testing "new style tool call with just query"
|
||||
(execute-test! #(metabot-v3.tools.find-outliers/find-outliers
|
||||
{:data-source {:query (:query query-details)
|
||||
:result_field_id result-field-id}})))))
|
||||
|
||||
(deftest ^:parallel metric-find-outliers-no-temporal-dimension-test
|
||||
(mt/with-temp [:model/Card {metric-id :id} (-> (test-card)
|
||||
(m/dissoc-in [:dataset_query :query :breakout]
|
||||
[:dataset_query :query :breakout-idents])
|
||||
(assoc :type :metric))]
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(is (= {:output "No temporal dimension found. Outliers can only be detected when a temporal dimension is available."}
|
||||
(metabot-v3.tools.find-outliers/find-outliers
|
||||
{:data-source {:metric_id metric-id}}))))))
|
||||
|
||||
(deftest ^:parallel metric-find-outliers-no-numeric-dimension-test
|
||||
(mt/with-temp [:model/Card {metric-id :id} (-> (test-card)
|
||||
(m/dissoc-in [:dataset_query :query :aggregation]
|
||||
[:dataset_query :query :aggregation-idents])
|
||||
(assoc :type :metric))]
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(is (= {:output "Could not determine result field."}
|
||||
(metabot-v3.tools.find-outliers/find-outliers
|
||||
{:data-source {:metric_id metric-id}}))))))
|
||||
|
||||
(deftest ^:parallel find-outliers-wrong-query-test
|
||||
(let [query-id (u/generate-nano-id)
|
||||
query-details (#'metabot-v3.dummy-tools/execute-query query-id (:dataset_query (test-card)))
|
||||
->field-id #(u/prog1 (-> query-details :result_columns (by-name %) :field_id)
|
||||
(when-not <>
|
||||
(throw (ex-info (str "Column " % " not found") {:column %}))))
|
||||
result-field-id (->field-id "Average of Subtotal")]
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(are [details output] (= {:output output}
|
||||
(metabot-v3.tools.find-outliers/find-outliers
|
||||
{:data-source {:query (:query details)
|
||||
:result_field_id result-field-id}}))
|
||||
|
||||
(assoc-in query-details [:query :query :source-table] Integer/MAX_VALUE)
|
||||
"Unexpected error running query"
|
||||
|
||||
(m/dissoc-in query-details [:query :query :breakout])
|
||||
"No temporal dimension found. Outliers can only be detected when a temporal dimension is available.")
|
||||
(let [wrong-result-field-id (str result-field-id "99999")]
|
||||
(is (= {:output (str "Invalid result_field_id " wrong-result-field-id)}
|
||||
(metabot-v3.tools.find-outliers/find-outliers
|
||||
{:data-source {:query (:query query-details)
|
||||
:result_field_id wrong-result-field-id}})))))))
|
||||
|
||||
(deftest ^:parallel invalid-ids-test
|
||||
(are [data-source output] (= {:output output}
|
||||
(metabot-v3.tools.find-outliers/find-outliers {:data-source data-source}))
|
||||
{:metric_id "42"} "Invalid metric_id as data_source"
|
||||
{:report_id "42"} "Invalid report_id as data_source"
|
||||
{:table_id 42} "Invalid data_source"))
|
||||
@@ -0,0 +1,49 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.generate-insights-test
|
||||
(:require
|
||||
[buddy.core.codecs :as codecs]
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.metabot-v3.tools.generate-insights :as metabot-v3.tools.generate-insights]
|
||||
[metabase.util.json :as json]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(deftest ^:parallel generate-insights-test
|
||||
(testing "Metrics"
|
||||
(let [results-url "/auto/dashboard/question/42"]
|
||||
(is (= {:output results-url
|
||||
:reactions [{:type :metabot.reaction/redirect :url results-url}]}
|
||||
(metabot-v3.tools.generate-insights/generate-insights {:for {:metric_id 42}})))))
|
||||
(testing "Reports"
|
||||
(let [results-url "/auto/dashboard/question/42"]
|
||||
(is (= {:output results-url
|
||||
:reactions [{:type :metabot.reaction/redirect :url results-url}]}
|
||||
(metabot-v3.tools.generate-insights/generate-insights {:for {:report_id 42}})))))
|
||||
(testing "Tables"
|
||||
(let [results-url "/auto/dashboard/table/42"]
|
||||
(are [id] (= {:output results-url
|
||||
:reactions [{:type :metabot.reaction/redirect :url results-url}]}
|
||||
(metabot-v3.tools.generate-insights/generate-insights {:for {:table_id id}}))
|
||||
42
|
||||
"42"))
|
||||
(let [results-url "/auto/dashboard/table/card__42"]
|
||||
(is (= {:output results-url
|
||||
:reactions [{:type :metabot.reaction/redirect :url results-url}]}
|
||||
(metabot-v3.tools.generate-insights/generate-insights {:for {:table_id "card__42"}}))))
|
||||
(are [id] (= {:output "Invalid table_id"}
|
||||
(metabot-v3.tools.generate-insights/generate-insights {:for {:table_id id}}))
|
||||
:42
|
||||
"table_name"))
|
||||
(testing "Queries"
|
||||
(let [query {:database 1
|
||||
:type :query
|
||||
:query {:source-table 3
|
||||
:breakout [[:field 13 {:base-type :type/DateTime, :temporal-unit :month}]]
|
||||
:aggregation [[:metric 123]]
|
||||
:filter [:=
|
||||
[:get-year [:field 13 {:base-type :type/DateTime, :temporal-unit :month}]]
|
||||
2024]}}
|
||||
entity-id (-> query json/encode .getBytes codecs/bytes->b64-str)
|
||||
results-url (str "/auto/dashboard/adhoc/" entity-id)]
|
||||
(is (= {:output results-url
|
||||
:reactions [{:type :metabot.reaction/redirect :url results-url}]}
|
||||
(metabot-v3.tools.generate-insights/generate-insights {:for {:query query}}))))))
|
||||
@@ -0,0 +1,22 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.invite-user-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.metabot-v3.tools.invite-user :as metabot-v3.tools.invite-user]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(deftest invite-user-test
|
||||
(mt/with-model-cleanup [:model/User]
|
||||
(let [invoke-tool #(metabot-v3.tools.invite-user/invite-user {:email %})]
|
||||
(testing "The user has to have permission to invite users."
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You don't have permissions to do that."
|
||||
(invoke-tool "user@example.com"))))
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(testing "User can be invited"
|
||||
(is (= {:output "user@example.com has been invited to Metabase."}
|
||||
(invoke-tool "user@example.com"))))
|
||||
(testing "Return an error message if the user already exists."
|
||||
(is (= {:output "Email address already in use."}
|
||||
(invoke-tool "crowberto@metabase.com"))))
|
||||
(testing "Return an error message if email is invalid."
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Invalid input: .*"
|
||||
(invoke-tool "total garbage, not an email address"))))))))
|
||||
@@ -0,0 +1,24 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.show-results-to-user-test
|
||||
(:require
|
||||
[buddy.core.codecs :as codecs]
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.metabot-v3.tools.show-results-to-user :as metabot-v3.tools.show-results-to-user]
|
||||
[metabase.public-settings :as public-settings]
|
||||
[metabase.util.json :as json]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(deftest it-generates-a-reaction
|
||||
(let [query {:database 1
|
||||
:type :query
|
||||
:query {:source-table 3
|
||||
:breakout [[:field 13 {:base-type :type/DateTime, :temporal-unit :month}]]
|
||||
:aggregation [[:metric 123]]
|
||||
:filter [:=
|
||||
[:get-year [:field 13 {:base-type :type/DateTime, :temporal-unit :month}]]
|
||||
2024]}}
|
||||
query-hash (-> {:dataset_query query} json/encode .getBytes codecs/bytes->b64-str)
|
||||
results-url (str "/question#" query-hash)]
|
||||
(is (= {:output (str "Results can be seen at: " (public-settings/site-url) results-url)
|
||||
:reactions [{:type :metabot.reaction/redirect, :url results-url}]}
|
||||
(metabot-v3.tools.show-results-to-user/show-results-to-user {:query query})))))
|
||||
@@ -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,6 +1,10 @@
|
||||
export * from "./ai-sql-fixer";
|
||||
export * from "./audit-info";
|
||||
export * from "./api";
|
||||
export * from "./billing-info";
|
||||
export * from "./metabot";
|
||||
export * from "./gsheets";
|
||||
export * from "./query-validation";
|
||||
export * from "./scim";
|
||||
export * from "./tags";
|
||||
export * from "./upload-management";
|
||||
|
||||
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/v2/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,116 @@
|
||||
import cx from "classnames";
|
||||
|
||||
import Styles from "./MetabotIcon.module.css";
|
||||
|
||||
const COLORS = {
|
||||
brand: "var(--mb-color-brand)",
|
||||
brandLighter: "var(--mb-color-brand-lighter)",
|
||||
focus: "var(--mb-color-focus)",
|
||||
summarize: "var(--mb-color-summarize)",
|
||||
};
|
||||
|
||||
export const MetabotIcon = ({ isLoading }: { isLoading: boolean }) => {
|
||||
const dotsStrokeColor = isLoading ? COLORS.summarize : COLORS.brand;
|
||||
const dotsFillColor = isLoading ? COLORS.summarize : COLORS.focus;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width="33"
|
||||
height="24"
|
||||
viewBox="0 0 33 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<rect
|
||||
x="0.75"
|
||||
y="0.75"
|
||||
width="30.8721"
|
||||
height="22.5"
|
||||
rx="3.25"
|
||||
fill={COLORS.focus}
|
||||
stroke={COLORS.brand}
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<rect
|
||||
x="3.90698"
|
||||
y="3.34961"
|
||||
width="24.5581"
|
||||
height="14.5116"
|
||||
rx="2"
|
||||
fill={COLORS.brandLighter}
|
||||
stroke={COLORS.brand}
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{isLoading && (
|
||||
<>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M18 13H15V12H18V13Z"
|
||||
fill={COLORS.brand}
|
||||
/>
|
||||
<path
|
||||
d="M10 8.5C10 9 10.8 10 12 10C13.2 10 13.8333 9 14 8.5"
|
||||
stroke={COLORS.brand}
|
||||
/>
|
||||
<path
|
||||
d="M19 8.5C19 9 19.8 10 21 10C22.2 10 22.8333 9 23 8.5"
|
||||
stroke={COLORS.brand}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!isLoading && (
|
||||
<>
|
||||
<path
|
||||
d="M17.3023 11.4414H17.8605C17.8605 12.4289 17.2754 13.1158 16.186 13.1158C15.0967 13.1158 14.5116 12.4289 14.5116 11.4414H15.0698C15.0698 11.9995 15.3488 12.5577 16.186 12.5577C17.0233 12.5577 17.3023 11.9995 17.3023 11.4414Z"
|
||||
fill={COLORS.brand}
|
||||
/>
|
||||
<circle cx="11.5814" cy="8.7912" r="0.976744" fill={COLORS.brand} />
|
||||
<ellipse
|
||||
cx="20.5117"
|
||||
cy="8.7912"
|
||||
rx="0.976745"
|
||||
ry="0.976744"
|
||||
fill={COLORS.brand}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{/* dots */}
|
||||
{isLoading && (
|
||||
<circle
|
||||
cx="21.1163"
|
||||
cy="20.6509"
|
||||
r="1.11628"
|
||||
fill={dotsFillColor}
|
||||
className={cx(
|
||||
Styles.dot,
|
||||
isLoading && Styles.dotLoading,
|
||||
isLoading && Styles.dotOneLoading,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<path
|
||||
d="M27.9651 20.6514C27.9651 20.9918 27.6892 21.2677 27.3488 21.2677C27.0085 21.2677 26.7325 20.9918 26.7325 20.6514C26.7325 20.3111 27.0085 20.0352 27.3488 20.0352C27.6892 20.0352 27.9651 20.3111 27.9651 20.6514Z"
|
||||
fill={dotsFillColor}
|
||||
stroke={dotsStrokeColor}
|
||||
className={cx(
|
||||
Styles.dot,
|
||||
isLoading && Styles.dotLoading,
|
||||
isLoading && Styles.dotTwoLoading,
|
||||
)}
|
||||
/>
|
||||
<circle
|
||||
cx="24.2791"
|
||||
cy="20.6514"
|
||||
r="0.61628"
|
||||
fill={dotsFillColor}
|
||||
stroke={dotsStrokeColor}
|
||||
className={cx(
|
||||
Styles.dot,
|
||||
isLoading && Styles.dotLoading,
|
||||
isLoading && Styles.dotThreeLoading,
|
||||
)}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
64
enterprise/frontend/src/metabase-enterprise/metabot/hooks.ts
Normal file
64
enterprise/frontend/src/metabase-enterprise/metabot/hooks.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import { useMetabotContext } from "metabase/metabot";
|
||||
import { METABOT_TAG, useMetabotAgentMutation } from "metabase-enterprise/api";
|
||||
|
||||
import {
|
||||
dismissUserMessage,
|
||||
getConfirmationOptions,
|
||||
getIsProcessing,
|
||||
getMetabotVisisble,
|
||||
getUserMessages,
|
||||
setVisible,
|
||||
submitInput,
|
||||
} from "./state";
|
||||
|
||||
export const useMetabotAgent = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { getChatContext } = useMetabotContext();
|
||||
|
||||
// TODO: create an enterprise useSelector
|
||||
const userMessages = useSelector(getUserMessages as any) as ReturnType<
|
||||
typeof getUserMessages
|
||||
>;
|
||||
const confirmationOptions = useSelector(
|
||||
getConfirmationOptions as any,
|
||||
) as ReturnType<typeof getConfirmationOptions>;
|
||||
const isProcessing = useSelector(getIsProcessing as any) as ReturnType<
|
||||
typeof getIsProcessing
|
||||
>;
|
||||
|
||||
const [, sendMessageReq] = useMetabotAgentMutation({
|
||||
fixedCacheKey: METABOT_TAG,
|
||||
});
|
||||
|
||||
return {
|
||||
visible: useSelector(getMetabotVisisble as any) as ReturnType<
|
||||
typeof getMetabotVisisble
|
||||
>,
|
||||
setVisible: useCallback(
|
||||
(isVisible: boolean) => dispatch(setVisible(isVisible)),
|
||||
[dispatch],
|
||||
),
|
||||
userMessages,
|
||||
confirmationOptions,
|
||||
dismissUserMessage: (messageIndex: number) =>
|
||||
dispatch(dismissUserMessage(messageIndex)),
|
||||
submitInput: useCallback(
|
||||
async (message: string) => {
|
||||
const context = getChatContext();
|
||||
const history = sendMessageReq.data?.history || [];
|
||||
const state = sendMessageReq.data?.state || {};
|
||||
await dispatch(submitInput({ message, context, history, state }));
|
||||
},
|
||||
[
|
||||
dispatch,
|
||||
getChatContext,
|
||||
sendMessageReq.data?.history,
|
||||
sendMessageReq.data?.state,
|
||||
],
|
||||
),
|
||||
isDoingScience: sendMessageReq.isLoading || isProcessing,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { METAKEY } from "metabase/lib/browser";
|
||||
import type { PaletteAction } from "metabase/palette/types";
|
||||
import {
|
||||
PLUGIN_GO_MENU,
|
||||
PLUGIN_METABOT,
|
||||
PLUGIN_REDUCERS,
|
||||
} from "metabase/plugins";
|
||||
import { Flex, Text } from "metabase/ui";
|
||||
import { hasPremiumFeature } from "metabase-enterprise/settings";
|
||||
|
||||
import { Metabot } from "./components/Metabot";
|
||||
import { MetabotContext, MetabotProvider, defaultContext } from "./context";
|
||||
import { useMetabotAgent } from "./hooks";
|
||||
import { metabotReducer, setVisible } from "./state";
|
||||
|
||||
if (hasPremiumFeature("metabot_v3")) {
|
||||
PLUGIN_METABOT.Metabot = Metabot;
|
||||
|
||||
PLUGIN_METABOT.defaultMetabotContextValue = defaultContext;
|
||||
PLUGIN_METABOT.MetabotContext = MetabotContext;
|
||||
PLUGIN_METABOT.getMetabotProvider = () => MetabotProvider;
|
||||
PLUGIN_METABOT.useMetabotPalletteActions = (searchText: string) => {
|
||||
const { submitInput, setVisible } = useMetabotAgent();
|
||||
|
||||
return useMemo(() => {
|
||||
const ret: PaletteAction[] = [
|
||||
{
|
||||
id: "initialize_metabot",
|
||||
name: searchText
|
||||
? t`Ask Metabot, "${searchText}"`
|
||||
: t`Ask me to do something, or ask me a question`,
|
||||
section: "metabot",
|
||||
keywords: searchText,
|
||||
icon: "metabot",
|
||||
perform: _currentActionImpl => {
|
||||
setVisible(true);
|
||||
if (searchText) {
|
||||
submitInput(searchText);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
return ret;
|
||||
}, [searchText, submitInput, setVisible]);
|
||||
};
|
||||
|
||||
PLUGIN_REDUCERS.metabotPlugin = metabotReducer;
|
||||
|
||||
PLUGIN_GO_MENU.getMenuItems = dispatch => [
|
||||
{
|
||||
title: (
|
||||
<Flex align="center" justify="space-between" gap="md">
|
||||
<div>{t`Metabot request`}</div>
|
||||
<Text fz="sm" c="text-light">
|
||||
{METAKEY} + B
|
||||
</Text>
|
||||
</Flex>
|
||||
),
|
||||
icon: "metabot",
|
||||
action: () => dispatch(setVisible(true)),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
import { combineReducers } from "@reduxjs/toolkit";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import fetchMock from "fetch-mock";
|
||||
import { P, isMatching } from "ts-pattern";
|
||||
|
||||
import { setupEnterprisePlugins } from "__support__/enterprise";
|
||||
import { mockSettings } from "__support__/settings";
|
||||
import { act, renderWithProviders, screen, waitFor } from "__support__/ui";
|
||||
import { logout } from "metabase/auth/actions";
|
||||
import * as domModule from "metabase/lib/dom";
|
||||
import { isUuid, uuid } from "metabase/lib/uuid";
|
||||
import { useRegisterMetabotContextProvider } from "metabase/metabot";
|
||||
import type { User } from "metabase-types/api";
|
||||
import {
|
||||
createMockTokenFeatures,
|
||||
createMockUser,
|
||||
} from "metabase-types/api/mocks";
|
||||
import { createMockState } from "metabase-types/store/mocks";
|
||||
|
||||
import { Metabot } from "./components/Metabot";
|
||||
import { METABOT_AUTO_CLOSE_DURATION_MS } from "./components/MetabotChat";
|
||||
import { MetabotProvider } from "./context";
|
||||
import {
|
||||
type MetabotState,
|
||||
metabotInitialState,
|
||||
metabotReducer,
|
||||
setIsProcessing,
|
||||
setVisible,
|
||||
} from "./state";
|
||||
|
||||
function setup(
|
||||
options: {
|
||||
ui?: React.ReactElement;
|
||||
metabotPluginInitialState?: MetabotState;
|
||||
currentUser?: User | null | undefined;
|
||||
} | void,
|
||||
) {
|
||||
const settings = mockSettings({
|
||||
"token-features": createMockTokenFeatures({
|
||||
metabot_v3: true,
|
||||
}),
|
||||
});
|
||||
|
||||
setupEnterprisePlugins();
|
||||
|
||||
const {
|
||||
ui = <Metabot />,
|
||||
currentUser = createMockUser(),
|
||||
metabotPluginInitialState = {
|
||||
...metabotInitialState,
|
||||
conversationId: uuid(),
|
||||
visible: true,
|
||||
},
|
||||
} = options || {};
|
||||
|
||||
return renderWithProviders(<MetabotProvider>{ui}</MetabotProvider>, {
|
||||
storeInitialState: createMockState({
|
||||
settings,
|
||||
currentUser: currentUser ? currentUser : undefined,
|
||||
plugins: {
|
||||
metabotPlugin: metabotPluginInitialState,
|
||||
},
|
||||
} as any),
|
||||
customReducers: {
|
||||
plugins: combineReducers({
|
||||
metabotPlugin: metabotReducer,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const chat = () => screen.findByTestId("metabot-chat");
|
||||
const input = () => screen.findByTestId("metabot-chat-input");
|
||||
const enterChatMessage = async (message: string, send = true) =>
|
||||
userEvent.type(await input(), `${message}${send ? "{Enter}" : ""}`);
|
||||
const closeChatButton = () => screen.findByTestId("metabot-close-chat");
|
||||
|
||||
const assertVisible = async () =>
|
||||
expect(await screen.findByTestId("metabot-chat")).toBeInTheDocument();
|
||||
const assertNotVisible = async () =>
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("metabot-chat")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// NOTE: for some reason the keyboard shortcuts won't work with tinykeys while testing, using redux for now...
|
||||
const hideMetabot = (dispatch: any) => act(() => dispatch(setVisible(false)));
|
||||
const showMetabot = (dispatch: any) => act(() => dispatch(setVisible(true)));
|
||||
|
||||
const lastReqBody = async () => {
|
||||
const lastCall = fetchMock.lastCall();
|
||||
const bodyStr = String(await lastCall?.[1]?.body) || "";
|
||||
return JSON.parse(bodyStr);
|
||||
};
|
||||
|
||||
describe("metabot", () => {
|
||||
describe("ui", () => {
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it("should be able to render metabot", async () => {
|
||||
setup();
|
||||
await assertVisible();
|
||||
});
|
||||
|
||||
it("should be able to toggle visibility", async () => {
|
||||
const { store } = setup();
|
||||
expect(await chat()).toBeInTheDocument();
|
||||
await assertVisible();
|
||||
|
||||
hideMetabot(store.dispatch);
|
||||
await assertNotVisible();
|
||||
|
||||
showMetabot(store.dispatch);
|
||||
expect(await chat()).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(await closeChatButton());
|
||||
await assertNotVisible();
|
||||
});
|
||||
|
||||
it("should start a new conversation each time metabot is opened", async () => {
|
||||
const { store } = setup({
|
||||
metabotPluginInitialState: metabotInitialState,
|
||||
});
|
||||
fetchMock.post(
|
||||
`path:/api/ee/metabot-v3/v2/agent`,
|
||||
whoIsYourFavoriteResponse,
|
||||
);
|
||||
|
||||
showMetabot(store.dispatch);
|
||||
await enterChatMessage("Who is your favorite?");
|
||||
const firstConversationId = (await lastReqBody())?.conversation_id;
|
||||
expect(isUuid(firstConversationId)).toBeTruthy();
|
||||
hideMetabot(store.dispatch);
|
||||
|
||||
showMetabot(store.dispatch);
|
||||
await enterChatMessage("Who is your favorite?");
|
||||
const secondConversationId = (await lastReqBody())?.conversation_id;
|
||||
expect(isUuid(secondConversationId)).toBeTruthy();
|
||||
|
||||
expect(secondConversationId).not.toBe(firstConversationId);
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it("should auto-close metabot if inactive with no user input", async () => {
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
|
||||
const { store } = setup();
|
||||
|
||||
// auto-hides
|
||||
await assertVisible();
|
||||
act(() => jest.advanceTimersByTime(METABOT_AUTO_CLOSE_DURATION_MS));
|
||||
await assertNotVisible();
|
||||
|
||||
// does not auto-hide if there's user input
|
||||
showMetabot(store.dispatch);
|
||||
await userEvent.type(await input(), "Testing");
|
||||
act(() => jest.advanceTimersByTime(METABOT_AUTO_CLOSE_DURATION_MS));
|
||||
await assertVisible();
|
||||
hideMetabot(store.dispatch);
|
||||
|
||||
// does not auto-close if metabot is processing a response
|
||||
showMetabot(store.dispatch);
|
||||
act(() => store.dispatch(setIsProcessing(true)));
|
||||
act(() => jest.advanceTimersByTime(METABOT_AUTO_CLOSE_DURATION_MS));
|
||||
await assertVisible();
|
||||
act(() => store.dispatch(setIsProcessing(false)));
|
||||
act(() => jest.advanceTimersByTime(METABOT_AUTO_CLOSE_DURATION_MS));
|
||||
await assertNotVisible();
|
||||
|
||||
// does not auto-close if metabot is loading
|
||||
showMetabot(store.dispatch);
|
||||
fetchMock.post(
|
||||
`path:/api/ee/metabot-v3/v2/agent`,
|
||||
whoIsYourFavoriteResponse,
|
||||
{ delay: METABOT_AUTO_CLOSE_DURATION_MS + 1000 }, // load longer than delay
|
||||
);
|
||||
await enterChatMessage("Who is your favorite?");
|
||||
act(() => jest.advanceTimersByTime(METABOT_AUTO_CLOSE_DURATION_MS));
|
||||
await assertVisible();
|
||||
hideMetabot(store.dispatch);
|
||||
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it("should properly auto-close metabot if route changes", async () => {
|
||||
setup();
|
||||
|
||||
await assertVisible();
|
||||
act(() => window.history?.pushState(null, "", "/some-route"));
|
||||
await assertNotVisible();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it("should not close on route change if metabot is processing", async () => {
|
||||
const { store } = setup();
|
||||
|
||||
showMetabot(store.dispatch);
|
||||
act(() => store.dispatch(setIsProcessing(true)));
|
||||
act(() => window.history?.pushState(null, "", "/some-route"));
|
||||
await assertVisible();
|
||||
act(() => store.dispatch(setIsProcessing(false)));
|
||||
hideMetabot(store.dispatch);
|
||||
await assertNotVisible();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it("should not close on route change if there is user input", async () => {
|
||||
const { store } = setup();
|
||||
|
||||
// should not close if user has value in the input
|
||||
showMetabot(store.dispatch);
|
||||
await userEvent.type(await input(), "Some incomplete user prompt");
|
||||
act(() => window.history?.pushState(null, "", "/some-route"));
|
||||
await assertVisible();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it("should not close on route change if new location shares base bath segement", async () => {
|
||||
const { store } = setup();
|
||||
|
||||
act(() => window.history?.pushState(null, "", "/base-path"));
|
||||
showMetabot(store.dispatch);
|
||||
await assertVisible();
|
||||
act(() => window.history?.pushState(null, "", "/base-path/sub-path"));
|
||||
await assertVisible();
|
||||
act(() => window.history?.pushState(null, "", "/base-path"));
|
||||
await assertVisible();
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it("should hide metabot when the user logs out", async () => {
|
||||
jest.spyOn(domModule, "reload").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
const { store } = setup();
|
||||
fetchMock.delete(`path:/api/session`, 200);
|
||||
|
||||
await assertVisible();
|
||||
act(() => {
|
||||
store.dispatch(logout(undefined) as any);
|
||||
});
|
||||
await assertNotVisible();
|
||||
} finally {
|
||||
(domModule.reload as any).mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
// eslint-disable-next-line jest/expect-expect
|
||||
it("should not show metabot if the is not signed in user", async () => {
|
||||
// suppress expected console error
|
||||
const consoleErrorSpy = jest
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(message => {
|
||||
if (
|
||||
message ===
|
||||
"Metabot can not be opened while there is no signed in user"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
console.error(message);
|
||||
});
|
||||
|
||||
try {
|
||||
const { store } = setup({
|
||||
metabotPluginInitialState: metabotInitialState,
|
||||
currentUser: null,
|
||||
});
|
||||
await assertNotVisible();
|
||||
showMetabot(store.dispatch);
|
||||
await assertNotVisible();
|
||||
} finally {
|
||||
consoleErrorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("message", () => {
|
||||
it("should properly send chat messages", async () => {
|
||||
setup();
|
||||
fetchMock.post(
|
||||
`path:/api/ee/metabot-v3/v2/agent`,
|
||||
whoIsYourFavoriteResponse,
|
||||
{ delay: 50 }, // small delay to cause loading state
|
||||
);
|
||||
|
||||
await enterChatMessage("Who is your favorite?", false);
|
||||
expect(await input()).toHaveValue("Who is your favorite?");
|
||||
|
||||
await enterChatMessage("Who is your favorite?");
|
||||
expect(
|
||||
await screen.findByPlaceholderText("Doing science..."),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
await screen.findByText("You are... but don't tell anyone!"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// should auto-clear input + refocus
|
||||
expect(await input()).toHaveValue("");
|
||||
expect(await input()).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
describe("context", () => {
|
||||
it("should send along default context", async () => {
|
||||
setup();
|
||||
fetchMock.post(
|
||||
`path:/api/ee/metabot-v3/v2/agent`,
|
||||
whoIsYourFavoriteResponse,
|
||||
);
|
||||
|
||||
await enterChatMessage("Who is your favorite?");
|
||||
|
||||
expect(
|
||||
isMatching(
|
||||
{ current_time_with_timezone: P.string },
|
||||
(await lastReqBody())?.context,
|
||||
),
|
||||
).toEqual(true);
|
||||
});
|
||||
|
||||
it("should allow components to register additional context", async () => {
|
||||
fetchMock.post(
|
||||
`path:/api/ee/metabot-v3/v2/agent`,
|
||||
whoIsYourFavoriteResponse,
|
||||
);
|
||||
|
||||
const TestComponent = () => {
|
||||
useRegisterMetabotContextProvider(
|
||||
() => ({ user_is_viewing: [{ type: "question", id: 1 }] }),
|
||||
[],
|
||||
);
|
||||
return null;
|
||||
};
|
||||
|
||||
setup({
|
||||
ui: (
|
||||
<>
|
||||
<Metabot />
|
||||
<TestComponent />
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
await enterChatMessage("Who is your favorite?");
|
||||
|
||||
expect(
|
||||
isMatching(
|
||||
{
|
||||
current_time_with_timezone: P.string,
|
||||
user_is_viewing: [{ type: "question", id: 1 }],
|
||||
},
|
||||
(await lastReqBody())?.context,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("history", () => {
|
||||
it("should send history from last response along with next message", async () => {
|
||||
setup();
|
||||
fetchMock.post(
|
||||
`path:/api/ee/metabot-v3/v2/agent`,
|
||||
whoIsYourFavoriteResponse,
|
||||
);
|
||||
|
||||
// send a message to get some history back
|
||||
await enterChatMessage("Who is your favorite?");
|
||||
await waitFor(() => expect(fetchMock.calls()).toHaveLength(1));
|
||||
|
||||
// send another message and check that there is proper history
|
||||
await enterChatMessage("Hi!");
|
||||
const lastCall = fetchMock.lastCall();
|
||||
const bodyStr = String(await lastCall?.[1]?.body) || "";
|
||||
const sentHistory = JSON.parse(bodyStr)?.history;
|
||||
expect(sentHistory).toEqual(whoIsYourFavoriteResponse.history);
|
||||
});
|
||||
|
||||
it("should clear history if metabot is closed", async () => {
|
||||
const { store } = setup();
|
||||
fetchMock.post(
|
||||
`path:/api/ee/metabot-v3/v2/agent`,
|
||||
whoIsYourFavoriteResponse,
|
||||
);
|
||||
|
||||
// send a message to get some history back
|
||||
await enterChatMessage("Who is your favorite?");
|
||||
await waitFor(() => expect(fetchMock.calls()).toHaveLength(1));
|
||||
|
||||
// close, open, and then send another message and check that there is no history
|
||||
hideMetabot(store.dispatch);
|
||||
showMetabot(store.dispatch);
|
||||
await enterChatMessage("Hi!");
|
||||
const lastCall = fetchMock.lastCall();
|
||||
const bodyStr = String(await lastCall?.[1]?.body) || "";
|
||||
const sentHistory = JSON.parse(bodyStr)?.history;
|
||||
expect(sentHistory).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const whoIsYourFavoriteResponse = {
|
||||
reactions: [
|
||||
{
|
||||
type: "metabot.reaction/message",
|
||||
message: "You are... but don't tell anyone!",
|
||||
},
|
||||
],
|
||||
history: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Who is your favorite?",
|
||||
},
|
||||
{
|
||||
content: "",
|
||||
role: "assistant",
|
||||
"tool-calls": [
|
||||
{
|
||||
id: "call_PVmnR8mcnYFF2AmqupSKzJDh",
|
||||
name: "who-is-your-favorite",
|
||||
arguments: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
"tool-call-id": "call_PVmnR8mcnYFF2AmqupSKzJDh",
|
||||
content: "You are... but don't tell anyone!",
|
||||
},
|
||||
{
|
||||
content: "You are... but don't tell anyone!",
|
||||
role: "assistant",
|
||||
},
|
||||
],
|
||||
state: {},
|
||||
};
|
||||
@@ -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(([_key, value]) => value !== null),
|
||||
);
|
||||
|
||||
newSeriesSettings[settings.key] = {
|
||||
...newSeriesSettings[settings.key],
|
||||
...onlyIncludedSettings,
|
||||
};
|
||||
});
|
||||
|
||||
await dispatch(
|
||||
onUpdateVisualizationSettings({ series_settings: newSeriesSettings }),
|
||||
);
|
||||
};
|
||||
|
||||
export const changeColumnSettings: ReactionHandler<
|
||||
MetabotChangeColumnSettingsReaction
|
||||
> =
|
||||
reaction =>
|
||||
async ({ dispatch, getState }) => {
|
||||
const columnSettings =
|
||||
getQuestion(getState())?.settings().column_settings ?? {};
|
||||
|
||||
const newColumnSettings = { ...columnSettings };
|
||||
|
||||
reaction.column_settings.forEach(settings => {
|
||||
const columnKey = getColumnKey({ name: settings.key });
|
||||
const onlyIncludedSettings = Object.fromEntries(
|
||||
Object.entries(settings).filter(([_key, value]) => value !== null),
|
||||
);
|
||||
|
||||
newColumnSettings[columnKey] = {
|
||||
...newColumnSettings[columnKey],
|
||||
...onlyIncludedSettings,
|
||||
};
|
||||
});
|
||||
|
||||
await dispatch(
|
||||
onUpdateVisualizationSettings({ column_settings: newColumnSettings }),
|
||||
);
|
||||
};
|
||||
|
||||
export const changeChartAppearance: ReactionHandler<
|
||||
MetabotChangeChartAppearanceReaction
|
||||
> =
|
||||
({ type, ...payload }) =>
|
||||
async ({ dispatch }) => {
|
||||
const settingsUpdate: Partial<VisualizationSettings> = {};
|
||||
|
||||
if (payload.goal != null) {
|
||||
const { goal_value, show_goal, goal_label } = payload.goal;
|
||||
|
||||
if (goal_value != null) {
|
||||
settingsUpdate["graph.goal_value"] = goal_value;
|
||||
}
|
||||
if (show_goal != null) {
|
||||
settingsUpdate["graph.show_goal"] = show_goal;
|
||||
}
|
||||
if (goal_label != null) {
|
||||
settingsUpdate["graph.goal_label"] = goal_label;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.trend_line != null) {
|
||||
settingsUpdate["graph.show_trendline"] = payload.trend_line;
|
||||
}
|
||||
|
||||
if (payload.data_labels != null) {
|
||||
const {
|
||||
show_data_labels,
|
||||
data_label_format,
|
||||
pie_chart_percent_visibility,
|
||||
} = payload.data_labels;
|
||||
|
||||
if (show_data_labels != null) {
|
||||
settingsUpdate["graph.show_values"] = show_data_labels;
|
||||
}
|
||||
if (data_label_format != null) {
|
||||
settingsUpdate["graph.label_value_formatting"] = data_label_format;
|
||||
}
|
||||
if (pie_chart_percent_visibility != null) {
|
||||
settingsUpdate["pie.percent_visibility"] = pie_chart_percent_visibility;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.total != null) {
|
||||
settingsUpdate["pie.show_total"] = payload.total;
|
||||
}
|
||||
|
||||
if (payload.stack_type != null) {
|
||||
// Metabase expects "none" to be null in the settings object
|
||||
settingsUpdate["stackable.stack_type"] =
|
||||
payload.stack_type === "none" ? null : payload.stack_type;
|
||||
}
|
||||
|
||||
if (payload.max_series_count != null) {
|
||||
if (typeof payload.max_series_count === "number") {
|
||||
settingsUpdate["graph.max_categories"] = payload.max_series_count;
|
||||
settingsUpdate["graph.max_categories_enabled"] = true;
|
||||
} else if (payload.max_series_count === "all") {
|
||||
settingsUpdate["graph.max_categories_enabled"] = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.y_axis_range != null) {
|
||||
if (payload.y_axis_range.auto_range != null) {
|
||||
settingsUpdate["graph.y_axis.auto_range"] =
|
||||
payload.y_axis_range.auto_range;
|
||||
}
|
||||
if (payload.y_axis_range.min != null) {
|
||||
settingsUpdate["graph.y_axis.min"] = payload.y_axis_range.min;
|
||||
}
|
||||
if (payload.y_axis_range.max != null) {
|
||||
settingsUpdate["graph.y_axis.max"] = payload.y_axis_range.max;
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.axes_labels != null) {
|
||||
if (payload.axes_labels.x_axis_label != null) {
|
||||
settingsUpdate["graph.x_axis.title_text"] =
|
||||
payload.axes_labels.x_axis_label;
|
||||
}
|
||||
if (payload.axes_labels.y_axis_label != null) {
|
||||
settingsUpdate["graph.y_axis.title_text"] =
|
||||
payload.axes_labels.y_axis_label;
|
||||
}
|
||||
}
|
||||
|
||||
await dispatch(onUpdateVisualizationSettings(settingsUpdate));
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { getCurrentUser } from "metabase/admin/datamodel/selectors";
|
||||
import { createAsyncThunk } from "metabase/lib/redux";
|
||||
import { uuid } from "metabase/lib/uuid";
|
||||
import {
|
||||
EnterpriseApi,
|
||||
METABOT_TAG,
|
||||
metabotAgent,
|
||||
} from "metabase-enterprise/api";
|
||||
import type { MetabotChatContext, MetabotReaction } from "metabase-types/api";
|
||||
import type { Dispatch } from "metabase-types/store";
|
||||
|
||||
import { notifyUnknownReaction, reactionHandlers } from "../reactions";
|
||||
|
||||
import { metabot } from "./reducer";
|
||||
import {
|
||||
getConfirmationOptions,
|
||||
getIsProcessing,
|
||||
getLastHistoryValue,
|
||||
getLastSentContext,
|
||||
getMetabotConversationId,
|
||||
getMetabotState,
|
||||
} from "./selectors";
|
||||
|
||||
export const {
|
||||
addUserMessage,
|
||||
dismissUserMessage,
|
||||
clearUserMessages,
|
||||
setIsProcessing,
|
||||
setConfirmationOptions,
|
||||
setConversationId,
|
||||
} = metabot.actions;
|
||||
|
||||
export const setVisible =
|
||||
(isVisible: boolean) => (dispatch: Dispatch, getState: any) => {
|
||||
const currentUser = getCurrentUser(getState());
|
||||
if (!currentUser) {
|
||||
console.error(
|
||||
"Metabot can not be opened while there is no signed in user",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isVisible) {
|
||||
// reset the conversation history when closing metabot
|
||||
dispatch(
|
||||
EnterpriseApi.internalActions.removeMutationResult({
|
||||
fixedCacheKey: METABOT_TAG,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
dispatch(metabot.actions.setVisible(isVisible));
|
||||
};
|
||||
|
||||
export const submitInput = createAsyncThunk(
|
||||
"metabase-enterprise/metabot/submitInput",
|
||||
async (
|
||||
data: {
|
||||
message: string;
|
||||
context: MetabotChatContext;
|
||||
history: any[];
|
||||
state: any;
|
||||
},
|
||||
{ dispatch, getState },
|
||||
) => {
|
||||
const isProcessing = getIsProcessing(getState() as any);
|
||||
if (isProcessing) {
|
||||
return console.error("Metabot is actively serving a request");
|
||||
}
|
||||
|
||||
// handle if user is responding to a confirmation prompt
|
||||
const requestingUserConfirmation = !!getConfirmationOptions(
|
||||
getState() as any,
|
||||
);
|
||||
if (requestingUserConfirmation) {
|
||||
await dispatch(selectUserConfirmationOption(data.message));
|
||||
} else {
|
||||
dispatch(clearUserMessages());
|
||||
await dispatch(sendMessageRequest(data));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const sendMessageRequest = createAsyncThunk(
|
||||
"metabase-enterprise/metabot/sendMessageRequest",
|
||||
async (
|
||||
data: {
|
||||
message: string;
|
||||
context: MetabotChatContext;
|
||||
history: any[];
|
||||
state: any;
|
||||
},
|
||||
{ dispatch, getState },
|
||||
) => {
|
||||
// TODO: make enterprise store
|
||||
let sessionId = getMetabotConversationId(getState() as any);
|
||||
|
||||
// should not be needed, but just in case the value got unset
|
||||
if (!sessionId) {
|
||||
console.warn(
|
||||
"Metabot has no session id while open, this should never happen",
|
||||
);
|
||||
sessionId = uuid();
|
||||
dispatch(setConversationId(sessionId));
|
||||
}
|
||||
|
||||
const result = await dispatch(
|
||||
metabotAgent.initiate(
|
||||
{ ...data, conversation_id: sessionId },
|
||||
{ fixedCacheKey: METABOT_TAG },
|
||||
),
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
console.error("Metabot request returned error: ", result.error);
|
||||
dispatch(clearUserMessages());
|
||||
const message =
|
||||
(result.error as any).status >= 500
|
||||
? t`I'm currently offline, try again later.`
|
||||
: undefined;
|
||||
dispatch(stopProcessingAndNotify(message));
|
||||
} else {
|
||||
const reactions = result.data?.reactions || [];
|
||||
await dispatch(processMetabotReactions(reactions));
|
||||
return result;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const selectUserConfirmationOption = createAsyncThunk(
|
||||
"metabase-enterprise/metabot/selectUserConfirmationOption",
|
||||
async (message: string, { dispatch, getState }) => {
|
||||
const userConfirmationOptions = getConfirmationOptions(getState() as any);
|
||||
|
||||
if (!userConfirmationOptions) {
|
||||
console.warn("Metabot has no user options to let user choose from");
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmationOption = userConfirmationOptions[message];
|
||||
if (!confirmationOption) {
|
||||
const options = Object.keys(userConfirmationOptions);
|
||||
const quotedOptions = options.map(option => `“${option}”`).join(" or ");
|
||||
dispatch(addUserMessage(t`Sorry, could you give me a ${quotedOptions}?`));
|
||||
} else {
|
||||
dispatch(clearUserMessages());
|
||||
dispatch(setConfirmationOptions(undefined));
|
||||
await dispatch(processMetabotReactions(confirmationOption));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const sendWritebackMessageRequest = createAsyncThunk(
|
||||
"metabase-enterprise/metabot/sendWritebackMessageRequest",
|
||||
async (message: string, { dispatch, getState }) => {
|
||||
const lastSentContext = getLastSentContext(getState() as any);
|
||||
const lastHistory = getLastHistoryValue(getState() as any);
|
||||
const state = getMetabotState(getState() as any);
|
||||
|
||||
if (!lastSentContext) {
|
||||
console.warn(
|
||||
"Metabot expected to have a previously sent request before writing back to the server",
|
||||
);
|
||||
}
|
||||
|
||||
await dispatch(
|
||||
sendMessageRequest({
|
||||
message,
|
||||
history: lastHistory,
|
||||
context: lastSentContext ?? ({} as any),
|
||||
state: state ?? ({} as any),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const processMetabotReactions = createAsyncThunk(
|
||||
"metabase-enterprise/metabot/processMetabotReactions",
|
||||
async (reactions: MetabotReaction[], { dispatch, getState }) => {
|
||||
dispatch(setIsProcessing(true));
|
||||
|
||||
for (const reaction of reactions) {
|
||||
try {
|
||||
const reactionHandler =
|
||||
reactionHandlers[reaction.type] ?? notifyUnknownReaction;
|
||||
// TS isn't smart enough to know the reaction matches the handler
|
||||
await reactionHandler(reaction as any)({ dispatch, getState });
|
||||
} catch (error: any) {
|
||||
console.error("Halting processing of reactions.", error);
|
||||
dispatch(stopProcessingAndNotify());
|
||||
break;
|
||||
}
|
||||
|
||||
// TODO: make an EnterpriseStore
|
||||
const isProcessing = getIsProcessing(getState() as any);
|
||||
if (!isProcessing) {
|
||||
console.warn(
|
||||
"A handler has stopped further procesing of metabot reactions",
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(setIsProcessing(false));
|
||||
},
|
||||
);
|
||||
|
||||
export const stopProcessingAndNotify =
|
||||
(message?: string) => (dispatch: Dispatch) => {
|
||||
dispatch(setIsProcessing(false));
|
||||
dispatch(clearUserMessages());
|
||||
dispatch(addUserMessage(message || t`I can’t do that, unfortunately.`));
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./actions";
|
||||
export * from "./reducer";
|
||||
export * from "./selectors";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,88 @@
|
||||
import { type PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||
|
||||
import { logout } from "metabase/auth/actions";
|
||||
import { uuid } from "metabase/lib/uuid";
|
||||
import type {
|
||||
MetabotChatContext,
|
||||
MetabotHistory,
|
||||
MetabotReaction,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { sendMessageRequest } from "./actions";
|
||||
|
||||
type ConfirmationOptions =
|
||||
| { [key: string]: MetabotReaction[] | undefined }
|
||||
| undefined;
|
||||
|
||||
export interface MetabotState {
|
||||
confirmationOptions: ConfirmationOptions | undefined;
|
||||
isProcessing: boolean;
|
||||
lastSentContext: MetabotChatContext | undefined;
|
||||
lastHistoryValue: MetabotHistory | undefined;
|
||||
conversationId: string | undefined;
|
||||
userMessages: string[];
|
||||
visible: boolean;
|
||||
state: any;
|
||||
}
|
||||
|
||||
export const metabotInitialState: MetabotState = {
|
||||
confirmationOptions: undefined,
|
||||
isProcessing: false,
|
||||
lastSentContext: undefined,
|
||||
lastHistoryValue: undefined,
|
||||
conversationId: undefined,
|
||||
userMessages: [],
|
||||
visible: false,
|
||||
state: {},
|
||||
};
|
||||
|
||||
export const metabot = createSlice({
|
||||
name: "metabase-enterprise/metabot",
|
||||
initialState: metabotInitialState,
|
||||
reducers: {
|
||||
addUserMessage: (state, action: PayloadAction<string>) => {
|
||||
state.userMessages.push(action.payload);
|
||||
},
|
||||
clearUserMessages: state => {
|
||||
state.userMessages = [];
|
||||
},
|
||||
dismissUserMessage: (state, action: PayloadAction<number>) => {
|
||||
state.userMessages.splice(action.payload, 1);
|
||||
},
|
||||
setIsProcessing: (state, action: PayloadAction<boolean>) => {
|
||||
state.isProcessing = action.payload;
|
||||
},
|
||||
setConversationId: (state, action: PayloadAction<string | undefined>) => {
|
||||
state.conversationId = action.payload;
|
||||
},
|
||||
setVisible: (state, { payload: visible }: PayloadAction<boolean>) => {
|
||||
if (visible) {
|
||||
state.visible = true;
|
||||
state.conversationId = uuid();
|
||||
} else {
|
||||
return metabotInitialState;
|
||||
}
|
||||
},
|
||||
setConfirmationOptions: (
|
||||
state,
|
||||
action: PayloadAction<ConfirmationOptions>,
|
||||
) => {
|
||||
// Type cast solves type error of "Type instantiation is excessively deep and possibly infinite."
|
||||
// This approach will be removed in a follow up PR
|
||||
state.confirmationOptions = action.payload as any;
|
||||
},
|
||||
},
|
||||
extraReducers: builder => {
|
||||
builder
|
||||
.addCase(sendMessageRequest.pending, (state, action) => {
|
||||
state.lastSentContext = action.meta.arg.context;
|
||||
state.lastHistoryValue = action.meta.arg.history;
|
||||
})
|
||||
.addCase(sendMessageRequest.fulfilled, (state, action) => {
|
||||
state.lastHistoryValue = action.payload?.data?.history;
|
||||
})
|
||||
.addCase(logout.pending, () => metabotInitialState);
|
||||
},
|
||||
});
|
||||
|
||||
export const metabotReducer = metabot.reducer;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createSelector } from "@reduxjs/toolkit";
|
||||
|
||||
import type { MetabotStoreState } from "./types";
|
||||
|
||||
export const getMetabot = (state: MetabotStoreState) =>
|
||||
state.plugins.metabotPlugin;
|
||||
|
||||
export const getMetabotVisisble = createSelector(
|
||||
getMetabot,
|
||||
metabot => metabot.visible,
|
||||
);
|
||||
|
||||
export const getUserMessages = createSelector(
|
||||
getMetabot,
|
||||
metabot => metabot.userMessages,
|
||||
);
|
||||
|
||||
export const getConfirmationOptions = createSelector(
|
||||
getMetabot,
|
||||
metabot => metabot.confirmationOptions,
|
||||
);
|
||||
|
||||
export const getIsProcessing = createSelector(
|
||||
getMetabot,
|
||||
metabot => metabot.isProcessing,
|
||||
);
|
||||
|
||||
export const getLastSentContext = createSelector(
|
||||
getMetabot,
|
||||
metabot => metabot.lastSentContext,
|
||||
);
|
||||
|
||||
export const getLastHistoryValue = createSelector(
|
||||
getMetabot,
|
||||
metabot => metabot.lastHistoryValue,
|
||||
);
|
||||
|
||||
export const getMetabotConversationId = createSelector(
|
||||
getMetabot,
|
||||
metabot => metabot.conversationId,
|
||||
);
|
||||
|
||||
export const getMetabotState = createSelector(
|
||||
getMetabot,
|
||||
metabot => metabot.state,
|
||||
);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -96,13 +96,8 @@ export interface Dataset {
|
||||
row_count: number;
|
||||
running_time: number;
|
||||
json_query?: JsonQuery;
|
||||
error?:
|
||||
| string
|
||||
| {
|
||||
status: number; // HTTP status code
|
||||
data?: string;
|
||||
};
|
||||
error_type?: string;
|
||||
error?: DatasetError;
|
||||
error_type?: DatasetErrorType;
|
||||
error_is_curated?: boolean;
|
||||
context?: string;
|
||||
status?: string;
|
||||
@@ -114,6 +109,18 @@ export interface Dataset {
|
||||
started_at?: string;
|
||||
}
|
||||
|
||||
export type DatasetError =
|
||||
| string
|
||||
| {
|
||||
status: number; // HTTP status code
|
||||
data?: string;
|
||||
};
|
||||
|
||||
export type DatasetErrorType =
|
||||
| "invalid-query"
|
||||
| "missing-required-parameter"
|
||||
| string;
|
||||
|
||||
export interface EmbedDatasetData {
|
||||
rows: RowValues[];
|
||||
cols: DatasetColumn[];
|
||||
@@ -132,7 +139,7 @@ interface SuccessEmbedDataset {
|
||||
}
|
||||
|
||||
export interface ErrorEmbedDataset {
|
||||
error_type: string;
|
||||
error_type: DatasetErrorType;
|
||||
error: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -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,220 @@
|
||||
import type { CardDisplayType } from "./visualization";
|
||||
|
||||
import type {
|
||||
CardId,
|
||||
CardType,
|
||||
ColumnSettings,
|
||||
DashboardId,
|
||||
DatasetQuery,
|
||||
SeriesSettings,
|
||||
} from ".";
|
||||
|
||||
export type MetabotFeedbackType =
|
||||
| "great"
|
||||
| "wrong_data"
|
||||
| "incorrect_result"
|
||||
| "invalid_sql";
|
||||
|
||||
/* Metabot v3 - Base Types */
|
||||
|
||||
export type MetabotChatContext = {
|
||||
user_is_viewing: MetabotEntityInfo[];
|
||||
current_time_with_timezone: string;
|
||||
};
|
||||
|
||||
export type MetabotTool = {
|
||||
name: string; // TODO: make strictly typed - currently there's no tools
|
||||
parameters: Record<string, any>;
|
||||
};
|
||||
|
||||
export type MetabotHistoryUserMessageEntry = {
|
||||
role: "user";
|
||||
message: string;
|
||||
context: MetabotChatContext;
|
||||
};
|
||||
|
||||
export type MetabotHistoryToolEntry = {
|
||||
role: "assistant";
|
||||
assistant_response_type: "tools";
|
||||
tools: MetabotTool[];
|
||||
};
|
||||
|
||||
export type MetabotHistoryMessageEntry = {
|
||||
role: "assistant";
|
||||
assistant_response_type: "message";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type MetabotHistoryEntry =
|
||||
| MetabotHistoryUserMessageEntry
|
||||
| MetabotHistoryToolEntry
|
||||
| MetabotHistoryMessageEntry;
|
||||
|
||||
export type MetabotHistory = any;
|
||||
|
||||
export type MetabotMessageReaction = {
|
||||
type: "metabot.reaction/message";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type MetabotChangeDisplayTypeReaction = {
|
||||
type: "metabot.reaction/change-display-type";
|
||||
display_type: CardDisplayType;
|
||||
};
|
||||
|
||||
export type MetabotChangeVisiualizationSettingsReaction = {
|
||||
type: "metabot.reaction/change-table-visualization-settings";
|
||||
visible_columns: string[];
|
||||
};
|
||||
|
||||
export type MetabotConfirmationReaction = {
|
||||
type: "metabot.reaction/confirmation";
|
||||
description: string;
|
||||
options: Record<string, MetabotReaction[]>;
|
||||
};
|
||||
|
||||
export type MetabotWriteBackReaction = {
|
||||
type: "metabot.reaction/writeback";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type MetabotApiCallReaction = {
|
||||
type: "metabot.reaction/api-call";
|
||||
api_call: {
|
||||
method: string;
|
||||
url: string;
|
||||
body?: Record<string, any>;
|
||||
};
|
||||
};
|
||||
|
||||
export type MetabotRunQueryReaction = {
|
||||
type: "metabot.reaction/run-query";
|
||||
dataset_query: DatasetQuery;
|
||||
};
|
||||
|
||||
type SeriesSettingsEntry = SeriesSettings & { key: string };
|
||||
|
||||
export type MetabotChangeSeriesSettingsReaction = {
|
||||
type: "metabot.reaction/change-series-settings";
|
||||
series_settings: SeriesSettingsEntry[];
|
||||
};
|
||||
|
||||
type ColumnSettingsEntry = ColumnSettings & { key: string };
|
||||
|
||||
export type MetabotChangeColumnSettingsReaction = {
|
||||
type: "metabot.reaction/change-column-settings";
|
||||
column_settings: ColumnSettingsEntry[];
|
||||
};
|
||||
|
||||
export type MetabotChangeChartAppearanceReaction = {
|
||||
type: "metabot.reaction/change-chart-appearance";
|
||||
goal: {
|
||||
goal_value: number | null;
|
||||
show_goal: boolean | null;
|
||||
goal_label: string | null;
|
||||
} | null;
|
||||
trend_line: boolean | null;
|
||||
data_labels: {
|
||||
show_data_labels: boolean | null;
|
||||
data_label_format: "auto" | "compact" | "full" | null;
|
||||
pie_chart_percent_visibility: "off" | "legend" | "inside" | "both" | null;
|
||||
};
|
||||
total: boolean | null;
|
||||
stack_type: "stacked" | "normalized" | "none" | null;
|
||||
max_series_count: number | "all" | null;
|
||||
axes_labels: {
|
||||
x_axis_label: string | null;
|
||||
y_axis_label: string | null;
|
||||
} | null;
|
||||
y_axis_range: {
|
||||
auto_range: boolean | null;
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type MetabotRedirectReaction = {
|
||||
type: "metabot.reaction/redirect";
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type MetabotReaction =
|
||||
| MetabotChangeChartAppearanceReaction
|
||||
| MetabotChangeColumnSettingsReaction
|
||||
| MetabotChangeSeriesSettingsReaction
|
||||
| MetabotMessageReaction
|
||||
| MetabotChangeDisplayTypeReaction
|
||||
| MetabotChangeVisiualizationSettingsReaction
|
||||
| MetabotConfirmationReaction
|
||||
| MetabotWriteBackReaction
|
||||
| MetabotApiCallReaction
|
||||
| MetabotRunQueryReaction
|
||||
| MetabotRedirectReaction;
|
||||
|
||||
export type MetabotCardInfo = {
|
||||
type: CardType;
|
||||
id: CardId;
|
||||
};
|
||||
|
||||
export type MetabotDashboardInfo = {
|
||||
type: "dashboard";
|
||||
id: DashboardId;
|
||||
};
|
||||
|
||||
export type MetabotAdhocQueryInfo = {
|
||||
type: "adhoc";
|
||||
query: DatasetQuery;
|
||||
};
|
||||
|
||||
export type MetabotEntityInfo =
|
||||
| MetabotCardInfo
|
||||
| MetabotDashboardInfo
|
||||
| MetabotAdhocQueryInfo;
|
||||
|
||||
/* Metabot v3 - API Request Types */
|
||||
|
||||
export type MetabotAgentRequest = {
|
||||
message: string;
|
||||
context: MetabotChatContext;
|
||||
history: MetabotHistory[];
|
||||
conversation_id: string; // uuid
|
||||
state: any;
|
||||
};
|
||||
|
||||
export type MetabotAgentResponse = {
|
||||
reactions: MetabotReaction[];
|
||||
history: MetabotHistory[];
|
||||
conversation_id: string;
|
||||
state: any;
|
||||
};
|
||||
|
||||
/* Metabot v3 - Type Guards */
|
||||
|
||||
export const isMetabotMessageReaction = (
|
||||
reaction: MetabotReaction,
|
||||
): reaction is MetabotMessageReaction => {
|
||||
return reaction.type === "metabot.reaction/message";
|
||||
};
|
||||
|
||||
export const isMetabotToolMessage = (
|
||||
message: MetabotHistoryEntry,
|
||||
): message is MetabotHistoryToolEntry => {
|
||||
return (
|
||||
message.role === "assistant" && message.assistant_response_type === "tools"
|
||||
);
|
||||
};
|
||||
|
||||
export const isMetabotHistoryMessage = (
|
||||
message: MetabotHistoryEntry,
|
||||
): message is MetabotHistoryMessageEntry => {
|
||||
return (
|
||||
message.role === "assistant" &&
|
||||
message.assistant_response_type === "message"
|
||||
);
|
||||
};
|
||||
|
||||
export const isMetabotMessage = (
|
||||
message: MetabotHistoryEntry,
|
||||
): message is MetabotHistoryMessageEntry => {
|
||||
return message.role === "assistant";
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from "./actions";
|
||||
export * from "./activity";
|
||||
export * from "./ai-sql-fixer";
|
||||
export * from "./alert";
|
||||
export * from "./automagic-dashboards";
|
||||
export * from "./bookmark";
|
||||
|
||||
@@ -131,6 +131,8 @@ export const createMockTokenFeatures = (
|
||||
query_reference_validation: false,
|
||||
serialization: false,
|
||||
cache_preemptive: false,
|
||||
metabot_v3: false,
|
||||
ai_sql_fixer: false,
|
||||
...opts,
|
||||
});
|
||||
|
||||
|
||||
@@ -222,6 +222,8 @@ export const tokenFeatures = [
|
||||
"collection_cleanup",
|
||||
"query_reference_validation",
|
||||
"cache_preemptive",
|
||||
"metabot_v3",
|
||||
"ai_sql_fixer",
|
||||
] as const;
|
||||
|
||||
export type TokenFeature = (typeof tokenFeatures)[number];
|
||||
|
||||
@@ -33,6 +33,8 @@ export const createMockQueryBuilderUIControlsState = (
|
||||
modal: null,
|
||||
modalContext: null,
|
||||
dataReferenceStack: null,
|
||||
isNativeQueryFixApplied: false,
|
||||
highlightedNativeQueryLineNumbers: [],
|
||||
...opts,
|
||||
});
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ export interface QueryBuilderUIControls {
|
||||
modal: QueryModalType | null;
|
||||
modalContext: TimelineEventId | null;
|
||||
dataReferenceStack: null;
|
||||
isNativeQueryFixApplied: boolean;
|
||||
highlightedNativeQueryLineNumbers: number[];
|
||||
}
|
||||
|
||||
export interface QueryBuilderLoadingControls {
|
||||
|
||||
@@ -19,6 +19,7 @@ import { initializeIframeResizer } from "metabase/lib/dom";
|
||||
import { connect } from "metabase/lib/redux";
|
||||
import AppBar from "metabase/nav/containers/AppBar";
|
||||
import Navbar from "metabase/nav/containers/Navbar";
|
||||
import { PLUGIN_METABOT } from "metabase/plugins";
|
||||
import { setErrorPage } from "metabase/redux/app";
|
||||
import {
|
||||
getErrorPage,
|
||||
@@ -117,6 +118,7 @@ function App({
|
||||
<UndoListing />
|
||||
<StatusListing />
|
||||
<NewModals />
|
||||
<PLUGIN_METABOT.Metabot />
|
||||
</AppContentContainer>
|
||||
</AppContainer>
|
||||
<Palette />
|
||||
|
||||
@@ -5,11 +5,12 @@ import { usePrevious } from "react-use";
|
||||
import { msgid, ngettext, t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import { useListUsersQuery } from "metabase/api";
|
||||
import { LoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper";
|
||||
import { PaginationControls } from "metabase/components/PaginationControls";
|
||||
import AdminS from "metabase/css/admin.module.css";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import Group from "metabase/entities/groups";
|
||||
import Users from "metabase/entities/users";
|
||||
import { useConfirmation } from "metabase/hooks/use-confirmation";
|
||||
import { connect } from "metabase/lib/redux";
|
||||
import { PLUGIN_GROUP_MANAGERS } from "metabase/plugins";
|
||||
@@ -53,12 +54,12 @@ const mapDispatchToProps = {
|
||||
),
|
||||
};
|
||||
|
||||
const defaultUsersValue = [];
|
||||
|
||||
const PeopleList = ({
|
||||
currentUser,
|
||||
users,
|
||||
groups,
|
||||
query,
|
||||
metadata,
|
||||
membershipsByUser,
|
||||
isAdmin,
|
||||
loadMemberships,
|
||||
@@ -67,12 +68,27 @@ const PeopleList = ({
|
||||
updateMembership,
|
||||
confirmDeleteMembershipAction,
|
||||
confirmUpdateMembershipAction,
|
||||
reloadUsers,
|
||||
reloadGroups,
|
||||
onNextPage,
|
||||
onPreviousPage,
|
||||
}) => {
|
||||
const { modalContent, show } = useConfirmation();
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
refetch: reloadUsers,
|
||||
} = useListUsersQuery({
|
||||
query: query.searchText,
|
||||
status: query.status,
|
||||
limit: query.pageSize,
|
||||
offset: query.pageSize * query.page,
|
||||
});
|
||||
|
||||
const users = data?.data || defaultUsersValue;
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
const prevUsers = usePrevious(users);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -103,8 +119,6 @@ const PeopleList = ({
|
||||
}
|
||||
}, [prevUsers, reloadUsers, users]);
|
||||
|
||||
const { total } = metadata;
|
||||
|
||||
const { page, pageSize, status } = query;
|
||||
|
||||
const isCurrentUser = u => currentUser?.id === u.id;
|
||||
@@ -179,95 +193,97 @@ const PeopleList = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={CS.pb4}>
|
||||
<table
|
||||
data-testid="admin-people-list-table"
|
||||
className={cx(AdminS.ContentTable, CS.borderBottom)}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t`Name`}</th>
|
||||
<th />
|
||||
<th>{t`Email`}</th>
|
||||
{showDeactivated ? (
|
||||
<Fragment>
|
||||
<th>{t`Deactivated`}</th>
|
||||
<th />
|
||||
</Fragment>
|
||||
) : (
|
||||
<Fragment>
|
||||
<th>{t`Groups`}</th>
|
||||
<th>{t`Last Login`}</th>
|
||||
<th />
|
||||
</Fragment>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hasUsers &&
|
||||
users.map(user => (
|
||||
<PeopleListRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
showDeactivated={showDeactivated}
|
||||
groups={groups}
|
||||
userMemberships={membershipsByUser[user.id]}
|
||||
isCurrentUser={isCurrentUser(user)}
|
||||
isAdmin={isAdmin}
|
||||
onAdd={groupId => handleAdd(groupId, user.id)}
|
||||
onRemove={groupId => handleRemove(groupId, user.id)}
|
||||
onChange={(groupId, membershipData) =>
|
||||
handleChange(groupId, membershipData, user.id)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{hasUsers && (
|
||||
<div
|
||||
className={cx(CS.flex, CS.alignCenter, CS.justifyBetween, CS.p2)}
|
||||
data-testid="people-list-footer"
|
||||
<LoadingAndErrorWrapper loading={isLoading} error={error} noWrapper>
|
||||
<section className={CS.pb4}>
|
||||
<table
|
||||
data-testid="admin-people-list-table"
|
||||
className={cx(AdminS.ContentTable, CS.borderBottom)}
|
||||
>
|
||||
<div className={cx(CS.textMedium, CS.textBold)}>
|
||||
{ngettext(
|
||||
msgid`${total} person found`,
|
||||
`${total} people found`,
|
||||
total,
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t`Name`}</th>
|
||||
<th />
|
||||
<th>{t`Email`}</th>
|
||||
{showDeactivated ? (
|
||||
<Fragment>
|
||||
<th>{t`Deactivated`}</th>
|
||||
<th />
|
||||
</Fragment>
|
||||
) : (
|
||||
<Fragment>
|
||||
<th>{t`Groups`}</th>
|
||||
<th>{t`Last Login`}</th>
|
||||
<th />
|
||||
</Fragment>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{hasUsers &&
|
||||
users.map(user => (
|
||||
<PeopleListRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
showDeactivated={showDeactivated}
|
||||
groups={groups}
|
||||
userMemberships={membershipsByUser[user.id]}
|
||||
isCurrentUser={isCurrentUser(user)}
|
||||
isAdmin={isAdmin}
|
||||
onAdd={groupId => handleAdd(groupId, user.id)}
|
||||
onRemove={groupId => handleRemove(groupId, user.id)}
|
||||
onChange={(groupId, membershipData) =>
|
||||
handleChange(groupId, membershipData, user.id)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{hasUsers && (
|
||||
<div
|
||||
className={cx(CS.flex, CS.alignCenter, CS.justifyBetween, CS.p2)}
|
||||
data-testid="people-list-footer"
|
||||
>
|
||||
<div className={cx(CS.textMedium, CS.textBold)}>
|
||||
{ngettext(
|
||||
msgid`${total} person found`,
|
||||
`${total} people found`,
|
||||
total,
|
||||
)}
|
||||
</div>
|
||||
<PaginationControls
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
itemsLength={users.length}
|
||||
onNextPage={onNextPage}
|
||||
onPreviousPage={onPreviousPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasUsers && (
|
||||
<div
|
||||
className={cx(
|
||||
CS.flex,
|
||||
CS.flexColumn,
|
||||
CS.alignCenter,
|
||||
CS.justifyCenter,
|
||||
CS.p4,
|
||||
CS.textMedium,
|
||||
CS.textCentered,
|
||||
)}
|
||||
>
|
||||
<div className={CS.my3}>
|
||||
<Icon name="search" className={CS.mb1} size={32} />
|
||||
<h3 className={CS.textLight}>{t`No results found`}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<PaginationControls
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
itemsLength={users.length}
|
||||
onNextPage={onNextPage}
|
||||
onPreviousPage={onPreviousPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{!hasUsers && (
|
||||
<div
|
||||
className={cx(
|
||||
CS.flex,
|
||||
CS.flexColumn,
|
||||
CS.alignCenter,
|
||||
CS.justifyCenter,
|
||||
CS.p4,
|
||||
CS.textMedium,
|
||||
CS.textCentered,
|
||||
)}
|
||||
>
|
||||
<div className={CS.my3}>
|
||||
<Icon name="search" className={CS.mb1} size={32} />
|
||||
<h3 className={CS.textLight}>{t`No results found`}</h3>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modalContent}
|
||||
</section>
|
||||
{modalContent}
|
||||
</section>
|
||||
</LoadingAndErrorWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -291,25 +307,12 @@ PeopleList.propTypes = {
|
||||
confirmUpdateMembershipAction: PropTypes.func.isRequired,
|
||||
onNextPage: PropTypes.func,
|
||||
onPreviousPage: PropTypes.func,
|
||||
reloadUsers: PropTypes.func.isRequired,
|
||||
reloadGroups: PropTypes.func.isRequired,
|
||||
metadata: PropTypes.shape({
|
||||
total: PropTypes.number.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default _.compose(
|
||||
Group.loadList({
|
||||
reload: true,
|
||||
}),
|
||||
Users.loadList({
|
||||
reload: true,
|
||||
query: (_, { query }) => ({
|
||||
query: query.searchText,
|
||||
status: query.status,
|
||||
limit: query.pageSize,
|
||||
offset: query.pageSize * query.page,
|
||||
}),
|
||||
}),
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
)(PeopleList);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { usePagination } from "metabase/hooks/use-pagination";
|
||||
import { SEARCH_DEBOUNCE_DURATION } from "metabase/lib/constants";
|
||||
@@ -15,20 +15,28 @@ export const usePeopleQuery = pageSize => {
|
||||
const { handleNextPage, handlePreviousPage, setPage, page } = usePagination();
|
||||
|
||||
const [status, setStatus] = useState(USER_STATUS.active);
|
||||
const [searchInputValue, setSearchInputValue] = useState("");
|
||||
const [searchInputValue, _setSearchInputValue] = useState("");
|
||||
const dirtyRef = useRef(false);
|
||||
const setSearchInputValue = useCallback(setter => {
|
||||
_setSearchInputValue(setter);
|
||||
dirtyRef.current = true;
|
||||
}, []);
|
||||
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const timerId = setTimeout(() => {
|
||||
const searchText =
|
||||
searchInputValue.length >= MIN_SEARCH_LENGTH ? searchInputValue : "";
|
||||
// skip running if the user has not changed any input
|
||||
if (dirtyRef.current === true) {
|
||||
const timerId = setTimeout(() => {
|
||||
const searchText =
|
||||
searchInputValue.length >= MIN_SEARCH_LENGTH ? searchInputValue : "";
|
||||
|
||||
setPage(0);
|
||||
setSearchText(searchText);
|
||||
}, SEARCH_DEBOUNCE_DURATION);
|
||||
setPage(0);
|
||||
setSearchText(searchText);
|
||||
}, SEARCH_DEBOUNCE_DURATION);
|
||||
|
||||
return () => clearTimeout(timerId);
|
||||
return () => clearTimeout(timerId);
|
||||
}
|
||||
}, [searchInputValue, setPage]);
|
||||
|
||||
const updateStatus = status => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user