Repository: metabase/metabase
Pull request #54519: Convert dummy tools to normal tools
Author: login: metamben
Labels: name: Metabot/Tools
description: Tools that are agnostic of the LLM framework itself, could be reused for notifications framework als
Created: 2025-02-28T16:16:39Z | Merged: 2025-03-03T15:31:44Z

Fixes BOT-30
Fixes BOT-31
Fixes BOT-32
Fixes BOT-33
Fixes BOT-34
Fixes BOT-35

This PR builds on #54429, only commit 206fce5 is new.



Diff:
diff --git a/enterprise/backend/src/metabase_enterprise/metabot_v3/dummy_tools.clj b/enterprise/backend/src/metabase_enterprise/metabot_v3/dummy_tools.clj
index a3f75d718ba9..42572426a877 100644
--- a/enterprise/backend/src/metabase_enterprise/metabot_v3/dummy_tools.clj
+++ b/enterprise/backend/src/metabase_enterprise/metabot_v3/dummy_tools.clj
@@ -16,21 +16,25 @@
    [metabase.util :as u]
    [toucan2.core :as t2]))
 
-(defn- get-current-user
-  [_tool-id _arguments _e]
-  (if-let [{:keys [id email first_name last_name]}
-           (or (some-> api/*current-user* deref)
-               (t2/select-one [:model/User :id :email :first_name :last_name] api/*current-user-id*))]
-    {:structured-output {:id id
-                         :name (str first_name " " last_name)
-                         :email-address email}}
-    {:output  "current user not found"}))
+(defn get-current-user
+  "Get information about the current user."
+  ([_tool-id _arguments _e] (get-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
+                          :name (str first_name " " last_name)
+                          :email-address email}}
+     {:output "current user not found"})))
 
-(defn- get-dashboard-details
-  [_tool-id {:keys [dashboard-id]} _e]
-  (if-let [dashboard (t2/select-one [:model/Dashboard :id :description :name] dashboard-id)]
-    {:structured-output dashboard}
-    {:output "dashboard not found"}))
+(defn get-dashboard-details
+  "Get information about the dashboard with ID `dashboard-id`."
+  ([_tool-id arguments _e] (get-dashboard-details arguments))
+  ([{:keys [dashboard-id]}]
+   (if-let [dashboard (t2/select-one [:model/Dashboard :id :description :name] dashboard-id)]
+     {:structured-output dashboard}
+     {:output "dashboard not found"})))
 
 (defn metric-details
   "Get metric details as returned by tools."
@@ -121,16 +125,29 @@
                         :metrics (not-empty (mapv #(convert-metric % mp) (lib/available-metrics card-query)))
                         :queryable-foreign-key-tables (not-empty (foreign-key-tables mp cols)))))))
 
-(defn- get-table-details
-  [_tool-id {:keys [table-id]} _e]
-  (let [details (if-let [[_ card-id] (re-matches #"card__(\d+)" table-id)]
-                  (card-details (parse-long card-id))
-                  (if (re-matches #"\d+" table-id)
-                    (table-details (parse-long table-id) {:include-foreign-key-tables? true})
-                    "invalid table_id"))]
-    (if (map? details)
-      {:structured-output (assoc details :id table-id)}
-      {:output (or details "table not found")})))
+(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."
+  ([_tool-id arguments _e] (get-table-details arguments))
+  ([{: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 (assoc details :id (if (int? model-id)
+                                                (str "card__" model-id)
+                                                (str table-id)))}
+       {:output (or details "table not found")}))))
 
 (comment
   (binding [api/*current-user-permissions-set* (delay #{"/"})
@@ -140,26 +157,30 @@
       (get-table-details :get-table-details {:table-id id} {})))
   -)
 
-(defn- get-metric-details
-  [_tool-id {:keys [metric-id]} _e]
-  (let [details (if (int? metric-id)
-                  (metric-details metric-id)
-                  "invalid metric_id")]
-    (if (map? details)
-      {:structured-output details}
-      {:output (or details "metric not found")})))
+(defn get-metric-details
+  "Get information about the metric with ID `metric-id`."
+  ([_tool-id arguments _e] (get-metric-details arguments))
+  ([{: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
-  [_tool-id {:keys [report-id]} _e]
-  (let [details (if (int? report-id)
-                  (let [details (card-details report-id)]
-                    (some-> details
-                            (select-keys [:id :description :name])
-                            (assoc :result_columns (:fields details))))
-                  "invalid report_id")]
-    (if (map? details)
-      {:structured-output details}
-      {:output (or details "report not found")})))
+(defn get-report-details
+  "Get information about the report (card) with ID `report-id`."
+  ([_tool-id arguments _e] (get-report-details arguments))
+  ([{:keys [report-id]}]
+   (let [details (if (int? report-id)
+                   (let [details (card-details report-id)]
+                     (some-> details
+                             (select-keys [:id :description :name])
+                             (assoc :result_columns (:fields details))))
+                   "invalid report_id")]
+     (if (map? details)
+       {:structured-output details}
+       {:output (or details "report not found")}))))
 
 (comment
   (binding [api/*current-user-permissions-set* (delay #{"/"})]
@@ -234,7 +255,8 @@
 
 (defn- execute-query
   [query-id legacy-query]
-  (let [field-id-prefix (metabot-v3.tools.u/query-field-id-prefix query-id)
+  (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)]
@@ -245,13 +267,18 @@
                            (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)})
+
 (defn- dummy-run-query
   [{:keys [context] :as env}]
   (transduce (filter (comp #{"adhoc"} :type))
              (completing (fn [env {:keys [query]}]
                            (let [query-id (u/generate-nano-id)
                                  arguments {:query_id query-id}
-                                 result (execute-query query-id (mbql.normalize/normalize query))]
+                                 result (execute-query query-id query)]
                              (as-> env $env
                                (reduce envelope/add-dummy-message
                                        $env
diff --git a/enterprise/backend/src/metabase_enterprise/metabot_v3/tools/api.clj b/enterprise/backend/src/metabase_enterprise/metabot_v3/tools/api.clj
index 2c6a08863700..db44671198dd 100644
--- a/enterprise/backend/src/metabase_enterprise/metabot_v3/tools/api.clj
+++ b/enterprise/backend/src/metabase_enterprise/metabot_v3/tools/api.clj
@@ -6,8 +6,10 @@
    [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.tools.create-dashboard-subscription :as metabot-v3.tools.create-dashboard-subscription]
    [metabase-enterprise.metabot-v3.tools.filters :as metabot-v3.tools.filters]
@@ -257,6 +259,107 @@
           [: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]
+                         [: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]
+                         [:name :string]
+                         [:description {:optional true} :string]]]]
+   [:map [:output :string]]])
+
+(mr/def ::field-type
+  [:enum "boolean" "date" "number" "string"])
+
+(mr/def ::column
+  [:map
+   [:field_id :string]
+   [:name :string]
+   [:type [:maybe ::field-type]]
+   [:description {:optional true} [:maybe :string]]])
+
+(mr/def ::basic-metric
+  [:map
+   [:id :int]
+   [:name :string]
+   [:description {:optional true} [:maybe :string]]
+   [:default_time_dimension_field_id {:optional true} [:maybe :string]]])
+
+(mr/def ::columns
+  [:sequential ::column])
+
+(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 [:merge ::basic-metric [:map [:queryable_dimensions ::columns]]]]]
+   [: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]
+                         [: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]
+   [:name :string]
+   [:fields ::columns]
+   [:description {:optional true} [:maybe :string]]
+   [:metrics {:optional true} [:sequential ::basic-metric]]])
+
+(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 [:merge
+                         ::basic-table
+                         [:map [:queryable_foreign_key_tables {:optional true} [:sequential ::basic-table]]]]]]
+   [:map [:output :string]]])
+
 (api.macros/defendpoint :post "/create-dashboard-subscription" :- [:merge
                                                                    [:map [:output :string]]
                                                                    ::tool-request]
@@ -337,6 +440,92 @@
               (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
diff --git a/enterprise/backend/test/metabase_enterprise/metabot_v3/tools/api_test.clj b/enterprise/backend/test/metabase_enterprise/metabot_v3/tools/api_test.clj
index 7cbeaf2cd652..c6b1192d1d99 100644
--- a/enterprise/backend/test/metabase_enterprise/metabot_v3/tools/api_test.clj
+++ b/enterprise/backend/test/metabase_enterprise/metabot_v3/tools/api_test.clj
@@ -7,6 +7,10 @@
    [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.test :as mt]))
 
 (defn- ai-session-token
@@ -150,3 +154,170 @@
           (is (=? {:structured_output output
                    :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), :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)
+                   :conversation_id conversation-id}
+                  response)))))))
+
+(deftest ^:parallel 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 :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)))
+          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]
+        (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})]
+          (is (=? {:structured_output (-> metric-data
+                                          (select-keys [:name :description])
+                                          (assoc :id metric-id
+                                                 :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))
+                                                              [{:name "ID", :type "number"}
+                                                               {:name "Ean", :type "string"}
+                                                               {:name "Title", :type "string"}
+                                                               {:name "Category", :type "string"}
+                                                               {:name "Vendor", :type "string"}
+                                                               {:name "Price", :type "number"}
+                                                               {:name "Rating", :type "number"}
+                                                               {:name "Created At", :type "date"}])))
+                   :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 "date"}
+                                    {: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
+                                                 :result_columns
+                                                 (map-indexed #(assoc %2 :field_id (format "c%d/%d" question-id %1))
+                                                              [{:name "Created At: Week", :type "date"}
+                                                               {: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 (str "card__" model-id)
+                                                   :fields
+                                                   (map-indexed #(assoc %2 :field_id (format "c%d/%d" model-id %1))
+                                                                [{:name "Created At: Week", :type "date"}
+                                                                 {:name "Average of Rating", :type "number"}])))
+                     :conversation_id conversation-id}
+                    response))))))))
+
+(deftest ^:parallel get-table-details-test
+  (mt/with-premium-features #{:metabot-v3}
+    (let [table-id (mt/id :products)]
+      (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})]
+          (is (=? {:structured_output {:name "Products"
+                                       :id (str table-id)
+                                       :fields (map-indexed #(assoc %2 :field_id (format "t%d/%d" table-id %1))
+                                                            [{:name "ID", :type "number"}
+                                                             {:name "Ean", :type "string"}
+                                                             {:name "Title", :type "string"}
+                                                             {:name "Category", :type "string"}
+                                                             {:name "Vendor", :type "string"}
+                                                             {:name "Price", :type "number"}
+                                                             {:name "Rating", :type "number"}
+                                                             {:name "Created At", :type "date"}])}
+                   :conversation_id conversation-id}
+                  response)))))))


Reviews:
user: login: johnswanson
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T13:10:52Z
user: login: metamben
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T13:14:25Z
user: login: johnswanson
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-03T13:20:12Z

Review comments:
diff_hunk: @@ -75,7 +75,7 @@
   (update e :round-trips-remaining (fn [v]
                                      (u/prog1 (dec v)
                                        (when (neg? <>)
-                                         (let [msg (i18n/tru "I can't answer your question.")]
+                                         (let [msg (i18n/tru "I cannot answer your question.")]
path: enterprise/backend/src/metabase_enterprise/metabot_v3/envelope.clj
user: login: johnswanson
body: Maybe a silly note but I remember reading the system prompt for ai-service and noticing that it said to [use contractions](https://github.com/metabase/ai-service/blob/f51e5312d06ec3f871ccf2a0261f3e0f04d0cb0d/ai_service/prompts/system.jinja#L78). Is this intentional?
created_at: 2025-03-03T13:09:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,7 +75,7 @@
   (update e :round-trips-remaining (fn [v]
                                      (u/prog1 (dec v)
                                        (when (neg? <>)
-                                         (let [msg (i18n/tru "I can't answer your question.")]
+                                         (let [msg (i18n/tru "I cannot answer your question.")]
path: enterprise/backend/src/metabase_enterprise/metabot_v3/envelope.clj
user: login: metamben
body: I did this to get rid of the invalid i18n message. It doesn't affect the LLM and it will be removed very soon completely, since we are moving the loop to AI service.
created_at: 2025-03-03T13:14:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -75,7 +75,7 @@
   (update e :round-trips-remaining (fn [v]
                                      (u/prog1 (dec v)
                                        (when (neg? <>)
-                                         (let [msg (i18n/tru "I can't answer your question.")]
+                                         (let [msg (i18n/tru "I cannot answer your question.")]
path: enterprise/backend/src/metabase_enterprise/metabot_v3/envelope.clj
user: login: johnswanson
body: Ah gotcha. I'll fix the kondo warning to be more clear about how to fix the i18n (you want `can''t`) but sounds good.
created_at: 2025-03-03T13:20:12Z
author_association: CONTRIBUTOR

PR comments:
user: login: trunk-io[bot]
created_at: 2025-02-28T17:00:02Z
body: <!-- Trunk Test Analytics -->


[![Static Badge](https://img.shields.io/badge/100137_tests-e143b7da-purple)](https://app.trunk.io/metabase/flaky-tests/pr/54519?repo=metabase/metabase&commitHash=e143b7dabc7b0a47862b7daf4f058796aadac7c6&utm_source=gh-comment) &ensp; [![Static Badge](https://img.shields.io/badge/1-failed-crimson)](https://app.trunk.io/metabase/flaky-tests/pr/54519?repo=metabase/metabase&commitHash=e143b7dabc7b0a47862b7daf4f058796aadac7c6&utm_source=gh-comment&runConclusion=FAILURE) &ensp; [![Static Badge](https://img.shields.io/badge/2-flaky-red)](https://app.trunk.io/metabase/flaky-tests/pr/54519?repo=metabase/metabase&commitHash=e143b7dabc7b0a47862b7daf4f058796aadac7c6&utm_source=gh-comment&runConclusion=FAILURE) &ensp; [![Static Badge](https://img.shields.io/badge/0-quarantined-yellow)](https://app.trunk.io/metabase/flaky-tests/pr/54519?repo=metabase/metabase&commitHash=e143b7dabc7b0a47862b7daf4f058796aadac7c6&utm_source=gh-comment&runConclusion=QUARANTINED)


| Failed Test | Failure Summary | Logs |
| --- | --- | --- |
| <code>bin-single-column-20-bins-test</code> | The query execution failed due to a closed statement. | [Logs ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/54519?repo=metabase/metabase&commitHash=e143b7dabc7b0a47862b7daf4f058796aadac7c6&utm_source=gh-comment&runConclusion=FAILURE&runId=c7f882bb-dbc9-548e-ba50-586886e6bd53) |


| Flaky Test | Failure Summary | Logs |
| --- | --- | --- |
| <code>identity-hash-test</code> | Card hashes did not match the expected value. | [Logs ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/54519?repo=metabase/metabase&commitHash=e143b7dabc7b0a47862b7daf4f058796aadac7c6&utm_source=gh-comment&runConclusion=FAILURE&runId=829a7368-ec67-5cad-83a7-a34571f3c1da) |
| <code>scenarios > dashboard warn before leave should warn a user before leaving after adding, removed, moving, or duplicating a tab</code> | Unable to find an element with the text "You're editing this dashboard" within 4 seconds. | [Logs ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/54519?repo=metabase/metabase&commitHash=e143b7dabc7b0a47862b7daf4f058796aadac7c6&utm_source=gh-comment&runConclusion=FAILURE&runId=735c02fd-bcf6-53b2-b037-5fc48244f099) |
<sub>

[View Full Report ↗︎](https://app.trunk.io/metabase/flaky-tests/pr/54519?repo=metabase/metabase&commitHash=e143b7dabc7b0a47862b7daf4f058796aadac7c6&utm_source=gh-comment) ⋅ [Docs](https://docs.trunk.io/flaky-tests?utm_source=gh-comment)

</sub>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-03-03T14:31:06Z
body: 
  ## e2e tests failed on `e143b7dabc7b0a47862b7daf4f058796aadac7c6-1`

  [e2e test run](https://github.com/metabase/metabase/actions/runs/13633005594?pr=54519)

  File | Test Name
  ---- | ---------
  `dashboard.cy.spec.js` | (_flaky_) scenarios > dashboard > warn before leave > should warn a user before leaving after adding, removed, moving, or duplicating a tab

author_association: CONTRIBUTOR