Compare commits
157 Commits
python-sec
...
mk-agent-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7f13e393b | ||
|
|
4f4ae5ff8c | ||
|
|
fbcea63a96 | ||
|
|
d4b8d27f29 | ||
|
|
639c932359 | ||
|
|
b9bffb1c1b | ||
|
|
76d66f2873 | ||
|
|
1f30d83dd8 | ||
|
|
1248913b01 | ||
|
|
fa65962b5b | ||
|
|
421813bc17 | ||
|
|
86535670e1 | ||
|
|
ddaf5e6011 | ||
|
|
e1313e4d7e | ||
|
|
0d2266c4d9 | ||
|
|
b2cbb49c87 | ||
|
|
97478ecbd9 | ||
|
|
157ac541d4 | ||
|
|
60557cf7f4 | ||
|
|
5d553f13f8 | ||
|
|
2be0b07ad5 | ||
|
|
1b3b446370 | ||
|
|
85df04918c | ||
|
|
7722319837 | ||
|
|
92126999df | ||
|
|
eff185561d | ||
|
|
312712414d | ||
|
|
4e3f2ae330 | ||
|
|
75538d9043 | ||
|
|
fb2879e655 | ||
|
|
b4134886aa | ||
|
|
a1145cfa62 | ||
|
|
9ceadc771d | ||
|
|
3db23bb2f4 | ||
|
|
3a4b3d8f86 | ||
|
|
b9291369c8 | ||
|
|
32c5546bf7 | ||
|
|
f55633438f | ||
|
|
89457f666c | ||
|
|
9eb3353b80 | ||
|
|
3865ae0121 | ||
|
|
0a6af96c82 | ||
|
|
1c8f0a0f9d | ||
|
|
4c6b4454b4 | ||
|
|
c6338a89df | ||
|
|
785de90d84 | ||
|
|
685f4c5932 | ||
|
|
04a5d55543 | ||
|
|
9dccceafc5 | ||
|
|
20cfbc41d9 | ||
|
|
e1ff254829 | ||
|
|
be383dc351 | ||
|
|
d00c6eee63 | ||
|
|
16a8000868 | ||
|
|
295aa7c0c3 | ||
|
|
390f9de00e | ||
|
|
3ad739cc4b | ||
|
|
6b6e577d99 | ||
|
|
35e63b4100 | ||
|
|
a16e9434fe | ||
|
|
b30954b198 | ||
|
|
a7688576be | ||
|
|
cd66872edf | ||
|
|
428e655e26 | ||
|
|
b0287a3367 | ||
|
|
976071c2a0 | ||
|
|
00423c20c4 | ||
|
|
5e7a1448b7 | ||
|
|
bdad323565 | ||
|
|
610dfa96e5 | ||
|
|
338f3a830e | ||
|
|
08089fc951 | ||
|
|
5140f984dc | ||
|
|
f80a7e06cf | ||
|
|
fed8768d5d | ||
|
|
23a4137f78 | ||
|
|
f8eaf26a6a | ||
|
|
5a6065c83d | ||
|
|
7ea883b663 | ||
|
|
8cef495a82 | ||
|
|
56753fe2a0 | ||
|
|
6ad273fce7 | ||
|
|
62320a4090 | ||
|
|
653d9b4993 | ||
|
|
5f768e8c7e | ||
|
|
25020d85e3 | ||
|
|
79d8d7363d | ||
|
|
854de08118 | ||
|
|
22cb2eb1a9 | ||
|
|
1fc3ae85fe | ||
|
|
5dac01a389 | ||
|
|
886c26f32a | ||
|
|
02a09ee32d | ||
|
|
63d5473b04 | ||
|
|
93abbca651 | ||
|
|
c2532b98e0 | ||
|
|
b70859b99b | ||
|
|
b600d31688 | ||
|
|
5aece945ec | ||
|
|
f80ae41aa5 | ||
|
|
416852a407 | ||
|
|
68d932df4b | ||
|
|
bf2b2c9369 | ||
|
|
f5885009a4 | ||
|
|
1c8ee758e2 | ||
|
|
1a9bae0e69 | ||
|
|
80b905eea9 | ||
|
|
0a66b38889 | ||
|
|
157cd3968e | ||
|
|
871df43d9c | ||
|
|
8bd0ae2a2c | ||
|
|
6d0c7ce1ff | ||
|
|
68af9d6e71 | ||
|
|
d7aa4d8447 | ||
|
|
cbdf45cca3 | ||
|
|
c7adc6c39a | ||
|
|
7c04cf83ee | ||
|
|
0a7c965278 | ||
|
|
c4c62eea39 | ||
|
|
7e6596504f | ||
|
|
b6c8797db2 | ||
|
|
039fe45c4a | ||
|
|
03b8a6d384 | ||
|
|
d6c6800859 | ||
|
|
0cbda9a877 | ||
|
|
232e389154 | ||
|
|
8a5892f291 | ||
|
|
74e18c1d91 | ||
|
|
276219c449 | ||
|
|
2704ff16cd | ||
|
|
896f1128ae | ||
|
|
ec85f6edc5 | ||
|
|
f6b0e484bd | ||
|
|
9a06fc8bf3 | ||
|
|
dde605fe55 | ||
|
|
2a6378a759 | ||
|
|
874794fb03 | ||
|
|
e34f33dc6a | ||
|
|
080e87a8b7 | ||
|
|
bd3d84099a | ||
|
|
cc0ee1edb5 | ||
|
|
5b8a1bc023 | ||
|
|
68c8d44a34 | ||
|
|
963b82b049 | ||
|
|
b7c9619a05 | ||
|
|
5fa11593fa | ||
|
|
3c68e4abea | ||
|
|
57fc009ea2 | ||
|
|
715072acc0 | ||
|
|
aba4de83e8 | ||
|
|
92a6dd2ab3 | ||
|
|
94be085bf5 | ||
|
|
e9f8a7d23c | ||
|
|
c6b3848f19 | ||
|
|
a9a4640a26 | ||
|
|
54e14d22bf | ||
|
|
419774dbb2 |
173
.claude/skills/clojure-eval/SKILL.md
Normal file
173
.claude/skills/clojure-eval/SKILL.md
Normal file
@@ -0,0 +1,173 @@
|
||||
---
|
||||
name: clojure-eval
|
||||
description: Evaluate Clojure code via nREPL using clj-nrepl-eval. Use this when you need to test code, check if edited files compile, verify function behavior, or interact with a running REPL session.
|
||||
---
|
||||
|
||||
# Clojure REPL Evaluation
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when you need to:
|
||||
- **Verify that edited Clojure files compile and load correctly**
|
||||
- Test function behavior interactively
|
||||
- Check the current state of the REPL
|
||||
- Debug code by evaluating expressions
|
||||
- Require or load namespaces for testing
|
||||
- Validate that code changes work before committing
|
||||
|
||||
## How It Works
|
||||
|
||||
The `clj-nrepl-eval` command evaluates Clojure code against an nREPL server. **Session state persists between evaluations**, so you can require a namespace in one evaluation and use it in subsequent calls. Each host:port combination maintains its own session file.
|
||||
|
||||
## Instructions
|
||||
|
||||
### 0. Discover and select nREPL server
|
||||
|
||||
First, discover what nREPL servers are running in the current directory:
|
||||
|
||||
```bash
|
||||
clj-nrepl-eval --discover-ports
|
||||
```
|
||||
|
||||
This will show all nREPL servers (Clojure, Babashka, shadow-cljs, etc.) running in the current project directory.
|
||||
|
||||
**Then use the AskUserQuestion tool:**
|
||||
|
||||
- **If ports are discovered:** Prompt user to select which nREPL port to use:
|
||||
- **question:** "Which nREPL port would you like to use?"
|
||||
- **header:** "nREPL Port"
|
||||
- **options:** Present each discovered port as an option with:
|
||||
- **label:** The port number
|
||||
- **description:** The server type and status (e.g., "Clojure nREPL server in current directory")
|
||||
- Include up to 4 discovered ports as options
|
||||
- The user can select "Other" to enter a custom port number
|
||||
|
||||
- **If no ports are discovered:** Prompt user how to start an nREPL server:
|
||||
- **question:** "No nREPL servers found. How would you like to start one?"
|
||||
- **header:** "Start nREPL"
|
||||
- **options:**
|
||||
- **label:** "deps.edn alias", **description:** "Find and use an nREPL alias in deps.edn"
|
||||
- **label:** "Leiningen", **description:** "Start nREPL using 'lein repl'"
|
||||
- The user can select "Other" for alternative methods or if they already have a server running on a specific port
|
||||
|
||||
IMPORTANT: IF you start a REPL do not supply a port let the nREPL start and return the port that it was started on.
|
||||
|
||||
### 1. Evaluate Clojure Code
|
||||
|
||||
> Evaluation automatically connects to the given port
|
||||
|
||||
Use the `-p` flag to specify the port and pass your Clojure code.
|
||||
|
||||
**Recommended: Pass code as a command-line argument:**
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> "(+ 1 2 3)"
|
||||
```
|
||||
|
||||
**For multiple expressions (single line):**
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> "(def x 10) (+ x 20)"
|
||||
```
|
||||
|
||||
**Alternative: Using heredoc (may require permission approval for multiline commands):**
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> <<'EOF'
|
||||
(def x 10)
|
||||
(+ x 20)
|
||||
EOF
|
||||
```
|
||||
|
||||
**Alternative: Via stdin pipe:**
|
||||
```bash
|
||||
echo "(+ 1 2 3)" | clj-nrepl-eval -p <PORT>
|
||||
```
|
||||
|
||||
### 2. Display nREPL Sessions
|
||||
|
||||
**Discover all nREPL servers in current directory:**
|
||||
```bash
|
||||
clj-nrepl-eval --discover-ports
|
||||
```
|
||||
Shows all running nREPL servers in the current project directory, including their type (clj/bb/basilisp) and whether they match the current working directory.
|
||||
|
||||
**Check previously connected sessions:**
|
||||
```bash
|
||||
clj-nrepl-eval --connected-ports
|
||||
```
|
||||
Shows only connections you have made before (appears after first evaluation on a port).
|
||||
|
||||
### 3. Common Patterns
|
||||
|
||||
**Require a namespace (always use :reload to pick up changes):**
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> "(require '[my.namespace :as ns] :reload)"
|
||||
```
|
||||
|
||||
**Test a function after requiring:**
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> "(ns/my-function arg1 arg2)"
|
||||
```
|
||||
|
||||
**Check if a file compiles:**
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> "(require 'my.namespace :reload)"
|
||||
```
|
||||
|
||||
**Multiple expressions:**
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> "(def x 10) (* x 2) (+ x 5)"
|
||||
```
|
||||
|
||||
**Complex multiline code (using heredoc):**
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> <<'EOF'
|
||||
(def x 10)
|
||||
(* x 2)
|
||||
(+ x 5)
|
||||
EOF
|
||||
```
|
||||
*Note: Heredoc syntax may require permission approval.*
|
||||
|
||||
**With custom timeout (in milliseconds):**
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> --timeout 5000 "(long-running-fn)"
|
||||
```
|
||||
|
||||
**Reset the session (clears all state):**
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> --reset-session
|
||||
clj-nrepl-eval -p <PORT> --reset-session "(def x 1)"
|
||||
```
|
||||
|
||||
## Available Options
|
||||
|
||||
- `-p, --port PORT` - nREPL port (required)
|
||||
- `-H, --host HOST` - nREPL host (default: 127.0.0.1)
|
||||
- `-t, --timeout MILLISECONDS` - Timeout (default: 120000 = 2 minutes)
|
||||
- `-r, --reset-session` - Reset the persistent nREPL session
|
||||
- `-c, --connected-ports` - List previously connected nREPL sessions
|
||||
- `-d, --discover-ports` - Discover nREPL servers in current directory
|
||||
- `-h, --help` - Show help message
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **Prefer command-line arguments:** Pass code as quoted strings: `clj-nrepl-eval -p <PORT> "(+ 1 2 3)"` - works with existing permissions
|
||||
- **Heredoc for complex code:** Use heredoc (`<<'EOF' ... EOF`) for truly multiline code, but note it may require permission approval
|
||||
- **Sessions persist:** State (vars, namespaces, loaded libraries) persists across invocations until the nREPL server restarts or `--reset-session` is used
|
||||
- **Automatic delimiter repair:** The tool automatically repairs missing or mismatched parentheses
|
||||
- **Always use :reload:** When requiring namespaces, use `:reload` to pick up recent changes
|
||||
- **Default timeout:** 2 minutes (120000ms) - increase for long-running operations
|
||||
- **Input precedence:** Command-line arguments take precedence over stdin
|
||||
|
||||
## Typical Workflow
|
||||
|
||||
1. Discover nREPL servers: `clj-nrepl-eval --discover-ports`
|
||||
2. Use **AskUserQuestion** tool to prompt user to select a port
|
||||
3. Require namespace:
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> "(require '[my.ns :as ns] :reload)"
|
||||
```
|
||||
4. Test function:
|
||||
```bash
|
||||
clj-nrepl-eval -p <PORT> "(ns/my-fn ...)"
|
||||
```
|
||||
5. Iterate: Make changes, re-require with `:reload`, test again
|
||||
@@ -796,7 +796,6 @@
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
premium-features
|
||||
request
|
||||
settings
|
||||
sql-tools
|
||||
@@ -2238,12 +2237,17 @@
|
||||
enterprise/metabot-v3
|
||||
{:team "Metabot"
|
||||
:api #{metabase-enterprise.metabot-v3.api
|
||||
metabase-enterprise.metabot-v3.client
|
||||
metabase-enterprise.metabot-v3.core
|
||||
metabase-enterprise.metabot-v3.init
|
||||
metabase-enterprise.metabot-v3.tools.api}
|
||||
:friends #{enterprise/agent-api}
|
||||
metabase-enterprise.metabot-v3.tools.api
|
||||
metabase-enterprise.metabot-v3.tools.deftool
|
||||
metabase-enterprise.metabot-v3.tools.entity-details
|
||||
metabase-enterprise.metabot-v3.tools.field-stats
|
||||
metabase-enterprise.metabot-v3.tools.filters
|
||||
metabase-enterprise.metabot-v3.tools.search
|
||||
metabase-enterprise.metabot-v3.util}
|
||||
:uses #{activity-feed
|
||||
analytics
|
||||
api
|
||||
app-db
|
||||
audit-app
|
||||
@@ -2272,10 +2276,10 @@
|
||||
task
|
||||
transforms
|
||||
transforms-base
|
||||
users
|
||||
util
|
||||
warehouses
|
||||
enterprise/dependencies
|
||||
enterprise/llm
|
||||
enterprise/transforms-python}}
|
||||
|
||||
enterprise/permission-debug
|
||||
@@ -2377,8 +2381,7 @@
|
||||
settings
|
||||
task
|
||||
util
|
||||
enterprise/llm
|
||||
enterprise/metabot-v3}}
|
||||
enterprise/llm}}
|
||||
|
||||
enterprise/serialization
|
||||
{:team "Querying"
|
||||
|
||||
@@ -6,8 +6,10 @@ For detailed guidance on writing and reviewing code and documentation, see the s
|
||||
|
||||
## Clojure
|
||||
|
||||
- **[clojure-eval](.claude/skills/clojure-eval/SKILL.md)** - Always use this to evaluate Clojure code, **run tests**, and verify edits/compile. Prefer this over `clojure -X:...` shell commands.
|
||||
- **[clojure-write](.claude/skills/clojure-write/SKILL.md)** - Clojure/ClojureScript development with REPL-driven workflow and coding conventions
|
||||
- **[clojure-review](.claude/skills/clojure-review/SKILL.md)** - Clojure/ClojureScript code review guidelines and style enforcement
|
||||
- **[clojure-eval](.claude/skills/clojure-eval/SKILL.md)** - Clojure code evaluation against running nREPL
|
||||
|
||||
## TypeScript
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Common Patterns
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
export METABASE_URL="http://localhost:3000"
|
||||
export METABASE_API_KEY="mb_abc123..."
|
||||
```
|
||||
|
||||
Or pass as flags: `--url`, `--api-key`, `--session-token`.
|
||||
|
||||
## Global Flags
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--fields id,name` | Only include these keys in response JSON |
|
||||
| `--max-rows 200` | Override default 50-row cap on query results |
|
||||
| `--dry-run` | Show API calls without executing |
|
||||
| `--url <url>` | Metabase URL (overrides METABASE_URL) |
|
||||
| `--api-key <key>` | API key (overrides METABASE_API_KEY) |
|
||||
| `--session-token <token>` | Session token (overrides METABASE_SESSION_TOKEN) |
|
||||
|
||||
## JSON Payload Format
|
||||
|
||||
All mutation commands use `--json '<payload>'`.
|
||||
|
||||
Shell escaping for single quotes in SQL:
|
||||
```bash
|
||||
./metabase-agent create-question --json '{"sql": "SELECT * FROM t WHERE name = '"'"'test'"'"'"}'
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
All errors are structured JSON on stderr:
|
||||
```json
|
||||
{
|
||||
"error": "error_code",
|
||||
"message": "Human-readable description",
|
||||
"hint": "Suggested fix (when available)"
|
||||
}
|
||||
```
|
||||
|
||||
Common error codes: `missing_config`, `api_error`, `unknown_field`, `invalid_parameter`, `missing_parameter`, `unknown_filter`, `field_resolution`.
|
||||
|
||||
## Introspection
|
||||
|
||||
```bash
|
||||
# List all commands with JSON schemas
|
||||
./metabase-agent schema
|
||||
|
||||
# Get exact JSON Schema for any command
|
||||
./metabase-agent schema create-dashboard
|
||||
|
||||
# Preview API calls without executing
|
||||
./metabase-agent create-dashboard --dry-run --json '...'
|
||||
```
|
||||
@@ -0,0 +1,35 @@
|
||||
# Field Name Resolution
|
||||
|
||||
The CLI resolves human-readable field names to numeric Metabase field IDs automatically. You never need to look up field IDs manually.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. You provide a field name string (e.g., `"created_at"` or `"Created At"`)
|
||||
2. The CLI fetches table metadata via the API
|
||||
3. It matches against both `name` (snake_case) and `display_name` (Title Case), case-insensitively
|
||||
4. Table metadata is cached per session -- repeated lookups for the same table cost nothing
|
||||
|
||||
## Commands That Accept Field Names
|
||||
|
||||
- `create-segment` / `update-segment`: the `field` property in each filter
|
||||
- `create-dashboard`: filter targets `field` property
|
||||
- `add-card-to-dashboard`: filter_mappings `field` property
|
||||
|
||||
## Error Behavior
|
||||
|
||||
If a field name is not found, the error includes a closest-match suggestion and all available fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "unknown_field",
|
||||
"message": "Field 'create_at' not found on table 5",
|
||||
"hint": "Did you mean 'created_at'? Available fields: id, created_at, total, status",
|
||||
"available_fields": ["id", "created_at", "total", "status"]
|
||||
}
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Run `get-table <id>` first to see all field names and types
|
||||
- Prefer `name` (snake_case) over `display_name` -- it's more stable across locale changes
|
||||
- Numeric string values like `"42"` pass through as numeric IDs without resolution
|
||||
546
agent-toolkit-runner/.claude/skills/_shared/mbql-construction.md
Normal file
546
agent-toolkit-runner/.claude/skills/_shared/mbql-construction.md
Normal file
@@ -0,0 +1,546 @@
|
||||
# MBQL Query Construction
|
||||
|
||||
MBQL (Metabase Query Language) queries are constructed using Clojure code evaluated by the `construct-query` command. The code uses `metabase.lib` functions — the same library powering Metabase's visual query builder. Queries are correct by construction: if the code evaluates, the query is valid.
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
# 1. Discover schema (get table names and field names)
|
||||
./metabase-agent get-table <table_id>
|
||||
|
||||
# 2. Construct and run MBQL query in one step
|
||||
./metabase-agent construct-query --database-id <id> --run --clj '<clojure code>'
|
||||
|
||||
# OR: construct MBQL JSON, then use with other commands
|
||||
./metabase-agent construct-query --database-id <id> --clj '<clojure code>'
|
||||
# --> returns MBQL JSON to pass to create-question, execute-query, create-transform, or create-measure
|
||||
```
|
||||
|
||||
## construct-query Command
|
||||
|
||||
```bash
|
||||
# Return MBQL JSON
|
||||
./metabase-agent construct-query --database-id <id> --clj '<code>'
|
||||
|
||||
# Construct AND execute in one step (returns query results)
|
||||
./metabase-agent construct-query --database-id <id> --run --clj '<code>'
|
||||
```
|
||||
|
||||
Without `--run`, returns legacy MBQL JSON that can be passed to:
|
||||
- `create-question --json '{"query": <result>, ...}'`
|
||||
- `execute-query --json '{"query": <result>, ...}'`
|
||||
- `create-transform --json '{"query": <result>, ...}'`
|
||||
- `create-measure --json '{"definition": <result>, ...}'`
|
||||
|
||||
With `--run`, executes the query and returns results directly (columns, rows, metadata).
|
||||
|
||||
## Helper Functions
|
||||
|
||||
### `table` — Look up table metadata
|
||||
|
||||
```clojure
|
||||
(table "ORDERS") ; by name (case-insensitive)
|
||||
(table 42) ; by numeric ID
|
||||
```
|
||||
|
||||
Both string names and numeric IDs are accepted. Names are case-insensitive. On error, lists available tables with their IDs.
|
||||
|
||||
### `field` — Look up field metadata
|
||||
|
||||
```clojure
|
||||
(field "ORDERS" "TOTAL") ; by table name + field name (case-insensitive)
|
||||
(field "total") ; by field name only (searches all tables)
|
||||
(field 123) ; by numeric ID
|
||||
```
|
||||
|
||||
**Two-arity** `(field "TABLE" "FIELD")` is the explicit form — always unambiguous.
|
||||
|
||||
**Single-arity** `(field "name")` searches across all tables. If the field name exists in exactly one table, it resolves automatically. If ambiguous (exists in multiple tables), it throws an error listing which tables have that field.
|
||||
|
||||
**Numeric ID** `(field 123)` looks up by field ID directly.
|
||||
|
||||
### `query` — Create a new query
|
||||
|
||||
```clojure
|
||||
(query (table "ORDERS")) ; pass table metadata from (table ...)
|
||||
```
|
||||
|
||||
## Function Reference
|
||||
|
||||
All functions are available at the top level — no namespace prefix needed. The query is built using threading: `(-> (query ...) (filter ...) (aggregate ...) ...)`.
|
||||
|
||||
### Filtering (22 functions)
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `filter` | `(filter query clause)` | Add a filter clause to a query |
|
||||
| `=` | `(= field value)` | Equal to |
|
||||
| `!=` | `(!= field value)` | Not equal to |
|
||||
| `<` | `(< field value)` | Less than |
|
||||
| `<=` | `(<= field value)` | Less than or equal |
|
||||
| `>` | `(> field value)` | Greater than |
|
||||
| `>=` | `(>= field value)` | Greater than or equal |
|
||||
| `between` | `(between field low high)` | Between two values (inclusive) |
|
||||
| `contains` | `(contains field string)` | String contains |
|
||||
| `does-not-contain` | `(does-not-contain field string)` | String does not contain |
|
||||
| `starts-with` | `(starts-with field string)` | String starts with |
|
||||
| `ends-with` | `(ends-with field string)` | String ends with |
|
||||
| `is-null` | `(is-null field)` | Value is null |
|
||||
| `not-null` | `(not-null field)` | Value is not null |
|
||||
| `is-empty` | `(is-empty field)` | Empty string |
|
||||
| `not-empty` | `(not-empty field)` | Not empty string |
|
||||
| `in` | `(in field [v1 v2 ...])` | Value in set |
|
||||
| `not-in` | `(not-in field [v1 v2 ...])` | Value not in set |
|
||||
| `time-interval` | `(time-interval field n unit)` | Within time interval (e.g., last 30 days) |
|
||||
| `and` | `(and clause1 clause2 ...)` | Logical AND of filter clauses |
|
||||
| `or` | `(or clause1 clause2 ...)` | Logical OR of filter clauses |
|
||||
| `not` | `(not clause)` | Negate a filter clause |
|
||||
| `inside` | `(inside lat-field lon-field n s e w)` | Geographic bounding box filter |
|
||||
|
||||
#### Filtering Examples
|
||||
|
||||
```clojure
|
||||
;; Simple equality filter
|
||||
(-> (query (table "ORDERS"))
|
||||
(filter (= (field "ORDERS" "STATUS") "completed")))
|
||||
|
||||
;; Compound filter with AND/OR
|
||||
(-> (query (table "ORDERS"))
|
||||
(filter (and (>= (field "ORDERS" "TOTAL") 100)
|
||||
(or (= (field "ORDERS" "STATUS") "completed")
|
||||
(= (field "ORDERS" "STATUS") "shipped")))))
|
||||
|
||||
;; Time interval: orders from the last 30 days
|
||||
(-> (query (table "ORDERS"))
|
||||
(filter (time-interval (field "ORDERS" "CREATED_AT") -30 :day)))
|
||||
|
||||
;; Null check
|
||||
(-> (query (table "ORDERS"))
|
||||
(filter (not-null (field "ORDERS" "DISCOUNT"))))
|
||||
|
||||
;; String matching
|
||||
(-> (query (table "PRODUCTS"))
|
||||
(filter (contains (field "PRODUCTS" "TITLE") "Widget")))
|
||||
```
|
||||
|
||||
### Aggregation (17 functions)
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `aggregate` | `(aggregate query clause)` | Add an aggregation to a query |
|
||||
| `count` | `(count)` | Count rows |
|
||||
| `sum` | `(sum field)` | Sum of values |
|
||||
| `avg` | `(avg field)` | Average |
|
||||
| `min` | `(min field)` | Minimum |
|
||||
| `max` | `(max field)` | Maximum |
|
||||
| `distinct` | `(distinct field)` | Count distinct values |
|
||||
| `median` | `(median field)` | Median value |
|
||||
| `stddev` | `(stddev field)` | Standard deviation |
|
||||
| `var` | `(var field)` | Variance |
|
||||
| `percentile` | `(percentile field p)` | Percentile (p between 0 and 1) |
|
||||
| `share` | `(share clause)` | Proportion of rows matching a filter |
|
||||
| `count-where` | `(count-where clause)` | Count rows matching a filter |
|
||||
| `sum-where` | `(sum-where field clause)` | Sum where filter matches |
|
||||
| `distinct-where` | `(distinct-where field clause)` | Distinct count where filter matches |
|
||||
| `cum-count` | `(cum-count)` | Cumulative count |
|
||||
| `cum-sum` | `(cum-sum field)` | Cumulative sum |
|
||||
|
||||
#### Aggregation Result Column Names
|
||||
|
||||
When a query has aggregations, the result columns are auto-named by operator:
|
||||
- First of each type: `count`, `sum`, `avg`, `min`, `max`, etc.
|
||||
- Duplicates get a suffix: `sum_2`, `sum_3`, `count_2`, etc.
|
||||
|
||||
Use these names in `visualization.y_axis` when creating questions:
|
||||
```json
|
||||
{"visualization": {"y_axis": ["sum", "count"]}}
|
||||
```
|
||||
|
||||
#### Aggregation Examples
|
||||
|
||||
```clojure
|
||||
;; Simple count
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (count)))
|
||||
|
||||
;; Multiple aggregations (result columns: "sum", "count", "avg")
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(aggregate (count))
|
||||
(aggregate (avg (field "ORDERS" "TOTAL"))))
|
||||
|
||||
;; Conditional aggregation: count of completed orders
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (count-where (= (field "ORDERS" "STATUS") "completed"))))
|
||||
|
||||
;; Percentage of completed orders
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (share (= (field "ORDERS" "STATUS") "completed"))))
|
||||
|
||||
;; 95th percentile of order totals
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (percentile (field "ORDERS" "TOTAL") 0.95)))
|
||||
```
|
||||
|
||||
### Breakout / Grouping (3 functions)
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `breakout` | `(breakout query field-or-expr)` | Group results by a column |
|
||||
| `with-temporal-bucket` | `(with-temporal-bucket field bucket)` | Apply time bucketing to a column |
|
||||
| `with-binning` | `(with-binning field strategy)` | Apply numeric binning |
|
||||
|
||||
**Temporal bucket values**: `:minute`, `:hour`, `:day`, `:week`, `:month`, `:quarter`, `:year`, `:minute-of-hour`, `:hour-of-day`, `:day-of-week`, `:day-of-month`, `:day-of-year`, `:week-of-year`, `:month-of-year`, `:quarter-of-year`
|
||||
|
||||
#### Breakout Examples
|
||||
|
||||
```clojure
|
||||
;; Group by month
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))
|
||||
|
||||
;; Group by multiple columns
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (count))
|
||||
(breakout (field "ORDERS" "STATUS"))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :quarter)))
|
||||
|
||||
;; Group by day of week (for pattern analysis)
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (count))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :day-of-week)))
|
||||
```
|
||||
|
||||
### Ordering and Limiting (4 functions)
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `order-by` | `(order-by query orderable)` | Sort ascending (default) |
|
||||
| `order-by` | `(order-by query orderable :desc)` | Sort with explicit direction |
|
||||
| `order-by` | `(order-by query :desc orderable)` | Direction-first syntax (also works) |
|
||||
| `order-by` | `(order-by query (desc orderable))` | Using desc/asc wrappers |
|
||||
| `asc` | `(asc orderable)` | Mark for ascending sort |
|
||||
| `desc` | `(desc orderable)` | Mark for descending sort |
|
||||
| `limit` | `(limit query n)` | Limit number of rows |
|
||||
|
||||
`order-by` accepts field metadata, aggregation clauses, and breakout columns. For aggregations (like `(sum ...)`), it automatically finds the matching orderable column.
|
||||
|
||||
#### Order/Limit Examples
|
||||
|
||||
```clojure
|
||||
;; Top 10 orders by total, descending
|
||||
(-> (query (table "ORDERS"))
|
||||
(order-by (field "ORDERS" "TOTAL") :desc)
|
||||
(limit 10))
|
||||
|
||||
;; Same thing using desc wrapper
|
||||
(-> (query (table "ORDERS"))
|
||||
(order-by (desc (field "ORDERS" "TOTAL")))
|
||||
(limit 10))
|
||||
|
||||
;; Direction-first syntax (also valid)
|
||||
(-> (query (table "ORDERS"))
|
||||
(order-by :desc (field "ORDERS" "TOTAL"))
|
||||
(limit 10))
|
||||
|
||||
;; Order by aggregation result — top categories by revenue
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (field "ORDERS" "STATUS"))
|
||||
(order-by (desc (sum (field "ORDERS" "TOTAL")))))
|
||||
|
||||
;; Revenue by month, chronological order
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month))
|
||||
(order-by (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month) :asc))
|
||||
```
|
||||
|
||||
### Expressions (35+ functions)
|
||||
|
||||
Named expressions create computed columns. Wrap each in `(expression query "name" expr)`.
|
||||
|
||||
#### Arithmetic
|
||||
|
||||
| Function | Example |
|
||||
|----------|---------|
|
||||
| `+` | `(+ (field "T" "A") (field "T" "B"))` |
|
||||
| `-` | `(- (field "T" "PRICE") (field "T" "COST"))` |
|
||||
| `*` | `(* (field "T" "QTY") (field "T" "PRICE"))` |
|
||||
| `/` | `(/ (field "T" "TOTAL") (field "T" "COUNT"))` |
|
||||
|
||||
#### Conditional
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `case` | `(case [[cond1 val1] [cond2 val2] ...])` | Case expression with condition/value pairs |
|
||||
| `coalesce` | `(coalesce field1 field2 ...)` | First non-null value |
|
||||
|
||||
#### String Functions
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `concat` | `(concat str1 str2 ...)` | Concatenate strings |
|
||||
| `substring` | `(substring field start length)` | Extract substring (1-indexed) |
|
||||
| `replace` | `(replace field old new)` | Replace occurrences |
|
||||
| `upper` | `(upper field)` | Uppercase |
|
||||
| `lower` | `(lower field)` | Lowercase |
|
||||
| `trim` | `(trim field)` | Trim whitespace |
|
||||
| `ltrim` | `(ltrim field)` | Trim leading whitespace |
|
||||
| `rtrim` | `(rtrim field)` | Trim trailing whitespace |
|
||||
| `length` | `(length field)` | String length |
|
||||
| `regex-match-first` | `(regex-match-first field pattern)` | First regex match |
|
||||
|
||||
#### Math Functions
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `abs` | Absolute value |
|
||||
| `ceil` | Ceiling |
|
||||
| `floor` | Floor |
|
||||
| `round` | Round |
|
||||
| `power` | Power (`(power field exp)`) |
|
||||
| `sqrt` | Square root |
|
||||
| `log` | Natural logarithm |
|
||||
| `exp` | e^x |
|
||||
|
||||
#### Temporal Functions
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `now` | `(now)` | Current timestamp |
|
||||
| `relative-datetime` | `(relative-datetime n unit)` | Relative time (e.g., -7 :day) |
|
||||
| `absolute-datetime` | `(absolute-datetime str unit)` | Parse absolute datetime |
|
||||
| `datetime-add` | `(datetime-add field n unit)` | Add time to datetime |
|
||||
| `datetime-subtract` | `(datetime-subtract field n unit)` | Subtract time from datetime |
|
||||
| `get-year` | `(get-year field)` | Extract year |
|
||||
| `get-quarter` | `(get-quarter field)` | Extract quarter (1-4) |
|
||||
| `get-month` | `(get-month field)` | Extract month (1-12) |
|
||||
| `get-week` | `(get-week field)` | Extract week of year |
|
||||
| `get-day` | `(get-day field)` | Extract day of month |
|
||||
| `get-day-of-week` | `(get-day-of-week field)` | Extract day of week (1-7) |
|
||||
| `get-hour` | `(get-hour field)` | Extract hour (0-23) |
|
||||
| `get-minute` | `(get-minute field)` | Extract minute (0-59) |
|
||||
| `get-second` | `(get-second field)` | Extract second (0-59) |
|
||||
| `convert-timezone` | `(convert-timezone field target source)` | Convert timezone |
|
||||
| `relative-time-interval` | `(relative-time-interval field n unit offset-n offset-unit)` | Relative time interval filter |
|
||||
|
||||
#### Advanced
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `expression` | `(expression query "name" expr)` | Define a named expression (computed column) |
|
||||
| `offset` | `(offset expr n)` | Access value from N rows before/after |
|
||||
| `segment` | `(segment id)` | Reference a saved segment by ID |
|
||||
|
||||
#### Expression Examples
|
||||
|
||||
```clojure
|
||||
;; Computed column: profit margin
|
||||
(-> (query (table "PRODUCTS"))
|
||||
(expression "profit_margin"
|
||||
(/ (- (field "PRODUCTS" "PRICE") (field "PRODUCTS" "COST"))
|
||||
(field "PRODUCTS" "PRICE"))))
|
||||
|
||||
;; Case expression: order size category
|
||||
(-> (query (table "ORDERS"))
|
||||
(expression "size_category"
|
||||
(case [[(< (field "ORDERS" "TOTAL") 50) "Small"]
|
||||
[(< (field "ORDERS" "TOTAL") 200) "Medium"]]
|
||||
{:default "Large"})))
|
||||
|
||||
;; Date math: days since order
|
||||
(-> (query (table "ORDERS"))
|
||||
(expression "days_since_order"
|
||||
(datetime-subtract (now) (field "ORDERS" "CREATED_AT") :day)))
|
||||
|
||||
;; Previous month comparison using offset
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month))
|
||||
(expression "prev_month_revenue" (offset (sum (field "ORDERS" "TOTAL")) -1)))
|
||||
```
|
||||
|
||||
### Joins (9 functions)
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `join` | `(join query join-clause)` | Add a join to a query |
|
||||
| `join-clause` | `(join-clause table-meta)` | Create a join clause for a table |
|
||||
| `with-join-conditions` | `(with-join-conditions clause [cond ...])` | Set join conditions |
|
||||
| `with-join-strategy` | `(with-join-strategy clause strategy)` | Set join type |
|
||||
| `with-join-fields` | `(with-join-fields clause :all/:none)` | Include joined fields |
|
||||
| `with-join-alias` | `(with-join-alias clause alias)` | Set join alias |
|
||||
| `suggested-join-conditions` | `(suggested-join-conditions query table)` | Auto-detect join conditions |
|
||||
| `joinable-columns` | `(joinable-columns query join-clause)` | Get columns available for join conditions |
|
||||
| `joins` | `(joins query)` | Get existing joins on a query |
|
||||
|
||||
**Join strategies**: `:left-join` (default), `:right-join`, `:inner-join`
|
||||
|
||||
#### Join Examples
|
||||
|
||||
```clojure
|
||||
;; Join orders with products
|
||||
(let [q (query (table "ORDERS"))
|
||||
products (table "PRODUCTS")]
|
||||
(-> q
|
||||
(join (-> (join-clause products)
|
||||
(with-join-conditions
|
||||
[(= (field "ORDERS" "PRODUCT_ID")
|
||||
(field "PRODUCTS" "ID"))])
|
||||
(with-join-fields :all)))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (field "PRODUCTS" "CATEGORY"))))
|
||||
|
||||
;; Inner join with alias
|
||||
(let [q (query (table "ORDERS"))
|
||||
users (table "PEOPLE")]
|
||||
(-> q
|
||||
(join (-> (join-clause users)
|
||||
(with-join-conditions
|
||||
[(= (field "ORDERS" "USER_ID")
|
||||
(field "PEOPLE" "ID"))])
|
||||
(with-join-strategy :inner-join)
|
||||
(with-join-fields :all)))
|
||||
(filter (= (field "PEOPLE" "STATE") "CA"))
|
||||
(aggregate (count))))
|
||||
```
|
||||
|
||||
### Column Introspection (6 functions)
|
||||
|
||||
These functions return available columns at a given query stage. Useful for dynamic resolution.
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `visible-columns` | All visible columns in the current stage |
|
||||
| `filterable-columns` | Columns that can be used in filters |
|
||||
| `breakoutable-columns` | Columns that can be used for grouping |
|
||||
| `aggregable-columns` | Columns that can be aggregated |
|
||||
| `orderable-columns` | Columns that can be used for sorting |
|
||||
| `expressionable-columns` | Columns available for use in expressions |
|
||||
|
||||
### Query Lifecycle
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `->legacy-MBQL` | Convert a pMBQL query to legacy MBQL format (done automatically by construct-query) |
|
||||
|
||||
## Complete Examples
|
||||
|
||||
### Revenue by Month (Line Chart)
|
||||
|
||||
```clojure
|
||||
(-> (query (table "ORDERS"))
|
||||
(filter (= (field "ORDERS" "STATUS") "completed"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))
|
||||
```
|
||||
|
||||
### Top Categories by Revenue (Bar Chart)
|
||||
|
||||
```clojure
|
||||
(let [q (query (table "ORDERS"))
|
||||
products (table "PRODUCTS")]
|
||||
(-> q
|
||||
(join (-> (join-clause products)
|
||||
(with-join-conditions
|
||||
[(= (field "ORDERS" "PRODUCT_ID")
|
||||
(field "PRODUCTS" "ID"))])
|
||||
(with-join-fields :all)))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (field "PRODUCTS" "CATEGORY"))
|
||||
(order-by (desc (sum (field "ORDERS" "TOTAL"))))
|
||||
(limit 10)))
|
||||
```
|
||||
|
||||
### Conversion Rate (Scalar)
|
||||
|
||||
```clojure
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (share (= (field "ORDERS" "STATUS") "completed"))))
|
||||
```
|
||||
|
||||
### Orders with Computed Columns
|
||||
|
||||
```clojure
|
||||
(-> (query (table "ORDERS"))
|
||||
(expression "discount_pct"
|
||||
(/ (field "ORDERS" "DISCOUNT") (field "ORDERS" "SUBTOTAL")))
|
||||
(expression "is_large_order"
|
||||
(case [[(>= (field "ORDERS" "TOTAL") 200) "Yes"]]
|
||||
{:default "No"}))
|
||||
(filter (not-null (field "ORDERS" "DISCOUNT")))
|
||||
(order-by (desc (field "ORDERS" "TOTAL")))
|
||||
(limit 50))
|
||||
```
|
||||
|
||||
### Using let for Readable Multi-Table Queries
|
||||
|
||||
```clojure
|
||||
(let [orders (table "ORDERS")
|
||||
products (table "PRODUCTS")
|
||||
people (table "PEOPLE")
|
||||
q (query orders)]
|
||||
(-> q
|
||||
(join (-> (join-clause products)
|
||||
(with-join-conditions
|
||||
[(= (field "ORDERS" "PRODUCT_ID")
|
||||
(field "PRODUCTS" "ID"))])
|
||||
(with-join-fields :all)))
|
||||
(join (-> (join-clause people)
|
||||
(with-join-conditions
|
||||
[(= (field "ORDERS" "USER_ID")
|
||||
(field "PEOPLE" "ID"))])
|
||||
(with-join-fields :all)))
|
||||
(filter (and (= (field "PEOPLE" "STATE") "CA")
|
||||
(time-interval (field "ORDERS" "CREATED_AT") -90 :day)))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(aggregate (count))
|
||||
(breakout (field "PRODUCTS" "CATEGORY"))))
|
||||
```
|
||||
|
||||
### Top 10 Customers by Lifetime Value
|
||||
|
||||
```clojure
|
||||
(let [q (query (table "ORDERS"))
|
||||
people (table "PEOPLE")]
|
||||
(-> q
|
||||
(join (-> (join-clause people)
|
||||
(with-join-conditions
|
||||
[(= (field "ORDERS" "USER_ID")
|
||||
(field "PEOPLE" "ID"))])
|
||||
(with-join-fields :all)))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(aggregate (count))
|
||||
(breakout (field "PEOPLE" "NAME"))
|
||||
(order-by (desc (sum (field "ORDERS" "TOTAL"))))
|
||||
(limit 10)))
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`field` with two args**: `(field "TABLE" "FIELD")` — always works, even with joins.
|
||||
- **`field` with one arg**: `(field "FIELD")` — searches all tables. Throws if ambiguous (field exists in multiple tables). Use the two-arg form to disambiguate.
|
||||
- **`table` and `field` accept IDs**: `(table 42)` and `(field 123)` work with numeric IDs from discovery APIs.
|
||||
- **Table/field names are case-insensitive**: `(field "orders" "total")` and `(field "ORDERS" "TOTAL")` are equivalent.
|
||||
- **Multiple `aggregate` calls chain**: Each `(aggregate ...)` adds one aggregation. Don't pass multiple aggregations in a single call.
|
||||
- **Aggregation result column names**: `sum`, `count`, `avg`, etc. Duplicates get `_2`, `_3` suffixes. Use these names in `visualization.y_axis`.
|
||||
- **Ordering by aggregations**: Use `(order-by query (desc (sum (field ...))))` — the aggregation clause is auto-matched to the query's orderable columns.
|
||||
- **`--run` flag**: Use `construct-query --run` to construct and execute in one step, avoiding two-step shell piping.
|
||||
- **Direction in `order-by`**: All three syntaxes work: `(order-by q field :desc)`, `(order-by q :desc field)`, `(order-by q (desc field))`.
|
||||
|
||||
## Clojure Syntax Quick Reference
|
||||
|
||||
The SCI evaluator supports standard Clojure syntax:
|
||||
|
||||
| Form | Example | Description |
|
||||
|------|---------|-------------|
|
||||
| `->` | `(-> x (f) (g))` | Thread-first macro: `(g (f x))` |
|
||||
| `let` | `(let [x 1] x)` | Local bindings |
|
||||
| `fn` | `(fn [x] (* x 2))` | Anonymous function |
|
||||
| `if` | `(if test then else)` | Conditional |
|
||||
| Keywords | `:month`, `:desc` | Used for options/enums |
|
||||
| Vectors | `[1 2 3]` | Used for lists of values |
|
||||
| Maps | `{:key "value"}` | Key-value pairs |
|
||||
| Strings | `"text"` | Double-quoted strings |
|
||||
@@ -0,0 +1,61 @@
|
||||
# Visualization Types and Settings
|
||||
|
||||
## Display Types
|
||||
|
||||
| Type | Use for | Key settings |
|
||||
|------|---------|-------------|
|
||||
| `table` | Detailed data (default) | none needed |
|
||||
| `line` | Time series, trends | x_axis, y_axis |
|
||||
| `bar` | Comparisons, distributions | x_axis, y_axis, stack |
|
||||
| `area` | Cumulative, stacked trends | x_axis, y_axis, stack |
|
||||
| `combo` | Mixed chart types | x_axis, y_axis |
|
||||
| `scatter` | Correlations | x_axis, y_axis |
|
||||
| `waterfall` | Sequential changes | x_axis, y_axis |
|
||||
| `pie` | Part-of-whole | dimension, metric |
|
||||
| `row` | Horizontal bar chart | x_axis, y_axis |
|
||||
| `scalar` | Single KPI number | none |
|
||||
| `smartscalar` | KPI with trend | none |
|
||||
| `gauge` | Progress toward target | none |
|
||||
| `progress` | Progress bar | none |
|
||||
| `pivot` | Cross-tabulations | x_axis, y_axis |
|
||||
| `funnel` | Conversion funnels | x_axis, y_axis |
|
||||
| `map` | Geographic data | map_type, map_region |
|
||||
| `boxplot` | Statistical distribution | x_axis, y_axis |
|
||||
| `sankey` | Flow diagrams | x_axis, y_axis |
|
||||
|
||||
## Settings Object
|
||||
|
||||
All settings are optional. The CLI translates these to Metabase's internal format.
|
||||
|
||||
| CLI key | Metabase key | Type | Notes |
|
||||
|---------|-------------|------|-------|
|
||||
| `x_axis` | `graph.dimensions` | string[] | X axis columns |
|
||||
| `y_axis` | `graph.metrics` | string[] | Y axis columns |
|
||||
| `stack` | `stackable.stack_type` | `"stacked"` / `"normalized"` | bar/area |
|
||||
| `show_values` | `graph.show_values` | boolean | data labels |
|
||||
| `x_axis_label` | `graph.x_axis.title_text` | string | axis title |
|
||||
| `y_axis_label` | `graph.y_axis.title_text` | string | axis title |
|
||||
| `line_style` | `line.style` | `"solid"` / `"dashed"` / `"dotted"` | |
|
||||
| `line_interpolation` | `line.interpolate` | `"linear"` / `"cardinal"` / `"monotone"` | |
|
||||
| `dimension` | `pie.dimension` | string[] | pie categories |
|
||||
| `metric` | `pie.metric` | string | pie value column |
|
||||
| `show_legend` | `pie.show_legend` | boolean | |
|
||||
| `map_type` | `map.type` | `"pin"` / `"region"` / `"grid"` | |
|
||||
| `map_region` | `map.region` | string | |
|
||||
|
||||
## Column Name Matching
|
||||
|
||||
`x_axis` and `y_axis` values must match SQL column aliases exactly:
|
||||
|
||||
```sql
|
||||
SELECT DATE_TRUNC('month', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY 1
|
||||
```
|
||||
Correct: `"x_axis": ["month"], "y_axis": ["revenue"]`
|
||||
Wrong: `"x_axis": ["created_at"], "y_axis": ["total"]`
|
||||
|
||||
## Chart-Specific Notes
|
||||
|
||||
- **pie**: Use `dimension` and `metric` instead of `x_axis`/`y_axis`
|
||||
- **scalar/smartscalar**: SQL should return one row, one column. No viz settings needed.
|
||||
- **gauge/progress**: SQL should return a single numeric value.
|
||||
- **pivot**: `x_axis` columns become row headers, `y_axis` is the aggregated value.
|
||||
180
agent-toolkit-runner/.claude/skills/building-dashboards/SKILL.md
Normal file
180
agent-toolkit-runner/.claude/skills/building-dashboards/SKILL.md
Normal file
@@ -0,0 +1,180 @@
|
||||
---
|
||||
name: building-dashboards
|
||||
description: Creates and manages Metabase dashboards with card placement, auto-layout, and filter wiring. Use when building dashboards, placing question cards on grids, or connecting dashboard filters to card fields.
|
||||
---
|
||||
|
||||
# Building Dashboards
|
||||
|
||||
@./../_shared/field-resolution.md
|
||||
|
||||
## create-dashboard
|
||||
|
||||
Create a dashboard with cards and filters in one step.
|
||||
|
||||
```bash
|
||||
./metabase-agent create-dashboard --json '<payload>'
|
||||
```
|
||||
|
||||
Full JSON template:
|
||||
```json
|
||||
{
|
||||
"name": "Sales Dashboard",
|
||||
"description": "Q4 sales overview",
|
||||
"collection_id": 7,
|
||||
"cards": [
|
||||
{"card_id": 42, "width": 12, "height": 6},
|
||||
{"card_id": 43, "width": 6, "height": 4},
|
||||
{"card_id": 44, "width": 6, "height": 4}
|
||||
],
|
||||
"filters": [
|
||||
{
|
||||
"name": "Date Range",
|
||||
"type": "date/range",
|
||||
"targets": [
|
||||
{"card_id": 42, "field": "created_at"},
|
||||
{"card_id": 43, "field": "order_date"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `name` | yes | | Dashboard name |
|
||||
| `description` | no | | Dashboard description |
|
||||
| `collection_id` | no | | Collection to save in |
|
||||
| `cards` | no | | Cards to place (see layout below) |
|
||||
| `cards[].card_id` | yes | | Question/card ID from `create-question` |
|
||||
| `cards[].width` | no | 6 | Width in grid units (max 24) |
|
||||
| `cards[].height` | no | 4 | Height in grid units |
|
||||
| `cards[].row` | no | auto | Row position |
|
||||
| `cards[].col` | no | auto | Column position |
|
||||
| `filters` | no | | Dashboard-level filters |
|
||||
| `filters[].name` | yes | | Label shown on dashboard |
|
||||
| `filters[].type` | yes | | Filter widget type (see table below) |
|
||||
| `filters[].targets` | yes | | Cards and fields this filter connects to |
|
||||
|
||||
Internally chains POST (create dashboard) + PUT (add cards and filters) in one command.
|
||||
|
||||
Output:
|
||||
```json
|
||||
{"id": 15, "name": "Sales Dashboard", "cards_added": 3, "filters_added": 1}
|
||||
```
|
||||
|
||||
## Auto-Layout (24-Column Grid)
|
||||
|
||||
Cards are placed left-to-right, wrapping to the next row when a card exceeds column 24.
|
||||
|
||||
If `row` and `col` are both specified, those exact positions are used. Otherwise, auto-layout applies.
|
||||
|
||||
Common layout patterns:
|
||||
```json
|
||||
// Full width
|
||||
{"card_id": 1, "width": 24, "height": 6}
|
||||
|
||||
// Two equal columns
|
||||
{"card_id": 2, "width": 12, "height": 4},
|
||||
{"card_id": 3, "width": 12, "height": 4}
|
||||
|
||||
// Three equal columns
|
||||
{"card_id": 4, "width": 8, "height": 4},
|
||||
{"card_id": 5, "width": 8, "height": 4},
|
||||
{"card_id": 6, "width": 8, "height": 4}
|
||||
|
||||
// Four equal columns (KPI row)
|
||||
{"card_id": 7, "width": 6, "height": 3},
|
||||
{"card_id": 8, "width": 6, "height": 3},
|
||||
{"card_id": 9, "width": 6, "height": 3},
|
||||
{"card_id": 10, "width": 6, "height": 3}
|
||||
|
||||
// One wide + one narrow
|
||||
{"card_id": 11, "width": 16, "height": 6},
|
||||
{"card_id": 12, "width": 8, "height": 6}
|
||||
```
|
||||
|
||||
## Dashboard Filter Types
|
||||
|
||||
| type | Widget | Use for |
|
||||
|------|--------|---------|
|
||||
| `date/range` | Date range picker | Date columns |
|
||||
| `date/single` | Single date picker | Exact date |
|
||||
| `date/month-year` | Month picker | Monthly filtering |
|
||||
| `date/quarter-year` | Quarter picker | Quarterly filtering |
|
||||
| `date/relative` | Relative date | Dynamic ranges (last 30 days) |
|
||||
| `category` | Dropdown | String/enum columns |
|
||||
| `id` | ID input | Numeric ID columns |
|
||||
| `number` | Number input | Numeric columns |
|
||||
| `string` | Text input | Free text |
|
||||
| `string/contains` | Text (contains) | Partial match |
|
||||
| `string/starts-with` | Text (starts-with) | Prefix match |
|
||||
|
||||
Filter targets use field names that are resolved automatically (see field-resolution.md).
|
||||
|
||||
## add-card-to-dashboard
|
||||
|
||||
Add a card to an existing dashboard with optional filter wiring.
|
||||
|
||||
```bash
|
||||
./metabase-agent add-card-to-dashboard --json '<payload>'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"dashboard_id": 15,
|
||||
"card_id": 50,
|
||||
"width": 6,
|
||||
"height": 4,
|
||||
"filter_mappings": [
|
||||
{"filter_name": "Date Range", "field": "created_at"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `dashboard_id` | yes | | Dashboard ID |
|
||||
| `card_id` | yes | | Card/question ID to add |
|
||||
| `width` | no | 6 | Width in grid units |
|
||||
| `height` | no | 4 | Height in grid units |
|
||||
| `filter_mappings` | no | | Wire to existing dashboard filters |
|
||||
| `filter_mappings[].filter_name` | yes | | Must match existing filter name (case-insensitive) |
|
||||
| `filter_mappings[].field` | yes | | Field name on this card to wire to |
|
||||
|
||||
New card is placed at the bottom of the dashboard below all existing cards.
|
||||
|
||||
Output:
|
||||
```json
|
||||
{"dashboard_id": 15, "card_id": 50, "position": {"row": 8, "col": 0}}
|
||||
```
|
||||
|
||||
## get-dashboard
|
||||
|
||||
```bash
|
||||
./metabase-agent get-dashboard <id>
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"id": 15,
|
||||
"name": "Sales Dashboard",
|
||||
"description": "Q4 overview",
|
||||
"parameters": [{"id": "abc12345", "name": "Date Range", "type": "date/range"}],
|
||||
"cards": [
|
||||
{"dashcard_id": 100, "card_id": 42, "card_name": "Revenue by Month",
|
||||
"size_x": 12, "size_y": 6, "row": 0, "col": 0}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Use this to see current layout before adding cards or to verify dashboard creation.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `card_id` is the `id` from `create-question`, NOT the `dashcard_id` shown in `get-dashboard`
|
||||
- For SQL cards, filter wiring requires the card to have been run at least once (so `result_metadata` exists). Run the question in Metabase UI or use `execute-query` first.
|
||||
- `add-card-to-dashboard` reads existing cards and PUTs the entire array back. It handles this correctly -- you just provide the new card.
|
||||
- Filter `targets` use field names, not IDs. The field must exist on the card's source table or SQL result columns.
|
||||
- Card `width` must be between 1 and 24. Common widths: 6 (quarter), 8 (third), 12 (half), 16 (two-thirds), 24 (full).
|
||||
- Mixing auto-layout (no row/col) with explicit positions: auto-layout cards fill in array order. For predictable results, use all-auto or all-explicit.
|
||||
435
agent-toolkit-runner/.claude/skills/creating-questions/SKILL.md
Normal file
435
agent-toolkit-runner/.claude/skills/creating-questions/SKILL.md
Normal file
@@ -0,0 +1,435 @@
|
||||
---
|
||||
name: creating-questions
|
||||
description: Creates saved questions with visualizations, reusable filter segments, measures, metrics, and runs ad-hoc MBQL or SQL queries in Metabase. Use when saving queries as named questions with charts, defining reusable filters/aggregations, or running exploratory queries.
|
||||
---
|
||||
|
||||
# Creating Questions, Segments, Measures, and Queries
|
||||
|
||||
@./../_shared/visualization-settings.md
|
||||
@./../_shared/field-resolution.md
|
||||
@./../_shared/mbql-construction.md
|
||||
|
||||
## construct-query
|
||||
|
||||
Construct an MBQL query by evaluating Clojure code with `metabase.lib` functions. Returns legacy MBQL JSON or (with `--run`) executes the query directly.
|
||||
|
||||
```bash
|
||||
# Return MBQL JSON
|
||||
./metabase-agent construct-query --database-id <id> --clj '<clojure code>'
|
||||
|
||||
# Construct AND execute in one step
|
||||
./metabase-agent construct-query --database-id <id> --run --clj '<clojure code>'
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
./metabase-agent construct-query --database-id 1 --clj '
|
||||
(-> (query (table "ORDERS"))
|
||||
(filter (= (field "ORDERS" "STATUS") "completed"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))'
|
||||
```
|
||||
|
||||
Returns a JSON object like:
|
||||
```json
|
||||
{"database": 1, "type": "query", "query": {"source-table": 5, "aggregation": [["sum", ...]], ...}}
|
||||
```
|
||||
|
||||
With `--run`, returns query results (columns, rows, metadata) instead of MBQL JSON.
|
||||
|
||||
See the MBQL Construction reference for all available functions and examples.
|
||||
|
||||
## create-question
|
||||
|
||||
Create a saved question (card) with visualization settings. **Always prefer MBQL** — Metabase can do much more with structured queries (drill-down, auto-filters, column-level metadata, cross-database compatibility). Use SQL only when MBQL can't express the query.
|
||||
|
||||
```bash
|
||||
./metabase-agent create-question --json '<payload>'
|
||||
```
|
||||
|
||||
### MBQL question (structured query — preferred)
|
||||
|
||||
First construct the MBQL query with `construct-query`, then use the output as the `query` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Revenue by Month",
|
||||
"database_id": 1,
|
||||
"query": {"source-table": 5, "aggregation": [["sum", ["field", 23, null]]], "breakout": [["field", 20, {"temporal-unit": "month"}]]},
|
||||
"display": "line",
|
||||
"visualization": {
|
||||
"x_axis": ["CREATED_AT"],
|
||||
"y_axis": ["sum"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SQL question (native query — use only when MBQL can't express the query)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Revenue by Month",
|
||||
"database_id": 1,
|
||||
"sql": "SELECT DATE_TRUNC('month', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY 1 ORDER BY 1",
|
||||
"display": "line",
|
||||
"type": "question",
|
||||
"visualization": {
|
||||
"x_axis": ["month"],
|
||||
"y_axis": ["revenue"],
|
||||
"y_axis_label": "Revenue ($)",
|
||||
"show_values": true
|
||||
},
|
||||
"collection_id": 7,
|
||||
"description": "Monthly revenue trend"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `name` | yes | | Question name |
|
||||
| `database_id` | yes | | Database ID |
|
||||
| `sql` | one of sql/query | | SQL query (native question) |
|
||||
| `query` | one of sql/query | | MBQL query object from `construct-query` |
|
||||
| `display` | no | `"table"` | Chart type (see visualization-settings.md) |
|
||||
| `type` | no | `"question"` | `"question"`, `"model"`, or `"metric"` |
|
||||
| `visualization` | no | | Chart settings (see visualization-settings.md) |
|
||||
| `collection_id` | no | | Save to specific collection |
|
||||
| `description` | no | | Question description |
|
||||
|
||||
Exactly one of `sql` or `query` must be provided.
|
||||
|
||||
Output:
|
||||
```json
|
||||
{"id": 42, "name": "Revenue by Month", "display": "line", "type": "question"}
|
||||
```
|
||||
|
||||
The returned `id` is the `card_id` you pass to dashboard commands.
|
||||
|
||||
### Example: Line chart (MBQL — recommended)
|
||||
|
||||
Two-step workflow: first construct the MBQL, then create the question.
|
||||
|
||||
```bash
|
||||
# Step 1: Construct MBQL query
|
||||
./metabase-agent construct-query --database-id 1 --clj '
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))'
|
||||
# --> returns {"database": 1, "type": "query", "query": {...}}
|
||||
|
||||
# Step 2: Create question with the MBQL output as the "query" field
|
||||
./metabase-agent create-question --json '{
|
||||
"name": "Revenue Trend",
|
||||
"database_id": 1,
|
||||
"query": <paste output from step 1>,
|
||||
"display": "line",
|
||||
"visualization": {"x_axis": ["CREATED_AT"], "y_axis": ["sum"]}
|
||||
}'
|
||||
```
|
||||
|
||||
For MBQL questions, `visualization.y_axis` uses the aggregation operator name (`sum`, `count`, `avg`, etc.), not SQL aliases.
|
||||
|
||||
### Example: Top 10 bar chart (MBQL with ordering)
|
||||
|
||||
```bash
|
||||
./metabase-agent construct-query --database-id 1 --clj '
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (field "ORDERS" "STATUS"))
|
||||
(order-by (desc (sum (field "ORDERS" "TOTAL"))))
|
||||
(limit 10))'
|
||||
```
|
||||
|
||||
### Example: Pie chart (MBQL)
|
||||
|
||||
```bash
|
||||
./metabase-agent construct-query --database-id 1 --clj '
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (count))
|
||||
(breakout (field "ORDERS" "STATUS")))'
|
||||
|
||||
./metabase-agent create-question --json '{
|
||||
"name": "Orders by Status",
|
||||
"database_id": 1,
|
||||
"query": <paste output>,
|
||||
"display": "pie",
|
||||
"visualization": {"dimension": ["STATUS"], "metric": "count"}
|
||||
}'
|
||||
```
|
||||
|
||||
### Example: KPI scalar (MBQL)
|
||||
|
||||
```bash
|
||||
./metabase-agent construct-query --database-id 1 --clj '
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL"))))'
|
||||
|
||||
./metabase-agent create-question --json '{
|
||||
"name": "Total Revenue",
|
||||
"database_id": 1,
|
||||
"query": <paste output>,
|
||||
"display": "scalar"
|
||||
}'
|
||||
```
|
||||
|
||||
### Example: Metric (reusable aggregation on a card)
|
||||
|
||||
Metrics are cards with `type: "metric"`. They define a reusable aggregation:
|
||||
|
||||
```bash
|
||||
./metabase-agent create-question --json '{
|
||||
"name": "Total Revenue",
|
||||
"database_id": 1,
|
||||
"type": "metric",
|
||||
"query": {"source-table": 5, "aggregation": [["sum", ["field", 23, null]]]},
|
||||
"display": "scalar",
|
||||
"description": "Sum of all order totals"
|
||||
}'
|
||||
```
|
||||
|
||||
### Example: SQL fallback (use only when MBQL can't express the query)
|
||||
|
||||
```bash
|
||||
./metabase-agent create-question --json '{
|
||||
"name": "Revenue Trend",
|
||||
"database_id": 1,
|
||||
"sql": "SELECT DATE_TRUNC('"'"'month'"'"', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY 1 ORDER BY 1",
|
||||
"display": "line",
|
||||
"visualization": {"x_axis": ["month"], "y_axis": ["revenue"]}
|
||||
}'
|
||||
```
|
||||
|
||||
## update-question
|
||||
|
||||
```bash
|
||||
./metabase-agent update-question <id> --json '<payload>'
|
||||
```
|
||||
|
||||
All fields are optional. If updating `sql`, you MUST include `database_id`.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Updated Name",
|
||||
"display": "bar",
|
||||
"sql": "SELECT ...",
|
||||
"database_id": 1,
|
||||
"visualization": {"x_axis": ["month"], "y_axis": ["revenue"], "stack": "stacked"},
|
||||
"description": "Updated description"
|
||||
}
|
||||
```
|
||||
|
||||
## create-segment
|
||||
|
||||
Create a reusable filter (segment) on a table using field names.
|
||||
|
||||
```bash
|
||||
./metabase-agent create-segment --json '<payload>'
|
||||
```
|
||||
|
||||
Full JSON template:
|
||||
```json
|
||||
{
|
||||
"name": "Active Users",
|
||||
"table_id": 5,
|
||||
"filters": [
|
||||
{"field": "last_login", "op": "greater-than", "value": "2024-01-01"},
|
||||
{"field": "status", "op": "equals", "value": "active"}
|
||||
],
|
||||
"description": "Users active in 2024"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `name` | yes | Segment name |
|
||||
| `table_id` | yes | Table ID |
|
||||
| `filters` | yes (min 1) | Filter conditions using field names |
|
||||
| `description` | no | Segment description |
|
||||
|
||||
Multiple filters are ANDed together automatically. Field names are resolved to IDs via the table's metadata (see field-resolution.md).
|
||||
|
||||
### Filter Operations
|
||||
|
||||
| Operation | Description | Needs value? |
|
||||
|-----------|-------------|-------------|
|
||||
| `equals` | Exact match | yes |
|
||||
| `not-equals` | Not equal | yes |
|
||||
| `greater-than` | > | yes |
|
||||
| `greater-than-or-equal` | >= | yes |
|
||||
| `less-than` | < | yes |
|
||||
| `less-than-or-equal` | <= | yes |
|
||||
| `contains` | String contains | yes |
|
||||
| `not-contains` | Does not contain | yes |
|
||||
| `starts-with` | String starts with | yes |
|
||||
| `ends-with` | String ends with | yes |
|
||||
| `is-null` | Value is null | no |
|
||||
| `is-not-null` | Value is not null | no |
|
||||
| `is-empty` | Empty string | no |
|
||||
| `is-not-empty` | Not empty string | no |
|
||||
|
||||
Use `values` (array) instead of `value` for multi-value matching.
|
||||
|
||||
Output:
|
||||
```json
|
||||
{"id": 10, "name": "Active Users", "table_id": 5}
|
||||
```
|
||||
|
||||
## update-segment
|
||||
|
||||
```bash
|
||||
./metabase-agent update-segment <id> --json '<payload>'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Updated Name",
|
||||
"description": "New description",
|
||||
"table_id": 5,
|
||||
"filters": [{"field": "status", "op": "equals", "value": "active"}],
|
||||
"revision_message": "Updated filter criteria"
|
||||
}
|
||||
```
|
||||
|
||||
If updating `filters`, you MUST include `table_id`. `revision_message` defaults to "Updated via CLI".
|
||||
|
||||
## create-measure
|
||||
|
||||
Create a reusable aggregation expression (measure) tied to a table. Measures use MBQL definitions with exactly one aggregation.
|
||||
|
||||
```bash
|
||||
./metabase-agent create-measure --json '<payload>'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Total Revenue",
|
||||
"table_id": 5,
|
||||
"definition": {"database": 1, "type": "query", "query": {"source-table": 5, "aggregation": [["sum", ["field", 23, null]]]}},
|
||||
"description": "Sum of all order totals"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `name` | yes | Measure name |
|
||||
| `table_id` | yes | Table ID this measure belongs to |
|
||||
| `definition` | yes | MBQL5 query object (from `construct-query`) |
|
||||
| `description` | no | Measure description |
|
||||
|
||||
Workflow: Use `construct-query` to build the MBQL, then pass the output as `definition`.
|
||||
|
||||
Output:
|
||||
```json
|
||||
{"id": 3, "name": "Total Revenue", "table_id": 5}
|
||||
```
|
||||
|
||||
## list-measures
|
||||
|
||||
```bash
|
||||
./metabase-agent list-measures
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
[{"id": 3, "name": "Total Revenue", "table_id": 5, "description": "Sum of all order totals"}]
|
||||
```
|
||||
|
||||
## get-measure
|
||||
|
||||
```bash
|
||||
./metabase-agent get-measure <id>
|
||||
```
|
||||
|
||||
## update-measure
|
||||
|
||||
```bash
|
||||
./metabase-agent update-measure <id> --json '<payload>'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Updated Name",
|
||||
"definition": {"database": 1, "type": "query", "query": {"source-table": 5, "aggregation": [["avg", ["field", 23, null]]]}},
|
||||
"revision_message": "Changed from sum to avg"
|
||||
}
|
||||
```
|
||||
|
||||
`revision_message` defaults to "Updated via CLI".
|
||||
|
||||
## execute-query
|
||||
|
||||
Run ad-hoc queries without saving. Prefer `construct-query --run` for MBQL — it's simpler. Use `execute-query` when you already have a raw query object or need SQL.
|
||||
|
||||
```bash
|
||||
./metabase-agent execute-query --json '<payload>'
|
||||
```
|
||||
|
||||
### MBQL execution (preferred — or use `construct-query --run` instead)
|
||||
|
||||
```json
|
||||
{
|
||||
"database_id": 1,
|
||||
"query": {"source-table": 5, "aggregation": [["count"]], "filter": ["=", ["field", 50, null], "completed"]}
|
||||
}
|
||||
```
|
||||
|
||||
### SQL execution (use only when MBQL can't express the query)
|
||||
|
||||
```json
|
||||
{
|
||||
"database_id": 1,
|
||||
"sql": "SELECT COUNT(*) AS total_orders FROM orders WHERE status = 'completed'"
|
||||
}
|
||||
```
|
||||
|
||||
Exactly one of `sql` or `query` must be provided.
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"status": "completed",
|
||||
"columns": [{"name": "total_orders", "display_name": "Total Orders", "base_type": "type/Integer"}],
|
||||
"rows": [[1523]],
|
||||
"row_count": 1,
|
||||
"running_time": 245,
|
||||
"_meta": {"truncated": false, "total_count": 1, "returned_count": 1}
|
||||
}
|
||||
```
|
||||
|
||||
Default max 50 rows. Override with `--max-rows 200`. Results are ephemeral -- use `create-question` to save.
|
||||
|
||||
## run-question
|
||||
|
||||
Run a saved question and return sample results.
|
||||
|
||||
```bash
|
||||
./metabase-agent run-question <id>
|
||||
./metabase-agent run-question <id> --max-rows 20
|
||||
```
|
||||
|
||||
Default: 10 rows. Use this to verify a question works before adding it to a dashboard, or to inspect the data shape.
|
||||
|
||||
Output:
|
||||
```json
|
||||
{"card_id": 42, "status": "completed",
|
||||
"columns": [{"name": "month", "display_name": "Month", "base_type": "type/DateTime"},
|
||||
{"name": "revenue", "display_name": "Revenue", "base_type": "type/Float"}],
|
||||
"rows": [["2024-01-01T00:00:00Z", 12345.67]],
|
||||
"row_count": 12, "running_time": 150,
|
||||
"_meta": {"truncated": true, "total_count": 12, "returned_count": 10}}
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Always use MBQL** unless the query requires SQL-specific features (CTEs, window functions, database-specific syntax, etc.). MBQL questions support drill-down, auto-filters, and cross-database compatibility that SQL questions cannot provide.
|
||||
- `create-question` returns `id` -- this is the `card_id` for dashboard commands
|
||||
- **MBQL y_axis**: Use aggregation operator names: `"y_axis": ["sum"]`, `"y_axis": ["sum", "count"]`. Duplicates get suffixes: `sum_2`, `sum_3`
|
||||
- **SQL y_axis**: Column aliases must match `x_axis`/`y_axis` exactly: `SELECT SUM(total) AS revenue` means `"y_axis": ["revenue"]`
|
||||
- For pie charts, use `dimension` and `metric`, not `x_axis`/`y_axis`
|
||||
- `type: "model"` creates a Metabase model (queryable like a table)
|
||||
- `type: "metric"` creates a reusable metric aggregation
|
||||
- Segment field names are resolved against table metadata. Run `get-table <table_id>` first to confirm field names.
|
||||
- `execute-query` does NOT save anything. Use `create-question` for persistent results.
|
||||
- MBQL queries from `construct-query` include the full `dataset_query` structure -- pass the entire output as the `query` field.
|
||||
- Measures require MBQL definitions -- use `construct-query` to build them.
|
||||
- Use `construct-query --run` to test MBQL queries ad-hoc without creating a saved question.
|
||||
170
agent-toolkit-runner/.claude/skills/discovering-data/SKILL.md
Normal file
170
agent-toolkit-runner/.claude/skills/discovering-data/SKILL.md
Normal file
@@ -0,0 +1,170 @@
|
||||
---
|
||||
name: discovering-data
|
||||
description: Discovers databases, tables, fields, and existing content in Metabase. Use when exploring data sources, understanding schema, or searching for existing dashboards and questions before building new analytics.
|
||||
---
|
||||
|
||||
# Discovering Data
|
||||
|
||||
Always start here before creating transforms, questions, or dashboards.
|
||||
|
||||
@./../_shared/common-patterns.md
|
||||
|
||||
## list-databases
|
||||
|
||||
List all databases, optionally including their tables.
|
||||
|
||||
```bash
|
||||
# Compact list
|
||||
./metabase-agent list-databases --fields id,name,engine
|
||||
|
||||
# Include table names (recommended first step)
|
||||
./metabase-agent list-databases --include tables
|
||||
```
|
||||
|
||||
Output shape:
|
||||
```json
|
||||
[{"id": 1, "name": "Production DB", "engine": "postgres",
|
||||
"tables": [{"id": 5, "name": "orders", "schema": "public"}]}]
|
||||
```
|
||||
|
||||
This gives you `database_id` and `table_id` values needed by all other commands.
|
||||
|
||||
## get-database
|
||||
|
||||
Full database metadata with every table and field.
|
||||
|
||||
```bash
|
||||
./metabase-agent get-database 1
|
||||
```
|
||||
|
||||
Output shape:
|
||||
```json
|
||||
{"id": 1, "name": "Production DB", "engine": "postgres",
|
||||
"tables": [{"id": 5, "name": "orders", "schema": "public", "display_name": "Orders",
|
||||
"fields": [{"id": 42, "name": "id", "display_name": "ID",
|
||||
"base_type": "type/Integer", "semantic_type": "type/PK"}]}]}
|
||||
```
|
||||
|
||||
Use when you need field-level detail for all tables in one call. Output can be large for big databases -- prefer `get-table` for specific tables.
|
||||
|
||||
## get-table
|
||||
|
||||
Single table with all fields, types, and foreign keys.
|
||||
|
||||
```bash
|
||||
./metabase-agent get-table 5
|
||||
```
|
||||
|
||||
Output shape:
|
||||
```json
|
||||
{"id": 5, "name": "orders", "display_name": "Orders", "schema": "public",
|
||||
"db_id": 1, "description": "...",
|
||||
"fields": [{"id": 42, "name": "created_at", "display_name": "Created At",
|
||||
"base_type": "type/DateTime", "semantic_type": null,
|
||||
"fk_target_field_id": null}]}
|
||||
```
|
||||
|
||||
Key field properties:
|
||||
- `base_type`: `type/Integer`, `type/Text`, `type/DateTime`, `type/Float`, `type/Boolean`
|
||||
- `semantic_type`: `type/FK` (foreign key), `type/PK` (primary key), `type/Email`, etc.
|
||||
- `fk_target_field_id`: non-null for foreign keys, points to the target field
|
||||
|
||||
This command tells you what field names to use in segments, questions, and dashboard filter targets.
|
||||
|
||||
## search
|
||||
|
||||
Free-text search across all Metabase content.
|
||||
|
||||
```bash
|
||||
./metabase-agent search "revenue" --models table,card,dashboard
|
||||
```
|
||||
|
||||
`--models` values: `table`, `dashboard`, `card`, `metric`, `segment`
|
||||
|
||||
Output shape:
|
||||
```json
|
||||
{"results": [{"id": 5, "name": "Revenue Report", "model": "card",
|
||||
"description": "...", "database_id": 1, "table_id": 5}],
|
||||
"_meta": {"total_count": 3}}
|
||||
```
|
||||
|
||||
Use to find existing content before creating duplicates.
|
||||
|
||||
## get-field
|
||||
|
||||
Get full field metadata including statistical fingerprint.
|
||||
|
||||
```bash
|
||||
./metabase-agent get-field 42
|
||||
```
|
||||
|
||||
Output shape:
|
||||
```json
|
||||
{"id": 42, "name": "total", "display_name": "Total", "table_id": 5,
|
||||
"base_type": "type/Float", "semantic_type": null,
|
||||
"has_field_values": "none",
|
||||
"fingerprint": {
|
||||
"distinct_count": 200, "null_percent": 0.0,
|
||||
"min": 12.5, "max": 1250.0, "avg": 89.43, "q1": 35.0, "q3": 120.0, "sd": 75.2
|
||||
}}
|
||||
```
|
||||
|
||||
Fingerprint content depends on field type:
|
||||
- **Numbers**: `min`, `max`, `avg`, `q1`, `q3`, `sd` (standard deviation), `distinct_count`, `null_percent`
|
||||
- **Dates**: `earliest`, `latest`, `distinct_count`, `null_percent`
|
||||
- **Text**: `average_length`, `distinct_count`, `null_percent`
|
||||
|
||||
Use to understand value ranges before building filters (e.g., knowing the min/max of a price field).
|
||||
|
||||
## get-field-values
|
||||
|
||||
Get distinct values stored in a field. Essential for building correct filters.
|
||||
|
||||
```bash
|
||||
./metabase-agent get-field-values 50
|
||||
```
|
||||
|
||||
Output shape:
|
||||
```json
|
||||
{"field_id": 50, "values": ["cancelled", "completed", "pending", "shipped"], "has_more_values": false}
|
||||
```
|
||||
|
||||
Use this to discover valid filter values. For example, before writing `(filter (= (field "ORDERS" "STATUS") "completed"))`, run `get-field-values` on the status field to see all possible values.
|
||||
|
||||
Notes:
|
||||
- Only works for fields with `has_field_values: "list"` (typically low-cardinality fields like status, category, etc.)
|
||||
- For high-cardinality fields (IDs, timestamps), returns empty values — use `get-field` fingerprint instead for range info
|
||||
- If a field has remapped display values, returns `[original_value, display_name]` pairs
|
||||
|
||||
## ping
|
||||
|
||||
Health check. No arguments.
|
||||
|
||||
```bash
|
||||
./metabase-agent ping
|
||||
```
|
||||
|
||||
## Discovery Workflow
|
||||
|
||||
```
|
||||
1. ./metabase-agent list-databases --include tables --fields id,name
|
||||
--> Pick database_id, note table names
|
||||
|
||||
2. ./metabase-agent get-table <table_id>
|
||||
--> Learn field names, types, relationships
|
||||
|
||||
3. ./metabase-agent get-field-values <field_id>
|
||||
--> Discover valid values for categorical fields (status, type, category)
|
||||
|
||||
4. ./metabase-agent get-field <field_id>
|
||||
--> Check value ranges for numeric/date fields (min, max, avg, date range)
|
||||
|
||||
5. ./metabase-agent search "keyword" --models card,dashboard
|
||||
--> Check if the analysis already exists
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `get-database` returns ALL tables. For large databases (100+ tables), use `list-databases --include tables` then `get-table` for specific tables.
|
||||
- Search results are capped server-side. Use `--models` to narrow results.
|
||||
- Table IDs, database IDs, and field IDs are stable. You can reference them across commands.
|
||||
@@ -0,0 +1,164 @@
|
||||
---
|
||||
name: metabase-agent-workflow
|
||||
description: Orchestrates the Metabase agent CLI workflow for building analytics from raw data. Use when the goal involves discovering databases, creating transforms, defining questions or segments, or building dashboards.
|
||||
---
|
||||
|
||||
# Metabase Agent Workflow
|
||||
|
||||
Build analytics from raw data in four phases:
|
||||
|
||||
```
|
||||
1. Discover --> 2. Model --> 3. Define --> 4. Visualize
|
||||
```
|
||||
|
||||
| Phase | What it does | What it produces |
|
||||
|-------|-------------|-----------------|
|
||||
| Discover | Explore databases, tables, fields | database_id, table_id, field names |
|
||||
| Model | Clean/join/aggregate data with MBQL or SQL transforms | New tables in the database |
|
||||
| Define | Save MBQL (preferred) or SQL as questions with charts; create segments, measures, metrics | card_id values for dashboards |
|
||||
| Visualize | Build dashboards with cards and filters | Stakeholder-ready dashboards |
|
||||
|
||||
## Command Reference
|
||||
|
||||
| Goal | Command | Phase |
|
||||
|------|---------|-------|
|
||||
| Find databases and tables | `list-databases --include tables` | Discover |
|
||||
| Inspect table fields and types | `get-table <id>` | Discover |
|
||||
| Search existing content | `search "query" --models table,card` | Discover |
|
||||
| Full database schema | `get-database <id>` | Discover |
|
||||
| Field metadata + fingerprint | `get-field <id>` | Discover |
|
||||
| Distinct values for a field | `get-field-values <id>` | Discover |
|
||||
| Create MBQL/SQL transform | `create-transform --json '{...}'` | Model |
|
||||
| Inspect transform | `get-transform <id>` | Model |
|
||||
| List transforms with status | `list-transforms` | Model |
|
||||
| Check transform run | `get-transform-run <run_id>` | Model |
|
||||
| Construct MBQL query | `construct-query --database-id <id> --clj '<code>'` | Define |
|
||||
| Construct + run MBQL | `construct-query --database-id <id> --run --clj '<code>'` | Define |
|
||||
| Save MBQL/SQL as a chart | `create-question --json '{...}'` | Define |
|
||||
| Create reusable filter | `create-segment --json '{...}'` | Define |
|
||||
| Create reusable aggregation | `create-measure --json '{...}'` | Define |
|
||||
| List all measures | `list-measures` | Define |
|
||||
| Get measure details | `get-measure <id>` | Define |
|
||||
| Update a measure | `update-measure <id> --json '{...}'` | Define |
|
||||
| Run ad-hoc MBQL or SQL | `execute-query --json '{...}'` | Define |
|
||||
| Run saved question | `run-question <id>` | Define |
|
||||
| Build dashboard with cards | `create-dashboard --json '{...}'` | Visualize |
|
||||
| Add card to dashboard | `add-card-to-dashboard --json '{...}'` | Visualize |
|
||||
| View dashboard layout | `get-dashboard <id>` | Visualize |
|
||||
|
||||
## Essential Rules
|
||||
|
||||
- **Always use MBQL** for queries. MBQL questions support drill-down, auto-filters, column-level metadata, and cross-database compatibility that SQL questions cannot provide. Use SQL only when MBQL genuinely can't express the query (e.g., CTEs, window functions, database-specific syntax).
|
||||
- All mutations use `--json '<payload>'` with simplified JSON (not raw API format)
|
||||
- Field names (like `"created_at"`) are resolved to numeric IDs automatically -- never look up IDs manually
|
||||
- Queries use MBQL constructed via `construct-query` (preferred) or native SQL (fallback)
|
||||
- `construct-query --run` lets you construct and execute MBQL in one step (no need to pipe through `execute-query`)
|
||||
- In MBQL, `table` and `field` accept both string names and numeric IDs: `(table "ORDERS")` or `(table 42)`
|
||||
- `field` with one argument searches all tables: `(field "total")` — use two args if ambiguous
|
||||
- Use `--dry-run` before mutations to preview API calls
|
||||
- Use `--fields id,name` to keep responses compact
|
||||
- Use `./metabase-agent schema <command>` to see exact JSON Schema for any command
|
||||
|
||||
## End-to-End Example (MBQL — recommended)
|
||||
|
||||
```bash
|
||||
# 1. Discover: find the database and inspect tables
|
||||
./metabase-agent list-databases --include tables --fields id,name
|
||||
./metabase-agent get-table 5
|
||||
|
||||
# 2. Explore: test an MBQL query ad-hoc (construct + execute in one step)
|
||||
./metabase-agent construct-query --database-id 1 --run --clj '
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))'
|
||||
# --> returns query results (columns, rows)
|
||||
|
||||
# 3. Define: construct MBQL (without --run) to get the query JSON
|
||||
./metabase-agent construct-query --database-id 1 --clj '
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))'
|
||||
# --> returns {"database": 1, "type": "query", "query": {...}}
|
||||
|
||||
# 4. Define: create question with MBQL result
|
||||
./metabase-agent create-question --json '{
|
||||
"name": "Revenue by Month",
|
||||
"database_id": 1,
|
||||
"query": <output from construct-query>,
|
||||
"display": "line",
|
||||
"visualization": {"x_axis": ["CREATED_AT"], "y_axis": ["sum"]}
|
||||
}'
|
||||
|
||||
# 5. Define: create a reusable measure
|
||||
./metabase-agent construct-query --database-id 1 --clj '
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL"))))'
|
||||
./metabase-agent create-measure --json '{
|
||||
"name": "Total Revenue",
|
||||
"table_id": 5,
|
||||
"definition": <output from construct-query>
|
||||
}'
|
||||
|
||||
# 6. Visualize: build a dashboard
|
||||
./metabase-agent create-dashboard --json '{
|
||||
"name": "Sales Dashboard",
|
||||
"cards": [{"card_id": 42, "width": 24, "height": 6}]
|
||||
}'
|
||||
```
|
||||
|
||||
## End-to-End Example (SQL — use only when MBQL can't express the query)
|
||||
|
||||
```bash
|
||||
# 1. Discover: find the database and tables
|
||||
./metabase-agent list-databases --include tables --fields id,name
|
||||
|
||||
# 2. Discover: inspect the orders table
|
||||
./metabase-agent get-table 5
|
||||
|
||||
# 3. Define: create a chart question
|
||||
./metabase-agent create-question --json '{
|
||||
"name": "Revenue by Month",
|
||||
"database_id": 1,
|
||||
"sql": "SELECT DATE_TRUNC('"'"'month'"'"', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY 1 ORDER BY 1",
|
||||
"display": "line",
|
||||
"visualization": {"x_axis": ["month"], "y_axis": ["revenue"]}
|
||||
}'
|
||||
# --> returns {"id": 42, ...}
|
||||
|
||||
# 3b. Verify: run the question and check sample results
|
||||
./metabase-agent run-question 42
|
||||
|
||||
# 4. Visualize: build a dashboard
|
||||
./metabase-agent create-dashboard --json '{
|
||||
"name": "Sales Dashboard",
|
||||
"cards": [{"card_id": 42, "width": 24, "height": 6}],
|
||||
"filters": [{"name": "Date", "type": "date/range",
|
||||
"targets": [{"card_id": 42, "field": "month"}]}]
|
||||
}'
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] Discover: identify database_id and table_ids
|
||||
- [ ] Discover: inspect field names and types with get-table
|
||||
- [ ] Discover: check field values (get-field-values) for filter fields
|
||||
- [ ] (Optional) Model: create-transform if raw data needs cleaning
|
||||
- [ ] Define: construct-query for MBQL (preferred) — use SQL only if MBQL can't express it
|
||||
- [ ] Define: create-question for each visualization needed
|
||||
- [ ] Define: (optional) create-segment for reusable filters
|
||||
- [ ] Define: (optional) create-measure for reusable aggregations
|
||||
- [ ] Define: (optional) create metric with type: "metric"
|
||||
- [ ] Visualize: create-dashboard with card_ids from above
|
||||
- [ ] Visualize: wire dashboard filters to card fields
|
||||
```
|
||||
|
||||
## Detailed Skill References
|
||||
|
||||
For complete JSON formats, examples, and gotchas for each command group:
|
||||
|
||||
- [Discovering Data](./../discovering-data/SKILL.md) -- databases, tables, search
|
||||
- [Modeling Transforms](./../modeling-transforms/SKILL.md) -- MBQL/SQL transforms
|
||||
- [Creating Questions](./../creating-questions/SKILL.md) -- questions, segments, measures, queries, MBQL construction
|
||||
- [Building Dashboards](./../building-dashboards/SKILL.md) -- dashboards, cards, filters
|
||||
- [Common Patterns](./../_shared/common-patterns.md) -- setup, global flags, errors
|
||||
195
agent-toolkit-runner/.claude/skills/modeling-transforms/SKILL.md
Normal file
195
agent-toolkit-runner/.claude/skills/modeling-transforms/SKILL.md
Normal file
@@ -0,0 +1,195 @@
|
||||
---
|
||||
name: modeling-transforms
|
||||
description: Creates and manages MBQL or SQL transforms that materialize query results into database tables. Use when raw data needs cleaning, joining, or restructuring before building questions and dashboards.
|
||||
---
|
||||
|
||||
# Modeling Transforms
|
||||
|
||||
Transforms run MBQL (preferred) or SQL queries and write the results to a new database table. Use them when raw tables need cleaning, joining, or aggregation before downstream analysis. Use SQL only when MBQL can't express the query.
|
||||
|
||||
Do NOT use transforms for: one-off queries (use `execute-query`) or saved questions that just need a chart (use `create-question`).
|
||||
|
||||
@./../_shared/mbql-construction.md
|
||||
|
||||
## create-transform
|
||||
|
||||
```bash
|
||||
./metabase-agent create-transform --json '<payload>'
|
||||
```
|
||||
|
||||
### MBQL transform (preferred)
|
||||
|
||||
First use `construct-query` to build the MBQL query, then pass the output as the `query` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Clean Orders",
|
||||
"database_id": 1,
|
||||
"query": {"source-table": 5, "filter": ["!=", ["field", 50, null], "cancelled"]},
|
||||
"target_table": "clean_orders",
|
||||
"target_schema": "transforms",
|
||||
"description": "Orders excluding cancelled",
|
||||
"run": true
|
||||
}
|
||||
```
|
||||
|
||||
### SQL transform (use only when MBQL can't express the query)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Clean Orders",
|
||||
"database_id": 1,
|
||||
"sql": "SELECT * FROM orders WHERE status != 'cancelled'",
|
||||
"target_table": "clean_orders",
|
||||
"target_schema": "transforms",
|
||||
"target_database_id": 2,
|
||||
"description": "Orders excluding cancelled",
|
||||
"run": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `name` | yes | Transform name |
|
||||
| `database_id` | yes | Source database ID |
|
||||
| `sql` | one of sql/query | SQL query for the transform |
|
||||
| `query` | one of sql/query | MBQL query object from `construct-query` |
|
||||
| `target_table` | yes | Output table name |
|
||||
| `target_schema` | no | Schema for output table |
|
||||
| `target_database_id` | no | Defaults to `database_id` |
|
||||
| `description` | no | Description |
|
||||
| `run` | no | Execute immediately after creation |
|
||||
|
||||
Exactly one of `sql` or `query` must be provided.
|
||||
|
||||
### Example: MBQL transform (recommended)
|
||||
|
||||
```bash
|
||||
# 1. Construct the MBQL query
|
||||
./metabase-agent construct-query --database-id 1 --clj '
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))'
|
||||
|
||||
# 2. Use the output as the query field
|
||||
./metabase-agent create-transform --json '{
|
||||
"name": "Monthly Revenue",
|
||||
"database_id": 1,
|
||||
"query": <output from construct-query>,
|
||||
"target_table": "monthly_revenue",
|
||||
"target_schema": "transforms",
|
||||
"run": true
|
||||
}'
|
||||
```
|
||||
|
||||
### Example: SQL transform (fallback)
|
||||
|
||||
```bash
|
||||
./metabase-agent create-transform --json '{
|
||||
"name": "Monthly Revenue",
|
||||
"database_id": 1,
|
||||
"sql": "SELECT DATE_TRUNC('"'"'month'"'"', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY 1",
|
||||
"target_table": "monthly_revenue",
|
||||
"target_schema": "transforms",
|
||||
"run": true
|
||||
}'
|
||||
```
|
||||
|
||||
Output when `run: true`:
|
||||
```json
|
||||
{"transform": {"id": 15, "name": "Monthly Revenue"},
|
||||
"run": {"status": "succeeded", "run_id": 123}}
|
||||
```
|
||||
|
||||
When `run: true`, the CLI polls until completion (up to 5 minutes) or returns `polling_timeout`.
|
||||
|
||||
Output when `run: false` or omitted:
|
||||
```json
|
||||
{"id": 15, "name": "Monthly Revenue", "source_type": "query"}
|
||||
```
|
||||
|
||||
## get-transform
|
||||
|
||||
Get a transform's full definition including its query, target table, and last run status.
|
||||
|
||||
```bash
|
||||
./metabase-agent get-transform <id>
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"id": 15,
|
||||
"name": "Clean Orders",
|
||||
"description": "Orders excluding cancelled",
|
||||
"database_id": 1,
|
||||
"sql": "SELECT * FROM orders WHERE status != 'cancelled'",
|
||||
"target_table": "clean_orders",
|
||||
"target_schema": "transforms",
|
||||
"last_run_status": "succeeded",
|
||||
"last_run_at": "2024-12-01T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Use this to inspect an existing transform's query before updating it.
|
||||
|
||||
## list-transforms
|
||||
|
||||
```bash
|
||||
./metabase-agent list-transforms
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
[{"id": 15, "name": "Clean Orders", "source_type": "query", "last_run_status": "succeeded"}]
|
||||
```
|
||||
|
||||
`last_run_status` values: `succeeded`, `failed`, `running`, `null` (never run).
|
||||
|
||||
## get-transform-run
|
||||
|
||||
```bash
|
||||
./metabase-agent get-transform-run <run_id>
|
||||
```
|
||||
|
||||
Check status of a specific run. Terminal statuses: `succeeded`, `failed`, `timeout`, `canceled`.
|
||||
|
||||
## update-transform
|
||||
|
||||
```bash
|
||||
./metabase-agent update-transform <id> --json '<payload>'
|
||||
```
|
||||
|
||||
### Update with MBQL (preferred)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Updated Name",
|
||||
"query": {"source-table": 5, "filter": ["!=", ["field", 50, null], "cancelled"]},
|
||||
"database_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Update with SQL (fallback)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Updated Name",
|
||||
"description": "New description",
|
||||
"sql": "SELECT ...",
|
||||
"database_id": 1,
|
||||
"target_table": "new_table_name",
|
||||
"target_schema": "new_schema"
|
||||
}
|
||||
```
|
||||
|
||||
All fields are optional. If updating `sql` or `query`, you MUST include `database_id`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- SQL strings with single quotes need shell escaping: `'"'"'` or `'\''`
|
||||
- `target_table` creates a real table in the target database. Choose names carefully.
|
||||
- After a transform runs, the output table appears in `list-databases --include tables`
|
||||
- Transforms are independent of questions. A question can query the transform's output table using MBQL or SQL.
|
||||
- Transform runs can take time. The `--run` flag polls automatically, but long-running transforms may hit the 5-minute polling timeout. Use `get-transform-run` to check afterward.
|
||||
- **Always prefer MBQL transforms** — they benefit from type safety, field resolution, and cross-database compatibility. Use SQL only for queries MBQL can't express (CTEs, window functions, database-specific syntax).
|
||||
2
agent-toolkit-runner/metabase-agent
Executable file
2
agent-toolkit-runner/metabase-agent
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node --import tsx
|
||||
import "../agent-toolkit/src/index.ts";
|
||||
56
agent-toolkit/.claude/skills/_shared/common-patterns.md
Normal file
56
agent-toolkit/.claude/skills/_shared/common-patterns.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Common Patterns
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
export METABASE_URL="http://localhost:3000"
|
||||
export METABASE_API_KEY="mb_abc123..."
|
||||
```
|
||||
|
||||
Or pass as flags: `--url`, `--api-key`, `--session-token`.
|
||||
|
||||
## Global Flags
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--fields id,name` | Only include these keys in response JSON |
|
||||
| `--max-rows 200` | Override default 50-row cap on query results |
|
||||
| `--dry-run` | Show API calls without executing |
|
||||
| `--url <url>` | Metabase URL (overrides METABASE_URL) |
|
||||
| `--api-key <key>` | API key (overrides METABASE_API_KEY) |
|
||||
| `--session-token <token>` | Session token (overrides METABASE_SESSION_TOKEN) |
|
||||
|
||||
## JSON Payload Format
|
||||
|
||||
All mutation commands use `--json '<payload>'`.
|
||||
|
||||
Shell escaping for single quotes in SQL:
|
||||
```bash
|
||||
./metabase-agent create-question --json '{"sql": "SELECT * FROM t WHERE name = '"'"'test'"'"'"}'
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
All errors are structured JSON on stderr:
|
||||
```json
|
||||
{
|
||||
"error": "error_code",
|
||||
"message": "Human-readable description",
|
||||
"hint": "Suggested fix (when available)"
|
||||
}
|
||||
```
|
||||
|
||||
Common error codes: `missing_config`, `api_error`, `unknown_field`, `invalid_parameter`, `missing_parameter`, `unknown_filter`, `field_resolution`.
|
||||
|
||||
## Introspection
|
||||
|
||||
```bash
|
||||
# List all commands with JSON schemas
|
||||
./metabase-agent schema
|
||||
|
||||
# Get exact JSON Schema for any command
|
||||
./metabase-agent schema create-dashboard
|
||||
|
||||
# Preview API calls without executing
|
||||
./metabase-agent create-dashboard --dry-run --json '...'
|
||||
```
|
||||
35
agent-toolkit/.claude/skills/_shared/field-resolution.md
Normal file
35
agent-toolkit/.claude/skills/_shared/field-resolution.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Field Name Resolution
|
||||
|
||||
The CLI resolves human-readable field names to numeric Metabase field IDs automatically. You never need to look up field IDs manually.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. You provide a field name string (e.g., `"created_at"` or `"Created At"`)
|
||||
2. The CLI fetches table metadata via the API
|
||||
3. It matches against both `name` (snake_case) and `display_name` (Title Case), case-insensitively
|
||||
4. Table metadata is cached per session -- repeated lookups for the same table cost nothing
|
||||
|
||||
## Commands That Accept Field Names
|
||||
|
||||
- `create-segment` / `update-segment`: the `field` property in each filter
|
||||
- `create-dashboard`: filter targets `field` property
|
||||
- `add-card-to-dashboard`: filter_mappings `field` property
|
||||
|
||||
## Error Behavior
|
||||
|
||||
If a field name is not found, the error includes a closest-match suggestion and all available fields:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "unknown_field",
|
||||
"message": "Field 'create_at' not found on table 5",
|
||||
"hint": "Did you mean 'created_at'? Available fields: id, created_at, total, status",
|
||||
"available_fields": ["id", "created_at", "total", "status"]
|
||||
}
|
||||
```
|
||||
|
||||
## Tips
|
||||
|
||||
- Run `get-table <id>` first to see all field names and types
|
||||
- Prefer `name` (snake_case) over `display_name` -- it's more stable across locale changes
|
||||
- Numeric string values like `"42"` pass through as numeric IDs without resolution
|
||||
546
agent-toolkit/.claude/skills/_shared/mbql-construction.md
Normal file
546
agent-toolkit/.claude/skills/_shared/mbql-construction.md
Normal file
@@ -0,0 +1,546 @@
|
||||
# MBQL Query Construction
|
||||
|
||||
MBQL (Metabase Query Language) queries are constructed using Clojure code evaluated by the `construct-query` command. The code uses `metabase.lib` functions — the same library powering Metabase's visual query builder. Queries are correct by construction: if the code evaluates, the query is valid.
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
# 1. Discover schema (get table names and field names)
|
||||
./metabase-agent get-table <table_id>
|
||||
|
||||
# 2. Construct and run MBQL query in one step
|
||||
./metabase-agent construct-query --database-id <id> --run --clj '<clojure code>'
|
||||
|
||||
# OR: construct MBQL JSON, then use with other commands
|
||||
./metabase-agent construct-query --database-id <id> --clj '<clojure code>'
|
||||
# --> returns MBQL JSON to pass to create-question, execute-query, create-transform, or create-measure
|
||||
```
|
||||
|
||||
## construct-query Command
|
||||
|
||||
```bash
|
||||
# Return MBQL JSON
|
||||
./metabase-agent construct-query --database-id <id> --clj '<code>'
|
||||
|
||||
# Construct AND execute in one step (returns query results)
|
||||
./metabase-agent construct-query --database-id <id> --run --clj '<code>'
|
||||
```
|
||||
|
||||
Without `--run`, returns legacy MBQL JSON that can be passed to:
|
||||
- `create-question --json '{"query": <result>, ...}'`
|
||||
- `execute-query --json '{"query": <result>, ...}'`
|
||||
- `create-transform --json '{"query": <result>, ...}'`
|
||||
- `create-measure --json '{"definition": <result>, ...}'`
|
||||
|
||||
With `--run`, executes the query and returns results directly (columns, rows, metadata).
|
||||
|
||||
## Helper Functions
|
||||
|
||||
### `table` — Look up table metadata
|
||||
|
||||
```clojure
|
||||
(table "ORDERS") ; by name (case-insensitive)
|
||||
(table 42) ; by numeric ID
|
||||
```
|
||||
|
||||
Both string names and numeric IDs are accepted. Names are case-insensitive. On error, lists available tables with their IDs.
|
||||
|
||||
### `field` — Look up field metadata
|
||||
|
||||
```clojure
|
||||
(field "ORDERS" "TOTAL") ; by table name + field name (case-insensitive)
|
||||
(field "total") ; by field name only (searches all tables)
|
||||
(field 123) ; by numeric ID
|
||||
```
|
||||
|
||||
**Two-arity** `(field "TABLE" "FIELD")` is the explicit form — always unambiguous.
|
||||
|
||||
**Single-arity** `(field "name")` searches across all tables. If the field name exists in exactly one table, it resolves automatically. If ambiguous (exists in multiple tables), it throws an error listing which tables have that field.
|
||||
|
||||
**Numeric ID** `(field 123)` looks up by field ID directly.
|
||||
|
||||
### `query` — Create a new query
|
||||
|
||||
```clojure
|
||||
(query (table "ORDERS")) ; pass table metadata from (table ...)
|
||||
```
|
||||
|
||||
## Function Reference
|
||||
|
||||
All functions are available at the top level — no namespace prefix needed. The query is built using threading: `(-> (query ...) (filter ...) (aggregate ...) ...)`.
|
||||
|
||||
### Filtering (22 functions)
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `filter` | `(filter query clause)` | Add a filter clause to a query |
|
||||
| `=` | `(= field value)` | Equal to |
|
||||
| `!=` | `(!= field value)` | Not equal to |
|
||||
| `<` | `(< field value)` | Less than |
|
||||
| `<=` | `(<= field value)` | Less than or equal |
|
||||
| `>` | `(> field value)` | Greater than |
|
||||
| `>=` | `(>= field value)` | Greater than or equal |
|
||||
| `between` | `(between field low high)` | Between two values (inclusive) |
|
||||
| `contains` | `(contains field string)` | String contains |
|
||||
| `does-not-contain` | `(does-not-contain field string)` | String does not contain |
|
||||
| `starts-with` | `(starts-with field string)` | String starts with |
|
||||
| `ends-with` | `(ends-with field string)` | String ends with |
|
||||
| `is-null` | `(is-null field)` | Value is null |
|
||||
| `not-null` | `(not-null field)` | Value is not null |
|
||||
| `is-empty` | `(is-empty field)` | Empty string |
|
||||
| `not-empty` | `(not-empty field)` | Not empty string |
|
||||
| `in` | `(in field [v1 v2 ...])` | Value in set |
|
||||
| `not-in` | `(not-in field [v1 v2 ...])` | Value not in set |
|
||||
| `time-interval` | `(time-interval field n unit)` | Within time interval (e.g., last 30 days) |
|
||||
| `and` | `(and clause1 clause2 ...)` | Logical AND of filter clauses |
|
||||
| `or` | `(or clause1 clause2 ...)` | Logical OR of filter clauses |
|
||||
| `not` | `(not clause)` | Negate a filter clause |
|
||||
| `inside` | `(inside lat-field lon-field n s e w)` | Geographic bounding box filter |
|
||||
|
||||
#### Filtering Examples
|
||||
|
||||
```clojure
|
||||
;; Simple equality filter
|
||||
(-> (query (table "ORDERS"))
|
||||
(filter (= (field "ORDERS" "STATUS") "completed")))
|
||||
|
||||
;; Compound filter with AND/OR
|
||||
(-> (query (table "ORDERS"))
|
||||
(filter (and (>= (field "ORDERS" "TOTAL") 100)
|
||||
(or (= (field "ORDERS" "STATUS") "completed")
|
||||
(= (field "ORDERS" "STATUS") "shipped")))))
|
||||
|
||||
;; Time interval: orders from the last 30 days
|
||||
(-> (query (table "ORDERS"))
|
||||
(filter (time-interval (field "ORDERS" "CREATED_AT") -30 :day)))
|
||||
|
||||
;; Null check
|
||||
(-> (query (table "ORDERS"))
|
||||
(filter (not-null (field "ORDERS" "DISCOUNT"))))
|
||||
|
||||
;; String matching
|
||||
(-> (query (table "PRODUCTS"))
|
||||
(filter (contains (field "PRODUCTS" "TITLE") "Widget")))
|
||||
```
|
||||
|
||||
### Aggregation (17 functions)
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `aggregate` | `(aggregate query clause)` | Add an aggregation to a query |
|
||||
| `count` | `(count)` | Count rows |
|
||||
| `sum` | `(sum field)` | Sum of values |
|
||||
| `avg` | `(avg field)` | Average |
|
||||
| `min` | `(min field)` | Minimum |
|
||||
| `max` | `(max field)` | Maximum |
|
||||
| `distinct` | `(distinct field)` | Count distinct values |
|
||||
| `median` | `(median field)` | Median value |
|
||||
| `stddev` | `(stddev field)` | Standard deviation |
|
||||
| `var` | `(var field)` | Variance |
|
||||
| `percentile` | `(percentile field p)` | Percentile (p between 0 and 1) |
|
||||
| `share` | `(share clause)` | Proportion of rows matching a filter |
|
||||
| `count-where` | `(count-where clause)` | Count rows matching a filter |
|
||||
| `sum-where` | `(sum-where field clause)` | Sum where filter matches |
|
||||
| `distinct-where` | `(distinct-where field clause)` | Distinct count where filter matches |
|
||||
| `cum-count` | `(cum-count)` | Cumulative count |
|
||||
| `cum-sum` | `(cum-sum field)` | Cumulative sum |
|
||||
|
||||
#### Aggregation Result Column Names
|
||||
|
||||
When a query has aggregations, the result columns are auto-named by operator:
|
||||
- First of each type: `count`, `sum`, `avg`, `min`, `max`, etc.
|
||||
- Duplicates get a suffix: `sum_2`, `sum_3`, `count_2`, etc.
|
||||
|
||||
Use these names in `visualization.y_axis` when creating questions:
|
||||
```json
|
||||
{"visualization": {"y_axis": ["sum", "count"]}}
|
||||
```
|
||||
|
||||
#### Aggregation Examples
|
||||
|
||||
```clojure
|
||||
;; Simple count
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (count)))
|
||||
|
||||
;; Multiple aggregations (result columns: "sum", "count", "avg")
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(aggregate (count))
|
||||
(aggregate (avg (field "ORDERS" "TOTAL"))))
|
||||
|
||||
;; Conditional aggregation: count of completed orders
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (count-where (= (field "ORDERS" "STATUS") "completed"))))
|
||||
|
||||
;; Percentage of completed orders
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (share (= (field "ORDERS" "STATUS") "completed"))))
|
||||
|
||||
;; 95th percentile of order totals
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (percentile (field "ORDERS" "TOTAL") 0.95)))
|
||||
```
|
||||
|
||||
### Breakout / Grouping (3 functions)
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `breakout` | `(breakout query field-or-expr)` | Group results by a column |
|
||||
| `with-temporal-bucket` | `(with-temporal-bucket field bucket)` | Apply time bucketing to a column |
|
||||
| `with-binning` | `(with-binning field strategy)` | Apply numeric binning |
|
||||
|
||||
**Temporal bucket values**: `:minute`, `:hour`, `:day`, `:week`, `:month`, `:quarter`, `:year`, `:minute-of-hour`, `:hour-of-day`, `:day-of-week`, `:day-of-month`, `:day-of-year`, `:week-of-year`, `:month-of-year`, `:quarter-of-year`
|
||||
|
||||
#### Breakout Examples
|
||||
|
||||
```clojure
|
||||
;; Group by month
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))
|
||||
|
||||
;; Group by multiple columns
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (count))
|
||||
(breakout (field "ORDERS" "STATUS"))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :quarter)))
|
||||
|
||||
;; Group by day of week (for pattern analysis)
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (count))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :day-of-week)))
|
||||
```
|
||||
|
||||
### Ordering and Limiting (4 functions)
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `order-by` | `(order-by query orderable)` | Sort ascending (default) |
|
||||
| `order-by` | `(order-by query orderable :desc)` | Sort with explicit direction |
|
||||
| `order-by` | `(order-by query :desc orderable)` | Direction-first syntax (also works) |
|
||||
| `order-by` | `(order-by query (desc orderable))` | Using desc/asc wrappers |
|
||||
| `asc` | `(asc orderable)` | Mark for ascending sort |
|
||||
| `desc` | `(desc orderable)` | Mark for descending sort |
|
||||
| `limit` | `(limit query n)` | Limit number of rows |
|
||||
|
||||
`order-by` accepts field metadata, aggregation clauses, and breakout columns. For aggregations (like `(sum ...)`), it automatically finds the matching orderable column.
|
||||
|
||||
#### Order/Limit Examples
|
||||
|
||||
```clojure
|
||||
;; Top 10 orders by total, descending
|
||||
(-> (query (table "ORDERS"))
|
||||
(order-by (field "ORDERS" "TOTAL") :desc)
|
||||
(limit 10))
|
||||
|
||||
;; Same thing using desc wrapper
|
||||
(-> (query (table "ORDERS"))
|
||||
(order-by (desc (field "ORDERS" "TOTAL")))
|
||||
(limit 10))
|
||||
|
||||
;; Direction-first syntax (also valid)
|
||||
(-> (query (table "ORDERS"))
|
||||
(order-by :desc (field "ORDERS" "TOTAL"))
|
||||
(limit 10))
|
||||
|
||||
;; Order by aggregation result — top categories by revenue
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (field "ORDERS" "STATUS"))
|
||||
(order-by (desc (sum (field "ORDERS" "TOTAL")))))
|
||||
|
||||
;; Revenue by month, chronological order
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month))
|
||||
(order-by (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month) :asc))
|
||||
```
|
||||
|
||||
### Expressions (35+ functions)
|
||||
|
||||
Named expressions create computed columns. Wrap each in `(expression query "name" expr)`.
|
||||
|
||||
#### Arithmetic
|
||||
|
||||
| Function | Example |
|
||||
|----------|---------|
|
||||
| `+` | `(+ (field "T" "A") (field "T" "B"))` |
|
||||
| `-` | `(- (field "T" "PRICE") (field "T" "COST"))` |
|
||||
| `*` | `(* (field "T" "QTY") (field "T" "PRICE"))` |
|
||||
| `/` | `(/ (field "T" "TOTAL") (field "T" "COUNT"))` |
|
||||
|
||||
#### Conditional
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `case` | `(case [[cond1 val1] [cond2 val2] ...])` | Case expression with condition/value pairs |
|
||||
| `coalesce` | `(coalesce field1 field2 ...)` | First non-null value |
|
||||
|
||||
#### String Functions
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `concat` | `(concat str1 str2 ...)` | Concatenate strings |
|
||||
| `substring` | `(substring field start length)` | Extract substring (1-indexed) |
|
||||
| `replace` | `(replace field old new)` | Replace occurrences |
|
||||
| `upper` | `(upper field)` | Uppercase |
|
||||
| `lower` | `(lower field)` | Lowercase |
|
||||
| `trim` | `(trim field)` | Trim whitespace |
|
||||
| `ltrim` | `(ltrim field)` | Trim leading whitespace |
|
||||
| `rtrim` | `(rtrim field)` | Trim trailing whitespace |
|
||||
| `length` | `(length field)` | String length |
|
||||
| `regex-match-first` | `(regex-match-first field pattern)` | First regex match |
|
||||
|
||||
#### Math Functions
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `abs` | Absolute value |
|
||||
| `ceil` | Ceiling |
|
||||
| `floor` | Floor |
|
||||
| `round` | Round |
|
||||
| `power` | Power (`(power field exp)`) |
|
||||
| `sqrt` | Square root |
|
||||
| `log` | Natural logarithm |
|
||||
| `exp` | e^x |
|
||||
|
||||
#### Temporal Functions
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `now` | `(now)` | Current timestamp |
|
||||
| `relative-datetime` | `(relative-datetime n unit)` | Relative time (e.g., -7 :day) |
|
||||
| `absolute-datetime` | `(absolute-datetime str unit)` | Parse absolute datetime |
|
||||
| `datetime-add` | `(datetime-add field n unit)` | Add time to datetime |
|
||||
| `datetime-subtract` | `(datetime-subtract field n unit)` | Subtract time from datetime |
|
||||
| `get-year` | `(get-year field)` | Extract year |
|
||||
| `get-quarter` | `(get-quarter field)` | Extract quarter (1-4) |
|
||||
| `get-month` | `(get-month field)` | Extract month (1-12) |
|
||||
| `get-week` | `(get-week field)` | Extract week of year |
|
||||
| `get-day` | `(get-day field)` | Extract day of month |
|
||||
| `get-day-of-week` | `(get-day-of-week field)` | Extract day of week (1-7) |
|
||||
| `get-hour` | `(get-hour field)` | Extract hour (0-23) |
|
||||
| `get-minute` | `(get-minute field)` | Extract minute (0-59) |
|
||||
| `get-second` | `(get-second field)` | Extract second (0-59) |
|
||||
| `convert-timezone` | `(convert-timezone field target source)` | Convert timezone |
|
||||
| `relative-time-interval` | `(relative-time-interval field n unit offset-n offset-unit)` | Relative time interval filter |
|
||||
|
||||
#### Advanced
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `expression` | `(expression query "name" expr)` | Define a named expression (computed column) |
|
||||
| `offset` | `(offset expr n)` | Access value from N rows before/after |
|
||||
| `segment` | `(segment id)` | Reference a saved segment by ID |
|
||||
|
||||
#### Expression Examples
|
||||
|
||||
```clojure
|
||||
;; Computed column: profit margin
|
||||
(-> (query (table "PRODUCTS"))
|
||||
(expression "profit_margin"
|
||||
(/ (- (field "PRODUCTS" "PRICE") (field "PRODUCTS" "COST"))
|
||||
(field "PRODUCTS" "PRICE"))))
|
||||
|
||||
;; Case expression: order size category
|
||||
(-> (query (table "ORDERS"))
|
||||
(expression "size_category"
|
||||
(case [[(< (field "ORDERS" "TOTAL") 50) "Small"]
|
||||
[(< (field "ORDERS" "TOTAL") 200) "Medium"]]
|
||||
{:default "Large"})))
|
||||
|
||||
;; Date math: days since order
|
||||
(-> (query (table "ORDERS"))
|
||||
(expression "days_since_order"
|
||||
(datetime-subtract (now) (field "ORDERS" "CREATED_AT") :day)))
|
||||
|
||||
;; Previous month comparison using offset
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month))
|
||||
(expression "prev_month_revenue" (offset (sum (field "ORDERS" "TOTAL")) -1)))
|
||||
```
|
||||
|
||||
### Joins (9 functions)
|
||||
|
||||
| Function | Signature | Description |
|
||||
|----------|-----------|-------------|
|
||||
| `join` | `(join query join-clause)` | Add a join to a query |
|
||||
| `join-clause` | `(join-clause table-meta)` | Create a join clause for a table |
|
||||
| `with-join-conditions` | `(with-join-conditions clause [cond ...])` | Set join conditions |
|
||||
| `with-join-strategy` | `(with-join-strategy clause strategy)` | Set join type |
|
||||
| `with-join-fields` | `(with-join-fields clause :all/:none)` | Include joined fields |
|
||||
| `with-join-alias` | `(with-join-alias clause alias)` | Set join alias |
|
||||
| `suggested-join-conditions` | `(suggested-join-conditions query table)` | Auto-detect join conditions |
|
||||
| `joinable-columns` | `(joinable-columns query join-clause)` | Get columns available for join conditions |
|
||||
| `joins` | `(joins query)` | Get existing joins on a query |
|
||||
|
||||
**Join strategies**: `:left-join` (default), `:right-join`, `:inner-join`
|
||||
|
||||
#### Join Examples
|
||||
|
||||
```clojure
|
||||
;; Join orders with products
|
||||
(let [q (query (table "ORDERS"))
|
||||
products (table "PRODUCTS")]
|
||||
(-> q
|
||||
(join (-> (join-clause products)
|
||||
(with-join-conditions
|
||||
[(= (field "ORDERS" "PRODUCT_ID")
|
||||
(field "PRODUCTS" "ID"))])
|
||||
(with-join-fields :all)))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (field "PRODUCTS" "CATEGORY"))))
|
||||
|
||||
;; Inner join with alias
|
||||
(let [q (query (table "ORDERS"))
|
||||
users (table "PEOPLE")]
|
||||
(-> q
|
||||
(join (-> (join-clause users)
|
||||
(with-join-conditions
|
||||
[(= (field "ORDERS" "USER_ID")
|
||||
(field "PEOPLE" "ID"))])
|
||||
(with-join-strategy :inner-join)
|
||||
(with-join-fields :all)))
|
||||
(filter (= (field "PEOPLE" "STATE") "CA"))
|
||||
(aggregate (count))))
|
||||
```
|
||||
|
||||
### Column Introspection (6 functions)
|
||||
|
||||
These functions return available columns at a given query stage. Useful for dynamic resolution.
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `visible-columns` | All visible columns in the current stage |
|
||||
| `filterable-columns` | Columns that can be used in filters |
|
||||
| `breakoutable-columns` | Columns that can be used for grouping |
|
||||
| `aggregable-columns` | Columns that can be aggregated |
|
||||
| `orderable-columns` | Columns that can be used for sorting |
|
||||
| `expressionable-columns` | Columns available for use in expressions |
|
||||
|
||||
### Query Lifecycle
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `->legacy-MBQL` | Convert a pMBQL query to legacy MBQL format (done automatically by construct-query) |
|
||||
|
||||
## Complete Examples
|
||||
|
||||
### Revenue by Month (Line Chart)
|
||||
|
||||
```clojure
|
||||
(-> (query (table "ORDERS"))
|
||||
(filter (= (field "ORDERS" "STATUS") "completed"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))
|
||||
```
|
||||
|
||||
### Top Categories by Revenue (Bar Chart)
|
||||
|
||||
```clojure
|
||||
(let [q (query (table "ORDERS"))
|
||||
products (table "PRODUCTS")]
|
||||
(-> q
|
||||
(join (-> (join-clause products)
|
||||
(with-join-conditions
|
||||
[(= (field "ORDERS" "PRODUCT_ID")
|
||||
(field "PRODUCTS" "ID"))])
|
||||
(with-join-fields :all)))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (field "PRODUCTS" "CATEGORY"))
|
||||
(order-by (desc (sum (field "ORDERS" "TOTAL"))))
|
||||
(limit 10)))
|
||||
```
|
||||
|
||||
### Conversion Rate (Scalar)
|
||||
|
||||
```clojure
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (share (= (field "ORDERS" "STATUS") "completed"))))
|
||||
```
|
||||
|
||||
### Orders with Computed Columns
|
||||
|
||||
```clojure
|
||||
(-> (query (table "ORDERS"))
|
||||
(expression "discount_pct"
|
||||
(/ (field "ORDERS" "DISCOUNT") (field "ORDERS" "SUBTOTAL")))
|
||||
(expression "is_large_order"
|
||||
(case [[(>= (field "ORDERS" "TOTAL") 200) "Yes"]]
|
||||
{:default "No"}))
|
||||
(filter (not-null (field "ORDERS" "DISCOUNT")))
|
||||
(order-by (desc (field "ORDERS" "TOTAL")))
|
||||
(limit 50))
|
||||
```
|
||||
|
||||
### Using let for Readable Multi-Table Queries
|
||||
|
||||
```clojure
|
||||
(let [orders (table "ORDERS")
|
||||
products (table "PRODUCTS")
|
||||
people (table "PEOPLE")
|
||||
q (query orders)]
|
||||
(-> q
|
||||
(join (-> (join-clause products)
|
||||
(with-join-conditions
|
||||
[(= (field "ORDERS" "PRODUCT_ID")
|
||||
(field "PRODUCTS" "ID"))])
|
||||
(with-join-fields :all)))
|
||||
(join (-> (join-clause people)
|
||||
(with-join-conditions
|
||||
[(= (field "ORDERS" "USER_ID")
|
||||
(field "PEOPLE" "ID"))])
|
||||
(with-join-fields :all)))
|
||||
(filter (and (= (field "PEOPLE" "STATE") "CA")
|
||||
(time-interval (field "ORDERS" "CREATED_AT") -90 :day)))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(aggregate (count))
|
||||
(breakout (field "PRODUCTS" "CATEGORY"))))
|
||||
```
|
||||
|
||||
### Top 10 Customers by Lifetime Value
|
||||
|
||||
```clojure
|
||||
(let [q (query (table "ORDERS"))
|
||||
people (table "PEOPLE")]
|
||||
(-> q
|
||||
(join (-> (join-clause people)
|
||||
(with-join-conditions
|
||||
[(= (field "ORDERS" "USER_ID")
|
||||
(field "PEOPLE" "ID"))])
|
||||
(with-join-fields :all)))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(aggregate (count))
|
||||
(breakout (field "PEOPLE" "NAME"))
|
||||
(order-by (desc (sum (field "ORDERS" "TOTAL"))))
|
||||
(limit 10)))
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **`field` with two args**: `(field "TABLE" "FIELD")` — always works, even with joins.
|
||||
- **`field` with one arg**: `(field "FIELD")` — searches all tables. Throws if ambiguous (field exists in multiple tables). Use the two-arg form to disambiguate.
|
||||
- **`table` and `field` accept IDs**: `(table 42)` and `(field 123)` work with numeric IDs from discovery APIs.
|
||||
- **Table/field names are case-insensitive**: `(field "orders" "total")` and `(field "ORDERS" "TOTAL")` are equivalent.
|
||||
- **Multiple `aggregate` calls chain**: Each `(aggregate ...)` adds one aggregation. Don't pass multiple aggregations in a single call.
|
||||
- **Aggregation result column names**: `sum`, `count`, `avg`, etc. Duplicates get `_2`, `_3` suffixes. Use these names in `visualization.y_axis`.
|
||||
- **Ordering by aggregations**: Use `(order-by query (desc (sum (field ...))))` — the aggregation clause is auto-matched to the query's orderable columns.
|
||||
- **`--run` flag**: Use `construct-query --run` to construct and execute in one step, avoiding two-step shell piping.
|
||||
- **Direction in `order-by`**: All three syntaxes work: `(order-by q field :desc)`, `(order-by q :desc field)`, `(order-by q (desc field))`.
|
||||
|
||||
## Clojure Syntax Quick Reference
|
||||
|
||||
The SCI evaluator supports standard Clojure syntax:
|
||||
|
||||
| Form | Example | Description |
|
||||
|------|---------|-------------|
|
||||
| `->` | `(-> x (f) (g))` | Thread-first macro: `(g (f x))` |
|
||||
| `let` | `(let [x 1] x)` | Local bindings |
|
||||
| `fn` | `(fn [x] (* x 2))` | Anonymous function |
|
||||
| `if` | `(if test then else)` | Conditional |
|
||||
| Keywords | `:month`, `:desc` | Used for options/enums |
|
||||
| Vectors | `[1 2 3]` | Used for lists of values |
|
||||
| Maps | `{:key "value"}` | Key-value pairs |
|
||||
| Strings | `"text"` | Double-quoted strings |
|
||||
@@ -0,0 +1,61 @@
|
||||
# Visualization Types and Settings
|
||||
|
||||
## Display Types
|
||||
|
||||
| Type | Use for | Key settings |
|
||||
|------|---------|-------------|
|
||||
| `table` | Detailed data (default) | none needed |
|
||||
| `line` | Time series, trends | x_axis, y_axis |
|
||||
| `bar` | Comparisons, distributions | x_axis, y_axis, stack |
|
||||
| `area` | Cumulative, stacked trends | x_axis, y_axis, stack |
|
||||
| `combo` | Mixed chart types | x_axis, y_axis |
|
||||
| `scatter` | Correlations | x_axis, y_axis |
|
||||
| `waterfall` | Sequential changes | x_axis, y_axis |
|
||||
| `pie` | Part-of-whole | dimension, metric |
|
||||
| `row` | Horizontal bar chart | x_axis, y_axis |
|
||||
| `scalar` | Single KPI number | none |
|
||||
| `smartscalar` | KPI with trend | none |
|
||||
| `gauge` | Progress toward target | none |
|
||||
| `progress` | Progress bar | none |
|
||||
| `pivot` | Cross-tabulations | x_axis, y_axis |
|
||||
| `funnel` | Conversion funnels | x_axis, y_axis |
|
||||
| `map` | Geographic data | map_type, map_region |
|
||||
| `boxplot` | Statistical distribution | x_axis, y_axis |
|
||||
| `sankey` | Flow diagrams | x_axis, y_axis |
|
||||
|
||||
## Settings Object
|
||||
|
||||
All settings are optional. The CLI translates these to Metabase's internal format.
|
||||
|
||||
| CLI key | Metabase key | Type | Notes |
|
||||
|---------|-------------|------|-------|
|
||||
| `x_axis` | `graph.dimensions` | string[] | X axis columns |
|
||||
| `y_axis` | `graph.metrics` | string[] | Y axis columns |
|
||||
| `stack` | `stackable.stack_type` | `"stacked"` / `"normalized"` | bar/area |
|
||||
| `show_values` | `graph.show_values` | boolean | data labels |
|
||||
| `x_axis_label` | `graph.x_axis.title_text` | string | axis title |
|
||||
| `y_axis_label` | `graph.y_axis.title_text` | string | axis title |
|
||||
| `line_style` | `line.style` | `"solid"` / `"dashed"` / `"dotted"` | |
|
||||
| `line_interpolation` | `line.interpolate` | `"linear"` / `"cardinal"` / `"monotone"` | |
|
||||
| `dimension` | `pie.dimension` | string[] | pie categories |
|
||||
| `metric` | `pie.metric` | string | pie value column |
|
||||
| `show_legend` | `pie.show_legend` | boolean | |
|
||||
| `map_type` | `map.type` | `"pin"` / `"region"` / `"grid"` | |
|
||||
| `map_region` | `map.region` | string | |
|
||||
|
||||
## Column Name Matching
|
||||
|
||||
`x_axis` and `y_axis` values must match SQL column aliases exactly:
|
||||
|
||||
```sql
|
||||
SELECT DATE_TRUNC('month', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY 1
|
||||
```
|
||||
Correct: `"x_axis": ["month"], "y_axis": ["revenue"]`
|
||||
Wrong: `"x_axis": ["created_at"], "y_axis": ["total"]`
|
||||
|
||||
## Chart-Specific Notes
|
||||
|
||||
- **pie**: Use `dimension` and `metric` instead of `x_axis`/`y_axis`
|
||||
- **scalar/smartscalar**: SQL should return one row, one column. No viz settings needed.
|
||||
- **gauge/progress**: SQL should return a single numeric value.
|
||||
- **pivot**: `x_axis` columns become row headers, `y_axis` is the aggregated value.
|
||||
180
agent-toolkit/.claude/skills/building-dashboards/SKILL.md
Normal file
180
agent-toolkit/.claude/skills/building-dashboards/SKILL.md
Normal file
@@ -0,0 +1,180 @@
|
||||
---
|
||||
name: building-dashboards
|
||||
description: Creates and manages Metabase dashboards with card placement, auto-layout, and filter wiring. Use when building dashboards, placing question cards on grids, or connecting dashboard filters to card fields.
|
||||
---
|
||||
|
||||
# Building Dashboards
|
||||
|
||||
@./../_shared/field-resolution.md
|
||||
|
||||
## create-dashboard
|
||||
|
||||
Create a dashboard with cards and filters in one step.
|
||||
|
||||
```bash
|
||||
./metabase-agent create-dashboard --json '<payload>'
|
||||
```
|
||||
|
||||
Full JSON template:
|
||||
```json
|
||||
{
|
||||
"name": "Sales Dashboard",
|
||||
"description": "Q4 sales overview",
|
||||
"collection_id": 7,
|
||||
"cards": [
|
||||
{"card_id": 42, "width": 12, "height": 6},
|
||||
{"card_id": 43, "width": 6, "height": 4},
|
||||
{"card_id": 44, "width": 6, "height": 4}
|
||||
],
|
||||
"filters": [
|
||||
{
|
||||
"name": "Date Range",
|
||||
"type": "date/range",
|
||||
"targets": [
|
||||
{"card_id": 42, "field": "created_at"},
|
||||
{"card_id": 43, "field": "order_date"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `name` | yes | | Dashboard name |
|
||||
| `description` | no | | Dashboard description |
|
||||
| `collection_id` | no | | Collection to save in |
|
||||
| `cards` | no | | Cards to place (see layout below) |
|
||||
| `cards[].card_id` | yes | | Question/card ID from `create-question` |
|
||||
| `cards[].width` | no | 6 | Width in grid units (max 24) |
|
||||
| `cards[].height` | no | 4 | Height in grid units |
|
||||
| `cards[].row` | no | auto | Row position |
|
||||
| `cards[].col` | no | auto | Column position |
|
||||
| `filters` | no | | Dashboard-level filters |
|
||||
| `filters[].name` | yes | | Label shown on dashboard |
|
||||
| `filters[].type` | yes | | Filter widget type (see table below) |
|
||||
| `filters[].targets` | yes | | Cards and fields this filter connects to |
|
||||
|
||||
Internally chains POST (create dashboard) + PUT (add cards and filters) in one command.
|
||||
|
||||
Output:
|
||||
```json
|
||||
{"id": 15, "name": "Sales Dashboard", "cards_added": 3, "filters_added": 1}
|
||||
```
|
||||
|
||||
## Auto-Layout (24-Column Grid)
|
||||
|
||||
Cards are placed left-to-right, wrapping to the next row when a card exceeds column 24.
|
||||
|
||||
If `row` and `col` are both specified, those exact positions are used. Otherwise, auto-layout applies.
|
||||
|
||||
Common layout patterns:
|
||||
```json
|
||||
// Full width
|
||||
{"card_id": 1, "width": 24, "height": 6}
|
||||
|
||||
// Two equal columns
|
||||
{"card_id": 2, "width": 12, "height": 4},
|
||||
{"card_id": 3, "width": 12, "height": 4}
|
||||
|
||||
// Three equal columns
|
||||
{"card_id": 4, "width": 8, "height": 4},
|
||||
{"card_id": 5, "width": 8, "height": 4},
|
||||
{"card_id": 6, "width": 8, "height": 4}
|
||||
|
||||
// Four equal columns (KPI row)
|
||||
{"card_id": 7, "width": 6, "height": 3},
|
||||
{"card_id": 8, "width": 6, "height": 3},
|
||||
{"card_id": 9, "width": 6, "height": 3},
|
||||
{"card_id": 10, "width": 6, "height": 3}
|
||||
|
||||
// One wide + one narrow
|
||||
{"card_id": 11, "width": 16, "height": 6},
|
||||
{"card_id": 12, "width": 8, "height": 6}
|
||||
```
|
||||
|
||||
## Dashboard Filter Types
|
||||
|
||||
| type | Widget | Use for |
|
||||
|------|--------|---------|
|
||||
| `date/range` | Date range picker | Date columns |
|
||||
| `date/single` | Single date picker | Exact date |
|
||||
| `date/month-year` | Month picker | Monthly filtering |
|
||||
| `date/quarter-year` | Quarter picker | Quarterly filtering |
|
||||
| `date/relative` | Relative date | Dynamic ranges (last 30 days) |
|
||||
| `category` | Dropdown | String/enum columns |
|
||||
| `id` | ID input | Numeric ID columns |
|
||||
| `number` | Number input | Numeric columns |
|
||||
| `string` | Text input | Free text |
|
||||
| `string/contains` | Text (contains) | Partial match |
|
||||
| `string/starts-with` | Text (starts-with) | Prefix match |
|
||||
|
||||
Filter targets use field names that are resolved automatically (see field-resolution.md).
|
||||
|
||||
## add-card-to-dashboard
|
||||
|
||||
Add a card to an existing dashboard with optional filter wiring.
|
||||
|
||||
```bash
|
||||
./metabase-agent add-card-to-dashboard --json '<payload>'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"dashboard_id": 15,
|
||||
"card_id": 50,
|
||||
"width": 6,
|
||||
"height": 4,
|
||||
"filter_mappings": [
|
||||
{"filter_name": "Date Range", "field": "created_at"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `dashboard_id` | yes | | Dashboard ID |
|
||||
| `card_id` | yes | | Card/question ID to add |
|
||||
| `width` | no | 6 | Width in grid units |
|
||||
| `height` | no | 4 | Height in grid units |
|
||||
| `filter_mappings` | no | | Wire to existing dashboard filters |
|
||||
| `filter_mappings[].filter_name` | yes | | Must match existing filter name (case-insensitive) |
|
||||
| `filter_mappings[].field` | yes | | Field name on this card to wire to |
|
||||
|
||||
New card is placed at the bottom of the dashboard below all existing cards.
|
||||
|
||||
Output:
|
||||
```json
|
||||
{"dashboard_id": 15, "card_id": 50, "position": {"row": 8, "col": 0}}
|
||||
```
|
||||
|
||||
## get-dashboard
|
||||
|
||||
```bash
|
||||
./metabase-agent get-dashboard <id>
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"id": 15,
|
||||
"name": "Sales Dashboard",
|
||||
"description": "Q4 overview",
|
||||
"parameters": [{"id": "abc12345", "name": "Date Range", "type": "date/range"}],
|
||||
"cards": [
|
||||
{"dashcard_id": 100, "card_id": 42, "card_name": "Revenue by Month",
|
||||
"size_x": 12, "size_y": 6, "row": 0, "col": 0}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Use this to see current layout before adding cards or to verify dashboard creation.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `card_id` is the `id` from `create-question`, NOT the `dashcard_id` shown in `get-dashboard`
|
||||
- For SQL cards, filter wiring requires the card to have been run at least once (so `result_metadata` exists). Run the question in Metabase UI or use `execute-query` first.
|
||||
- `add-card-to-dashboard` reads existing cards and PUTs the entire array back. It handles this correctly -- you just provide the new card.
|
||||
- Filter `targets` use field names, not IDs. The field must exist on the card's source table or SQL result columns.
|
||||
- Card `width` must be between 1 and 24. Common widths: 6 (quarter), 8 (third), 12 (half), 16 (two-thirds), 24 (full).
|
||||
- Mixing auto-layout (no row/col) with explicit positions: auto-layout cards fill in array order. For predictable results, use all-auto or all-explicit.
|
||||
435
agent-toolkit/.claude/skills/creating-questions/SKILL.md
Normal file
435
agent-toolkit/.claude/skills/creating-questions/SKILL.md
Normal file
@@ -0,0 +1,435 @@
|
||||
---
|
||||
name: creating-questions
|
||||
description: Creates saved questions with visualizations, reusable filter segments, measures, metrics, and runs ad-hoc MBQL or SQL queries in Metabase. Use when saving queries as named questions with charts, defining reusable filters/aggregations, or running exploratory queries.
|
||||
---
|
||||
|
||||
# Creating Questions, Segments, Measures, and Queries
|
||||
|
||||
@./../_shared/visualization-settings.md
|
||||
@./../_shared/field-resolution.md
|
||||
@./../_shared/mbql-construction.md
|
||||
|
||||
## construct-query
|
||||
|
||||
Construct an MBQL query by evaluating Clojure code with `metabase.lib` functions. Returns legacy MBQL JSON or (with `--run`) executes the query directly.
|
||||
|
||||
```bash
|
||||
# Return MBQL JSON
|
||||
./metabase-agent construct-query --database-id <id> --clj '<clojure code>'
|
||||
|
||||
# Construct AND execute in one step
|
||||
./metabase-agent construct-query --database-id <id> --run --clj '<clojure code>'
|
||||
```
|
||||
|
||||
Example:
|
||||
```bash
|
||||
./metabase-agent construct-query --database-id 1 --clj '
|
||||
(-> (query (table "ORDERS"))
|
||||
(filter (= (field "ORDERS" "STATUS") "completed"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))'
|
||||
```
|
||||
|
||||
Returns a JSON object like:
|
||||
```json
|
||||
{"database": 1, "type": "query", "query": {"source-table": 5, "aggregation": [["sum", ...]], ...}}
|
||||
```
|
||||
|
||||
With `--run`, returns query results (columns, rows, metadata) instead of MBQL JSON.
|
||||
|
||||
See the MBQL Construction reference for all available functions and examples.
|
||||
|
||||
## create-question
|
||||
|
||||
Create a saved question (card) with visualization settings. **Always prefer MBQL** — Metabase can do much more with structured queries (drill-down, auto-filters, column-level metadata, cross-database compatibility). Use SQL only when MBQL can't express the query.
|
||||
|
||||
```bash
|
||||
./metabase-agent create-question --json '<payload>'
|
||||
```
|
||||
|
||||
### MBQL question (structured query — preferred)
|
||||
|
||||
First construct the MBQL query with `construct-query`, then use the output as the `query` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Revenue by Month",
|
||||
"database_id": 1,
|
||||
"query": {"source-table": 5, "aggregation": [["sum", ["field", 23, null]]], "breakout": [["field", 20, {"temporal-unit": "month"}]]},
|
||||
"display": "line",
|
||||
"visualization": {
|
||||
"x_axis": ["CREATED_AT"],
|
||||
"y_axis": ["sum"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### SQL question (native query — use only when MBQL can't express the query)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Revenue by Month",
|
||||
"database_id": 1,
|
||||
"sql": "SELECT DATE_TRUNC('month', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY 1 ORDER BY 1",
|
||||
"display": "line",
|
||||
"type": "question",
|
||||
"visualization": {
|
||||
"x_axis": ["month"],
|
||||
"y_axis": ["revenue"],
|
||||
"y_axis_label": "Revenue ($)",
|
||||
"show_values": true
|
||||
},
|
||||
"collection_id": 7,
|
||||
"description": "Monthly revenue trend"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Default | Description |
|
||||
|-------|----------|---------|-------------|
|
||||
| `name` | yes | | Question name |
|
||||
| `database_id` | yes | | Database ID |
|
||||
| `sql` | one of sql/query | | SQL query (native question) |
|
||||
| `query` | one of sql/query | | MBQL query object from `construct-query` |
|
||||
| `display` | no | `"table"` | Chart type (see visualization-settings.md) |
|
||||
| `type` | no | `"question"` | `"question"`, `"model"`, or `"metric"` |
|
||||
| `visualization` | no | | Chart settings (see visualization-settings.md) |
|
||||
| `collection_id` | no | | Save to specific collection |
|
||||
| `description` | no | | Question description |
|
||||
|
||||
Exactly one of `sql` or `query` must be provided.
|
||||
|
||||
Output:
|
||||
```json
|
||||
{"id": 42, "name": "Revenue by Month", "display": "line", "type": "question"}
|
||||
```
|
||||
|
||||
The returned `id` is the `card_id` you pass to dashboard commands.
|
||||
|
||||
### Example: Line chart (MBQL — recommended)
|
||||
|
||||
Two-step workflow: first construct the MBQL, then create the question.
|
||||
|
||||
```bash
|
||||
# Step 1: Construct MBQL query
|
||||
./metabase-agent construct-query --database-id 1 --clj '
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))'
|
||||
# --> returns {"database": 1, "type": "query", "query": {...}}
|
||||
|
||||
# Step 2: Create question with the MBQL output as the "query" field
|
||||
./metabase-agent create-question --json '{
|
||||
"name": "Revenue Trend",
|
||||
"database_id": 1,
|
||||
"query": <paste output from step 1>,
|
||||
"display": "line",
|
||||
"visualization": {"x_axis": ["CREATED_AT"], "y_axis": ["sum"]}
|
||||
}'
|
||||
```
|
||||
|
||||
For MBQL questions, `visualization.y_axis` uses the aggregation operator name (`sum`, `count`, `avg`, etc.), not SQL aliases.
|
||||
|
||||
### Example: Top 10 bar chart (MBQL with ordering)
|
||||
|
||||
```bash
|
||||
./metabase-agent construct-query --database-id 1 --clj '
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (field "ORDERS" "STATUS"))
|
||||
(order-by (desc (sum (field "ORDERS" "TOTAL"))))
|
||||
(limit 10))'
|
||||
```
|
||||
|
||||
### Example: Pie chart (MBQL)
|
||||
|
||||
```bash
|
||||
./metabase-agent construct-query --database-id 1 --clj '
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (count))
|
||||
(breakout (field "ORDERS" "STATUS")))'
|
||||
|
||||
./metabase-agent create-question --json '{
|
||||
"name": "Orders by Status",
|
||||
"database_id": 1,
|
||||
"query": <paste output>,
|
||||
"display": "pie",
|
||||
"visualization": {"dimension": ["STATUS"], "metric": "count"}
|
||||
}'
|
||||
```
|
||||
|
||||
### Example: KPI scalar (MBQL)
|
||||
|
||||
```bash
|
||||
./metabase-agent construct-query --database-id 1 --clj '
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL"))))'
|
||||
|
||||
./metabase-agent create-question --json '{
|
||||
"name": "Total Revenue",
|
||||
"database_id": 1,
|
||||
"query": <paste output>,
|
||||
"display": "scalar"
|
||||
}'
|
||||
```
|
||||
|
||||
### Example: Metric (reusable aggregation on a card)
|
||||
|
||||
Metrics are cards with `type: "metric"`. They define a reusable aggregation:
|
||||
|
||||
```bash
|
||||
./metabase-agent create-question --json '{
|
||||
"name": "Total Revenue",
|
||||
"database_id": 1,
|
||||
"type": "metric",
|
||||
"query": {"source-table": 5, "aggregation": [["sum", ["field", 23, null]]]},
|
||||
"display": "scalar",
|
||||
"description": "Sum of all order totals"
|
||||
}'
|
||||
```
|
||||
|
||||
### Example: SQL fallback (use only when MBQL can't express the query)
|
||||
|
||||
```bash
|
||||
./metabase-agent create-question --json '{
|
||||
"name": "Revenue Trend",
|
||||
"database_id": 1,
|
||||
"sql": "SELECT DATE_TRUNC('"'"'month'"'"', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY 1 ORDER BY 1",
|
||||
"display": "line",
|
||||
"visualization": {"x_axis": ["month"], "y_axis": ["revenue"]}
|
||||
}'
|
||||
```
|
||||
|
||||
## update-question
|
||||
|
||||
```bash
|
||||
./metabase-agent update-question <id> --json '<payload>'
|
||||
```
|
||||
|
||||
All fields are optional. If updating `sql`, you MUST include `database_id`.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Updated Name",
|
||||
"display": "bar",
|
||||
"sql": "SELECT ...",
|
||||
"database_id": 1,
|
||||
"visualization": {"x_axis": ["month"], "y_axis": ["revenue"], "stack": "stacked"},
|
||||
"description": "Updated description"
|
||||
}
|
||||
```
|
||||
|
||||
## create-segment
|
||||
|
||||
Create a reusable filter (segment) on a table using field names.
|
||||
|
||||
```bash
|
||||
./metabase-agent create-segment --json '<payload>'
|
||||
```
|
||||
|
||||
Full JSON template:
|
||||
```json
|
||||
{
|
||||
"name": "Active Users",
|
||||
"table_id": 5,
|
||||
"filters": [
|
||||
{"field": "last_login", "op": "greater-than", "value": "2024-01-01"},
|
||||
{"field": "status", "op": "equals", "value": "active"}
|
||||
],
|
||||
"description": "Users active in 2024"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `name` | yes | Segment name |
|
||||
| `table_id` | yes | Table ID |
|
||||
| `filters` | yes (min 1) | Filter conditions using field names |
|
||||
| `description` | no | Segment description |
|
||||
|
||||
Multiple filters are ANDed together automatically. Field names are resolved to IDs via the table's metadata (see field-resolution.md).
|
||||
|
||||
### Filter Operations
|
||||
|
||||
| Operation | Description | Needs value? |
|
||||
|-----------|-------------|-------------|
|
||||
| `equals` | Exact match | yes |
|
||||
| `not-equals` | Not equal | yes |
|
||||
| `greater-than` | > | yes |
|
||||
| `greater-than-or-equal` | >= | yes |
|
||||
| `less-than` | < | yes |
|
||||
| `less-than-or-equal` | <= | yes |
|
||||
| `contains` | String contains | yes |
|
||||
| `not-contains` | Does not contain | yes |
|
||||
| `starts-with` | String starts with | yes |
|
||||
| `ends-with` | String ends with | yes |
|
||||
| `is-null` | Value is null | no |
|
||||
| `is-not-null` | Value is not null | no |
|
||||
| `is-empty` | Empty string | no |
|
||||
| `is-not-empty` | Not empty string | no |
|
||||
|
||||
Use `values` (array) instead of `value` for multi-value matching.
|
||||
|
||||
Output:
|
||||
```json
|
||||
{"id": 10, "name": "Active Users", "table_id": 5}
|
||||
```
|
||||
|
||||
## update-segment
|
||||
|
||||
```bash
|
||||
./metabase-agent update-segment <id> --json '<payload>'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Updated Name",
|
||||
"description": "New description",
|
||||
"table_id": 5,
|
||||
"filters": [{"field": "status", "op": "equals", "value": "active"}],
|
||||
"revision_message": "Updated filter criteria"
|
||||
}
|
||||
```
|
||||
|
||||
If updating `filters`, you MUST include `table_id`. `revision_message` defaults to "Updated via CLI".
|
||||
|
||||
## create-measure
|
||||
|
||||
Create a reusable aggregation expression (measure) tied to a table. Measures use MBQL definitions with exactly one aggregation.
|
||||
|
||||
```bash
|
||||
./metabase-agent create-measure --json '<payload>'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Total Revenue",
|
||||
"table_id": 5,
|
||||
"definition": {"database": 1, "type": "query", "query": {"source-table": 5, "aggregation": [["sum", ["field", 23, null]]]}},
|
||||
"description": "Sum of all order totals"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `name` | yes | Measure name |
|
||||
| `table_id` | yes | Table ID this measure belongs to |
|
||||
| `definition` | yes | MBQL5 query object (from `construct-query`) |
|
||||
| `description` | no | Measure description |
|
||||
|
||||
Workflow: Use `construct-query` to build the MBQL, then pass the output as `definition`.
|
||||
|
||||
Output:
|
||||
```json
|
||||
{"id": 3, "name": "Total Revenue", "table_id": 5}
|
||||
```
|
||||
|
||||
## list-measures
|
||||
|
||||
```bash
|
||||
./metabase-agent list-measures
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
[{"id": 3, "name": "Total Revenue", "table_id": 5, "description": "Sum of all order totals"}]
|
||||
```
|
||||
|
||||
## get-measure
|
||||
|
||||
```bash
|
||||
./metabase-agent get-measure <id>
|
||||
```
|
||||
|
||||
## update-measure
|
||||
|
||||
```bash
|
||||
./metabase-agent update-measure <id> --json '<payload>'
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Updated Name",
|
||||
"definition": {"database": 1, "type": "query", "query": {"source-table": 5, "aggregation": [["avg", ["field", 23, null]]]}},
|
||||
"revision_message": "Changed from sum to avg"
|
||||
}
|
||||
```
|
||||
|
||||
`revision_message` defaults to "Updated via CLI".
|
||||
|
||||
## execute-query
|
||||
|
||||
Run ad-hoc queries without saving. Prefer `construct-query --run` for MBQL — it's simpler. Use `execute-query` when you already have a raw query object or need SQL.
|
||||
|
||||
```bash
|
||||
./metabase-agent execute-query --json '<payload>'
|
||||
```
|
||||
|
||||
### MBQL execution (preferred — or use `construct-query --run` instead)
|
||||
|
||||
```json
|
||||
{
|
||||
"database_id": 1,
|
||||
"query": {"source-table": 5, "aggregation": [["count"]], "filter": ["=", ["field", 50, null], "completed"]}
|
||||
}
|
||||
```
|
||||
|
||||
### SQL execution (use only when MBQL can't express the query)
|
||||
|
||||
```json
|
||||
{
|
||||
"database_id": 1,
|
||||
"sql": "SELECT COUNT(*) AS total_orders FROM orders WHERE status = 'completed'"
|
||||
}
|
||||
```
|
||||
|
||||
Exactly one of `sql` or `query` must be provided.
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"status": "completed",
|
||||
"columns": [{"name": "total_orders", "display_name": "Total Orders", "base_type": "type/Integer"}],
|
||||
"rows": [[1523]],
|
||||
"row_count": 1,
|
||||
"running_time": 245,
|
||||
"_meta": {"truncated": false, "total_count": 1, "returned_count": 1}
|
||||
}
|
||||
```
|
||||
|
||||
Default max 50 rows. Override with `--max-rows 200`. Results are ephemeral -- use `create-question` to save.
|
||||
|
||||
## run-question
|
||||
|
||||
Run a saved question and return sample results.
|
||||
|
||||
```bash
|
||||
./metabase-agent run-question <id>
|
||||
./metabase-agent run-question <id> --max-rows 20
|
||||
```
|
||||
|
||||
Default: 10 rows. Use this to verify a question works before adding it to a dashboard, or to inspect the data shape.
|
||||
|
||||
Output:
|
||||
```json
|
||||
{"card_id": 42, "status": "completed",
|
||||
"columns": [{"name": "month", "display_name": "Month", "base_type": "type/DateTime"},
|
||||
{"name": "revenue", "display_name": "Revenue", "base_type": "type/Float"}],
|
||||
"rows": [["2024-01-01T00:00:00Z", 12345.67]],
|
||||
"row_count": 12, "running_time": 150,
|
||||
"_meta": {"truncated": true, "total_count": 12, "returned_count": 10}}
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
- **Always use MBQL** unless the query requires SQL-specific features (CTEs, window functions, database-specific syntax, etc.). MBQL questions support drill-down, auto-filters, and cross-database compatibility that SQL questions cannot provide.
|
||||
- `create-question` returns `id` -- this is the `card_id` for dashboard commands
|
||||
- **MBQL y_axis**: Use aggregation operator names: `"y_axis": ["sum"]`, `"y_axis": ["sum", "count"]`. Duplicates get suffixes: `sum_2`, `sum_3`
|
||||
- **SQL y_axis**: Column aliases must match `x_axis`/`y_axis` exactly: `SELECT SUM(total) AS revenue` means `"y_axis": ["revenue"]`
|
||||
- For pie charts, use `dimension` and `metric`, not `x_axis`/`y_axis`
|
||||
- `type: "model"` creates a Metabase model (queryable like a table)
|
||||
- `type: "metric"` creates a reusable metric aggregation
|
||||
- Segment field names are resolved against table metadata. Run `get-table <table_id>` first to confirm field names.
|
||||
- `execute-query` does NOT save anything. Use `create-question` for persistent results.
|
||||
- MBQL queries from `construct-query` include the full `dataset_query` structure -- pass the entire output as the `query` field.
|
||||
- Measures require MBQL definitions -- use `construct-query` to build them.
|
||||
- Use `construct-query --run` to test MBQL queries ad-hoc without creating a saved question.
|
||||
170
agent-toolkit/.claude/skills/discovering-data/SKILL.md
Normal file
170
agent-toolkit/.claude/skills/discovering-data/SKILL.md
Normal file
@@ -0,0 +1,170 @@
|
||||
---
|
||||
name: discovering-data
|
||||
description: Discovers databases, tables, fields, and existing content in Metabase. Use when exploring data sources, understanding schema, or searching for existing dashboards and questions before building new analytics.
|
||||
---
|
||||
|
||||
# Discovering Data
|
||||
|
||||
Always start here before creating transforms, questions, or dashboards.
|
||||
|
||||
@./../_shared/common-patterns.md
|
||||
|
||||
## list-databases
|
||||
|
||||
List all databases, optionally including their tables.
|
||||
|
||||
```bash
|
||||
# Compact list
|
||||
./metabase-agent list-databases --fields id,name,engine
|
||||
|
||||
# Include table names (recommended first step)
|
||||
./metabase-agent list-databases --include tables
|
||||
```
|
||||
|
||||
Output shape:
|
||||
```json
|
||||
[{"id": 1, "name": "Production DB", "engine": "postgres",
|
||||
"tables": [{"id": 5, "name": "orders", "schema": "public"}]}]
|
||||
```
|
||||
|
||||
This gives you `database_id` and `table_id` values needed by all other commands.
|
||||
|
||||
## get-database
|
||||
|
||||
Full database metadata with every table and field.
|
||||
|
||||
```bash
|
||||
./metabase-agent get-database 1
|
||||
```
|
||||
|
||||
Output shape:
|
||||
```json
|
||||
{"id": 1, "name": "Production DB", "engine": "postgres",
|
||||
"tables": [{"id": 5, "name": "orders", "schema": "public", "display_name": "Orders",
|
||||
"fields": [{"id": 42, "name": "id", "display_name": "ID",
|
||||
"base_type": "type/Integer", "semantic_type": "type/PK"}]}]}
|
||||
```
|
||||
|
||||
Use when you need field-level detail for all tables in one call. Output can be large for big databases -- prefer `get-table` for specific tables.
|
||||
|
||||
## get-table
|
||||
|
||||
Single table with all fields, types, and foreign keys.
|
||||
|
||||
```bash
|
||||
./metabase-agent get-table 5
|
||||
```
|
||||
|
||||
Output shape:
|
||||
```json
|
||||
{"id": 5, "name": "orders", "display_name": "Orders", "schema": "public",
|
||||
"db_id": 1, "description": "...",
|
||||
"fields": [{"id": 42, "name": "created_at", "display_name": "Created At",
|
||||
"base_type": "type/DateTime", "semantic_type": null,
|
||||
"fk_target_field_id": null}]}
|
||||
```
|
||||
|
||||
Key field properties:
|
||||
- `base_type`: `type/Integer`, `type/Text`, `type/DateTime`, `type/Float`, `type/Boolean`
|
||||
- `semantic_type`: `type/FK` (foreign key), `type/PK` (primary key), `type/Email`, etc.
|
||||
- `fk_target_field_id`: non-null for foreign keys, points to the target field
|
||||
|
||||
This command tells you what field names to use in segments, questions, and dashboard filter targets.
|
||||
|
||||
## search
|
||||
|
||||
Free-text search across all Metabase content.
|
||||
|
||||
```bash
|
||||
./metabase-agent search "revenue" --models table,card,dashboard
|
||||
```
|
||||
|
||||
`--models` values: `table`, `dashboard`, `card`, `metric`, `segment`
|
||||
|
||||
Output shape:
|
||||
```json
|
||||
{"results": [{"id": 5, "name": "Revenue Report", "model": "card",
|
||||
"description": "...", "database_id": 1, "table_id": 5}],
|
||||
"_meta": {"total_count": 3}}
|
||||
```
|
||||
|
||||
Use to find existing content before creating duplicates.
|
||||
|
||||
## get-field
|
||||
|
||||
Get full field metadata including statistical fingerprint.
|
||||
|
||||
```bash
|
||||
./metabase-agent get-field 42
|
||||
```
|
||||
|
||||
Output shape:
|
||||
```json
|
||||
{"id": 42, "name": "total", "display_name": "Total", "table_id": 5,
|
||||
"base_type": "type/Float", "semantic_type": null,
|
||||
"has_field_values": "none",
|
||||
"fingerprint": {
|
||||
"distinct_count": 200, "null_percent": 0.0,
|
||||
"min": 12.5, "max": 1250.0, "avg": 89.43, "q1": 35.0, "q3": 120.0, "sd": 75.2
|
||||
}}
|
||||
```
|
||||
|
||||
Fingerprint content depends on field type:
|
||||
- **Numbers**: `min`, `max`, `avg`, `q1`, `q3`, `sd` (standard deviation), `distinct_count`, `null_percent`
|
||||
- **Dates**: `earliest`, `latest`, `distinct_count`, `null_percent`
|
||||
- **Text**: `average_length`, `distinct_count`, `null_percent`
|
||||
|
||||
Use to understand value ranges before building filters (e.g., knowing the min/max of a price field).
|
||||
|
||||
## get-field-values
|
||||
|
||||
Get distinct values stored in a field. Essential for building correct filters.
|
||||
|
||||
```bash
|
||||
./metabase-agent get-field-values 50
|
||||
```
|
||||
|
||||
Output shape:
|
||||
```json
|
||||
{"field_id": 50, "values": ["cancelled", "completed", "pending", "shipped"], "has_more_values": false}
|
||||
```
|
||||
|
||||
Use this to discover valid filter values. For example, before writing `(filter (= (field "ORDERS" "STATUS") "completed"))`, run `get-field-values` on the status field to see all possible values.
|
||||
|
||||
Notes:
|
||||
- Only works for fields with `has_field_values: "list"` (typically low-cardinality fields like status, category, etc.)
|
||||
- For high-cardinality fields (IDs, timestamps), returns empty values — use `get-field` fingerprint instead for range info
|
||||
- If a field has remapped display values, returns `[original_value, display_name]` pairs
|
||||
|
||||
## ping
|
||||
|
||||
Health check. No arguments.
|
||||
|
||||
```bash
|
||||
./metabase-agent ping
|
||||
```
|
||||
|
||||
## Discovery Workflow
|
||||
|
||||
```
|
||||
1. ./metabase-agent list-databases --include tables --fields id,name
|
||||
--> Pick database_id, note table names
|
||||
|
||||
2. ./metabase-agent get-table <table_id>
|
||||
--> Learn field names, types, relationships
|
||||
|
||||
3. ./metabase-agent get-field-values <field_id>
|
||||
--> Discover valid values for categorical fields (status, type, category)
|
||||
|
||||
4. ./metabase-agent get-field <field_id>
|
||||
--> Check value ranges for numeric/date fields (min, max, avg, date range)
|
||||
|
||||
5. ./metabase-agent search "keyword" --models card,dashboard
|
||||
--> Check if the analysis already exists
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `get-database` returns ALL tables. For large databases (100+ tables), use `list-databases --include tables` then `get-table` for specific tables.
|
||||
- Search results are capped server-side. Use `--models` to narrow results.
|
||||
- Table IDs, database IDs, and field IDs are stable. You can reference them across commands.
|
||||
164
agent-toolkit/.claude/skills/metabase-agent-workflow/SKILL.md
Normal file
164
agent-toolkit/.claude/skills/metabase-agent-workflow/SKILL.md
Normal file
@@ -0,0 +1,164 @@
|
||||
---
|
||||
name: metabase-agent-workflow
|
||||
description: Orchestrates the Metabase agent CLI workflow for building analytics from raw data. Use when the goal involves discovering databases, creating transforms, defining questions or segments, or building dashboards.
|
||||
---
|
||||
|
||||
# Metabase Agent Workflow
|
||||
|
||||
Build analytics from raw data in four phases:
|
||||
|
||||
```
|
||||
1. Discover --> 2. Model --> 3. Define --> 4. Visualize
|
||||
```
|
||||
|
||||
| Phase | What it does | What it produces |
|
||||
|-------|-------------|-----------------|
|
||||
| Discover | Explore databases, tables, fields | database_id, table_id, field names |
|
||||
| Model | Clean/join/aggregate data with MBQL or SQL transforms | New tables in the database |
|
||||
| Define | Save MBQL (preferred) or SQL as questions with charts; create segments, measures, metrics | card_id values for dashboards |
|
||||
| Visualize | Build dashboards with cards and filters | Stakeholder-ready dashboards |
|
||||
|
||||
## Command Reference
|
||||
|
||||
| Goal | Command | Phase |
|
||||
|------|---------|-------|
|
||||
| Find databases and tables | `list-databases --include tables` | Discover |
|
||||
| Inspect table fields and types | `get-table <id>` | Discover |
|
||||
| Search existing content | `search "query" --models table,card` | Discover |
|
||||
| Full database schema | `get-database <id>` | Discover |
|
||||
| Field metadata + fingerprint | `get-field <id>` | Discover |
|
||||
| Distinct values for a field | `get-field-values <id>` | Discover |
|
||||
| Create MBQL/SQL transform | `create-transform --json '{...}'` | Model |
|
||||
| Inspect transform | `get-transform <id>` | Model |
|
||||
| List transforms with status | `list-transforms` | Model |
|
||||
| Check transform run | `get-transform-run <run_id>` | Model |
|
||||
| Construct MBQL query | `construct-query --database-id <id> --clj '<code>'` | Define |
|
||||
| Construct + run MBQL | `construct-query --database-id <id> --run --clj '<code>'` | Define |
|
||||
| Save MBQL/SQL as a chart | `create-question --json '{...}'` | Define |
|
||||
| Create reusable filter | `create-segment --json '{...}'` | Define |
|
||||
| Create reusable aggregation | `create-measure --json '{...}'` | Define |
|
||||
| List all measures | `list-measures` | Define |
|
||||
| Get measure details | `get-measure <id>` | Define |
|
||||
| Update a measure | `update-measure <id> --json '{...}'` | Define |
|
||||
| Run ad-hoc MBQL or SQL | `execute-query --json '{...}'` | Define |
|
||||
| Run saved question | `run-question <id>` | Define |
|
||||
| Build dashboard with cards | `create-dashboard --json '{...}'` | Visualize |
|
||||
| Add card to dashboard | `add-card-to-dashboard --json '{...}'` | Visualize |
|
||||
| View dashboard layout | `get-dashboard <id>` | Visualize |
|
||||
|
||||
## Essential Rules
|
||||
|
||||
- **Always use MBQL** for queries. MBQL questions support drill-down, auto-filters, column-level metadata, and cross-database compatibility that SQL questions cannot provide. Use SQL only when MBQL genuinely can't express the query (e.g., CTEs, window functions, database-specific syntax).
|
||||
- All mutations use `--json '<payload>'` with simplified JSON (not raw API format)
|
||||
- Field names (like `"created_at"`) are resolved to numeric IDs automatically -- never look up IDs manually
|
||||
- Queries use MBQL constructed via `construct-query` (preferred) or native SQL (fallback)
|
||||
- `construct-query --run` lets you construct and execute MBQL in one step (no need to pipe through `execute-query`)
|
||||
- In MBQL, `table` and `field` accept both string names and numeric IDs: `(table "ORDERS")` or `(table 42)`
|
||||
- `field` with one argument searches all tables: `(field "total")` — use two args if ambiguous
|
||||
- Use `--dry-run` before mutations to preview API calls
|
||||
- Use `--fields id,name` to keep responses compact
|
||||
- Use `./metabase-agent schema <command>` to see exact JSON Schema for any command
|
||||
|
||||
## End-to-End Example (MBQL — recommended)
|
||||
|
||||
```bash
|
||||
# 1. Discover: find the database and inspect tables
|
||||
./metabase-agent list-databases --include tables --fields id,name
|
||||
./metabase-agent get-table 5
|
||||
|
||||
# 2. Explore: test an MBQL query ad-hoc (construct + execute in one step)
|
||||
./metabase-agent construct-query --database-id 1 --run --clj '
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))'
|
||||
# --> returns query results (columns, rows)
|
||||
|
||||
# 3. Define: construct MBQL (without --run) to get the query JSON
|
||||
./metabase-agent construct-query --database-id 1 --clj '
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))'
|
||||
# --> returns {"database": 1, "type": "query", "query": {...}}
|
||||
|
||||
# 4. Define: create question with MBQL result
|
||||
./metabase-agent create-question --json '{
|
||||
"name": "Revenue by Month",
|
||||
"database_id": 1,
|
||||
"query": <output from construct-query>,
|
||||
"display": "line",
|
||||
"visualization": {"x_axis": ["CREATED_AT"], "y_axis": ["sum"]}
|
||||
}'
|
||||
|
||||
# 5. Define: create a reusable measure
|
||||
./metabase-agent construct-query --database-id 1 --clj '
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL"))))'
|
||||
./metabase-agent create-measure --json '{
|
||||
"name": "Total Revenue",
|
||||
"table_id": 5,
|
||||
"definition": <output from construct-query>
|
||||
}'
|
||||
|
||||
# 6. Visualize: build a dashboard
|
||||
./metabase-agent create-dashboard --json '{
|
||||
"name": "Sales Dashboard",
|
||||
"cards": [{"card_id": 42, "width": 24, "height": 6}]
|
||||
}'
|
||||
```
|
||||
|
||||
## End-to-End Example (SQL — use only when MBQL can't express the query)
|
||||
|
||||
```bash
|
||||
# 1. Discover: find the database and tables
|
||||
./metabase-agent list-databases --include tables --fields id,name
|
||||
|
||||
# 2. Discover: inspect the orders table
|
||||
./metabase-agent get-table 5
|
||||
|
||||
# 3. Define: create a chart question
|
||||
./metabase-agent create-question --json '{
|
||||
"name": "Revenue by Month",
|
||||
"database_id": 1,
|
||||
"sql": "SELECT DATE_TRUNC('"'"'month'"'"', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY 1 ORDER BY 1",
|
||||
"display": "line",
|
||||
"visualization": {"x_axis": ["month"], "y_axis": ["revenue"]}
|
||||
}'
|
||||
# --> returns {"id": 42, ...}
|
||||
|
||||
# 3b. Verify: run the question and check sample results
|
||||
./metabase-agent run-question 42
|
||||
|
||||
# 4. Visualize: build a dashboard
|
||||
./metabase-agent create-dashboard --json '{
|
||||
"name": "Sales Dashboard",
|
||||
"cards": [{"card_id": 42, "width": 24, "height": 6}],
|
||||
"filters": [{"name": "Date", "type": "date/range",
|
||||
"targets": [{"card_id": 42, "field": "month"}]}]
|
||||
}'
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
```
|
||||
- [ ] Discover: identify database_id and table_ids
|
||||
- [ ] Discover: inspect field names and types with get-table
|
||||
- [ ] Discover: check field values (get-field-values) for filter fields
|
||||
- [ ] (Optional) Model: create-transform if raw data needs cleaning
|
||||
- [ ] Define: construct-query for MBQL (preferred) — use SQL only if MBQL can't express it
|
||||
- [ ] Define: create-question for each visualization needed
|
||||
- [ ] Define: (optional) create-segment for reusable filters
|
||||
- [ ] Define: (optional) create-measure for reusable aggregations
|
||||
- [ ] Define: (optional) create metric with type: "metric"
|
||||
- [ ] Visualize: create-dashboard with card_ids from above
|
||||
- [ ] Visualize: wire dashboard filters to card fields
|
||||
```
|
||||
|
||||
## Detailed Skill References
|
||||
|
||||
For complete JSON formats, examples, and gotchas for each command group:
|
||||
|
||||
- [Discovering Data](./../discovering-data/SKILL.md) -- databases, tables, search
|
||||
- [Modeling Transforms](./../modeling-transforms/SKILL.md) -- MBQL/SQL transforms
|
||||
- [Creating Questions](./../creating-questions/SKILL.md) -- questions, segments, measures, queries, MBQL construction
|
||||
- [Building Dashboards](./../building-dashboards/SKILL.md) -- dashboards, cards, filters
|
||||
- [Common Patterns](./../_shared/common-patterns.md) -- setup, global flags, errors
|
||||
195
agent-toolkit/.claude/skills/modeling-transforms/SKILL.md
Normal file
195
agent-toolkit/.claude/skills/modeling-transforms/SKILL.md
Normal file
@@ -0,0 +1,195 @@
|
||||
---
|
||||
name: modeling-transforms
|
||||
description: Creates and manages MBQL or SQL transforms that materialize query results into database tables. Use when raw data needs cleaning, joining, or restructuring before building questions and dashboards.
|
||||
---
|
||||
|
||||
# Modeling Transforms
|
||||
|
||||
Transforms run MBQL (preferred) or SQL queries and write the results to a new database table. Use them when raw tables need cleaning, joining, or aggregation before downstream analysis. Use SQL only when MBQL can't express the query.
|
||||
|
||||
Do NOT use transforms for: one-off queries (use `execute-query`) or saved questions that just need a chart (use `create-question`).
|
||||
|
||||
@./../_shared/mbql-construction.md
|
||||
|
||||
## create-transform
|
||||
|
||||
```bash
|
||||
./metabase-agent create-transform --json '<payload>'
|
||||
```
|
||||
|
||||
### MBQL transform (preferred)
|
||||
|
||||
First use `construct-query` to build the MBQL query, then pass the output as the `query` field:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Clean Orders",
|
||||
"database_id": 1,
|
||||
"query": {"source-table": 5, "filter": ["!=", ["field", 50, null], "cancelled"]},
|
||||
"target_table": "clean_orders",
|
||||
"target_schema": "transforms",
|
||||
"description": "Orders excluding cancelled",
|
||||
"run": true
|
||||
}
|
||||
```
|
||||
|
||||
### SQL transform (use only when MBQL can't express the query)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Clean Orders",
|
||||
"database_id": 1,
|
||||
"sql": "SELECT * FROM orders WHERE status != 'cancelled'",
|
||||
"target_table": "clean_orders",
|
||||
"target_schema": "transforms",
|
||||
"target_database_id": 2,
|
||||
"description": "Orders excluding cancelled",
|
||||
"run": true
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `name` | yes | Transform name |
|
||||
| `database_id` | yes | Source database ID |
|
||||
| `sql` | one of sql/query | SQL query for the transform |
|
||||
| `query` | one of sql/query | MBQL query object from `construct-query` |
|
||||
| `target_table` | yes | Output table name |
|
||||
| `target_schema` | no | Schema for output table |
|
||||
| `target_database_id` | no | Defaults to `database_id` |
|
||||
| `description` | no | Description |
|
||||
| `run` | no | Execute immediately after creation |
|
||||
|
||||
Exactly one of `sql` or `query` must be provided.
|
||||
|
||||
### Example: MBQL transform (recommended)
|
||||
|
||||
```bash
|
||||
# 1. Construct the MBQL query
|
||||
./metabase-agent construct-query --database-id 1 --clj '
|
||||
(-> (query (table "ORDERS"))
|
||||
(aggregate (sum (field "ORDERS" "TOTAL")))
|
||||
(breakout (with-temporal-bucket (field "ORDERS" "CREATED_AT") :month)))'
|
||||
|
||||
# 2. Use the output as the query field
|
||||
./metabase-agent create-transform --json '{
|
||||
"name": "Monthly Revenue",
|
||||
"database_id": 1,
|
||||
"query": <output from construct-query>,
|
||||
"target_table": "monthly_revenue",
|
||||
"target_schema": "transforms",
|
||||
"run": true
|
||||
}'
|
||||
```
|
||||
|
||||
### Example: SQL transform (fallback)
|
||||
|
||||
```bash
|
||||
./metabase-agent create-transform --json '{
|
||||
"name": "Monthly Revenue",
|
||||
"database_id": 1,
|
||||
"sql": "SELECT DATE_TRUNC('"'"'month'"'"', created_at) AS month, SUM(total) AS revenue FROM orders GROUP BY 1",
|
||||
"target_table": "monthly_revenue",
|
||||
"target_schema": "transforms",
|
||||
"run": true
|
||||
}'
|
||||
```
|
||||
|
||||
Output when `run: true`:
|
||||
```json
|
||||
{"transform": {"id": 15, "name": "Monthly Revenue"},
|
||||
"run": {"status": "succeeded", "run_id": 123}}
|
||||
```
|
||||
|
||||
When `run: true`, the CLI polls until completion (up to 5 minutes) or returns `polling_timeout`.
|
||||
|
||||
Output when `run: false` or omitted:
|
||||
```json
|
||||
{"id": 15, "name": "Monthly Revenue", "source_type": "query"}
|
||||
```
|
||||
|
||||
## get-transform
|
||||
|
||||
Get a transform's full definition including its query, target table, and last run status.
|
||||
|
||||
```bash
|
||||
./metabase-agent get-transform <id>
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"id": 15,
|
||||
"name": "Clean Orders",
|
||||
"description": "Orders excluding cancelled",
|
||||
"database_id": 1,
|
||||
"sql": "SELECT * FROM orders WHERE status != 'cancelled'",
|
||||
"target_table": "clean_orders",
|
||||
"target_schema": "transforms",
|
||||
"last_run_status": "succeeded",
|
||||
"last_run_at": "2024-12-01T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Use this to inspect an existing transform's query before updating it.
|
||||
|
||||
## list-transforms
|
||||
|
||||
```bash
|
||||
./metabase-agent list-transforms
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
[{"id": 15, "name": "Clean Orders", "source_type": "query", "last_run_status": "succeeded"}]
|
||||
```
|
||||
|
||||
`last_run_status` values: `succeeded`, `failed`, `running`, `null` (never run).
|
||||
|
||||
## get-transform-run
|
||||
|
||||
```bash
|
||||
./metabase-agent get-transform-run <run_id>
|
||||
```
|
||||
|
||||
Check status of a specific run. Terminal statuses: `succeeded`, `failed`, `timeout`, `canceled`.
|
||||
|
||||
## update-transform
|
||||
|
||||
```bash
|
||||
./metabase-agent update-transform <id> --json '<payload>'
|
||||
```
|
||||
|
||||
### Update with MBQL (preferred)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Updated Name",
|
||||
"query": {"source-table": 5, "filter": ["!=", ["field", 50, null], "cancelled"]},
|
||||
"database_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Update with SQL (fallback)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Updated Name",
|
||||
"description": "New description",
|
||||
"sql": "SELECT ...",
|
||||
"database_id": 1,
|
||||
"target_table": "new_table_name",
|
||||
"target_schema": "new_schema"
|
||||
}
|
||||
```
|
||||
|
||||
All fields are optional. If updating `sql` or `query`, you MUST include `database_id`.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- SQL strings with single quotes need shell escaping: `'"'"'` or `'\''`
|
||||
- `target_table` creates a real table in the target database. Choose names carefully.
|
||||
- After a transform runs, the output table appears in `list-databases --include tables`
|
||||
- Transforms are independent of questions. A question can query the transform's output table using MBQL or SQL.
|
||||
- Transform runs can take time. The `--run` flag polls automatically, but long-running transforms may hit the 5-minute polling timeout. Use `get-transform-run` to check afterward.
|
||||
- **Always prefer MBQL transforms** — they benefit from type safety, field resolution, and cross-database compatibility. Use SQL only for queries MBQL can't express (CTEs, window functions, database-specific syntax).
|
||||
4
agent-toolkit/.gitignore
vendored
Normal file
4
agent-toolkit/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
lib/cljs/
|
||||
*.tsbuildinfo
|
||||
79
agent-toolkit/CHANGE_REQUEST.md
Normal file
79
agent-toolkit/CHANGE_REQUEST.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Agent Toolkit CLI — Change Requests
|
||||
|
||||
## Round 2: Shopify Dashboard (MBQL-based)
|
||||
|
||||
### Context
|
||||
|
||||
An AI agent built a 10-card Shopify overview dashboard using MBQL queries via `construct-query`. Many issues from Round 1 have been fixed (single-arity `field`, `asc`/`desc` helpers, `--run` flag, skill docs, MBQL examples in `create-question`). Two new issues were encountered.
|
||||
|
||||
### Issue 1: `field` two-arg form doesn't accept numeric table IDs
|
||||
|
||||
**What happened**: After discovering tables with `list-databases --include tables` (which returns `{"id": 142, "name": "int_shopify_order_facts", ...}`), I tried using the table ID in `field`:
|
||||
|
||||
```clojure
|
||||
(field 142 "is_cancelled")
|
||||
;; Error: "Field not found: 142.is_cancelled"
|
||||
```
|
||||
|
||||
Had to switch to string table names:
|
||||
```clojure
|
||||
(field "int_shopify_order_facts" "is_cancelled") ;; works
|
||||
```
|
||||
|
||||
**Why this matters**: The discovery phase returns numeric IDs. The `table` helper accepts both `(table 142)` and `(table "int_shopify_order_facts")`, but `field` only accepts `(field "table_name" "field_name")` in two-arg form. This is inconsistent — if `table` accepts IDs, `field` should too.
|
||||
|
||||
**Suggested fix**: Support `(field <table-id> "field_name")` when the first argument is a number:
|
||||
```clojure
|
||||
(field 142 "is_cancelled") ;; resolve table by ID 142, then find "is_cancelled" in it
|
||||
```
|
||||
|
||||
### Issue 2: `order-by` fails with multiple same-operator aggregations on different fields
|
||||
|
||||
**What happened**: A query with two `sum` aggregations on different fields fails when trying to sort by one of them:
|
||||
|
||||
```clojure
|
||||
(-> (query (table "int_shopify_order_line_facts"))
|
||||
(aggregate (sum (field "int_shopify_order_line_facts" "line_net_amount")))
|
||||
(aggregate (sum (field "int_shopify_order_line_facts" "quantity")))
|
||||
(breakout (field "int_shopify_order_line_facts" "product_title"))
|
||||
(order-by (desc (sum (field "int_shopify_order_line_facts" "line_net_amount"))))
|
||||
(limit 10))
|
||||
;; Error: "Ambiguous: multiple 'sum' aggregations found.
|
||||
;; Use orderable-columns to pick the specific column."
|
||||
```
|
||||
|
||||
**Why this matters**: The `order-by` expression includes the full field reference `(sum (field ... "line_net_amount"))`, which should be enough to identify which `sum` to sort by. The two aggregations operate on different fields (`line_net_amount` vs `quantity`), so they're distinguishable.
|
||||
|
||||
The workaround was to drop the second aggregation entirely, losing useful data from the visualization. I wanted "Top 10 Products" showing both revenue and units sold, sorted by revenue — but had to show revenue only.
|
||||
|
||||
**Suggested fix**: When matching `order-by` expressions to aggregations, compare the inner field reference — not just the operator name. `["sum", ["field", 2649, ...]]` and `["sum", ["field", 2656, ...]]` are different aggregations and should be disambiguated by their field IDs. Only error when truly ambiguous (same operator on the same field).
|
||||
|
||||
### Priority
|
||||
|
||||
| Issue | Impact | Effort |
|
||||
|-------|--------|--------|
|
||||
| `field` two-arg with numeric table ID | **Medium** — inconsistent with `table`, forces name lookup | Low |
|
||||
| `order-by` disambiguation for same-operator aggregations | **High** — forces dropping aggregations as workaround | Medium |
|
||||
|
||||
---
|
||||
|
||||
## Round 1: Stripe Dashboard (historical, most issues now resolved)
|
||||
|
||||
### Context
|
||||
|
||||
An AI agent attempted to build a full Stripe performance dashboard using MBQL queries via `construct-query`. While the task was completed, there were significant friction points that required reading source code, trial-and-error, and falling back to SQL for queries that should be expressible in MBQL.
|
||||
|
||||
### Issues (resolved)
|
||||
|
||||
The following issues from Round 1 have been addressed:
|
||||
|
||||
- ~~`asc`/`desc` helpers missing~~ — Added
|
||||
- ~~No skill doc for `construct-query`~~ — MBQL construction reference now exists
|
||||
- ~~Single-arity `field` not supported~~ — `(field "name")` now works (errors if ambiguous)
|
||||
- ~~No `--run` flag for `construct-query`~~ — Added
|
||||
- ~~`create-question` skill only showed SQL~~ — MBQL examples now documented
|
||||
- ~~Aggregation column naming undocumented~~ — Naming convention now in docs
|
||||
|
||||
### Issues (still open from Round 1)
|
||||
|
||||
- **Accept numeric IDs in `table`/`field`**: `table` now accepts IDs, but `field` two-arg form does not (see Round 2 Issue 1 above)
|
||||
44
agent-toolkit/dist/index.d.ts
vendored
Normal file
44
agent-toolkit/dist/index.d.ts
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Typed API client. We use openapi-fetch for path/method safety,
|
||||
* but relax params with `as any` since the generated spec marks many
|
||||
* optional query params as required (a quirk of Metabase's spec generation).
|
||||
*
|
||||
* All methods return `{ data }` where data is cast to the expected type.
|
||||
*/
|
||||
interface MetabaseClient {
|
||||
GET: (path: string, opts?: {
|
||||
params?: Record<string, unknown>;
|
||||
}) => Promise<{
|
||||
data: unknown;
|
||||
}>;
|
||||
POST: (path: string, opts?: {
|
||||
params?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
}) => Promise<{
|
||||
data: unknown;
|
||||
}>;
|
||||
PUT: (path: string, opts?: {
|
||||
params?: Record<string, unknown>;
|
||||
body?: unknown;
|
||||
}) => Promise<{
|
||||
data: unknown;
|
||||
}>;
|
||||
DELETE: (path: string, opts?: {
|
||||
params?: Record<string, unknown>;
|
||||
}) => Promise<{
|
||||
data: unknown;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface OutputOptions {
|
||||
fields?: string[];
|
||||
maxRows?: number;
|
||||
}
|
||||
|
||||
interface GlobalContext {
|
||||
client: MetabaseClient;
|
||||
outputOpts: OutputOptions;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
export type { GlobalContext };
|
||||
1377
agent-toolkit/dist/index.js
vendored
Executable file
1377
agent-toolkit/dist/index.js
vendored
Executable file
File diff suppressed because it is too large
Load Diff
1
agent-toolkit/dist/index.js.map
vendored
Normal file
1
agent-toolkit/dist/index.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
2
agent-toolkit/metabase-agent
Executable file
2
agent-toolkit/metabase-agent
Executable file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node --import tsx
|
||||
import "./src/index.ts";
|
||||
2460
agent-toolkit/package-lock.json
generated
Normal file
2460
agent-toolkit/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
agent-toolkit/package.json
Normal file
39
agent-toolkit/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@metabase/agent-toolkit",
|
||||
"version": "0.1.0",
|
||||
"description": "Agent-friendly CLI for Metabase — intent-oriented commands for discovering data, creating transforms, building dashboards",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"metabase-agent": "./dist/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build:cljs": "cd .. && npx shadow-cljs release agent",
|
||||
"build": "tsup",
|
||||
"dev": "tsx src/index.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"generate-types": "node scripts/fix-openapi.mjs && openapi-typescript openapi_fixed.json -o src/core/api-types.ts && rm openapi_fixed.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^13.1.0",
|
||||
"openapi-fetch": "^0.13.0",
|
||||
"zod": "^3.24.0",
|
||||
"zod-to-json-schema": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"openapi-typescript": "^7.6.0",
|
||||
"tsup": "^8.4.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0",
|
||||
"@types/node": "^22.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"SKILL.md"
|
||||
]
|
||||
}
|
||||
26
agent-toolkit/scripts/fix-openapi.mjs
Normal file
26
agent-toolkit/scripts/fix-openapi.mjs
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Fixes known issues in the Metabase OpenAPI spec that prevent
|
||||
* openapi-typescript from generating types.
|
||||
*
|
||||
* Known issues:
|
||||
* - Schema property key "nil%" contains a bare % that breaks decodeURIComponent
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const inputPath = "../openapi_export.json";
|
||||
const outputPath = "openapi_fixed.json";
|
||||
|
||||
const spec = JSON.parse(readFileSync(inputPath, "utf8"));
|
||||
|
||||
// Fix: rename "nil%" property key to "nil_pct"
|
||||
const schema =
|
||||
spec.components?.schemas?.[
|
||||
"metabase.lib.schema.metadata.fingerprint..fingerprint.global"
|
||||
];
|
||||
if (schema?.properties?.["nil%"]) {
|
||||
schema.properties["nil_pct"] = schema.properties["nil%"];
|
||||
delete schema.properties["nil%"];
|
||||
}
|
||||
|
||||
writeFileSync(outputPath, JSON.stringify(spec));
|
||||
console.log("Fixed OpenAPI spec written to", outputPath);
|
||||
60
agent-toolkit/src/__tests__/core/auth.test.ts
Normal file
60
agent-toolkit/src/__tests__/core/auth.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { resolveAuth, getAuthHeaders } from "../../core/auth.js";
|
||||
|
||||
describe("resolveAuth", () => {
|
||||
const origEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.METABASE_API_KEY;
|
||||
delete process.env.METABASE_SESSION_TOKEN;
|
||||
});
|
||||
afterEach(() => {
|
||||
process.env = { ...origEnv };
|
||||
});
|
||||
|
||||
it("uses apiKey from opts", () => {
|
||||
const auth = resolveAuth({ apiKey: "test-key" });
|
||||
expect(auth.apiKey).toBe("test-key");
|
||||
});
|
||||
|
||||
it("uses apiKey from env", () => {
|
||||
process.env.METABASE_API_KEY = "env-key";
|
||||
const auth = resolveAuth({});
|
||||
expect(auth.apiKey).toBe("env-key");
|
||||
});
|
||||
|
||||
it("uses sessionToken from opts", () => {
|
||||
const auth = resolveAuth({ sessionToken: "tok" });
|
||||
expect(auth.sessionToken).toBe("tok");
|
||||
});
|
||||
|
||||
it("throws when no auth provided", () => {
|
||||
expect(() => resolveAuth({})).toThrow("Authentication required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAuthHeaders", () => {
|
||||
it("returns X-Api-Key header", () => {
|
||||
expect(getAuthHeaders({ apiKey: "key123" })).toEqual({
|
||||
"X-Api-Key": "key123",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns X-Metabase-Session header", () => {
|
||||
expect(getAuthHeaders({ sessionToken: "sess" })).toEqual({
|
||||
"X-Metabase-Session": "sess",
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers apiKey over sessionToken", () => {
|
||||
const headers = getAuthHeaders({
|
||||
apiKey: "key",
|
||||
sessionToken: "sess",
|
||||
});
|
||||
expect(headers).toEqual({ "X-Api-Key": "key" });
|
||||
});
|
||||
|
||||
it("returns empty when no auth", () => {
|
||||
expect(getAuthHeaders({})).toEqual({});
|
||||
});
|
||||
});
|
||||
105
agent-toolkit/src/__tests__/core/field-resolver.test.ts
Normal file
105
agent-toolkit/src/__tests__/core/field-resolver.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { FieldResolver } from "../../core/field-resolver.js";
|
||||
import type { MetabaseClient } from "../../core/client.js";
|
||||
import { CliError } from "../../core/validation.js";
|
||||
|
||||
function mockClient(
|
||||
fields: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
display_name: string;
|
||||
base_type: string;
|
||||
semantic_type: string | null;
|
||||
}>,
|
||||
): MetabaseClient {
|
||||
return {
|
||||
GET: vi.fn().mockResolvedValue({
|
||||
data: { fields },
|
||||
}),
|
||||
POST: vi.fn(),
|
||||
PUT: vi.fn(),
|
||||
DELETE: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("FieldResolver", () => {
|
||||
const fields = [
|
||||
{
|
||||
id: 10,
|
||||
name: "created_at",
|
||||
display_name: "Created At",
|
||||
base_type: "type/DateTime",
|
||||
semantic_type: null,
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
name: "total",
|
||||
display_name: "Total Amount",
|
||||
base_type: "type/Float",
|
||||
semantic_type: null,
|
||||
},
|
||||
];
|
||||
|
||||
it("passes through numeric IDs", async () => {
|
||||
const client = mockClient(fields);
|
||||
const resolver = new FieldResolver(client);
|
||||
expect(await resolver.resolve(1, 42)).toBe(42);
|
||||
expect(client.GET).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes through string numeric IDs", async () => {
|
||||
const client = mockClient(fields);
|
||||
const resolver = new FieldResolver(client);
|
||||
expect(await resolver.resolve(1, "42")).toBe(42);
|
||||
expect(client.GET).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves by field name", async () => {
|
||||
const client = mockClient(fields);
|
||||
const resolver = new FieldResolver(client);
|
||||
expect(await resolver.resolve(1, "created_at")).toBe(10);
|
||||
});
|
||||
|
||||
it("resolves by display name", async () => {
|
||||
const client = mockClient(fields);
|
||||
const resolver = new FieldResolver(client);
|
||||
expect(await resolver.resolve(1, "Total Amount")).toBe(20);
|
||||
});
|
||||
|
||||
it("resolves case-insensitively", async () => {
|
||||
const client = mockClient(fields);
|
||||
const resolver = new FieldResolver(client);
|
||||
expect(await resolver.resolve(1, "CREATED_AT")).toBe(10);
|
||||
expect(await resolver.resolve(1, "total amount")).toBe(20);
|
||||
});
|
||||
|
||||
it("throws CliError for unknown field with suggestion", async () => {
|
||||
const client = mockClient(fields);
|
||||
const resolver = new FieldResolver(client);
|
||||
try {
|
||||
await resolver.resolve(1, "created");
|
||||
expect.fail("should have thrown");
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(CliError);
|
||||
const err = e as CliError;
|
||||
expect(err.code).toBe("unknown_field");
|
||||
expect(err.hint).toContain("created_at");
|
||||
}
|
||||
});
|
||||
|
||||
it("caches table metadata", async () => {
|
||||
const client = mockClient(fields);
|
||||
const resolver = new FieldResolver(client);
|
||||
await resolver.resolve(1, "total");
|
||||
await resolver.resolve(1, "created_at");
|
||||
expect(client.GET).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fetches separately for different tables", async () => {
|
||||
const client = mockClient(fields);
|
||||
const resolver = new FieldResolver(client);
|
||||
await resolver.resolve(1, "total");
|
||||
await resolver.resolve(2, "total");
|
||||
expect(client.GET).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
79
agent-toolkit/src/__tests__/core/output.test.ts
Normal file
79
agent-toolkit/src/__tests__/core/output.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
applyFieldMask,
|
||||
applyFieldMaskToArray,
|
||||
truncateRows,
|
||||
formatOutput,
|
||||
} from "../../core/output.js";
|
||||
|
||||
describe("applyFieldMask", () => {
|
||||
it("filters to specified fields", () => {
|
||||
const data = { id: 1, name: "test", secret: "hidden" };
|
||||
expect(applyFieldMask(data, ["id", "name"])).toEqual({
|
||||
id: 1,
|
||||
name: "test",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores non-existent fields", () => {
|
||||
const data = { id: 1 };
|
||||
expect(applyFieldMask(data, ["id", "missing"])).toEqual({ id: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyFieldMaskToArray", () => {
|
||||
it("masks each item", () => {
|
||||
const data = [
|
||||
{ id: 1, name: "a", extra: true },
|
||||
{ id: 2, name: "b", extra: false },
|
||||
];
|
||||
expect(applyFieldMaskToArray(data, ["id", "name"])).toEqual([
|
||||
{ id: 1, name: "a" },
|
||||
{ id: 2, name: "b" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateRows", () => {
|
||||
it("passes through when under limit", () => {
|
||||
const rows = [[1], [2], [3]];
|
||||
const result = truncateRows(rows, 10);
|
||||
expect(result.rows).toEqual(rows);
|
||||
expect(result.meta.truncated).toBe(false);
|
||||
expect(result.meta.total_count).toBe(3);
|
||||
expect(result.meta.returned_count).toBe(3);
|
||||
});
|
||||
|
||||
it("truncates when over limit", () => {
|
||||
const rows = [[1], [2], [3], [4], [5]];
|
||||
const result = truncateRows(rows, 3);
|
||||
expect(result.rows).toEqual([[1], [2], [3]]);
|
||||
expect(result.meta.truncated).toBe(true);
|
||||
expect(result.meta.total_count).toBe(5);
|
||||
expect(result.meta.returned_count).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatOutput", () => {
|
||||
it("outputs JSON", () => {
|
||||
expect(formatOutput({ id: 1 })).toBe('{\n "id": 1\n}');
|
||||
});
|
||||
|
||||
it("applies field mask when opts provided", () => {
|
||||
const result = formatOutput(
|
||||
{ id: 1, name: "test", secret: "x" },
|
||||
{ fields: ["id", "name"] },
|
||||
);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual({ id: 1, name: "test" });
|
||||
});
|
||||
|
||||
it("applies field mask to arrays", () => {
|
||||
const result = formatOutput(
|
||||
[{ id: 1, extra: true }, { id: 2, extra: false }],
|
||||
{ fields: ["id"] },
|
||||
);
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed).toEqual([{ id: 1 }, { id: 2 }]);
|
||||
});
|
||||
});
|
||||
155
agent-toolkit/src/__tests__/core/schemas.test.ts
Normal file
155
agent-toolkit/src/__tests__/core/schemas.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
CreateQuestionInputSchema,
|
||||
CreateDashboardInputSchema,
|
||||
CreateSegmentInputSchema,
|
||||
CreateTransformInputSchema,
|
||||
ExecuteQueryInputSchema,
|
||||
getSchemaForCommand,
|
||||
listSchemaCommands,
|
||||
} from "../../core/schemas.js";
|
||||
|
||||
describe("CreateTransformInputSchema", () => {
|
||||
it("validates minimal input", () => {
|
||||
const result = CreateTransformInputSchema.parse({
|
||||
name: "Clean Orders",
|
||||
database_id: 1,
|
||||
sql: "SELECT * FROM orders",
|
||||
target_table: "clean_orders",
|
||||
});
|
||||
expect(result.name).toBe("Clean Orders");
|
||||
expect(result.run).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects missing required fields", () => {
|
||||
expect(() =>
|
||||
CreateTransformInputSchema.parse({ name: "Test" }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateQuestionInputSchema", () => {
|
||||
it("validates with defaults", () => {
|
||||
const result = CreateQuestionInputSchema.parse({
|
||||
name: "Revenue",
|
||||
database_id: 1,
|
||||
sql: "SELECT 1",
|
||||
});
|
||||
expect(result.display).toBe("table");
|
||||
expect(result.type).toBe("question");
|
||||
});
|
||||
|
||||
it("accepts visualization options", () => {
|
||||
const result = CreateQuestionInputSchema.parse({
|
||||
name: "Revenue",
|
||||
database_id: 1,
|
||||
sql: "SELECT 1",
|
||||
display: "line",
|
||||
visualization: {
|
||||
x_axis: ["month"],
|
||||
y_axis: ["revenue"],
|
||||
show_values: true,
|
||||
},
|
||||
});
|
||||
expect(result.visualization?.x_axis).toEqual(["month"]);
|
||||
});
|
||||
|
||||
it("rejects invalid display type", () => {
|
||||
expect(() =>
|
||||
CreateQuestionInputSchema.parse({
|
||||
name: "Test",
|
||||
database_id: 1,
|
||||
sql: "SELECT 1",
|
||||
display: "invalid_type",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateSegmentInputSchema", () => {
|
||||
it("validates filter input", () => {
|
||||
const result = CreateSegmentInputSchema.parse({
|
||||
name: "Active Users",
|
||||
table_id: 5,
|
||||
filters: [
|
||||
{ field: "last_login", op: "greater-than", value: "2024-01-01" },
|
||||
],
|
||||
});
|
||||
expect(result.filters).toHaveLength(1);
|
||||
expect(result.filters[0].field).toBe("last_login");
|
||||
});
|
||||
|
||||
it("rejects empty filters", () => {
|
||||
expect(() =>
|
||||
CreateSegmentInputSchema.parse({
|
||||
name: "Test",
|
||||
table_id: 5,
|
||||
filters: [],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("CreateDashboardInputSchema", () => {
|
||||
it("validates minimal dashboard", () => {
|
||||
const result = CreateDashboardInputSchema.parse({
|
||||
name: "Sales Dashboard",
|
||||
});
|
||||
expect(result.name).toBe("Sales Dashboard");
|
||||
expect(result.cards).toBeUndefined();
|
||||
});
|
||||
|
||||
it("validates with cards and filters", () => {
|
||||
const result = CreateDashboardInputSchema.parse({
|
||||
name: "Sales",
|
||||
cards: [{ card_id: 42, width: 6, height: 4 }],
|
||||
filters: [
|
||||
{
|
||||
name: "Date Range",
|
||||
type: "date/range",
|
||||
targets: [{ card_id: 42, field: "created_at" }],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.cards).toHaveLength(1);
|
||||
expect(result.filters).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("applies defaults for card dimensions", () => {
|
||||
const result = CreateDashboardInputSchema.parse({
|
||||
name: "Test",
|
||||
cards: [{ card_id: 1 }],
|
||||
});
|
||||
expect(result.cards![0].width).toBe(6);
|
||||
expect(result.cards![0].height).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExecuteQueryInputSchema", () => {
|
||||
it("validates query input", () => {
|
||||
const result = ExecuteQueryInputSchema.parse({
|
||||
database_id: 1,
|
||||
sql: "SELECT COUNT(*) FROM orders",
|
||||
});
|
||||
expect(result.database_id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema introspection", () => {
|
||||
it("lists available schemas", () => {
|
||||
const schemas = listSchemaCommands();
|
||||
expect(schemas).toContain("create-dashboard");
|
||||
expect(schemas).toContain("create-question");
|
||||
expect(schemas).toContain("execute-query");
|
||||
});
|
||||
|
||||
it("returns JSON Schema for known command", () => {
|
||||
const schema = getSchemaForCommand("create-dashboard");
|
||||
expect(schema).toBeDefined();
|
||||
expect(schema).toHaveProperty("definitions");
|
||||
});
|
||||
|
||||
it("returns undefined for unknown command", () => {
|
||||
expect(getSchemaForCommand("nonexistent")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
91
agent-toolkit/src/__tests__/core/validation.test.ts
Normal file
91
agent-toolkit/src/__tests__/core/validation.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
validatePositiveInt,
|
||||
sanitizeString,
|
||||
validateEnum,
|
||||
CliError,
|
||||
} from "../../core/validation.js";
|
||||
|
||||
describe("validatePositiveInt", () => {
|
||||
it("accepts positive integers", () => {
|
||||
expect(validatePositiveInt(1, "id")).toBe(1);
|
||||
expect(validatePositiveInt(42, "id")).toBe(42);
|
||||
});
|
||||
|
||||
it("parses string integers", () => {
|
||||
expect(validatePositiveInt("5", "id")).toBe(5);
|
||||
expect(validatePositiveInt("100", "id")).toBe(100);
|
||||
});
|
||||
|
||||
it("rejects zero", () => {
|
||||
expect(() => validatePositiveInt(0, "id")).toThrow(CliError);
|
||||
});
|
||||
|
||||
it("rejects negative numbers", () => {
|
||||
expect(() => validatePositiveInt(-1, "id")).toThrow(CliError);
|
||||
});
|
||||
|
||||
it("rejects non-integers", () => {
|
||||
expect(() => validatePositiveInt(1.5, "id")).toThrow(CliError);
|
||||
expect(() => validatePositiveInt("abc", "id")).toThrow(CliError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sanitizeString", () => {
|
||||
it("allows normal strings", () => {
|
||||
expect(sanitizeString("hello world")).toBe("hello world");
|
||||
});
|
||||
|
||||
it("allows newlines and tabs", () => {
|
||||
expect(sanitizeString("line1\nline2\ttab")).toBe("line1\nline2\ttab");
|
||||
});
|
||||
|
||||
it("rejects control characters", () => {
|
||||
expect(() => sanitizeString("hello\x00world")).toThrow(CliError);
|
||||
expect(() => sanitizeString("test\x07")).toThrow(CliError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateEnum", () => {
|
||||
const allowed = ["a", "b", "c"] as const;
|
||||
|
||||
it("accepts valid values", () => {
|
||||
expect(validateEnum("a", allowed, "test")).toBe("a");
|
||||
expect(validateEnum("c", allowed, "test")).toBe("c");
|
||||
});
|
||||
|
||||
it("rejects invalid values", () => {
|
||||
expect(() => validateEnum("d", allowed, "test")).toThrow(CliError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CliError", () => {
|
||||
it("serializes to JSON with code and message", () => {
|
||||
const err = new CliError("test_error", { message: "Something broke" });
|
||||
expect(err.toJSON()).toEqual({
|
||||
error: "test_error",
|
||||
message: "Something broke",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes hint when provided", () => {
|
||||
const err = new CliError("test_error", {
|
||||
message: "Not found",
|
||||
hint: "Try this instead",
|
||||
});
|
||||
expect(err.toJSON()).toEqual({
|
||||
error: "test_error",
|
||||
message: "Not found",
|
||||
hint: "Try this instead",
|
||||
});
|
||||
});
|
||||
|
||||
it("spreads details into JSON", () => {
|
||||
const err = new CliError("test_error", {
|
||||
message: "Not found",
|
||||
details: { available_fields: ["a", "b"] },
|
||||
});
|
||||
const json = err.toJSON();
|
||||
expect(json.available_fields).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
103
agent-toolkit/src/commands/construct-query.ts
Normal file
103
agent-toolkit/src/commands/construct-query.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { Command } from "commander";
|
||||
import type { GlobalContext } from "../index.js";
|
||||
import { printResult, formatQueryResult } from "../core/output.js";
|
||||
import { CliError, validatePositiveInt } from "../core/validation.js";
|
||||
import { fetchMetadataBundle } from "../core/metadata.js";
|
||||
import { loadCljsModule } from "../core/cljs-bridge.js";
|
||||
|
||||
export function registerConstructQueryCommands(
|
||||
program: Command,
|
||||
withContext: (
|
||||
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
|
||||
) => (...args: unknown[]) => Promise<void>,
|
||||
) {
|
||||
program
|
||||
.command("construct-query")
|
||||
.description(
|
||||
"Construct an MBQL query by evaluating Clojure code with metabase.lib functions. Use --run to also execute.",
|
||||
)
|
||||
.requiredOption(
|
||||
"--database-id <id>",
|
||||
"Database ID",
|
||||
)
|
||||
.requiredOption(
|
||||
"--clj <code>",
|
||||
"Clojure code string using metabase.lib functions (query, table, field, filter, aggregate, breakout, etc.)",
|
||||
)
|
||||
.option(
|
||||
"--run",
|
||||
"Execute the constructed query and return results instead of the MBQL JSON",
|
||||
)
|
||||
.action(
|
||||
withContext(async (ctx, opts: unknown) => {
|
||||
const options = opts as {
|
||||
databaseId: string;
|
||||
clj: string;
|
||||
run?: boolean;
|
||||
};
|
||||
const dbId = validatePositiveInt(options.databaseId, "database-id");
|
||||
|
||||
if (ctx.dryRun) {
|
||||
const apiCalls = [
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/database/${dbId}/metadata`,
|
||||
description: "Fetch database metadata (tables + fields)",
|
||||
},
|
||||
];
|
||||
if (options.run) {
|
||||
apiCalls.push({
|
||||
method: "POST",
|
||||
path: "/api/dataset",
|
||||
description: "Execute the constructed MBQL query",
|
||||
});
|
||||
}
|
||||
printResult({
|
||||
dry_run: true,
|
||||
api_calls: apiCalls,
|
||||
clj_code: options.clj,
|
||||
note: options.run
|
||||
? "Code will be evaluated and the query executed"
|
||||
: "Code will be evaluated with metabase.lib functions after metadata fetch",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Load CLJS module
|
||||
const cljs = await loadCljsModule();
|
||||
|
||||
// 2. Fetch metadata from API
|
||||
const metadata = await fetchMetadataBundle(ctx.client, dbId);
|
||||
|
||||
// 3. Evaluate Clojure code
|
||||
let result: unknown;
|
||||
try {
|
||||
result = cljs.evaluate(options.clj, dbId, metadata);
|
||||
} catch (e) {
|
||||
throw new CliError("query_construction_error", {
|
||||
message: e instanceof Error ? e.message : String(e),
|
||||
hint: "Check your Clojure code syntax and function usage. Available functions: query, table, field, filter, aggregate, breakout, order-by, limit, asc, desc, and all metabase.lib operators.",
|
||||
});
|
||||
}
|
||||
|
||||
// 4. If --run, execute the query; otherwise return MBQL JSON
|
||||
if (options.run) {
|
||||
const apiPayload = {
|
||||
...(result as Record<string, unknown>),
|
||||
database: dbId,
|
||||
};
|
||||
|
||||
const { data } = await ctx.client.POST("/api/dataset", {
|
||||
body: apiPayload as never,
|
||||
});
|
||||
|
||||
const maxRows = ctx.outputOpts.maxRows ?? 50;
|
||||
printResult(
|
||||
formatQueryResult(data as Record<string, unknown>, maxRows),
|
||||
);
|
||||
} else {
|
||||
printResult(result);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
434
agent-toolkit/src/commands/dashboard.ts
Normal file
434
agent-toolkit/src/commands/dashboard.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import type { Command } from "commander";
|
||||
import type { GlobalContext } from "../index.js";
|
||||
import { FieldResolver } from "../core/field-resolver.js";
|
||||
import { printResult } from "../core/output.js";
|
||||
import {
|
||||
AddCardToDashboardInputSchema,
|
||||
CreateDashboardInputSchema,
|
||||
} from "../core/schemas.js";
|
||||
import { CliError, validatePositiveInt } from "../core/validation.js";
|
||||
|
||||
const GRID_WIDTH = 24;
|
||||
|
||||
interface CardLayout {
|
||||
card_id: number;
|
||||
width: number;
|
||||
height: number;
|
||||
row: number;
|
||||
col: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-layout cards left-to-right, wrapping at grid width.
|
||||
*/
|
||||
function autoLayout(
|
||||
cards: Array<{
|
||||
card_id: number;
|
||||
width: number;
|
||||
height: number;
|
||||
row?: number;
|
||||
col?: number;
|
||||
}>,
|
||||
startRow = 0,
|
||||
): CardLayout[] {
|
||||
let currentRow = startRow;
|
||||
let currentCol = 0;
|
||||
let maxHeightInRow = 0;
|
||||
|
||||
return cards.map((card) => {
|
||||
// If explicit position given, use it
|
||||
if (card.row !== undefined && card.col !== undefined) {
|
||||
return {
|
||||
card_id: card.card_id,
|
||||
width: card.width,
|
||||
height: card.height,
|
||||
row: card.row,
|
||||
col: card.col,
|
||||
};
|
||||
}
|
||||
|
||||
// Auto-layout: wrap if card doesn't fit
|
||||
if (currentCol + card.width > GRID_WIDTH) {
|
||||
currentRow += maxHeightInRow;
|
||||
currentCol = 0;
|
||||
maxHeightInRow = 0;
|
||||
}
|
||||
|
||||
const layout: CardLayout = {
|
||||
card_id: card.card_id,
|
||||
width: card.width,
|
||||
height: card.height,
|
||||
row: currentRow,
|
||||
col: currentCol,
|
||||
};
|
||||
|
||||
currentCol += card.width;
|
||||
maxHeightInRow = Math.max(maxHeightInRow, card.height);
|
||||
|
||||
return layout;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a field name to a numeric field ID for a given card.
|
||||
* Fetches the card to find its table_id, then resolves the field.
|
||||
*/
|
||||
async function resolveCardField(
|
||||
ctx: GlobalContext,
|
||||
resolver: FieldResolver,
|
||||
cardId: number,
|
||||
fieldName: string,
|
||||
): Promise<number> {
|
||||
// Get the card to find its source table
|
||||
const { data } = await ctx.client.GET(`/api/card/${cardId}`);
|
||||
const card = data as Record<string, unknown>;
|
||||
const query = card.dataset_query as Record<string, unknown>;
|
||||
|
||||
// For native queries, we need the result_metadata to find field IDs
|
||||
// For MBQL queries, we can get the source table
|
||||
if (query?.type === "native") {
|
||||
// For native queries, field mapping works differently
|
||||
// We need to look at result_metadata for column names
|
||||
const resultMeta = card.result_metadata as
|
||||
| Array<Record<string, unknown>>
|
||||
| undefined;
|
||||
if (resultMeta) {
|
||||
const col = resultMeta.find(
|
||||
(c) =>
|
||||
(c.name as string)?.toLowerCase() === fieldName.toLowerCase() ||
|
||||
(c.display_name as string)?.toLowerCase() ===
|
||||
fieldName.toLowerCase(),
|
||||
);
|
||||
if (col?.id && typeof col.id === "number") {
|
||||
return col.id;
|
||||
}
|
||||
}
|
||||
// For native SQL cards, return the field name as-is for dimension target
|
||||
// Dashboard parameter mapping can use column name directly
|
||||
throw new CliError("field_resolution", {
|
||||
message: `Cannot resolve field '${fieldName}' on native SQL card ${cardId}`,
|
||||
hint: "For SQL cards, dashboard filter mapping requires the card to have been run at least once so result metadata is available.",
|
||||
});
|
||||
}
|
||||
|
||||
// MBQL query - get source table
|
||||
const innerQuery = query?.query as Record<string, unknown> | undefined;
|
||||
const tableId = innerQuery?.["source-table"] as number | undefined;
|
||||
if (!tableId) {
|
||||
throw new CliError("field_resolution", {
|
||||
message: `Cannot determine source table for card ${cardId}`,
|
||||
});
|
||||
}
|
||||
|
||||
return resolver.resolve(tableId, fieldName);
|
||||
}
|
||||
|
||||
export function registerDashboardCommands(
|
||||
program: Command,
|
||||
withContext: (
|
||||
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
|
||||
) => (...args: unknown[]) => Promise<void>,
|
||||
) {
|
||||
program
|
||||
.command("create-dashboard")
|
||||
.description(
|
||||
"Create a dashboard with cards and filters in one step",
|
||||
)
|
||||
.requiredOption(
|
||||
"--json <payload>",
|
||||
"JSON input (see: schema create-dashboard)",
|
||||
)
|
||||
.action(
|
||||
withContext(async (ctx, opts: unknown) => {
|
||||
const options = opts as { json: string };
|
||||
const input = CreateDashboardInputSchema.parse(
|
||||
JSON.parse(options.json),
|
||||
);
|
||||
|
||||
// Step 1: Create the dashboard
|
||||
const createPayload = {
|
||||
name: input.name,
|
||||
...(input.description && { description: input.description }),
|
||||
...(input.collection_id && { collection_id: input.collection_id }),
|
||||
};
|
||||
|
||||
// Build parameters from filters
|
||||
const parameters = (input.filters ?? []).map((f) => ({
|
||||
id: randomUUID().slice(0, 8),
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
slug: f.name.toLowerCase().replace(/\s+/g, "_"),
|
||||
}));
|
||||
|
||||
if (ctx.dryRun) {
|
||||
const layouts = autoLayout(
|
||||
(input.cards ?? []).map((c) => ({
|
||||
card_id: c.card_id,
|
||||
width: c.width ?? 6,
|
||||
height: c.height ?? 4,
|
||||
row: c.row,
|
||||
col: c.col,
|
||||
})),
|
||||
);
|
||||
printResult({
|
||||
dry_run: true,
|
||||
api_calls: [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/dashboard",
|
||||
body: { ...createPayload, parameters },
|
||||
},
|
||||
...(input.cards?.length
|
||||
? [
|
||||
{
|
||||
method: "PUT",
|
||||
path: "/api/dashboard/{id}",
|
||||
body: {
|
||||
dashcards: layouts.map((l) => ({
|
||||
id: -1,
|
||||
card_id: l.card_id,
|
||||
size_x: l.width,
|
||||
size_y: l.height,
|
||||
row: l.row,
|
||||
col: l.col,
|
||||
})),
|
||||
parameters,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: dashboard } = await ctx.client.POST("/api/dashboard", {
|
||||
body: { ...createPayload, parameters } as never,
|
||||
});
|
||||
|
||||
const dashboardResult = dashboard as Record<string, unknown>;
|
||||
const dashboardId = dashboardResult.id as number;
|
||||
|
||||
// Step 2: Add cards if provided
|
||||
if (input.cards?.length) {
|
||||
const layouts = autoLayout(
|
||||
input.cards.map((c) => ({
|
||||
card_id: c.card_id,
|
||||
width: c.width ?? 6,
|
||||
height: c.height ?? 4,
|
||||
row: c.row,
|
||||
col: c.col,
|
||||
})),
|
||||
);
|
||||
|
||||
// Resolve filter field mappings
|
||||
const resolver = new FieldResolver(ctx.client);
|
||||
const dashcards = await Promise.all(
|
||||
layouts.map(async (layout, _idx) => {
|
||||
const parameterMappings: Array<Record<string, unknown>> = [];
|
||||
|
||||
// Wire filters to this card
|
||||
if (input.filters) {
|
||||
for (let fi = 0; fi < input.filters.length; fi++) {
|
||||
const filter = input.filters[fi];
|
||||
const target = filter.targets.find(
|
||||
(t) => t.card_id === layout.card_id,
|
||||
);
|
||||
if (target) {
|
||||
try {
|
||||
const fieldId = await resolveCardField(
|
||||
ctx,
|
||||
resolver,
|
||||
target.card_id,
|
||||
target.field,
|
||||
);
|
||||
parameterMappings.push({
|
||||
parameter_id: parameters[fi].id,
|
||||
card_id: layout.card_id,
|
||||
target: ["dimension", ["field", fieldId, null]],
|
||||
});
|
||||
} catch {
|
||||
// For native SQL cards, use column name target
|
||||
parameterMappings.push({
|
||||
parameter_id: parameters[fi].id,
|
||||
card_id: layout.card_id,
|
||||
target: [
|
||||
"dimension",
|
||||
["field", target.field, { "base-type": "type/Text" }],
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: -(layout.card_id), // negative = new dashcard
|
||||
card_id: layout.card_id,
|
||||
size_x: layout.width,
|
||||
size_y: layout.height,
|
||||
row: layout.row,
|
||||
col: layout.col,
|
||||
parameter_mappings: parameterMappings,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
await ctx.client.PUT(`/api/dashboard/${dashboardId}`, {
|
||||
body: { dashcards, parameters } as never,
|
||||
});
|
||||
}
|
||||
|
||||
printResult({
|
||||
id: dashboardId,
|
||||
name: input.name,
|
||||
cards_added: input.cards?.length ?? 0,
|
||||
filters_added: input.filters?.length ?? 0,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
program
|
||||
.command("add-card-to-dashboard")
|
||||
.description("Add a card to an existing dashboard")
|
||||
.requiredOption(
|
||||
"--json <payload>",
|
||||
"JSON input (see: schema add-card-to-dashboard)",
|
||||
)
|
||||
.action(
|
||||
withContext(async (ctx, opts: unknown) => {
|
||||
const options = opts as { json: string };
|
||||
const input = AddCardToDashboardInputSchema.parse(
|
||||
JSON.parse(options.json),
|
||||
);
|
||||
|
||||
// Get existing dashboard
|
||||
const { data: dashboard } = await ctx.client.GET(
|
||||
`/api/dashboard/${input.dashboard_id}`,
|
||||
);
|
||||
|
||||
const dashData = dashboard as Record<string, unknown>;
|
||||
const existingDashcards =
|
||||
(dashData.dashcards as Array<Record<string, unknown>>) ?? [];
|
||||
const existingParams =
|
||||
(dashData.parameters as Array<Record<string, unknown>>) ?? [];
|
||||
|
||||
// Find next available position
|
||||
let maxBottom = 0;
|
||||
for (const dc of existingDashcards) {
|
||||
const bottom =
|
||||
((dc.row as number) ?? 0) + ((dc.size_y as number) ?? 4);
|
||||
maxBottom = Math.max(maxBottom, bottom);
|
||||
}
|
||||
|
||||
// Build parameter mappings
|
||||
const parameterMappings: Array<Record<string, unknown>> = [];
|
||||
if (input.filter_mappings) {
|
||||
const resolver = new FieldResolver(ctx.client);
|
||||
for (const mapping of input.filter_mappings) {
|
||||
const param = existingParams.find(
|
||||
(p) =>
|
||||
(p.name as string)?.toLowerCase() ===
|
||||
mapping.filter_name.toLowerCase(),
|
||||
);
|
||||
if (!param) {
|
||||
throw new CliError("unknown_filter", {
|
||||
message: `Filter '${mapping.filter_name}' not found on dashboard ${input.dashboard_id}`,
|
||||
hint: `Available filters: ${existingParams.map((p) => p.name).join(", ")}`,
|
||||
});
|
||||
}
|
||||
try {
|
||||
const fieldId = await resolveCardField(
|
||||
ctx,
|
||||
resolver,
|
||||
input.card_id,
|
||||
mapping.field,
|
||||
);
|
||||
parameterMappings.push({
|
||||
parameter_id: param.id,
|
||||
card_id: input.card_id,
|
||||
target: ["dimension", ["field", fieldId, null]],
|
||||
});
|
||||
} catch {
|
||||
parameterMappings.push({
|
||||
parameter_id: param.id,
|
||||
card_id: input.card_id,
|
||||
target: [
|
||||
"dimension",
|
||||
["field", mapping.field, { "base-type": "type/Text" }],
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const newDashcard = {
|
||||
id: -(input.card_id),
|
||||
card_id: input.card_id,
|
||||
size_x: input.width ?? 6,
|
||||
size_y: input.height ?? 4,
|
||||
row: maxBottom,
|
||||
col: 0,
|
||||
parameter_mappings: parameterMappings,
|
||||
};
|
||||
|
||||
const allDashcards = [
|
||||
...existingDashcards.map((dc) => ({
|
||||
id: dc.id,
|
||||
card_id: dc.card_id,
|
||||
size_x: dc.size_x,
|
||||
size_y: dc.size_y,
|
||||
row: dc.row,
|
||||
col: dc.col,
|
||||
parameter_mappings: dc.parameter_mappings ?? [],
|
||||
})),
|
||||
newDashcard,
|
||||
];
|
||||
|
||||
await ctx.client.PUT(`/api/dashboard/${input.dashboard_id}`, {
|
||||
body: { dashcards: allDashcards } as never,
|
||||
});
|
||||
|
||||
printResult({
|
||||
dashboard_id: input.dashboard_id,
|
||||
card_id: input.card_id,
|
||||
position: { row: maxBottom, col: 0 },
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
program
|
||||
.command("get-dashboard <id>")
|
||||
.description("Get a dashboard with all its cards")
|
||||
.action(
|
||||
withContext(async (ctx, id: unknown) => {
|
||||
const dashId = validatePositiveInt(id, "dashboard id");
|
||||
const { data } = await ctx.client.GET(
|
||||
`/api/dashboard/${dashId}`,
|
||||
);
|
||||
|
||||
const raw = data as Record<string, unknown>;
|
||||
const dashcards =
|
||||
(raw.dashcards as Array<Record<string, unknown>>) ?? [];
|
||||
|
||||
const compact = {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
parameters: raw.parameters,
|
||||
cards: dashcards.map((dc) => ({
|
||||
dashcard_id: dc.id,
|
||||
card_id: dc.card_id,
|
||||
card_name: (dc.card as Record<string, unknown> | undefined)?.name,
|
||||
size_x: dc.size_x,
|
||||
size_y: dc.size_y,
|
||||
row: dc.row,
|
||||
col: dc.col,
|
||||
})),
|
||||
};
|
||||
|
||||
printResult(compact, ctx.outputOpts);
|
||||
}),
|
||||
);
|
||||
}
|
||||
84
agent-toolkit/src/commands/database.ts
Normal file
84
agent-toolkit/src/commands/database.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { Command } from "commander";
|
||||
import type { GlobalContext } from "../index.js";
|
||||
import { printResult } from "../core/output.js";
|
||||
import { validatePositiveInt } from "../core/validation.js";
|
||||
|
||||
export function registerDatabaseCommands(
|
||||
program: Command,
|
||||
withContext: (
|
||||
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
|
||||
) => (...args: unknown[]) => Promise<void>,
|
||||
) {
|
||||
program
|
||||
.command("list-databases")
|
||||
.description("List all databases")
|
||||
.option("--include <value>", 'Include related data: "tables"')
|
||||
.action(
|
||||
withContext(async (ctx, opts: unknown) => {
|
||||
const options = opts as { include?: string };
|
||||
const { data } = await ctx.client.GET("/api/database", {
|
||||
params: { query: { include: options.include } },
|
||||
});
|
||||
|
||||
const raw = data as { data?: unknown[] } | unknown[];
|
||||
const databases = Array.isArray(raw)
|
||||
? raw
|
||||
: ((raw as Record<string, unknown>)?.data ?? []);
|
||||
|
||||
const compact = (databases as Record<string, unknown>[]).map((db) => ({
|
||||
id: db.id,
|
||||
name: db.name,
|
||||
engine: db.engine,
|
||||
...(db.tables
|
||||
? {
|
||||
tables: (db.tables as Record<string, unknown>[]).map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
schema: t.schema,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
|
||||
printResult(compact, ctx.outputOpts);
|
||||
}),
|
||||
);
|
||||
|
||||
program
|
||||
.command("get-database <id>")
|
||||
.description("Get database schema — tables, fields, types")
|
||||
.action(
|
||||
withContext(async (ctx, id: unknown) => {
|
||||
const dbId = validatePositiveInt(id, "database id");
|
||||
const { data } = await ctx.client.GET(
|
||||
`/api/database/${dbId}/metadata`,
|
||||
);
|
||||
|
||||
const raw = data as Record<string, unknown>;
|
||||
const tables = (raw?.tables as Record<string, unknown>[]) ?? [];
|
||||
|
||||
const compact = {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
engine: raw.engine,
|
||||
tables: tables.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
schema: t.schema,
|
||||
display_name: t.display_name,
|
||||
fields: ((t.fields as Record<string, unknown>[]) ?? []).map(
|
||||
(f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
display_name: f.display_name,
|
||||
base_type: f.base_type,
|
||||
semantic_type: f.semantic_type,
|
||||
}),
|
||||
),
|
||||
})),
|
||||
};
|
||||
|
||||
printResult(compact, ctx.outputOpts);
|
||||
}),
|
||||
);
|
||||
}
|
||||
135
agent-toolkit/src/commands/field.ts
Normal file
135
agent-toolkit/src/commands/field.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import type { Command } from "commander";
|
||||
import type { GlobalContext } from "../index.js";
|
||||
import { printResult } from "../core/output.js";
|
||||
import { validatePositiveInt } from "../core/validation.js";
|
||||
|
||||
export function registerFieldCommands(
|
||||
program: Command,
|
||||
withContext: (
|
||||
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
|
||||
) => (...args: unknown[]) => Promise<void>,
|
||||
) {
|
||||
program
|
||||
.command("get-field <id>")
|
||||
.description(
|
||||
"Get field metadata including fingerprint (min/max/avg for numbers, date ranges for dates, distinct count)",
|
||||
)
|
||||
.action(
|
||||
withContext(async (ctx, id: unknown) => {
|
||||
const fieldId = validatePositiveInt(id, "field id");
|
||||
|
||||
if (ctx.dryRun) {
|
||||
printResult({
|
||||
dry_run: true,
|
||||
api_calls: [
|
||||
{ method: "GET", path: `/api/field/${fieldId}` },
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await ctx.client.GET(`/api/field/${fieldId}`);
|
||||
|
||||
const raw = data as Record<string, unknown>;
|
||||
const fingerprint = raw.fingerprint as Record<string, unknown> | null;
|
||||
const global = fingerprint?.global as Record<string, unknown> | null;
|
||||
const typeInfo = fingerprint?.type as Record<string, unknown> | null;
|
||||
|
||||
// Build a compact fingerprint summary
|
||||
const stats: Record<string, unknown> = {};
|
||||
if (global) {
|
||||
if (global["distinct-count"] != null)
|
||||
stats.distinct_count = global["distinct-count"];
|
||||
if (global["nil%"] != null) stats.null_percent = global["nil%"];
|
||||
}
|
||||
if (typeInfo) {
|
||||
const numInfo = typeInfo["type/Number"] as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
if (numInfo) {
|
||||
stats.min = numInfo.min;
|
||||
stats.max = numInfo.max;
|
||||
stats.avg = numInfo.avg;
|
||||
if (numInfo.q1 != null) stats.q1 = numInfo.q1;
|
||||
if (numInfo.q3 != null) stats.q3 = numInfo.q3;
|
||||
if (numInfo.sd != null) stats.sd = numInfo.sd;
|
||||
}
|
||||
const dateInfo = typeInfo["type/DateTime"] as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
if (dateInfo) {
|
||||
stats.earliest = dateInfo.earliest;
|
||||
stats.latest = dateInfo.latest;
|
||||
}
|
||||
const textInfo = typeInfo["type/Text"] as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null;
|
||||
if (textInfo) {
|
||||
stats.average_length = textInfo["average-length"];
|
||||
}
|
||||
}
|
||||
|
||||
printResult(
|
||||
{
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
display_name: raw.display_name,
|
||||
table_id: raw.table_id,
|
||||
base_type: raw.base_type,
|
||||
effective_type: raw.effective_type,
|
||||
semantic_type: raw.semantic_type,
|
||||
has_field_values: raw.has_field_values,
|
||||
fk_target_field_id: raw.fk_target_field_id ?? null,
|
||||
description: raw.description ?? null,
|
||||
...(Object.keys(stats).length > 0 && { fingerprint: stats }),
|
||||
},
|
||||
ctx.outputOpts,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
program
|
||||
.command("get-field-values <id>")
|
||||
.description(
|
||||
"Get distinct values for a field — use to discover valid filter values (e.g., status codes, categories)",
|
||||
)
|
||||
.action(
|
||||
withContext(async (ctx, id: unknown) => {
|
||||
const fieldId = validatePositiveInt(id, "field id");
|
||||
|
||||
if (ctx.dryRun) {
|
||||
printResult({
|
||||
dry_run: true,
|
||||
api_calls: [
|
||||
{ method: "GET", path: `/api/field/${fieldId}/values` },
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await ctx.client.GET(
|
||||
`/api/field/${fieldId}/values`,
|
||||
);
|
||||
|
||||
const raw = data as Record<string, unknown>;
|
||||
const rawValues = (raw.values as unknown[][]) ?? [];
|
||||
|
||||
// Flatten 1-tuples to plain values, keep 2-tuples as [value, display_name]
|
||||
const values = rawValues.map((tuple) =>
|
||||
tuple.length === 1 ? tuple[0] : tuple,
|
||||
);
|
||||
|
||||
printResult(
|
||||
{
|
||||
field_id: raw.field_id,
|
||||
values,
|
||||
has_more_values: raw.has_more_values ?? false,
|
||||
},
|
||||
ctx.outputOpts,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
119
agent-toolkit/src/commands/measure.ts
Normal file
119
agent-toolkit/src/commands/measure.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { Command } from "commander";
|
||||
import type { GlobalContext } from "../index.js";
|
||||
import { printResult } from "../core/output.js";
|
||||
import { CreateMeasureInputSchema } from "../core/schemas.js";
|
||||
import { validatePositiveInt } from "../core/validation.js";
|
||||
|
||||
export function registerMeasureCommands(
|
||||
program: Command,
|
||||
withContext: (
|
||||
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
|
||||
) => (...args: unknown[]) => Promise<void>,
|
||||
) {
|
||||
program
|
||||
.command("create-measure")
|
||||
.description(
|
||||
"Create a reusable measure (aggregation expression) on a table",
|
||||
)
|
||||
.requiredOption(
|
||||
"--json <payload>",
|
||||
"JSON input (see: schema create-measure)",
|
||||
)
|
||||
.action(
|
||||
withContext(async (ctx, opts: unknown) => {
|
||||
const options = opts as { json: string };
|
||||
const input = CreateMeasureInputSchema.parse(
|
||||
JSON.parse(options.json),
|
||||
);
|
||||
|
||||
const apiPayload = {
|
||||
name: input.name,
|
||||
table_id: input.table_id,
|
||||
definition: input.definition,
|
||||
...(input.description && { description: input.description }),
|
||||
};
|
||||
|
||||
if (ctx.dryRun) {
|
||||
printResult({
|
||||
dry_run: true,
|
||||
api_calls: [
|
||||
{ method: "POST", path: "/api/measure", body: apiPayload },
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await ctx.client.POST("/api/measure", {
|
||||
body: apiPayload as never,
|
||||
});
|
||||
|
||||
const result = data as Record<string, unknown>;
|
||||
printResult({
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
table_id: result.table_id,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
program
|
||||
.command("list-measures")
|
||||
.description("List all measures")
|
||||
.action(
|
||||
withContext(async (ctx) => {
|
||||
const { data } = await ctx.client.GET("/api/measure");
|
||||
|
||||
const raw = data as Record<string, unknown>[];
|
||||
const compact = raw.map((m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
table_id: m.table_id,
|
||||
description: m.description ?? null,
|
||||
}));
|
||||
|
||||
printResult(compact, ctx.outputOpts);
|
||||
}),
|
||||
);
|
||||
|
||||
program
|
||||
.command("get-measure <id>")
|
||||
.description("Get measure details")
|
||||
.action(
|
||||
withContext(async (ctx, id: unknown) => {
|
||||
const measureId = validatePositiveInt(id, "measure id");
|
||||
const { data } = await ctx.client.GET(
|
||||
`/api/measure/${measureId}`,
|
||||
);
|
||||
printResult(data, ctx.outputOpts);
|
||||
}),
|
||||
);
|
||||
|
||||
program
|
||||
.command("update-measure <id>")
|
||||
.description("Update a measure")
|
||||
.requiredOption("--json <payload>", "JSON input with fields to update")
|
||||
.action(
|
||||
withContext(async (ctx, id: unknown, opts: unknown) => {
|
||||
const measureId = validatePositiveInt(id, "measure id");
|
||||
const options = opts as { json: string };
|
||||
const input = JSON.parse(options.json) as Record<string, unknown>;
|
||||
|
||||
const apiPayload: Record<string, unknown> = {
|
||||
revision_message:
|
||||
(input.revision_message as string) ?? "Updated via CLI",
|
||||
};
|
||||
if (input.name) apiPayload.name = input.name;
|
||||
if (input.description !== undefined)
|
||||
apiPayload.description = input.description;
|
||||
if (input.definition) apiPayload.definition = input.definition;
|
||||
|
||||
const { data } = await ctx.client.PUT(
|
||||
`/api/measure/${measureId}`,
|
||||
{ body: apiPayload as never },
|
||||
);
|
||||
|
||||
const result = data as Record<string, unknown>;
|
||||
printResult({ id: result.id, name: result.name });
|
||||
}),
|
||||
);
|
||||
}
|
||||
57
agent-toolkit/src/commands/query.ts
Normal file
57
agent-toolkit/src/commands/query.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { Command } from "commander";
|
||||
import type { GlobalContext } from "../index.js";
|
||||
import { printResult, formatQueryResult } from "../core/output.js";
|
||||
import { ExecuteQueryInputSchema } from "../core/schemas.js";
|
||||
|
||||
export function registerQueryCommands(
|
||||
program: Command,
|
||||
withContext: (
|
||||
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
|
||||
) => (...args: unknown[]) => Promise<void>,
|
||||
) {
|
||||
program
|
||||
.command("execute-query")
|
||||
.description("Execute an ad-hoc SQL or MBQL query")
|
||||
.requiredOption(
|
||||
"--json <payload>",
|
||||
"JSON input (see: schema execute-query)",
|
||||
)
|
||||
.action(
|
||||
withContext(async (ctx, opts: unknown) => {
|
||||
const options = opts as { json: string };
|
||||
const input = ExecuteQueryInputSchema.parse(
|
||||
JSON.parse(options.json),
|
||||
);
|
||||
|
||||
const apiPayload = input.sql
|
||||
? {
|
||||
database: input.database_id,
|
||||
type: "native" as const,
|
||||
native: { query: input.sql },
|
||||
}
|
||||
: {
|
||||
...(input.query as Record<string, unknown>),
|
||||
database: input.database_id,
|
||||
};
|
||||
|
||||
if (ctx.dryRun) {
|
||||
printResult({
|
||||
dry_run: true,
|
||||
api_calls: [
|
||||
{ method: "POST", path: "/api/dataset", body: apiPayload },
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await ctx.client.POST("/api/dataset", {
|
||||
body: apiPayload as never,
|
||||
});
|
||||
|
||||
const maxRows = ctx.outputOpts.maxRows ?? 50;
|
||||
printResult(
|
||||
formatQueryResult(data as Record<string, unknown>, maxRows),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
186
agent-toolkit/src/commands/question.ts
Normal file
186
agent-toolkit/src/commands/question.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { Command } from "commander";
|
||||
import type { GlobalContext } from "../index.js";
|
||||
import { printResult, truncateRows } from "../core/output.js";
|
||||
import { CreateQuestionInputSchema } from "../core/schemas.js";
|
||||
import { validatePositiveInt } from "../core/validation.js";
|
||||
|
||||
/**
|
||||
* Translate simplified visualization options to Metabase visualization_settings.
|
||||
*/
|
||||
function buildVisualizationSettings(
|
||||
viz: Record<string, unknown> | undefined,
|
||||
): Record<string, unknown> {
|
||||
if (!viz) return {};
|
||||
const settings: Record<string, unknown> = {};
|
||||
|
||||
if (viz.x_axis) settings["graph.dimensions"] = viz.x_axis;
|
||||
if (viz.y_axis) settings["graph.metrics"] = viz.y_axis;
|
||||
if (viz.stack) settings["stackable.stack_type"] = viz.stack;
|
||||
if (viz.show_values !== undefined)
|
||||
settings["graph.show_values"] = viz.show_values;
|
||||
if (viz.x_axis_label) settings["graph.x_axis.title_text"] = viz.x_axis_label;
|
||||
if (viz.y_axis_label) settings["graph.y_axis.title_text"] = viz.y_axis_label;
|
||||
if (viz.line_style) settings["line.style"] = viz.line_style;
|
||||
if (viz.line_interpolation)
|
||||
settings["line.interpolate"] = viz.line_interpolation;
|
||||
|
||||
// Pie chart
|
||||
if (viz.dimension) settings["pie.dimension"] = viz.dimension;
|
||||
if (viz.metric) settings["pie.metric"] = viz.metric;
|
||||
if (viz.show_legend !== undefined)
|
||||
settings["pie.show_legend"] = viz.show_legend;
|
||||
|
||||
// Map
|
||||
if (viz.map_type) settings["map.type"] = viz.map_type;
|
||||
if (viz.map_region) settings["map.region"] = viz.map_region;
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
export function registerQuestionCommands(
|
||||
program: Command,
|
||||
withContext: (
|
||||
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
|
||||
) => (...args: unknown[]) => Promise<void>,
|
||||
) {
|
||||
program
|
||||
.command("create-question")
|
||||
.description(
|
||||
"Create a saved question (card) from SQL with visualization settings",
|
||||
)
|
||||
.requiredOption(
|
||||
"--json <payload>",
|
||||
"JSON input (see: schema create-question)",
|
||||
)
|
||||
.action(
|
||||
withContext(async (ctx, opts: unknown) => {
|
||||
const options = opts as { json: string };
|
||||
const input = CreateQuestionInputSchema.parse(
|
||||
JSON.parse(options.json),
|
||||
);
|
||||
|
||||
const vizSettings = buildVisualizationSettings(
|
||||
input.visualization as Record<string, unknown> | undefined,
|
||||
);
|
||||
|
||||
const datasetQuery = input.sql
|
||||
? {
|
||||
type: "native" as const,
|
||||
database: input.database_id,
|
||||
native: { query: input.sql },
|
||||
}
|
||||
: {
|
||||
...(input.query as Record<string, unknown>),
|
||||
database: input.database_id,
|
||||
};
|
||||
|
||||
const apiPayload = {
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
dataset_query: datasetQuery,
|
||||
display: input.display,
|
||||
visualization_settings: vizSettings,
|
||||
...(input.collection_id && { collection_id: input.collection_id }),
|
||||
...(input.description && { description: input.description }),
|
||||
};
|
||||
|
||||
if (ctx.dryRun) {
|
||||
printResult({
|
||||
dry_run: true,
|
||||
api_calls: [
|
||||
{ method: "POST", path: "/api/card", body: apiPayload },
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await ctx.client.POST("/api/card", {
|
||||
body: apiPayload as never,
|
||||
});
|
||||
|
||||
const result = data as Record<string, unknown>;
|
||||
printResult({
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
display: result.display,
|
||||
type: result.type,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
program
|
||||
.command("update-question <id>")
|
||||
.description("Update a question's SQL, display, or visualization")
|
||||
.requiredOption("--json <payload>", "JSON input with fields to update")
|
||||
.action(
|
||||
withContext(async (ctx, id: unknown, opts: unknown) => {
|
||||
const cardId = validatePositiveInt(id, "question id");
|
||||
const options = opts as { json: string };
|
||||
const input = JSON.parse(options.json) as Record<string, unknown>;
|
||||
|
||||
const apiPayload: Record<string, unknown> = {};
|
||||
if (input.name) apiPayload.name = input.name;
|
||||
if (input.description !== undefined)
|
||||
apiPayload.description = input.description;
|
||||
if (input.display) apiPayload.display = input.display;
|
||||
if (input.sql && input.database_id) {
|
||||
apiPayload.dataset_query = {
|
||||
type: "native",
|
||||
database: input.database_id,
|
||||
native: { query: input.sql },
|
||||
};
|
||||
}
|
||||
if (input.visualization) {
|
||||
apiPayload.visualization_settings = buildVisualizationSettings(
|
||||
input.visualization as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
const { data } = await ctx.client.PUT(`/api/card/${cardId}`, {
|
||||
body: apiPayload as never,
|
||||
});
|
||||
|
||||
const result = data as Record<string, unknown>;
|
||||
printResult({ id: result.id, name: result.name });
|
||||
}),
|
||||
);
|
||||
|
||||
program
|
||||
.command("run-question <id>")
|
||||
.description("Run a saved question and return sample results")
|
||||
.option("--max-rows <n>", "Max rows to return (default: 10)", "10")
|
||||
.action(
|
||||
withContext(async (ctx, id: unknown, opts: unknown) => {
|
||||
const cardId = validatePositiveInt(id, "question id");
|
||||
const options = opts as { maxRows?: string };
|
||||
const maxRows = options.maxRows ? parseInt(options.maxRows, 10) : 10;
|
||||
|
||||
const { data } = await ctx.client.POST(
|
||||
`/api/card/${cardId}/query`,
|
||||
{},
|
||||
);
|
||||
|
||||
const result = data as Record<string, unknown>;
|
||||
const resultData = result.data as Record<string, unknown>;
|
||||
const cols =
|
||||
(resultData?.cols as Array<Record<string, unknown>>) ?? [];
|
||||
const rows = (resultData?.rows as unknown[][]) ?? [];
|
||||
|
||||
const { rows: truncatedRows, meta } = truncateRows(rows, maxRows);
|
||||
|
||||
printResult({
|
||||
card_id: cardId,
|
||||
status: result.status,
|
||||
columns: cols.map((c) => ({
|
||||
name: c.name,
|
||||
display_name: c.display_name,
|
||||
base_type: c.base_type,
|
||||
})),
|
||||
rows: truncatedRows,
|
||||
row_count: result.row_count,
|
||||
running_time: result.running_time,
|
||||
_meta: meta,
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
34
agent-toolkit/src/commands/schema-cmd.ts
Normal file
34
agent-toolkit/src/commands/schema-cmd.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Command } from "commander";
|
||||
import { getSchemaForCommand, listSchemaCommands } from "../core/schemas.js";
|
||||
import { printResult } from "../core/output.js";
|
||||
|
||||
export function registerSchemaCommand(program: Command) {
|
||||
program
|
||||
.command("schema [command]")
|
||||
.description(
|
||||
"Print JSON Schema for a command's input. Run without args to list available schemas.",
|
||||
)
|
||||
.action((command?: string) => {
|
||||
if (!command) {
|
||||
printResult({
|
||||
available_schemas: listSchemaCommands(),
|
||||
usage: "metabase-agent schema <command-name>",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const schema = getSchemaForCommand(command);
|
||||
if (!schema) {
|
||||
const available = listSchemaCommands();
|
||||
printResult({
|
||||
error: "unknown_command",
|
||||
message: `No schema found for command '${command}'`,
|
||||
available_schemas: available,
|
||||
});
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
printResult(schema);
|
||||
});
|
||||
}
|
||||
58
agent-toolkit/src/commands/search.ts
Normal file
58
agent-toolkit/src/commands/search.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { Command } from "commander";
|
||||
import type { GlobalContext } from "../index.js";
|
||||
import { printResult } from "../core/output.js";
|
||||
|
||||
export function registerSearchCommand(
|
||||
program: Command,
|
||||
withContext: (
|
||||
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
|
||||
) => (...args: unknown[]) => Promise<void>,
|
||||
) {
|
||||
program
|
||||
.command("search <query>")
|
||||
.description("Search for tables, dashboards, cards, metrics, segments")
|
||||
.option(
|
||||
"--models <models>",
|
||||
"Comma-separated entity types to filter (table,dashboard,card,metric,segment)",
|
||||
)
|
||||
.action(
|
||||
withContext(async (ctx, query: unknown, opts: unknown) => {
|
||||
const options = opts as { models?: string };
|
||||
const params: Record<string, unknown> = {
|
||||
q: String(query),
|
||||
};
|
||||
// Metabase expects repeated query params: ?models=card&models=dashboard
|
||||
if (options.models) {
|
||||
params.models = options.models.split(",").map((m) => m.trim());
|
||||
}
|
||||
|
||||
const { data } = await ctx.client.GET("/api/search", {
|
||||
params: { query: params },
|
||||
});
|
||||
|
||||
// Compact output: only key fields for context window discipline
|
||||
const raw = data as {
|
||||
data?: Array<Record<string, unknown>>;
|
||||
total?: number;
|
||||
};
|
||||
const items = (raw.data ?? (Array.isArray(raw) ? raw : [])).map(
|
||||
(item: Record<string, unknown>) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
model: item.model,
|
||||
description: item.description,
|
||||
database_id: item.database_id,
|
||||
table_id: item.table_id,
|
||||
}),
|
||||
);
|
||||
|
||||
printResult(
|
||||
{
|
||||
results: items,
|
||||
_meta: { total_count: raw.total ?? items.length },
|
||||
},
|
||||
ctx.outputOpts,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
179
agent-toolkit/src/commands/segment.ts
Normal file
179
agent-toolkit/src/commands/segment.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import type { Command } from "commander";
|
||||
import type { GlobalContext } from "../index.js";
|
||||
import { FieldResolver } from "../core/field-resolver.js";
|
||||
import { printResult } from "../core/output.js";
|
||||
import { CreateSegmentInputSchema } from "../core/schemas.js";
|
||||
import { validatePositiveInt } from "../core/validation.js";
|
||||
|
||||
/** Map simplified filter ops to MBQL operators */
|
||||
const OP_MAP: Record<string, string> = {
|
||||
equals: "=",
|
||||
"not-equals": "!=",
|
||||
"greater-than": ">",
|
||||
"greater-than-or-equal": ">=",
|
||||
"less-than": "<",
|
||||
"less-than-or-equal": "<=",
|
||||
contains: "contains",
|
||||
"not-contains": "does-not-contain",
|
||||
"starts-with": "starts-with",
|
||||
"ends-with": "ends-with",
|
||||
"is-null": "is-null",
|
||||
"is-not-null": "not-null",
|
||||
"is-empty": "is-empty",
|
||||
"is-not-empty": "not-empty",
|
||||
};
|
||||
|
||||
/**
|
||||
* Build an MBQL filter clause from simplified filter input.
|
||||
*/
|
||||
async function buildMbqlFilter(
|
||||
resolver: FieldResolver,
|
||||
tableId: number,
|
||||
filters: Array<{
|
||||
field: string;
|
||||
op: string;
|
||||
value?: string | number | boolean;
|
||||
values?: (string | number)[];
|
||||
}>,
|
||||
): Promise<unknown[]> {
|
||||
if (filters.length === 1) {
|
||||
return await buildSingleFilter(resolver, tableId, filters[0]);
|
||||
}
|
||||
// Multiple filters → AND clause
|
||||
const clauses = await Promise.all(
|
||||
filters.map((f) => buildSingleFilter(resolver, tableId, f)),
|
||||
);
|
||||
return ["and", ...clauses];
|
||||
}
|
||||
|
||||
async function buildSingleFilter(
|
||||
resolver: FieldResolver,
|
||||
tableId: number,
|
||||
filter: {
|
||||
field: string;
|
||||
op: string;
|
||||
value?: string | number | boolean;
|
||||
values?: (string | number)[];
|
||||
},
|
||||
): Promise<unknown[]> {
|
||||
const fieldId = await resolver.resolve(tableId, filter.field);
|
||||
const mbqlOp = OP_MAP[filter.op] ?? filter.op;
|
||||
const fieldRef = ["field", fieldId, null];
|
||||
|
||||
// Existence operators (no value)
|
||||
if (
|
||||
mbqlOp === "is-null" ||
|
||||
mbqlOp === "not-null" ||
|
||||
mbqlOp === "is-empty" ||
|
||||
mbqlOp === "not-empty"
|
||||
) {
|
||||
return [mbqlOp, fieldRef];
|
||||
}
|
||||
|
||||
if (filter.values) {
|
||||
return [mbqlOp, fieldRef, ...filter.values];
|
||||
}
|
||||
|
||||
return [mbqlOp, fieldRef, filter.value];
|
||||
}
|
||||
|
||||
export function registerSegmentCommands(
|
||||
program: Command,
|
||||
withContext: (
|
||||
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
|
||||
) => (...args: unknown[]) => Promise<void>,
|
||||
) {
|
||||
program
|
||||
.command("create-segment")
|
||||
.description("Create a reusable filter (segment) on a table")
|
||||
.requiredOption(
|
||||
"--json <payload>",
|
||||
"JSON input (see: schema create-segment)",
|
||||
)
|
||||
.action(
|
||||
withContext(async (ctx, opts: unknown) => {
|
||||
const options = opts as { json: string };
|
||||
const input = CreateSegmentInputSchema.parse(
|
||||
JSON.parse(options.json),
|
||||
);
|
||||
|
||||
const resolver = new FieldResolver(ctx.client);
|
||||
const mbqlFilter = await buildMbqlFilter(
|
||||
resolver,
|
||||
input.table_id,
|
||||
input.filters,
|
||||
);
|
||||
|
||||
const apiPayload = {
|
||||
name: input.name,
|
||||
table_id: input.table_id,
|
||||
definition: {
|
||||
filter: mbqlFilter,
|
||||
},
|
||||
...(input.description && { description: input.description }),
|
||||
};
|
||||
|
||||
if (ctx.dryRun) {
|
||||
printResult({
|
||||
dry_run: true,
|
||||
api_calls: [
|
||||
{ method: "POST", path: "/api/segment", body: apiPayload },
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await ctx.client.POST("/api/segment", {
|
||||
body: apiPayload as never,
|
||||
});
|
||||
|
||||
const result = data as Record<string, unknown>;
|
||||
printResult({
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
table_id: result.table_id,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
program
|
||||
.command("update-segment <id>")
|
||||
.description("Update a segment")
|
||||
.requiredOption("--json <payload>", "JSON input with fields to update")
|
||||
.action(
|
||||
withContext(async (ctx, id: unknown, opts: unknown) => {
|
||||
const segmentId = validatePositiveInt(id, "segment id");
|
||||
const options = opts as { json: string };
|
||||
const input = JSON.parse(options.json) as Record<string, unknown>;
|
||||
|
||||
const apiPayload: Record<string, unknown> = {
|
||||
revision_message: (input.revision_message as string) ?? "Updated via CLI",
|
||||
};
|
||||
if (input.name) apiPayload.name = input.name;
|
||||
if (input.description !== undefined)
|
||||
apiPayload.description = input.description;
|
||||
|
||||
if (input.filters && input.table_id) {
|
||||
const resolver = new FieldResolver(ctx.client);
|
||||
const mbqlFilter = await buildMbqlFilter(
|
||||
resolver,
|
||||
input.table_id as number,
|
||||
input.filters as Array<{
|
||||
field: string;
|
||||
op: string;
|
||||
value?: string | number | boolean;
|
||||
values?: (string | number)[];
|
||||
}>,
|
||||
);
|
||||
apiPayload.definition = { filter: mbqlFilter };
|
||||
}
|
||||
|
||||
const { data } = await ctx.client.PUT(`/api/segment/${segmentId}`, {
|
||||
body: apiPayload as never,
|
||||
});
|
||||
|
||||
const result = data as Record<string, unknown>;
|
||||
printResult({ id: result.id, name: result.name });
|
||||
}),
|
||||
);
|
||||
}
|
||||
45
agent-toolkit/src/commands/table.ts
Normal file
45
agent-toolkit/src/commands/table.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { Command } from "commander";
|
||||
import type { GlobalContext } from "../index.js";
|
||||
import { printResult } from "../core/output.js";
|
||||
import { validatePositiveInt } from "../core/validation.js";
|
||||
|
||||
export function registerTableCommands(
|
||||
program: Command,
|
||||
withContext: (
|
||||
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
|
||||
) => (...args: unknown[]) => Promise<void>,
|
||||
) {
|
||||
program
|
||||
.command("get-table <id>")
|
||||
.description("Get table details — fields with names, types, and FKs")
|
||||
.action(
|
||||
withContext(async (ctx, id: unknown) => {
|
||||
const tableId = validatePositiveInt(id, "table id");
|
||||
const { data } = await ctx.client.GET(
|
||||
`/api/table/${tableId}/query_metadata`,
|
||||
);
|
||||
|
||||
const raw = data as Record<string, unknown>;
|
||||
const fields = (raw?.fields as Record<string, unknown>[]) ?? [];
|
||||
|
||||
const compact = {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
display_name: raw.display_name,
|
||||
schema: raw.schema,
|
||||
db_id: raw.db_id,
|
||||
description: raw.description,
|
||||
fields: fields.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
display_name: f.display_name,
|
||||
base_type: f.base_type,
|
||||
semantic_type: f.semantic_type,
|
||||
fk_target_field_id: f.fk_target_field_id ?? null,
|
||||
})),
|
||||
};
|
||||
|
||||
printResult(compact, ctx.outputOpts);
|
||||
}),
|
||||
);
|
||||
}
|
||||
260
agent-toolkit/src/commands/transform.ts
Normal file
260
agent-toolkit/src/commands/transform.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import type { Command } from "commander";
|
||||
import type { GlobalContext } from "../index.js";
|
||||
import { printResult } from "../core/output.js";
|
||||
import { CreateTransformInputSchema } from "../core/schemas.js";
|
||||
import { CliError, validatePositiveInt } from "../core/validation.js";
|
||||
|
||||
export function registerTransformCommands(
|
||||
program: Command,
|
||||
withContext: (
|
||||
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
|
||||
) => (...args: unknown[]) => Promise<void>,
|
||||
) {
|
||||
program
|
||||
.command("create-transform")
|
||||
.description("Create a SQL or MBQL transform. Use --run to execute immediately.")
|
||||
.requiredOption("--json <payload>", "JSON input (see: schema create-transform)")
|
||||
.action(
|
||||
withContext(async (ctx, opts: unknown) => {
|
||||
const options = opts as { json: string };
|
||||
const input = CreateTransformInputSchema.parse(
|
||||
JSON.parse(options.json),
|
||||
);
|
||||
|
||||
const sourceQuery = input.sql
|
||||
? {
|
||||
database: input.database_id,
|
||||
type: "native" as const,
|
||||
native: { query: input.sql },
|
||||
}
|
||||
: {
|
||||
...(input.query as Record<string, unknown>),
|
||||
database: input.database_id,
|
||||
};
|
||||
|
||||
const apiPayload = {
|
||||
name: input.name,
|
||||
description: input.description ?? null,
|
||||
source: {
|
||||
type: "query" as const,
|
||||
query: sourceQuery,
|
||||
},
|
||||
target: {
|
||||
type: "table" as const,
|
||||
name: input.target_table,
|
||||
...(input.target_schema && { schema: input.target_schema }),
|
||||
...(input.target_database_id && {
|
||||
database: input.target_database_id,
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
if (ctx.dryRun) {
|
||||
printResult({
|
||||
dry_run: true,
|
||||
api_calls: [
|
||||
{ method: "POST", path: "/api/transform", body: apiPayload },
|
||||
...(input.run
|
||||
? [
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/transform/{id}/run",
|
||||
note: "Would poll for completion",
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: transform } = await ctx.client.POST("/api/transform", {
|
||||
body: apiPayload as never,
|
||||
});
|
||||
|
||||
const result = transform as Record<string, unknown>;
|
||||
|
||||
if (input.run && result.id) {
|
||||
const transformId = result.id as number;
|
||||
const { data: runResult } = await ctx.client.POST(
|
||||
`/api/transform/${transformId}/run`,
|
||||
);
|
||||
|
||||
const run = runResult as { run_id?: number };
|
||||
|
||||
if (run.run_id) {
|
||||
// Poll until complete
|
||||
const finalStatus = await pollTransformRun(
|
||||
ctx,
|
||||
run.run_id,
|
||||
);
|
||||
printResult({
|
||||
transform: { id: result.id, name: result.name },
|
||||
run: finalStatus,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
printResult({
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
source_type: result.source_type,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
program
|
||||
.command("get-transform <id>")
|
||||
.description("Get transform details — name, SQL query, target table, run status")
|
||||
.action(
|
||||
withContext(async (ctx, id: unknown) => {
|
||||
const transformId = validatePositiveInt(id, "transform id");
|
||||
const { data } = await ctx.client.GET(
|
||||
`/api/transform/${transformId}`,
|
||||
);
|
||||
|
||||
const raw = data as Record<string, unknown>;
|
||||
const source = raw.source as Record<string, unknown> | undefined;
|
||||
const sourceQuery = source?.query as Record<string, unknown> | undefined;
|
||||
const nativeQuery = sourceQuery?.native as Record<string, unknown> | undefined;
|
||||
const target = raw.target as Record<string, unknown> | undefined;
|
||||
const lastRun = raw.last_run as Record<string, unknown> | null;
|
||||
|
||||
const compact = {
|
||||
id: raw.id,
|
||||
name: raw.name,
|
||||
description: raw.description,
|
||||
database_id: sourceQuery?.database ?? null,
|
||||
sql: nativeQuery?.query ?? null,
|
||||
target_table: target?.name ?? null,
|
||||
target_schema: target?.schema ?? null,
|
||||
last_run_status: lastRun?.status ?? null,
|
||||
last_run_at: lastRun?.started_at ?? null,
|
||||
};
|
||||
|
||||
printResult(compact, ctx.outputOpts);
|
||||
}),
|
||||
);
|
||||
|
||||
program
|
||||
.command("list-transforms")
|
||||
.description("List all transforms with latest run status")
|
||||
.action(
|
||||
withContext(async (ctx) => {
|
||||
const { data } = await ctx.client.GET("/api/transform");
|
||||
|
||||
const raw = data as Record<string, unknown>[];
|
||||
const compact = raw.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
source_type: t.source_type,
|
||||
last_run_status: (t.last_run as Record<string, unknown> | null)
|
||||
?.status ?? null,
|
||||
}));
|
||||
|
||||
printResult(compact, ctx.outputOpts);
|
||||
}),
|
||||
);
|
||||
|
||||
program
|
||||
.command("get-transform-run <runId>")
|
||||
.description("Check transform execution status")
|
||||
.action(
|
||||
withContext(async (ctx, runId: unknown) => {
|
||||
const id = validatePositiveInt(runId, "run id");
|
||||
const { data } = await ctx.client.GET(
|
||||
`/api/transform/run/${id}`,
|
||||
);
|
||||
printResult(data, ctx.outputOpts);
|
||||
}),
|
||||
);
|
||||
|
||||
program
|
||||
.command("update-transform <id>")
|
||||
.description("Update a transform")
|
||||
.requiredOption("--json <payload>", "JSON input with fields to update")
|
||||
.action(
|
||||
withContext(async (ctx, id: unknown, opts: unknown) => {
|
||||
const transformId = validatePositiveInt(id, "transform id");
|
||||
const options = opts as { json: string };
|
||||
const input = JSON.parse(options.json);
|
||||
|
||||
// Build API payload from simplified input
|
||||
const apiPayload: Record<string, unknown> = {};
|
||||
if (input.name) apiPayload.name = input.name;
|
||||
if (input.description !== undefined)
|
||||
apiPayload.description = input.description;
|
||||
if (input.sql || input.query) {
|
||||
const dbId = input.database_id;
|
||||
if (!dbId) {
|
||||
throw new CliError("missing_parameter", {
|
||||
message:
|
||||
"database_id is required when updating the source query",
|
||||
hint: "Include database_id in the JSON payload",
|
||||
});
|
||||
}
|
||||
const sourceQuery = input.sql
|
||||
? {
|
||||
database: dbId,
|
||||
type: "native",
|
||||
native: { query: input.sql },
|
||||
}
|
||||
: {
|
||||
...input.query,
|
||||
database: dbId,
|
||||
};
|
||||
apiPayload.source = {
|
||||
type: "query",
|
||||
query: sourceQuery,
|
||||
};
|
||||
}
|
||||
if (input.target_table) {
|
||||
apiPayload.target = {
|
||||
type: "table",
|
||||
name: input.target_table,
|
||||
...(input.target_schema && { schema: input.target_schema }),
|
||||
};
|
||||
}
|
||||
|
||||
const { data } = await ctx.client.PUT(
|
||||
`/api/transform/${transformId}`,
|
||||
{ body: apiPayload as never },
|
||||
);
|
||||
|
||||
const result = data as Record<string, unknown>;
|
||||
printResult({ id: result.id, name: result.name });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function pollTransformRun(
|
||||
ctx: GlobalContext,
|
||||
runId: number,
|
||||
maxWaitMs = 300_000,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const start = Date.now();
|
||||
let delayMs = 1000;
|
||||
|
||||
while (Date.now() - start < maxWaitMs) {
|
||||
const { data } = await ctx.client.GET(
|
||||
`/api/transform/run/${runId}`,
|
||||
);
|
||||
const run = data as Record<string, unknown>;
|
||||
const status = run.status as string;
|
||||
|
||||
if (
|
||||
status === "succeeded" ||
|
||||
status === "failed" ||
|
||||
status === "timeout" ||
|
||||
status === "canceled"
|
||||
) {
|
||||
return run;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
delayMs = Math.min(delayMs * 1.5, 10_000);
|
||||
}
|
||||
|
||||
return { status: "polling_timeout", run_id: runId };
|
||||
}
|
||||
41725
agent-toolkit/src/core/api-types.ts
Normal file
41725
agent-toolkit/src/core/api-types.ts
Normal file
File diff suppressed because one or more lines are too long
30
agent-toolkit/src/core/auth.ts
Normal file
30
agent-toolkit/src/core/auth.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface AuthConfig {
|
||||
apiKey?: string;
|
||||
sessionToken?: string;
|
||||
}
|
||||
|
||||
export function resolveAuth(opts: {
|
||||
apiKey?: string;
|
||||
sessionToken?: string;
|
||||
}): AuthConfig {
|
||||
const apiKey = opts.apiKey || process.env.METABASE_API_KEY;
|
||||
const sessionToken = opts.sessionToken || process.env.METABASE_SESSION_TOKEN;
|
||||
|
||||
if (!apiKey && !sessionToken) {
|
||||
throw new Error(
|
||||
"Authentication required. Set METABASE_API_KEY or METABASE_SESSION_TOKEN environment variable, or pass --api-key or --session-token.",
|
||||
);
|
||||
}
|
||||
|
||||
return { apiKey, sessionToken };
|
||||
}
|
||||
|
||||
export function getAuthHeaders(auth: AuthConfig): Record<string, string> {
|
||||
if (auth.apiKey) {
|
||||
return { "X-Api-Key": auth.apiKey };
|
||||
}
|
||||
if (auth.sessionToken) {
|
||||
return { "X-Metabase-Session": auth.sessionToken };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
81
agent-toolkit/src/core/client.ts
Normal file
81
agent-toolkit/src/core/client.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import createClient, { type Middleware } from "openapi-fetch";
|
||||
import type { paths } from "./api-types.js";
|
||||
import { type AuthConfig, getAuthHeaders } from "./auth.js";
|
||||
import { CliError } from "./validation.js";
|
||||
|
||||
export interface ClientConfig {
|
||||
baseUrl: string;
|
||||
auth: AuthConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed API client. We use openapi-fetch for path/method safety,
|
||||
* but relax params with `as any` since the generated spec marks many
|
||||
* optional query params as required (a quirk of Metabase's spec generation).
|
||||
*
|
||||
* All methods return `{ data }` where data is cast to the expected type.
|
||||
*/
|
||||
export interface MetabaseClient {
|
||||
GET: (path: string, opts?: { params?: Record<string, unknown> }) => Promise<{ data: unknown }>;
|
||||
POST: (path: string, opts?: { params?: Record<string, unknown>; body?: unknown }) => Promise<{ data: unknown }>;
|
||||
PUT: (path: string, opts?: { params?: Record<string, unknown>; body?: unknown }) => Promise<{ data: unknown }>;
|
||||
DELETE: (path: string, opts?: { params?: Record<string, unknown> }) => Promise<{ data: unknown }>;
|
||||
}
|
||||
|
||||
export function createMetabaseClient(config: ClientConfig): MetabaseClient {
|
||||
const authHeaders = getAuthHeaders(config.auth);
|
||||
|
||||
const errorMiddleware: Middleware = {
|
||||
async onResponse({ response }) {
|
||||
if (!response.ok && response.status !== 202) {
|
||||
let message: string;
|
||||
try {
|
||||
const body = await response.clone().json();
|
||||
message =
|
||||
typeof body === "object" && body !== null && "message" in body
|
||||
? String(body.message)
|
||||
: JSON.stringify(body);
|
||||
} catch {
|
||||
message = `HTTP ${response.status}`;
|
||||
}
|
||||
throw new CliError("api_error", {
|
||||
message,
|
||||
details: { status: response.status },
|
||||
});
|
||||
}
|
||||
return response;
|
||||
},
|
||||
};
|
||||
|
||||
const inner = createClient<paths>({
|
||||
baseUrl: config.baseUrl.replace(/\/+$/, ""),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...authHeaders,
|
||||
},
|
||||
});
|
||||
|
||||
inner.use(errorMiddleware);
|
||||
|
||||
// Wrap to relax strict param requirements and handle undefined data
|
||||
const wrap = (method: "GET" | "POST" | "PUT" | "DELETE") =>
|
||||
async (path: string, opts?: Record<string, unknown>) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result = await (inner as any)[method](path, opts);
|
||||
if (result.error && !result.data) {
|
||||
throw new CliError("api_error", {
|
||||
message: typeof result.error === "object" && result.error?.message
|
||||
? String(result.error.message)
|
||||
: JSON.stringify(result.error),
|
||||
});
|
||||
}
|
||||
return { data: result.data };
|
||||
};
|
||||
|
||||
return {
|
||||
GET: wrap("GET"),
|
||||
POST: wrap("POST"),
|
||||
PUT: wrap("PUT"),
|
||||
DELETE: wrap("DELETE"),
|
||||
};
|
||||
}
|
||||
32
agent-toolkit/src/core/cljs-bridge.ts
Normal file
32
agent-toolkit/src/core/cljs-bridge.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { CliError } from "./validation.js";
|
||||
|
||||
interface AgentCljs {
|
||||
evaluate: (
|
||||
code: string,
|
||||
databaseId: number,
|
||||
metadata: unknown,
|
||||
) => unknown;
|
||||
}
|
||||
|
||||
let _module: AgentCljs | null = null;
|
||||
|
||||
/**
|
||||
* Dynamically import the compiled CLJS module.
|
||||
* Uses ESM import() since both the agent-toolkit and the CLJS output are ESM.
|
||||
*/
|
||||
export async function loadCljsModule(): Promise<AgentCljs> {
|
||||
if (_module) return _module;
|
||||
|
||||
try {
|
||||
// Dynamic import of the ESM CLJS module
|
||||
const mod = await import("../../lib/cljs/agent.js");
|
||||
_module = mod as AgentCljs;
|
||||
return _module;
|
||||
} catch (e) {
|
||||
throw new CliError("cljs_not_built", {
|
||||
message:
|
||||
"CLJS module not found. Run 'bun run build:cljs' in the agent-toolkit directory first.",
|
||||
hint: "The MBQL query builder requires the ClojureScript module to be compiled.",
|
||||
});
|
||||
}
|
||||
}
|
||||
126
agent-toolkit/src/core/field-resolver.ts
Normal file
126
agent-toolkit/src/core/field-resolver.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { MetabaseClient } from "./client.js";
|
||||
import { CliError } from "./validation.js";
|
||||
|
||||
interface FieldInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
display_name: string;
|
||||
base_type: string;
|
||||
semantic_type: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves human-readable field names to numeric field IDs.
|
||||
* Caches table metadata to avoid redundant API calls within a session.
|
||||
*/
|
||||
export class FieldResolver {
|
||||
private cache = new Map<number, FieldInfo[]>();
|
||||
private client: MetabaseClient;
|
||||
|
||||
constructor(client: MetabaseClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a field reference to a numeric ID.
|
||||
* Accepts: numeric ID (passthrough), field name string (looked up from table metadata).
|
||||
*/
|
||||
async resolve(tableId: number, fieldRef: string | number): Promise<number> {
|
||||
if (typeof fieldRef === "number") return fieldRef;
|
||||
|
||||
// If it's a string that parses as a number, treat as numeric ID
|
||||
const asNum = Number(fieldRef);
|
||||
if (!isNaN(asNum) && Number.isInteger(asNum)) return asNum;
|
||||
|
||||
const fields = await this.getTableFields(tableId);
|
||||
const match = fields.find(
|
||||
(f) =>
|
||||
f.name.toLowerCase() === fieldRef.toLowerCase() ||
|
||||
f.display_name.toLowerCase() === fieldRef.toLowerCase(),
|
||||
);
|
||||
|
||||
if (!match) {
|
||||
const available = fields.map((f) => f.name);
|
||||
// Simple did-you-mean: find closest by edit distance
|
||||
const suggestion = findClosest(fieldRef, available);
|
||||
throw new CliError("unknown_field", {
|
||||
message: `Field '${fieldRef}' not found on table ${tableId}`,
|
||||
hint: suggestion
|
||||
? `Did you mean '${suggestion}'? Available fields: ${available.join(", ")}`
|
||||
: `Available fields: ${available.join(", ")}`,
|
||||
details: { available_fields: available },
|
||||
});
|
||||
}
|
||||
|
||||
return match.id;
|
||||
}
|
||||
|
||||
async getTableFields(tableId: number): Promise<FieldInfo[]> {
|
||||
if (this.cache.has(tableId)) {
|
||||
return this.cache.get(tableId)!;
|
||||
}
|
||||
|
||||
const { data } = await this.client.GET(
|
||||
`/api/table/${tableId}/query_metadata`,
|
||||
);
|
||||
|
||||
const raw = data as {
|
||||
fields?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
display_name: string;
|
||||
base_type: string;
|
||||
semantic_type: string | null;
|
||||
}>;
|
||||
};
|
||||
const fields: FieldInfo[] = (raw?.fields ?? []).map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
display_name: f.display_name,
|
||||
base_type: f.base_type,
|
||||
semantic_type: f.semantic_type,
|
||||
}));
|
||||
|
||||
this.cache.set(tableId, fields);
|
||||
return fields;
|
||||
}
|
||||
}
|
||||
|
||||
/** Simple Levenshtein-based closest match */
|
||||
function findClosest(
|
||||
target: string,
|
||||
candidates: string[],
|
||||
): string | undefined {
|
||||
const t = target.toLowerCase();
|
||||
let best: string | undefined;
|
||||
let bestDist = Infinity;
|
||||
|
||||
for (const c of candidates) {
|
||||
const d = levenshtein(t, c.toLowerCase());
|
||||
if (d < bestDist && d <= Math.max(t.length, c.length) * 0.5) {
|
||||
bestDist = d;
|
||||
best = c;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function levenshtein(a: string, b: string): number {
|
||||
const m = a.length;
|
||||
const n = b.length;
|
||||
const dp: number[][] = Array.from({ length: m + 1 }, () =>
|
||||
Array(n + 1).fill(0),
|
||||
);
|
||||
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
||||
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
||||
for (let i = 1; i <= m; i++) {
|
||||
for (let j = 1; j <= n; j++) {
|
||||
dp[i][j] = Math.min(
|
||||
dp[i - 1][j] + 1,
|
||||
dp[i][j - 1] + 1,
|
||||
dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
return dp[m][n];
|
||||
}
|
||||
71
agent-toolkit/src/core/metadata.ts
Normal file
71
agent-toolkit/src/core/metadata.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { MetabaseClient } from "./client.js";
|
||||
|
||||
/**
|
||||
* Shape expected by metabase.lib.js.metadata/metadata-provider:
|
||||
* {
|
||||
* databases: { "1": { id: 1, name: "...", engine: "...", ... } },
|
||||
* tables: { "5": { id: 5, name: "ORDERS", db_id: 1, ... } },
|
||||
* fields: { "23": { id: 23, name: "STATUS", table_id: 5, ... } },
|
||||
* metrics: {},
|
||||
* segments: {},
|
||||
* questions: {},
|
||||
* }
|
||||
*/
|
||||
export interface MetadataBundle {
|
||||
databases: Record<string, unknown>;
|
||||
tables: Record<string, unknown>;
|
||||
fields: Record<string, unknown>;
|
||||
metrics: Record<string, unknown>;
|
||||
segments: Record<string, unknown>;
|
||||
questions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch database metadata from the API and reshape it into the format
|
||||
* expected by metabase.lib.js.metadata/metadata-provider.
|
||||
*
|
||||
* Uses GET /api/database/:id/metadata which returns everything in one call.
|
||||
*/
|
||||
export async function fetchMetadataBundle(
|
||||
client: MetabaseClient,
|
||||
databaseId: number,
|
||||
): Promise<MetadataBundle> {
|
||||
const { data } = await client.GET(
|
||||
`/api/database/${databaseId}/metadata`,
|
||||
);
|
||||
|
||||
const raw = data as Record<string, unknown>;
|
||||
const tables = (raw?.tables as Array<Record<string, unknown>>) ?? [];
|
||||
|
||||
const bundle: MetadataBundle = {
|
||||
databases: {
|
||||
[String(databaseId)]: {
|
||||
id: databaseId,
|
||||
name: raw.name,
|
||||
engine: raw.engine,
|
||||
features: raw.features ?? [],
|
||||
...(raw.settings ? { settings: raw.settings } : {}),
|
||||
},
|
||||
},
|
||||
tables: {},
|
||||
fields: {},
|
||||
metrics: {},
|
||||
segments: {},
|
||||
questions: {},
|
||||
};
|
||||
|
||||
for (const table of tables) {
|
||||
const tableId = String(table.id);
|
||||
const fields = (table.fields as Array<Record<string, unknown>>) ?? [];
|
||||
|
||||
// Store table without nested fields (fields go in top-level map)
|
||||
const { fields: _fields, ...tableWithoutFields } = table;
|
||||
bundle.tables[tableId] = tableWithoutFields;
|
||||
|
||||
for (const field of fields) {
|
||||
bundle.fields[String(field.id)] = field;
|
||||
}
|
||||
}
|
||||
|
||||
return bundle;
|
||||
}
|
||||
105
agent-toolkit/src/core/output.ts
Normal file
105
agent-toolkit/src/core/output.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
export interface OutputOptions {
|
||||
fields?: string[];
|
||||
maxRows?: number;
|
||||
}
|
||||
|
||||
export interface OutputMeta {
|
||||
truncated: boolean;
|
||||
total_count?: number;
|
||||
returned_count?: number;
|
||||
}
|
||||
|
||||
export function applyFieldMask(
|
||||
data: Record<string, unknown>,
|
||||
fields: string[],
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
if (field in data) {
|
||||
result[field] = data[field];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function applyFieldMaskToArray(
|
||||
data: Record<string, unknown>[],
|
||||
fields: string[],
|
||||
): Record<string, unknown>[] {
|
||||
return data.map((item) => applyFieldMask(item, fields));
|
||||
}
|
||||
|
||||
export function truncateRows(
|
||||
rows: unknown[][],
|
||||
maxRows: number,
|
||||
): { rows: unknown[][]; meta: OutputMeta } {
|
||||
const total = rows.length;
|
||||
const truncated = total > maxRows;
|
||||
return {
|
||||
rows: truncated ? rows.slice(0, maxRows) : rows,
|
||||
meta: {
|
||||
truncated,
|
||||
total_count: total,
|
||||
returned_count: truncated ? maxRows : total,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function formatQueryResult(
|
||||
apiResult: Record<string, unknown>,
|
||||
maxRows: number,
|
||||
): Record<string, unknown> {
|
||||
const resultData = apiResult.data as Record<string, unknown>;
|
||||
const cols = (resultData?.cols as Array<Record<string, unknown>>) ?? [];
|
||||
const rows = (resultData?.rows as unknown[][]) ?? [];
|
||||
|
||||
const { rows: truncatedRows, meta } = truncateRows(rows, maxRows);
|
||||
|
||||
return {
|
||||
status: apiResult.status,
|
||||
columns: cols.map((c) => ({
|
||||
name: c.name,
|
||||
display_name: c.display_name,
|
||||
base_type: c.base_type,
|
||||
})),
|
||||
rows: truncatedRows,
|
||||
row_count: apiResult.row_count,
|
||||
running_time: apiResult.running_time,
|
||||
_meta: meta,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatOutput(data: unknown, opts?: OutputOptions): string {
|
||||
if (opts?.fields && typeof data === "object" && data !== null) {
|
||||
if (Array.isArray(data)) {
|
||||
data = applyFieldMaskToArray(
|
||||
data as Record<string, unknown>[],
|
||||
opts.fields,
|
||||
);
|
||||
} else {
|
||||
data = applyFieldMask(data as Record<string, unknown>, opts.fields);
|
||||
}
|
||||
}
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
|
||||
export function printResult(data: unknown, opts?: OutputOptions): void {
|
||||
process.stdout.write(formatOutput(data, opts) + "\n");
|
||||
}
|
||||
|
||||
export function printError(error: unknown): void {
|
||||
if (error && typeof error === "object" && "toJSON" in error) {
|
||||
process.stderr.write(
|
||||
JSON.stringify((error as { toJSON: () => unknown }).toJSON(), null, 2) +
|
||||
"\n",
|
||||
);
|
||||
} else if (error instanceof Error) {
|
||||
process.stderr.write(
|
||||
JSON.stringify({ error: "error", message: error.message }, null, 2) +
|
||||
"\n",
|
||||
);
|
||||
} else {
|
||||
process.stderr.write(JSON.stringify({ error: "error", message: String(error) }, null, 2) + "\n");
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
267
agent-toolkit/src/core/schemas.ts
Normal file
267
agent-toolkit/src/core/schemas.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import { z } from "zod";
|
||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
|
||||
// ----- CLI input schemas (simplified, agent-friendly) -----
|
||||
|
||||
export const SearchInputSchema = z.object({
|
||||
query: z.string().describe("Search query string"),
|
||||
models: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("Filter by entity type: table, dashboard, card, metric, segment"),
|
||||
});
|
||||
|
||||
export const CreateTransformInputSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).describe("Transform name"),
|
||||
database_id: z.number().int().positive().describe("Source database ID"),
|
||||
sql: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("SQL query for the transform source (mutually exclusive with 'query')"),
|
||||
query: z
|
||||
.record(z.unknown())
|
||||
.optional()
|
||||
.describe(
|
||||
"MBQL query object from construct-query (mutually exclusive with 'sql')",
|
||||
),
|
||||
target_table: z.string().min(1).describe("Name for the output table"),
|
||||
target_schema: z.string().optional().describe("Schema for the output table"),
|
||||
target_database_id: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe("Target database ID (defaults to source database)"),
|
||||
description: z.string().optional().describe("Transform description"),
|
||||
run: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("If true, run the transform immediately after creation"),
|
||||
})
|
||||
.refine((data) => (data.sql != null) !== (data.query != null), {
|
||||
message: "Exactly one of 'sql' or 'query' must be provided",
|
||||
});
|
||||
|
||||
const VisualizationSchema = z
|
||||
.object({
|
||||
x_axis: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("Column names for X axis (→ graph.dimensions)"),
|
||||
y_axis: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("Column names for Y axis (→ graph.metrics)"),
|
||||
stack: z
|
||||
.enum(["stacked", "normalized"])
|
||||
.optional()
|
||||
.describe("Stack type for bar/area charts"),
|
||||
show_values: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe("Show values on chart"),
|
||||
x_axis_label: z.string().optional().describe("X axis label"),
|
||||
y_axis_label: z.string().optional().describe("Y axis label"),
|
||||
line_style: z
|
||||
.enum(["solid", "dashed", "dotted"])
|
||||
.optional()
|
||||
.describe("Line style"),
|
||||
line_interpolation: z
|
||||
.enum(["linear", "cardinal", "monotone"])
|
||||
.optional()
|
||||
.describe("Line interpolation"),
|
||||
// Pie chart
|
||||
dimension: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("Pie chart: category columns"),
|
||||
metric: z.string().optional().describe("Pie chart: value column"),
|
||||
show_legend: z.boolean().optional().describe("Show legend"),
|
||||
// Map
|
||||
map_type: z.enum(["pin", "region", "grid"]).optional(),
|
||||
map_region: z.string().optional(),
|
||||
})
|
||||
.optional()
|
||||
.describe("Visualization settings (CLI translates to Metabase format)");
|
||||
|
||||
export const DISPLAY_TYPES = [
|
||||
"line",
|
||||
"bar",
|
||||
"area",
|
||||
"combo",
|
||||
"scatter",
|
||||
"waterfall",
|
||||
"funnel",
|
||||
"sankey",
|
||||
"scalar",
|
||||
"smartscalar",
|
||||
"gauge",
|
||||
"progress",
|
||||
"table",
|
||||
"pivot",
|
||||
"pie",
|
||||
"row",
|
||||
"map",
|
||||
"boxplot",
|
||||
] as const;
|
||||
|
||||
export const CreateQuestionInputSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).describe("Question name"),
|
||||
database_id: z.number().int().positive().describe("Database ID"),
|
||||
sql: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("SQL query (for native questions — mutually exclusive with 'query')"),
|
||||
query: z
|
||||
.record(z.unknown())
|
||||
.optional()
|
||||
.describe(
|
||||
"MBQL query object from construct-query (mutually exclusive with 'sql')",
|
||||
),
|
||||
display: z
|
||||
.enum(DISPLAY_TYPES)
|
||||
.default("table")
|
||||
.describe("Visualization type"),
|
||||
type: z
|
||||
.enum(["question", "model", "metric"])
|
||||
.default("question")
|
||||
.describe("Card type (question, model, or metric)"),
|
||||
visualization: VisualizationSchema,
|
||||
collection_id: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.optional()
|
||||
.describe("Collection ID"),
|
||||
description: z.string().optional().describe("Question description"),
|
||||
})
|
||||
.refine((data) => (data.sql != null) !== (data.query != null), {
|
||||
message: "Exactly one of 'sql' or 'query' must be provided",
|
||||
});
|
||||
|
||||
const FilterSchema = z.object({
|
||||
field: z.string().describe("Field name (resolved to ID automatically)"),
|
||||
op: z.string().describe("Filter operation: equals, greater-than, contains, is-null, etc."),
|
||||
value: z.union([z.string(), z.number(), z.boolean()]).optional(),
|
||||
values: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
});
|
||||
|
||||
export const CreateSegmentInputSchema = z.object({
|
||||
name: z.string().min(1).describe("Segment name"),
|
||||
table_id: z.number().int().positive().describe("Table ID"),
|
||||
filters: z
|
||||
.array(FilterSchema)
|
||||
.min(1)
|
||||
.describe("Filter conditions using field names"),
|
||||
description: z.string().optional().describe("Segment description"),
|
||||
});
|
||||
|
||||
const DashboardCardSchema = z.object({
|
||||
card_id: z.number().int().positive().describe("Question/card ID"),
|
||||
width: z.number().int().positive().default(6).describe("Width in grid units (max 24)"),
|
||||
height: z.number().int().positive().default(4).describe("Height in grid units"),
|
||||
row: z.number().int().min(0).optional().describe("Row position (auto-calculated if omitted)"),
|
||||
col: z.number().int().min(0).optional().describe("Column position (auto-calculated if omitted)"),
|
||||
});
|
||||
|
||||
const DashboardFilterTargetSchema = z.object({
|
||||
card_id: z.number().int().positive().describe("Card ID to wire this filter to"),
|
||||
field: z.string().describe("Field name on the card's table (resolved automatically)"),
|
||||
});
|
||||
|
||||
const DashboardFilterSchema = z.object({
|
||||
name: z.string().describe("Filter label shown on dashboard"),
|
||||
type: z
|
||||
.string()
|
||||
.describe("Filter type: date/range, category, number, string, id, etc."),
|
||||
targets: z
|
||||
.array(DashboardFilterTargetSchema)
|
||||
.describe("Cards and fields this filter connects to"),
|
||||
});
|
||||
|
||||
export const CreateDashboardInputSchema = z.object({
|
||||
name: z.string().min(1).describe("Dashboard name"),
|
||||
description: z.string().optional().describe("Dashboard description"),
|
||||
collection_id: z.number().int().positive().optional().describe("Collection ID"),
|
||||
cards: z
|
||||
.array(DashboardCardSchema)
|
||||
.optional()
|
||||
.describe("Cards to place on the dashboard"),
|
||||
filters: z
|
||||
.array(DashboardFilterSchema)
|
||||
.optional()
|
||||
.describe("Dashboard filters wired to card fields"),
|
||||
});
|
||||
|
||||
export const AddCardToDashboardInputSchema = z.object({
|
||||
dashboard_id: z.number().int().positive().describe("Dashboard ID"),
|
||||
card_id: z.number().int().positive().describe("Card/question ID to add"),
|
||||
width: z.number().int().positive().default(6),
|
||||
height: z.number().int().positive().default(4),
|
||||
filter_mappings: z
|
||||
.array(
|
||||
z.object({
|
||||
filter_name: z.string().describe("Name of existing dashboard filter"),
|
||||
field: z.string().describe("Field name to wire the filter to"),
|
||||
}),
|
||||
)
|
||||
.optional()
|
||||
.describe("Wire this card to existing dashboard filters"),
|
||||
});
|
||||
|
||||
export const ExecuteQueryInputSchema = z
|
||||
.object({
|
||||
database_id: z.number().int().positive().describe("Database ID"),
|
||||
sql: z
|
||||
.string()
|
||||
.min(1)
|
||||
.optional()
|
||||
.describe("SQL query to execute (mutually exclusive with 'query')"),
|
||||
query: z
|
||||
.record(z.unknown())
|
||||
.optional()
|
||||
.describe(
|
||||
"MBQL query object from construct-query (mutually exclusive with 'sql')",
|
||||
),
|
||||
})
|
||||
.refine((data) => (data.sql != null) !== (data.query != null), {
|
||||
message: "Exactly one of 'sql' or 'query' must be provided",
|
||||
});
|
||||
|
||||
export const CreateMeasureInputSchema = z.object({
|
||||
name: z.string().min(1).describe("Measure name"),
|
||||
table_id: z.number().int().positive().describe("Table ID"),
|
||||
definition: z
|
||||
.record(z.unknown())
|
||||
.describe("MBQL5 query definition with exactly one aggregation"),
|
||||
description: z.string().optional().describe("Measure description"),
|
||||
});
|
||||
|
||||
// ----- Schema registry for the `schema` command -----
|
||||
|
||||
const schemaRegistry: Record<string, z.ZodType> = {
|
||||
search: SearchInputSchema,
|
||||
"create-transform": CreateTransformInputSchema,
|
||||
"create-question": CreateQuestionInputSchema,
|
||||
"create-segment": CreateSegmentInputSchema,
|
||||
"create-dashboard": CreateDashboardInputSchema,
|
||||
"add-card-to-dashboard": AddCardToDashboardInputSchema,
|
||||
"execute-query": ExecuteQueryInputSchema,
|
||||
"create-measure": CreateMeasureInputSchema,
|
||||
};
|
||||
|
||||
export function getSchemaForCommand(
|
||||
commandName: string,
|
||||
): object | undefined {
|
||||
const schema = schemaRegistry[commandName];
|
||||
if (!schema) return undefined;
|
||||
return zodToJsonSchema(schema, { name: commandName, target: "openApi3" });
|
||||
}
|
||||
|
||||
export function listSchemaCommands(): string[] {
|
||||
return Object.keys(schemaRegistry);
|
||||
}
|
||||
63
agent-toolkit/src/core/validation.ts
Normal file
63
agent-toolkit/src/core/validation.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export function validatePositiveInt(
|
||||
value: unknown,
|
||||
name: string,
|
||||
): number {
|
||||
const num = typeof value === "string" ? parseInt(value, 10) : value;
|
||||
if (typeof num !== "number" || !Number.isInteger(num) || num < 1) {
|
||||
throw new CliError("invalid_parameter", {
|
||||
message: `${name} must be a positive integer, got '${value}'`,
|
||||
});
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
export function sanitizeString(input: string): string {
|
||||
// Reject control characters (ASCII < 0x20) except newlines and tabs in SQL
|
||||
const hasControlChars = /[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(input);
|
||||
if (hasControlChars) {
|
||||
throw new CliError("invalid_input", {
|
||||
message:
|
||||
"Input contains control characters. Remove non-printable characters and try again.",
|
||||
});
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
export function validateEnum<T extends string>(
|
||||
value: unknown,
|
||||
allowed: readonly T[],
|
||||
name: string,
|
||||
): T {
|
||||
if (!allowed.includes(value as T)) {
|
||||
throw new CliError("invalid_parameter", {
|
||||
message: `Invalid ${name}: '${value}'`,
|
||||
hint: `Valid values: ${allowed.join(", ")}`,
|
||||
});
|
||||
}
|
||||
return value as T;
|
||||
}
|
||||
|
||||
export class CliError extends Error {
|
||||
code: string;
|
||||
hint?: string;
|
||||
details?: Record<string, unknown>;
|
||||
|
||||
constructor(
|
||||
code: string,
|
||||
opts: { message: string; hint?: string; details?: Record<string, unknown> },
|
||||
) {
|
||||
super(opts.message);
|
||||
this.code = code;
|
||||
this.hint = opts.hint;
|
||||
this.details = opts.details;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
error: this.code,
|
||||
message: this.message,
|
||||
...(this.hint && { hint: this.hint }),
|
||||
...(this.details && { ...this.details }),
|
||||
};
|
||||
}
|
||||
}
|
||||
117
agent-toolkit/src/index.ts
Normal file
117
agent-toolkit/src/index.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Command } from "commander";
|
||||
import { resolveAuth } from "./core/auth.js";
|
||||
import { createMetabaseClient, type MetabaseClient } from "./core/client.js";
|
||||
import { printError, printResult, type OutputOptions } from "./core/output.js";
|
||||
import { CliError } from "./core/validation.js";
|
||||
import { registerSearchCommand } from "./commands/search.js";
|
||||
import { registerDatabaseCommands } from "./commands/database.js";
|
||||
import { registerTableCommands } from "./commands/table.js";
|
||||
import { registerTransformCommands } from "./commands/transform.js";
|
||||
import { registerQuestionCommands } from "./commands/question.js";
|
||||
import { registerSegmentCommands } from "./commands/segment.js";
|
||||
import { registerDashboardCommands } from "./commands/dashboard.js";
|
||||
import { registerQueryCommands } from "./commands/query.js";
|
||||
import { registerConstructQueryCommands } from "./commands/construct-query.js";
|
||||
import { registerMeasureCommands } from "./commands/measure.js";
|
||||
import { registerFieldCommands } from "./commands/field.js";
|
||||
import { registerSchemaCommand } from "./commands/schema-cmd.js";
|
||||
|
||||
export interface GlobalContext {
|
||||
client: MetabaseClient;
|
||||
outputOpts: OutputOptions;
|
||||
dryRun: boolean;
|
||||
}
|
||||
|
||||
function createContext(opts: {
|
||||
url?: string;
|
||||
apiKey?: string;
|
||||
sessionToken?: string;
|
||||
fields?: string;
|
||||
maxRows?: string;
|
||||
dryRun?: boolean;
|
||||
}): GlobalContext {
|
||||
const baseUrl = opts.url || process.env.METABASE_URL;
|
||||
if (!baseUrl) {
|
||||
throw new CliError("missing_config", {
|
||||
message:
|
||||
"Metabase URL required. Set METABASE_URL environment variable or pass --url.",
|
||||
});
|
||||
}
|
||||
|
||||
const auth = resolveAuth({
|
||||
apiKey: opts.apiKey,
|
||||
sessionToken: opts.sessionToken,
|
||||
});
|
||||
|
||||
const client = createMetabaseClient({ baseUrl, auth });
|
||||
|
||||
const fields = opts.fields?.split(",").map((f) => f.trim());
|
||||
const maxRows = opts.maxRows ? parseInt(opts.maxRows, 10) : 50;
|
||||
|
||||
return {
|
||||
client,
|
||||
outputOpts: { fields, maxRows },
|
||||
dryRun: opts.dryRun ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name("metabase-agent")
|
||||
.description(
|
||||
"Agent-friendly CLI for Metabase — discover data, create transforms, build dashboards",
|
||||
)
|
||||
.version("0.1.0")
|
||||
.option("--url <url>", "Metabase instance URL (or METABASE_URL env)")
|
||||
.option("--api-key <key>", "API key (or METABASE_API_KEY env)")
|
||||
.option(
|
||||
"--session-token <token>",
|
||||
"Session token (or METABASE_SESSION_TOKEN env)",
|
||||
)
|
||||
.option("--fields <fields>", "Comma-separated field mask for response")
|
||||
.option("--max-rows <n>", "Max rows in query results (default: 50)")
|
||||
.option("--dry-run", "Show resolved API calls without executing");
|
||||
|
||||
// Wrap command actions to handle errors and create context
|
||||
function withContext(
|
||||
fn: (ctx: GlobalContext, ...args: unknown[]) => Promise<void>,
|
||||
) {
|
||||
return async (...args: unknown[]) => {
|
||||
try {
|
||||
const cmd = args[args.length - 1] as Command;
|
||||
const globalOpts = cmd.optsWithGlobals();
|
||||
const ctx = createContext(globalOpts);
|
||||
await fn(ctx, ...args.slice(0, -1));
|
||||
} catch (error) {
|
||||
printError(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Register all commands
|
||||
registerSearchCommand(program, withContext);
|
||||
registerDatabaseCommands(program, withContext);
|
||||
registerTableCommands(program, withContext);
|
||||
registerTransformCommands(program, withContext);
|
||||
registerQuestionCommands(program, withContext);
|
||||
registerSegmentCommands(program, withContext);
|
||||
registerDashboardCommands(program, withContext);
|
||||
registerQueryCommands(program, withContext);
|
||||
registerConstructQueryCommands(program, withContext);
|
||||
registerMeasureCommands(program, withContext);
|
||||
registerFieldCommands(program, withContext);
|
||||
registerSchemaCommand(program);
|
||||
|
||||
// Ping command (simple, inline)
|
||||
program
|
||||
.command("ping")
|
||||
.description("Health check — verify connection to Metabase")
|
||||
.action(
|
||||
withContext(async (ctx) => {
|
||||
const { data } = await ctx.client.GET("/api/health");
|
||||
printResult(data);
|
||||
}),
|
||||
);
|
||||
|
||||
program.parse();
|
||||
18
agent-toolkit/tsconfig.json
Normal file
18
agent-toolkit/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "**/__tests__"]
|
||||
}
|
||||
12
agent-toolkit/tsup.config.ts
Normal file
12
agent-toolkit/tsup.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
sourcemap: true,
|
||||
clean: true,
|
||||
banner: {
|
||||
js: "#!/usr/bin/env node",
|
||||
},
|
||||
});
|
||||
8
agent-toolkit/vitest.config.ts
Normal file
8
agent-toolkit/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
include: ["src/**/__tests__/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
5
deps.edn
5
deps.edn
@@ -146,6 +146,7 @@
|
||||
org.clojure/core.match {:mvn/version "1.1.0"}
|
||||
org.clojure/core.memoize {:mvn/version "1.1.266"} ; useful FIFO, LRU, etc. caching mechanisms
|
||||
org.clojure/data.csv {:mvn/version "1.1.0"} ; CSV parsing / generation
|
||||
org.clojure/data.xml {:mvn/version "0.2.0-alpha10"} ; XML parsing / generation
|
||||
org.clojure/java.classpath {:mvn/version "1.1.0"} ; examine the Java classpath from Clojure programs
|
||||
org.clojure/java.jdbc {:mvn/version "0.7.12"} ; basic JDBC access from Clojure
|
||||
org.clojure/java.jmx {:mvn/version "1.1.0"} ; JMX bean library, for exporting diagnostic info
|
||||
@@ -191,6 +192,7 @@
|
||||
org.eclipse.jetty/jetty-unixdomain-server
|
||||
org.eclipse.jetty.ee9/jetty-ee9-servlet
|
||||
org.eclipse.jetty.ee9.websocket/jetty-ee9-websocket-jetty-server]}
|
||||
selmer/selmer {:mvn/version "1.12.70"} ; Jinja-like templates for Clojure
|
||||
slingshot/slingshot {:mvn/version "0.12.2"} ; enhanced throw/catch, used by other deps
|
||||
software.amazon.awssdk/s3 {:mvn/version "2.40.13"} ; AWS SDK v2 for S3
|
||||
software.amazon.awssdk/sts {:mvn/version "2.36.2"} ; Required for S3 client to use service account
|
||||
@@ -444,6 +446,7 @@
|
||||
cljs-bean/cljs-bean {:mvn/version "1.9.0"}
|
||||
com.lambdaisland/glogi {:mvn/version "1.3.169"}
|
||||
io.github.metabase/hawk {:mvn/version "1.0.13"}
|
||||
org.babashka/sci {:mvn/version "0.10.49"} ; Small Clojure Interpreter for agent MBQL construction
|
||||
org.clojars.mmb90/cljs-cache {:mvn/version "0.1.4"}
|
||||
refactor-nrepl/refactor-nrepl {:mvn/version "3.11.0"}
|
||||
;; keep it in sync with package.json
|
||||
@@ -741,7 +744,7 @@
|
||||
io.github.clojure/tools.build {:mvn/version "0.10.11"}
|
||||
io.github.seancorfield/build-uber-log4j2-handler
|
||||
{:git/tag "v2.25.1" :git/sha "1cfab45"} ; collect binary files for log4j2
|
||||
org.clojure/data.xml {:mvn/version "0.2.0-alpha9"}
|
||||
org.clojure/data.xml {:mvn/version "0.2.0-alpha10"}
|
||||
org.clojure/tools.deps.alpha {:mvn/version "0.15.1254"}
|
||||
org.fedorahosted.tennera/jgettext {:mvn/version "0.15.1"}}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@
|
||||
"Put everything needed for REPL development within easy reach"
|
||||
(:require
|
||||
[clojure.core.async :as a]
|
||||
[clojure.core.memoize :as memoize]
|
||||
[clojure.main]
|
||||
[clojure.string :as str]
|
||||
[clojure.test]
|
||||
@@ -467,3 +468,9 @@
|
||||
(mt/initialize-if-needed! :test-users)
|
||||
;; seed test db
|
||||
(mt/id))
|
||||
|
||||
(defn reset-static!
|
||||
"Reset static and template caches to pick up new js"
|
||||
[]
|
||||
((requiring-resolve 'stencil.loader/invalidate-cache))
|
||||
(memoize/memo-clear! @(requiring-resolve 'metabase.server.routes.index/load-inline-js)))
|
||||
|
||||
@@ -421,6 +421,8 @@ describe("scenarios > embedding > sdk iframe embedding > custom elements api", (
|
||||
});
|
||||
|
||||
it("should load the embedded Metabot component", () => {
|
||||
cy.request("PUT", "/api/setting/use-native-agent", { value: true });
|
||||
|
||||
H.visitCustomHtmlPage(`
|
||||
${H.getNewEmbedScriptTag()}
|
||||
${H.getNewEmbedConfigurationScript()}
|
||||
@@ -432,7 +434,7 @@ describe("scenarios > embedding > sdk iframe embedding > custom elements api", (
|
||||
cy.findByText("Ask questions to AI.").should("be.visible");
|
||||
cy.findByPlaceholderText("Ask AI a question...").type("Foo{enter}");
|
||||
cy.findByText(
|
||||
"Metabot is currently offline. Please try again later.",
|
||||
"Sorry, an error occurred: No OpenRouter API key is set. If this persists, please contact your administrator.",
|
||||
).should("be.visible");
|
||||
|
||||
cy.log(
|
||||
|
||||
@@ -206,11 +206,11 @@ d:{"finishReason":"stop","usage":{"promptTokens":4916,"completionTokens":8}}`,
|
||||
|
||||
H.lastChatMessage().should("have.text", "You, but don't tell anyone.");
|
||||
|
||||
H.mockMetabotResponse({ statusCode: 500 });
|
||||
H.mockMetabotResponse({ statusCode: 200, body: apiKeyInvalidResponse });
|
||||
H.sendMetabotMessage("Who is your favorite?");
|
||||
H.lastChatMessage().should(
|
||||
"have.text",
|
||||
"Metabot is currently offline. Please try again later.",
|
||||
"Sorry, an error occurred: Anthropic API key expired or invalid. If this persists, please contact your administrator.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -274,3 +274,6 @@ describe("Metabot in full-app embedding", () => {
|
||||
const whoIsYourFavoriteResponse = `0:"You, but don't tell anyone."
|
||||
2:{"type":"state","version":1,"value":{"queries":{}}}
|
||||
d:{"finishReason":"stop","usage":{"promptTokens":4916,"completionTokens":8}}`;
|
||||
|
||||
const apiKeyInvalidResponse = `3:"Anthropic API key expired or invalid"
|
||||
d:{"finishReason":"error","usage":{}}`;
|
||||
|
||||
@@ -85,7 +85,7 @@ describe(
|
||||
H.activateToken("bleeding-edge");
|
||||
H.resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: SOURCE_TABLE });
|
||||
|
||||
cy.intercept("POST", "/api/ee/metabot-v3/agent-streaming").as("agentReq");
|
||||
cy.intercept("POST", "/api/ee/metabot-v3/native-agent-streaming").as("agentReq");
|
||||
cy.intercept("POST", "/api/transform").as("createTransform");
|
||||
cy.intercept("PUT", "/api/transform/*").as("updateTransform");
|
||||
});
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
(ns metabase-enterprise.llm.settings
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase.settings.core :as setting :refer [defsetting]]
|
||||
[metabase.util.i18n :refer [deferred-tru]]))
|
||||
[metabase.util.i18n :refer [deferred-tru tru]]))
|
||||
|
||||
(defsetting ee-openai-model
|
||||
(deferred-tru "The OpenAI Model (e.g. ''gpt-4'', ''gpt-3.5-turbo'')")
|
||||
@@ -26,6 +27,14 @@
|
||||
:export? false
|
||||
:doc false)
|
||||
|
||||
(defsetting ee-anthropic-api-base-url
|
||||
(deferred-tru "The OpenAI embeddings base URL used in Metabase Enterprise.")
|
||||
:encryption :no
|
||||
:visibility :settings-manager
|
||||
:default "https://api.anthropic.com"
|
||||
:export? false
|
||||
:doc false)
|
||||
|
||||
(defsetting ee-anthropic-api-key
|
||||
(deferred-tru "The Anthropic API Key used in Metabase Enterprise.")
|
||||
:encryption :when-encryption-key-set
|
||||
@@ -33,6 +42,70 @@
|
||||
:export? false
|
||||
:doc false)
|
||||
|
||||
(defsetting ee-openrouter-api-base-url
|
||||
(deferred-tru "The OpenRouter API base URL used for Chat Completions.")
|
||||
:encryption :no
|
||||
:visibility :settings-manager
|
||||
:default "https://openrouter.ai/api"
|
||||
:export? false
|
||||
:doc false)
|
||||
|
||||
(defsetting ee-openrouter-api-key
|
||||
(deferred-tru "The OpenRouter API Key used in Metabase Enterprise.")
|
||||
:encryption :when-encryption-key-set
|
||||
:visibility :settings-manager
|
||||
:export? false
|
||||
:doc false)
|
||||
|
||||
(def ^:private supported-metabot-providers
|
||||
"Set of supported LLM provider prefixes for the `ee-ai-metabot-provider` setting."
|
||||
#{"anthropic" "openai" "openrouter"})
|
||||
|
||||
(defn- validate-metabot-provider!
|
||||
"Validate that `value` has the format `provider/model` with a supported provider prefix.
|
||||
Throws an exception with `:status-code 400` on invalid input."
|
||||
[value]
|
||||
(when-not (string? value)
|
||||
(throw (ex-info (tru "Metabot provider must be a string, got: {0}" (pr-str value))
|
||||
{:status-code 400})))
|
||||
(let [[provider model] (str/split value #"/" 2)]
|
||||
(when-not (contains? supported-metabot-providers provider)
|
||||
(throw (ex-info (tru "Unknown provider {0}. Supported providers: {1}"
|
||||
(pr-str provider) (str/join ", " (sort supported-metabot-providers)))
|
||||
{:status-code 400
|
||||
:provider provider
|
||||
:supported supported-metabot-providers})))
|
||||
(when (str/blank? model)
|
||||
(throw (ex-info (tru "Model name is required. Expected format: provider/model, e.g. \"anthropic/claude-haiku-4-5\"")
|
||||
{:status-code 400
|
||||
:value value})))))
|
||||
|
||||
(defsetting ee-ai-metabot-provider
|
||||
(deferred-tru "The AI provider and model for Metabot. Format: provider/model-name, e.g. `anthropic/claude-haiku-4-5`, `openai/gpt-4.1-mini`, `openrouter/anthropic/claude-haiku-4-5`.")
|
||||
:type :string
|
||||
:encryption :no
|
||||
:default "openrouter/anthropic/claude-haiku-4-5"
|
||||
:visibility :settings-manager
|
||||
:export? false
|
||||
:doc false
|
||||
:setter (fn [new-value]
|
||||
(when new-value
|
||||
(validate-metabot-provider! new-value))
|
||||
(setting/set-value-of-type! :string :ee-ai-metabot-provider new-value)))
|
||||
|
||||
(defsetting ee-ai-metabot-provider-lite
|
||||
(deferred-tru "The AI provider and model for lightweight Metabot tasks and internal bookkeeping (e.g. user intent classification snowplow metrics).")
|
||||
:type :string
|
||||
:encryption :no
|
||||
:default "openrouter/openai/gpt-oss-20b"
|
||||
:visibility :settings-manager
|
||||
:export? false
|
||||
:doc false
|
||||
:setter (fn [new-value]
|
||||
(when new-value
|
||||
(validate-metabot-provider! new-value))
|
||||
(setting/set-value-of-type! :string :ee-ai-metabot-provider-lite new-value)))
|
||||
|
||||
(defsetting ee-ai-features-enabled
|
||||
(deferred-tru "Enable AI features.")
|
||||
:type :boolean
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.analytics
|
||||
"Snowplow and Prometheus analytics and intent-classification helpers for the metabot agent."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.llm.settings :as llm]
|
||||
[metabase-enterprise.metabot-v3.self :as self]
|
||||
[metabase.analytics.core :as analytics]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.log :as log]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;;; User intent classification
|
||||
|
||||
(def ^:private user-intents
|
||||
{"create_or_modify_sql_query"
|
||||
{:description "User wants to write new SQL from scratch or edit existing SQL"
|
||||
:examples ["Write a SQL query to find all orders from last month."
|
||||
"Edit the query to include customer email addresses (while looking at a SQL query)"]}
|
||||
"fix_broken_sql_query"
|
||||
{:description "User has broken SQL that needs fixing"
|
||||
:examples ["My SQL query is giving an error, can you fix it?"
|
||||
"Fix this query."]}
|
||||
"query_data"
|
||||
{:description "User wants to query data to get insights."
|
||||
:examples ["Show me sales by region for Q1."
|
||||
"How many users signed up last week?"]}
|
||||
"data_discovery"
|
||||
{:description "User looking for relevant tables/data or existing Metabase dashboards/questions"
|
||||
:examples ["Which table has customer information?"
|
||||
"What data can I use to analyze user behavior?"]}
|
||||
"editing_visualizations"
|
||||
{:description "User wants to build or edit visualizations"
|
||||
:examples ["Create a bar chart for revenue by product."
|
||||
"Turn this into a bar chart."]}
|
||||
"interpret_results"
|
||||
{:description "User needs help understanding data/charts"
|
||||
:examples ["What does this chart mean?"
|
||||
"Explain the results of this query."]}
|
||||
"create_dashboard"
|
||||
{:description "User wants to build dashboard"
|
||||
:examples ["Build a dashboard for sales KPIs."
|
||||
"Create a dashboard with multiple visualizations."]}
|
||||
"navigation"
|
||||
{:description "User wants to navigate to a specific Metabase UI location or feature"
|
||||
:examples ["Take me to the SQL editor."
|
||||
"Show me the admin settings."]}
|
||||
"metabase_help"
|
||||
{:description "User needs help using Metabase"
|
||||
:examples ["How do I share a dashboard?"
|
||||
"Help me set up a new database connection."
|
||||
"How do I use custom expressions?"]}
|
||||
"other"
|
||||
{:description "User request doesn't fit standard categories"
|
||||
:examples ["Any other request not covered above."]}})
|
||||
|
||||
(defn- render-user-intents-table
|
||||
"Render user-intents as a plain-text table."
|
||||
[]
|
||||
(let [header "| Intent Category | Description | Examples |"
|
||||
sep "|-----------------|-------------|----------|"
|
||||
rows (map (fn [[intent {:keys [description examples]}]]
|
||||
(str "| " intent " | " description " | " (str/join "; " examples) " |"))
|
||||
user-intents)]
|
||||
(str/join "\n" (concat [header sep] rows))))
|
||||
|
||||
(defn- build-intent-prompt [conversation-context]
|
||||
(str "You are a user intent classification engine for requests to an AI agent operating on top of Metabase.\n"
|
||||
"Given a conversation, classify the user's intent into one of the following categories:\n\n"
|
||||
(render-user-intents-table)
|
||||
"\n\nThis is the conversation context:\n"
|
||||
"<conversation_context>\n"
|
||||
conversation-context
|
||||
"\n</conversation_context>\n\n"
|
||||
"## Output format\n"
|
||||
"Return the category wrapped in a <category> tag so that we can easily extract it from the string.\n"
|
||||
"Only respond with the <category> tag including the intent category name - nothing else.\n\n"
|
||||
"**Example:**\n"
|
||||
"User: \"I want to see sales by region for Q1.\"\n"
|
||||
"You: \"<category>query_data</category>\"\n\n"
|
||||
"Classify the user's intent into one of the Intent Categories above.\n"))
|
||||
|
||||
(defn- classify-user-intent!
|
||||
"Classify the user's message into one of the [[user-intent]] categories.
|
||||
Returns the intent string.
|
||||
Raises an exception if the classified intent is not in the known intents.
|
||||
Uses plain-text extraction (no tool calls) so it works with models that don't support function calling."
|
||||
[messages]
|
||||
(let [context (json/encode (take-last 3 messages))
|
||||
text (transduce (keep (fn [{:keys [type text]}] (when (= type :text) text)))
|
||||
str
|
||||
(self/call-llm (llm/ee-ai-metabot-provider-lite)
|
||||
nil
|
||||
[{:role "user" :content (build-intent-prompt context)}]
|
||||
[]
|
||||
;; Omit :request-id from tracking-opts to skip snowplow token_usage event
|
||||
;; since this is for internal usage.
|
||||
{:tag "user-intent-classification"}))
|
||||
intent (second (re-find #"<category>([^<]+)</category>" text))]
|
||||
(when-not (contains? user-intents intent)
|
||||
(throw (ex-info (str "Classified intent '" intent "' not in known intents")
|
||||
{:intent intent
|
||||
:valid-intents (keys user-intents)})))
|
||||
intent))
|
||||
|
||||
(defn- track-user-intent!
|
||||
"Fire user_intent :snowplow/ai_service_event synchronously."
|
||||
[intent tracking-opts]
|
||||
(analytics/track-event!
|
||||
:snowplow/ai_service_event
|
||||
{:hashed-metabase-license-token (analytics/hashed-metabase-token-or-uuid)
|
||||
:request-id (analytics/uuid->ai-service-hex-uuid (:request-id tracking-opts))
|
||||
:source (:source tracking-opts)
|
||||
:event "user_intent"
|
||||
:user-id api/*current-user-id*
|
||||
:session-id (:session-id tracking-opts)
|
||||
:profile (some-> (:profile-id tracking-opts) name)
|
||||
:event-details {"intent" intent}}))
|
||||
|
||||
(defn classify-and-track-user-intent-async!
|
||||
"Classify and [[track-user-intent!]] in a background future."
|
||||
[messages tracking-opts]
|
||||
(future
|
||||
(try
|
||||
(-> messages
|
||||
classify-user-intent!
|
||||
(track-user-intent! tracking-opts))
|
||||
(catch Exception e
|
||||
(log/warn e "Failed to track user intent")))))
|
||||
@@ -0,0 +1,472 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.core
|
||||
"Main agent loop implementation using reducible streaming infrastructure."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.metabot-v3.agent.analytics :as agent-analytics]
|
||||
[metabase-enterprise.metabot-v3.agent.memory :as memory]
|
||||
[metabase-enterprise.metabot-v3.agent.messages :as messages]
|
||||
[metabase-enterprise.metabot-v3.agent.profiles :as profiles]
|
||||
[metabase-enterprise.metabot-v3.agent.streaming :as streaming]
|
||||
[metabase-enterprise.metabot-v3.agent.tools :as agent-tools]
|
||||
[metabase-enterprise.metabot-v3.self :as self]
|
||||
[metabase.analytics.prometheus :as prometheus]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[metabase.util.o11y :refer [with-span]]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;;; Debug logging
|
||||
;;
|
||||
;; When `*debug-log*` is bound to an atom, the agent loop records full LLM
|
||||
;; request/response data for every iteration. This is invaluable for debugging
|
||||
;; benchmark failures — you can see exactly what the system prompt, message
|
||||
;; history, and raw response were for each turn.
|
||||
;;
|
||||
;; Usage:
|
||||
;; (binding [core/*debug-log* (atom [])]
|
||||
;; (into [] (run-agent-loop opts))
|
||||
;; @core/*debug-log*) ;; => [{:iteration 0 :request {...} :response [...]} ...]
|
||||
;;
|
||||
;; Or via the API with `"debug": true` in the request body, which emits the
|
||||
;; debug log as a "debug_log" data part in the SSE stream.
|
||||
|
||||
(def ^:dynamic *debug-log*
|
||||
"When bound to an atom, collects full LLM request/response data per iteration.
|
||||
Each entry is a map with :iteration, :request, and :response keys.
|
||||
The :request contains :model, :system, :messages (full history), and :tools.
|
||||
The :response contains the collected parts from that iteration."
|
||||
nil)
|
||||
|
||||
(defn- collect-text-from-parts
|
||||
"Collect all text content from parts into a single string."
|
||||
[parts]
|
||||
(->> parts
|
||||
(filter #(= :text (:type %)))
|
||||
(map :text)
|
||||
(str/join "")))
|
||||
|
||||
(defn- summarize-tool-ios
|
||||
"Summarize tool inputs and outputs from parts."
|
||||
[parts]
|
||||
(let [inputs (filter #(= :tool-input (:type %)) parts)
|
||||
outputs (filter #(= :tool-output (:type %)) parts)]
|
||||
{:tool-calls (mapv (fn [p] {:id (:id p)
|
||||
:function (:function p)
|
||||
:arguments (:arguments p)})
|
||||
inputs)
|
||||
:tool-results (mapv (fn [p] {:id (:id p)
|
||||
:output (let [r (:result p)]
|
||||
(or (:output r) r))
|
||||
:error (:error p)})
|
||||
outputs)}))
|
||||
|
||||
(defn- debug-log!
|
||||
"Append a debug entry if *debug-log* is bound."
|
||||
[entry]
|
||||
(when *debug-log*
|
||||
(swap! *debug-log* conj entry)))
|
||||
|
||||
;;; Schemas
|
||||
|
||||
;;; Message schemas
|
||||
;;
|
||||
;; Input messages to the agent loop arrive in Chat Completions format (from the
|
||||
;; API / frontend history). `messages.clj` converts them into AISDK parts.
|
||||
;;
|
||||
;; `:content` can be either a plain string or a sequential of content blocks
|
||||
;; (backward-compat with Claude-format messages stored by the old Python AI
|
||||
;; service).
|
||||
|
||||
(mr/def ::content-block
|
||||
"A content block in a multi-part message (Claude format, backward-compat)."
|
||||
[:map
|
||||
[:type :string]]) ;; "text", "tool_use", "tool_result", etc.
|
||||
|
||||
(mr/def ::content
|
||||
"Message content — string or sequential of content blocks."
|
||||
[:or :string [:sequential ::content-block]])
|
||||
|
||||
(mr/def ::tool-call
|
||||
"A tool call in an assistant message."
|
||||
[:map
|
||||
[:id :string]
|
||||
[:name :string]
|
||||
[:arguments [:or :string :map]]])
|
||||
|
||||
(mr/def ::user-message
|
||||
"A user message: plain text or a sequence of tool_result content blocks."
|
||||
[:map
|
||||
[:role [:= :user]]
|
||||
[:content ::content]])
|
||||
|
||||
(mr/def ::assistant-message
|
||||
"An assistant message with optional text and/or tool calls."
|
||||
[:map
|
||||
[:role [:= :assistant]]
|
||||
[:content {:optional true} [:maybe ::content]]
|
||||
[:tool_calls {:optional true} [:maybe [:sequential ::tool-call]]]])
|
||||
|
||||
(mr/def ::system-message
|
||||
"A system message."
|
||||
[:map
|
||||
[:role [:= :system]]
|
||||
[:content :string]])
|
||||
|
||||
(mr/def ::tool-message
|
||||
"A tool result message, referencing a previous tool call by ID."
|
||||
[:map
|
||||
[:role [:= :tool]]
|
||||
[:tool_call_id :string]
|
||||
[:content [:or :string :map]]])
|
||||
|
||||
(mr/def ::message
|
||||
"A single message in the conversation history.
|
||||
Discriminated by :role — each role has its own required/optional fields."
|
||||
[:multi {:dispatch :role}
|
||||
[:user ::user-message]
|
||||
[:assistant ::assistant-message]
|
||||
[:system ::system-message]
|
||||
[:tool ::tool-message]])
|
||||
|
||||
(mr/def ::messages
|
||||
"Sequence of messages in the conversation."
|
||||
[:sequential ::message])
|
||||
|
||||
(mr/def ::state
|
||||
"Agent state containing queries, charts, todos, and transforms."
|
||||
[:map
|
||||
[:queries {:optional true} [:map-of [:or :string :keyword] :map]]
|
||||
[:charts {:optional true} [:map-of [:or :string :keyword] :map]]
|
||||
[:todos {:optional true} [:sequential :map]]
|
||||
[:transforms {:optional true} [:map-of [:or :string :keyword] :map]]])
|
||||
|
||||
(mr/def ::context
|
||||
"Context information for the agent."
|
||||
[:map-of :keyword :any])
|
||||
|
||||
(mr/def ::profile-id
|
||||
"Profile identifier keyword."
|
||||
[:enum :embedding_next :internal :transforms_codegen :sql :nlq :document-generate-content])
|
||||
|
||||
(mr/def ::tracking-opts
|
||||
"Options for snowplow and prometheus analytics tracking."
|
||||
[:map
|
||||
[:session-id {:optional true} [:maybe ms/UUIDString]]
|
||||
[:source {:optional true} [:maybe :string]]
|
||||
[:tag {:optional true} [:maybe :string]]
|
||||
[:track-user-intent? {:optional true} [:maybe :boolean]]])
|
||||
|
||||
;;; Iteration control
|
||||
|
||||
(defn- has-tool-calls?
|
||||
"Check if any parts contain tool calls."
|
||||
[parts]
|
||||
(some #(= (:type %) :tool-input) parts))
|
||||
|
||||
(defn- has-final-response?
|
||||
"Check if any tool result signals a final response."
|
||||
[parts]
|
||||
(some #(and (= (:type %) :tool-output)
|
||||
(get-in % [:result :final-response?]))
|
||||
parts))
|
||||
|
||||
(defn- should-continue?
|
||||
"Determine if agent should continue iterating."
|
||||
[iteration max-iterations parts]
|
||||
(and (< iteration max-iterations)
|
||||
(has-tool-calls? parts)
|
||||
(not (has-final-response? parts))))
|
||||
|
||||
(defn- finish-reason
|
||||
"Determine why the agent loop stopped."
|
||||
[iteration max-iterations parts]
|
||||
(cond
|
||||
(>= iteration max-iterations) :max-iterations
|
||||
(has-final-response? parts) :final-response
|
||||
:else :stop))
|
||||
|
||||
;;; Call LLM
|
||||
|
||||
(defn- call-llm
|
||||
"Call the LLM and stream processed parts.
|
||||
|
||||
Builds AISDK parts from memory and passes them to the adapter which converts
|
||||
them to its native wire format."
|
||||
[memory context profile tools iteration tracking-opts]
|
||||
(let [model (:model profile)
|
||||
system-msg (messages/build-system-message context profile tools)
|
||||
input-parts (messages/build-message-history memory)]
|
||||
(when *debug-log*
|
||||
(debug-log! {:iteration iteration
|
||||
:phase :request
|
||||
:model model
|
||||
:system (:content system-msg)
|
||||
:parts input-parts
|
||||
:tools (mapv (fn [[name _]] name) tools)}))
|
||||
(eduction (streaming/post-process-xf (get-in memory [:state :queries] {})
|
||||
(get-in memory [:state :charts] {}))
|
||||
(self/call-llm model (:content system-msg) input-parts tools tracking-opts))))
|
||||
|
||||
;;; Memory management
|
||||
|
||||
(defn- get-structured-output [result]
|
||||
(or (:structured-output result) (:structured_output result)))
|
||||
|
||||
(defn- extract-queries
|
||||
"Extract and store queries from tool outputs."
|
||||
[memory parts]
|
||||
(transduce
|
||||
(comp (filter #(= :tool-output (:type %)))
|
||||
(map #(get-structured-output (:result %)))
|
||||
(filter #(and (:query-id %) (:query %))))
|
||||
(completing
|
||||
(fn [mem {:keys [query-id query]}]
|
||||
(memory/remember-query mem query-id query)))
|
||||
memory
|
||||
parts))
|
||||
|
||||
(defn- extract-charts
|
||||
"Extract and store charts from tool outputs."
|
||||
[memory parts]
|
||||
(transduce
|
||||
(comp (filter #(= :tool-output (:type %)))
|
||||
(map #(get-structured-output (:result %)))
|
||||
(filter #(and (:chart-id %) (:query-id %))))
|
||||
(completing
|
||||
(fn [mem {:keys [chart-id] :as chart}]
|
||||
(memory/store-chart mem chart-id chart)))
|
||||
memory
|
||||
parts))
|
||||
|
||||
(defn- update-memory
|
||||
"Update memory with parts from an iteration."
|
||||
[memory parts]
|
||||
(-> memory
|
||||
(memory/add-step parts)
|
||||
(extract-queries parts)
|
||||
(extract-charts parts)))
|
||||
|
||||
;;; Context seeding
|
||||
|
||||
(defn- normalize-type [t]
|
||||
(cond (keyword? t) (name t)
|
||||
(string? t) t
|
||||
:else (some-> t str)))
|
||||
|
||||
(defn- ensure-context-item-id
|
||||
"Ensure a viewing context item has an :id, generating a UUID if missing.
|
||||
The Python ai-service auto-generates UUIDs for context items; we match that behavior."
|
||||
[item]
|
||||
(cond-> item
|
||||
(not (:id item)) (assoc :id (str (random-uuid)))))
|
||||
|
||||
(defn- assign-context-ids
|
||||
"Assign IDs to all viewing context items that lack one.
|
||||
Must be called before both seed-state and enrich-context-for-template
|
||||
so they share consistent IDs."
|
||||
[context]
|
||||
(update context :user_is_viewing #(mapv ensure-context-item-id %)))
|
||||
|
||||
(defn- extract-query-from-context-item
|
||||
"Extract [query-id query] from viewing context item."
|
||||
[{:keys [id type query source] :as _item}]
|
||||
(let [t (normalize-type type)]
|
||||
(cond
|
||||
(and (#{"adhoc" "native"} t) (map? query) id)
|
||||
[(str id) query]
|
||||
|
||||
(and (= "transform" t)
|
||||
(= "query" (normalize-type (:type source)))
|
||||
(map? (:query source))
|
||||
id)
|
||||
[(str id) (:query source)])))
|
||||
|
||||
(defn- seed-state
|
||||
"Seed state with queries from viewing context."
|
||||
[state context]
|
||||
(reduce (fn [s item]
|
||||
(if-let [[qid q] (extract-query-from-context-item item)]
|
||||
(assoc-in s [:queries qid] q)
|
||||
s))
|
||||
state
|
||||
(:user_is_viewing context)))
|
||||
|
||||
;;; Main loop
|
||||
|
||||
(defn- init-agent
|
||||
"Initialize agent state."
|
||||
[{:keys [messages state profile-id context tracking-opts]}]
|
||||
(let [context (assign-context-ids context)
|
||||
profile (or (profiles/get-profile profile-id)
|
||||
(throw (ex-info "Unknown profile" {:profile-id profile-id})))
|
||||
capabilities (get context :capabilities #{})
|
||||
base-tools (profiles/get-tools-for-profile profile-id capabilities)
|
||||
seeded (seed-state (or state {}) context)
|
||||
memory (-> (memory/initialize messages seeded context)
|
||||
(memory/load-queries-from-state seeded)
|
||||
(memory/load-charts-from-state seeded)
|
||||
(memory/load-transforms-from-state seeded)
|
||||
(memory/load-todos-from-state seeded))
|
||||
memory-atom (atom memory)
|
||||
tools (agent-tools/wrap-tools-with-state base-tools memory-atom)]
|
||||
(log/info "Starting agent" {:profile profile-id
|
||||
:tools (count tools)
|
||||
:max-iter (:max-iterations profile)
|
||||
:msgs (count messages)})
|
||||
{:profile profile
|
||||
:tools tools
|
||||
:context context
|
||||
:memory-atom memory-atom
|
||||
:tracking-opts (merge {:profile-id profile-id
|
||||
:request-id (str (random-uuid))
|
||||
:source "metabot_agent"
|
||||
:tag "agent"}
|
||||
tracking-opts)}))
|
||||
|
||||
(defn- initial-loop-state
|
||||
"Create initial loop state from agent config and reduction context."
|
||||
[agent rf init usage-atom]
|
||||
{:agent agent
|
||||
:rf rf
|
||||
:result init
|
||||
:iteration 1
|
||||
:status :continue
|
||||
:usage-atom usage-atom})
|
||||
|
||||
(defn- final-state-part [memory]
|
||||
{:type :data, :data-type "state", :version 1, :data (memory/get-state memory)})
|
||||
|
||||
(defn- error-part [^Exception e]
|
||||
{:type :error, :error {:message (.getMessage e), :type (str (type e)), :data (ex-data e)}})
|
||||
|
||||
(defn- accumulate-usage-xf
|
||||
"Transducer that merges each `:usage` part into the cumulative usage atom
|
||||
(keyed by model) and replaces the part's `:usage` with the running total.
|
||||
Non-usage parts pass through unchanged."
|
||||
[usage-atom]
|
||||
(map (fn [{:keys [usage model] :as part}]
|
||||
(if (= (:type part) :usage)
|
||||
(let [model (or model "unknown")]
|
||||
(assoc part :usage
|
||||
(-> (swap! usage-atom update model (partial merge-with +) usage)
|
||||
(get model))))
|
||||
part))))
|
||||
|
||||
(defn- loop-step
|
||||
"Execute one iteration of the agent loop. Returns next loop state.
|
||||
|
||||
Streams parts to the consumer as they arrive while simultaneously accumulating
|
||||
them for memory updates and control flow decisions."
|
||||
[{:keys [agent rf result iteration usage-atom] :as loop-state}]
|
||||
(with-span :debug {:name :metabot-v3.agent/loop-step
|
||||
:iteration iteration}
|
||||
(let [{:keys [profile tools context memory-atom tracking-opts]} agent
|
||||
max-iter (:max-iterations profile 10)
|
||||
tracking-opts (assoc tracking-opts :iteration iteration)
|
||||
parts-atom (atom [])
|
||||
llm-call (call-llm @memory-atom context profile tools iteration tracking-opts)
|
||||
xf (comp (accumulate-usage-xf usage-atom)
|
||||
(u/tee-xf parts-atom))
|
||||
;; We use `reduce` instead of `transduce` because rf is the outer reducing
|
||||
;; function (e.g. aisdk-line-xf wrapping streaming-writer-rf) whose completion
|
||||
;; arity emits a finish message — that must only fire once, at the end of the
|
||||
;; entire agent loop, not after every iteration.
|
||||
result' (reduce (xf rf) result llm-call)
|
||||
parts @parts-atom]
|
||||
;; Capture response for debug log
|
||||
(when *debug-log*
|
||||
(debug-log! {:iteration iteration
|
||||
:phase :response
|
||||
:text (collect-text-from-parts parts)
|
||||
:tools (summarize-tool-ios parts)
|
||||
:all-parts parts}))
|
||||
(log/debug "Iteration" {:n iteration :parts-count (count parts)})
|
||||
(if (empty? parts)
|
||||
(assoc loop-state :status :done :result (rf result (final-state-part @memory-atom)))
|
||||
(do
|
||||
(log/debug "Got parts" {:count (count parts) :types (mapv :type parts)})
|
||||
(swap! memory-atom update-memory parts)
|
||||
(cond
|
||||
(reduced? result')
|
||||
(assoc loop-state :status :reduced :result @result')
|
||||
|
||||
(should-continue? iteration max-iter parts)
|
||||
(assoc loop-state :result result' :iteration (inc iteration))
|
||||
|
||||
:else
|
||||
(do (log/info "Agent loop complete"
|
||||
{:iterations iteration
|
||||
;; TODO: decide if we want this reason to float up to frontend
|
||||
:reason (finish-reason iteration max-iter parts)})
|
||||
(assoc loop-state
|
||||
:status :done
|
||||
:result (rf result' (final-state-part @memory-atom))))))))))
|
||||
|
||||
;;; Public API
|
||||
|
||||
(defn- debug-log-part
|
||||
"Create a data part containing the complete debug log.
|
||||
Emitted at the end of the stream when debug mode is active."
|
||||
[debug-log]
|
||||
{:type :data
|
||||
:data-type "debug_log"
|
||||
:version 1
|
||||
:data debug-log})
|
||||
|
||||
(mu/defn run-agent-loop
|
||||
"Run agent loop, returning a reducible of parts.
|
||||
|
||||
When `:debug?` is true, binds `*debug-log*` and emits a `debug_log` data part
|
||||
at the end of the stream with the complete LLM request/response data for every
|
||||
iteration. This is useful for debugging benchmark failures.
|
||||
|
||||
Usage:
|
||||
(into [] (run-agent-loop opts))
|
||||
(transduce xf rf (run-agent-loop opts))
|
||||
(into [] (run-agent-loop (assoc opts :debug? true))) ;; with debug log"
|
||||
[opts :- [:map
|
||||
[:messages ::messages]
|
||||
[:profile-id ::profile-id]
|
||||
[:state {:optional true} [:maybe ::state]]
|
||||
[:context {:optional true} [:maybe ::context]]
|
||||
[:tracking-opts {:optional true} [:maybe ::tracking-opts]]
|
||||
[:debug? {:optional true} [:maybe :boolean]]]]
|
||||
(let [profile-id (:profile-id opts)
|
||||
debug? (:debug? opts)
|
||||
track-user-intent? (some-> opts :tracking-opts :track-user-intent?)
|
||||
labels {:profile-id (name profile-id)}]
|
||||
(reify clojure.lang.IReduceInit
|
||||
(reduce [_ rf init]
|
||||
(with-span :info {:name :metabot-v3.agent/run-agent-loop
|
||||
:profile-id profile-id
|
||||
:msg-count (count (:messages opts))}
|
||||
(prometheus/inc! :metabase-metabot/agent-requests labels)
|
||||
(let [start-ms (u/start-timer)]
|
||||
(binding [*debug-log* (when debug? (atom []))]
|
||||
(try
|
||||
(let [agent (init-agent opts)
|
||||
_ (when track-user-intent?
|
||||
(agent-analytics/classify-and-track-user-intent-async!
|
||||
(:messages opts)
|
||||
(:tracking-opts agent)))
|
||||
{result :result
|
||||
iteration :iteration} (->> (initial-loop-state agent rf init (atom {}))
|
||||
(iterate loop-step)
|
||||
(drop-while #(= :continue (:status %)))
|
||||
first)]
|
||||
(prometheus/observe! :metabase-metabot/agent-iterations labels iteration)
|
||||
;; Emit debug log as a data part if debug mode was active
|
||||
(if (and debug? (seq @*debug-log*))
|
||||
(rf result (debug-log-part @*debug-log*))
|
||||
result))
|
||||
(catch Exception e
|
||||
(prometheus/inc! :metabase-metabot/agent-errors labels)
|
||||
(when-not (:api-error (ex-data e))
|
||||
(log/error e "Agent loop error"))
|
||||
(rf init (error-part e)))
|
||||
(finally
|
||||
(prometheus/observe! :metabase-metabot/agent-duration-ms labels (u/since-ms start-ms)))))))))))
|
||||
@@ -0,0 +1,187 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.links
|
||||
"Link resolution for metabase:// URIs in streaming text.
|
||||
Converts internal metabase:// links to proper Metabase URLs using agent memory state."
|
||||
(:require
|
||||
[buddy.core.codecs :as codecs]
|
||||
[clojure.string :as str]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.log :as log]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;;; Link Types
|
||||
|
||||
(def ^:private link-type-paths
|
||||
"Map of metabase:// link types to their URL paths."
|
||||
{"model" "/model"
|
||||
"metric" "/metric"
|
||||
"dashboard" "/dashboard"
|
||||
"question" "/question"
|
||||
"transform" "/admin/transforms"})
|
||||
|
||||
;;; Query/Chart URL Generation
|
||||
|
||||
(defn- query->url-hash
|
||||
"Convert an MBQL query to a base64-encoded URL hash for /question# URLs."
|
||||
[query]
|
||||
;; Frontend /question# URLs require legacy MBQL format
|
||||
#_{:clj-kondo/ignore [:discouraged-var]}
|
||||
(let [dataset-query (if (and (map? query) (:lib/type query))
|
||||
(lib/->legacy-MBQL query)
|
||||
query)]
|
||||
(-> {:dataset_query dataset-query}
|
||||
json/encode
|
||||
(.getBytes "UTF-8")
|
||||
codecs/bytes->b64-str)))
|
||||
|
||||
(defn- resolve-query-link
|
||||
"Resolve a metabase://query/{id} link to a /question# URL."
|
||||
[query-id queries-state]
|
||||
(if-let [query (get queries-state query-id)]
|
||||
(str "/question#" (query->url-hash query))
|
||||
(do
|
||||
(log/warn "Query not found for link resolution" {:query-id query-id
|
||||
:available (keys queries-state)})
|
||||
nil)))
|
||||
|
||||
(defn- resolve-chart-link
|
||||
"Resolve a metabase://chart/{id} link to a proper URL.
|
||||
Charts contain visualization settings and reference queries.
|
||||
Falls back to treating the ID as a query ID if no chart is found
|
||||
(handles LLM mistakes where it uses chart/ instead of query/)."
|
||||
[chart-id charts-state queries-state]
|
||||
(if-let [chart (get charts-state chart-id)]
|
||||
;; Chart has a query-id that points to the actual query
|
||||
(let [query-id (:query-id chart)]
|
||||
(if-let [query (get queries-state query-id)]
|
||||
(str "/question#" (query->url-hash query))
|
||||
(do
|
||||
(log/warn "Query not found for chart" {:chart-id chart-id
|
||||
:query-id query-id})
|
||||
nil)))
|
||||
;; Chart not found - fall back to checking if it's actually a query ID
|
||||
;; (LLM sometimes uses metabase://chart/ when it should use metabase://query/)
|
||||
(if-let [query (get queries-state chart-id)]
|
||||
(do
|
||||
(log/debug "Treating chart link as query link" {:id chart-id})
|
||||
(str "/question#" (query->url-hash query)))
|
||||
(do
|
||||
(log/warn "Chart not found for link resolution" {:chart-id chart-id
|
||||
:available-charts (keys charts-state)
|
||||
:available-queries (keys queries-state)})
|
||||
nil))))
|
||||
|
||||
(defn- resolve-entity-link
|
||||
"Resolve a metabase://{type}/{id} link to a proper URL."
|
||||
[entity-type entity-id]
|
||||
(if-let [path (get link-type-paths entity-type)]
|
||||
(str path "/" entity-id)
|
||||
(do
|
||||
(log/warn "Unknown entity type for link" {:type entity-type :id entity-id})
|
||||
nil)))
|
||||
|
||||
(defn- resolve-table-link
|
||||
"Resolve a metabase://table/{id} link to a table page URL.
|
||||
Note: This differs from ai-service (which links to /question#... by fetching database_id).
|
||||
We avoid a DB lookup during streaming by linking directly to the table."
|
||||
[table-id]
|
||||
(let [parsed-id (cond
|
||||
(int? table-id) table-id
|
||||
(string? table-id) (when (re-matches #"\d+" table-id)
|
||||
(parse-long table-id))
|
||||
:else nil)]
|
||||
(if-not parsed-id
|
||||
(do
|
||||
(log/warn "Invalid table id for link resolution" {:table-id table-id})
|
||||
nil)
|
||||
(str "/table/" parsed-id))))
|
||||
|
||||
;;; Main Link Resolution
|
||||
|
||||
(defn resolve-metabase-uri
|
||||
"Resolve a metabase:// URI to a proper Metabase URL.
|
||||
|
||||
Supported URI formats:
|
||||
- metabase://query/{uuid} - Links to query results
|
||||
- metabase://chart/{uuid} - Links to chart visualizations
|
||||
- metabase://question/{uuid} - Links to saved questions
|
||||
- metabase://model/{id} - Links to models
|
||||
- metabase://metric/{id} - Links to metrics
|
||||
- metabase://dashboard/{id} - Links to dashboards
|
||||
- metabase://table/{id} - Links to tables (as questions)
|
||||
- metabase://transform/{id} - Links to transforms
|
||||
|
||||
Returns the resolved URL or nil if resolution fails."
|
||||
[uri queries-state charts-state]
|
||||
(when (and uri (str/starts-with? uri "metabase://"))
|
||||
(let [path (subs uri 11) ; Remove "metabase://"
|
||||
[entity-type entity-id] (str/split path #"/" 2)]
|
||||
(when-not (or (str/blank? entity-type) (str/blank? entity-id))
|
||||
(case entity-type
|
||||
"query" (resolve-query-link entity-id queries-state)
|
||||
"chart" (resolve-chart-link entity-id charts-state queries-state)
|
||||
"question" (resolve-entity-link "question" entity-id)
|
||||
"table" (resolve-table-link entity-id)
|
||||
;; For other types, use simple path mapping
|
||||
(resolve-entity-link entity-type entity-id))))))
|
||||
|
||||
;;; Markdown Link Processing
|
||||
|
||||
(def link-pattern
|
||||
"Regex matching a complete markdown link [text](url)."
|
||||
#"\[([^\[\]]*)\]\(([^()]*)\)")
|
||||
|
||||
(defn resolve-links
|
||||
"Resolve all metabase:// links in text, replacing them with proper URLs.
|
||||
|
||||
Takes text containing markdown links like [Chart](metabase://chart/uuid)
|
||||
and replaces metabase:// URLs with proper Metabase URLs.
|
||||
Non-metabase:// links are preserved unchanged.
|
||||
|
||||
Returns the text with all resolvable links replaced."
|
||||
[text queries-state charts-state]
|
||||
(when (string? text)
|
||||
(str/replace text link-pattern
|
||||
(fn [[_ link-text url]]
|
||||
(if (str/starts-with? url "metabase://")
|
||||
(if-let [resolved (resolve-metabase-uri url queries-state charts-state)]
|
||||
(str "[" link-text "](" resolved ")")
|
||||
(do
|
||||
(log/warn "Failed to resolve link URL" {:url url})
|
||||
link-text))
|
||||
(str "[" link-text "](" url ")"))))))
|
||||
|
||||
(defn resolve-links-xf
|
||||
"Transducer that resolves metabase:// links in text parts.
|
||||
|
||||
Takes queries-state and charts-state for link resolution.
|
||||
For :text parts, resolves metabase:// links to proper URLs.
|
||||
For other part types, passes through unchanged."
|
||||
[queries-state charts-state]
|
||||
(map (fn [part]
|
||||
(if (and (= (:type part) :text) (:text part))
|
||||
(update part :text resolve-links queries-state charts-state)
|
||||
part))))
|
||||
|
||||
;;; Part Processing (deprecated, use resolve-links-xf)
|
||||
|
||||
(defn process-part-links
|
||||
"Process metabase:// links in a part's text content.
|
||||
|
||||
For :text parts, processes the text to resolve metabase:// links.
|
||||
For other part types, returns unchanged.
|
||||
|
||||
Deprecated: Use resolve-links-xf transducer instead."
|
||||
[part queries-state charts-state]
|
||||
(if (and (= (:type part) :text) (:text part))
|
||||
(update part :text resolve-links queries-state charts-state)
|
||||
part))
|
||||
|
||||
(defn process-parts-links
|
||||
"Process metabase:// links in all parts.
|
||||
Returns parts with all text links resolved.
|
||||
|
||||
Deprecated: Use resolve-links-xf transducer instead."
|
||||
[parts queries-state charts-state]
|
||||
(into [] (resolve-links-xf queries-state charts-state) parts))
|
||||
@@ -0,0 +1,121 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.markdown-link-buffer
|
||||
"Streaming markdown link buffer for resolving metabase:// links.
|
||||
|
||||
Buffers markdown links during streaming to prevent incomplete markdown rendering
|
||||
and resolves metabase:// protocol links to proper Metabase URLs.
|
||||
|
||||
Provides both a pure functional state machine and a transducer for stream processing."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.metabot-v3.agent.links :as links]
|
||||
[metabase.util.log :as log]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;;; State
|
||||
|
||||
(def initial-state
|
||||
"Initial parser state."
|
||||
{:buffer ""
|
||||
:queries {}
|
||||
:charts {}})
|
||||
|
||||
(defn with-context
|
||||
"Update the queries and charts context in the state."
|
||||
[state queries charts]
|
||||
(assoc state :queries queries :charts charts))
|
||||
|
||||
;;; Buffering Logic
|
||||
|
||||
(defn- find-potential-link-start
|
||||
"Find the index of a potential incomplete link start (unmatched '[').
|
||||
Returns nil if no potential incomplete link exists."
|
||||
[text]
|
||||
(let [last-open (str/last-index-of text "[")]
|
||||
(when last-open
|
||||
;; Check if there's a complete link starting at or after this position
|
||||
(let [suffix (subs text last-open)]
|
||||
(when-not (re-find links/link-pattern suffix)
|
||||
;; No complete link, but could be start of one if we have '[' without matching ']('...')'
|
||||
;; or '[...](' without closing ')'
|
||||
(when (or (re-find #"\[[^\]]*$" suffix) ; unclosed [
|
||||
(re-find #"\[[^\]]*\]\([^)]*$" suffix)) ; unclosed (
|
||||
last-open))))))
|
||||
|
||||
(defn step
|
||||
"Process a chunk of text through the state machine.
|
||||
Returns [new-state output-string]."
|
||||
[{:keys [buffer] :as state} chunk]
|
||||
(let [text (str buffer chunk)
|
||||
;; First resolve any complete links
|
||||
resolved (links/resolve-links text (:queries state) (:charts state))
|
||||
;; Then check for incomplete link at the end
|
||||
split-point (find-potential-link-start resolved)]
|
||||
(if split-point
|
||||
[(assoc state :buffer (subs resolved split-point))
|
||||
(subs resolved 0 split-point)]
|
||||
[(assoc state :buffer "")
|
||||
resolved])))
|
||||
|
||||
(defn flush-state
|
||||
"Flush remaining buffered content from state.
|
||||
Returns the buffered content string."
|
||||
[state]
|
||||
(:buffer state ""))
|
||||
|
||||
;;; Transducer
|
||||
|
||||
(defn- get-structured-output
|
||||
"Extract structured output from a tool result, handling key variations."
|
||||
[result]
|
||||
(or (:structured-output result)
|
||||
(:structured_output result)))
|
||||
|
||||
(defn resolve-xf
|
||||
"Stateful transducer that resolves metabase:// links in text parts.
|
||||
|
||||
This transducer:
|
||||
1. Accumulates queries/charts from tool-output parts (via :structured-output)
|
||||
2. Buffers markdown links to handle links split across text chunks
|
||||
3. Resolves metabase:// URLs to proper Metabase paths
|
||||
4. Flushes any remaining buffered content at stream end
|
||||
|
||||
Parameters:
|
||||
- initial-queries: Initial map of query-id to query data
|
||||
- initial-charts: Initial map of chart-id to chart data"
|
||||
[initial-queries initial-charts]
|
||||
(fn [rf]
|
||||
(let [state (volatile! (with-context initial-state
|
||||
(or initial-queries {})
|
||||
(or initial-charts {})))
|
||||
queries (volatile! (or initial-queries {}))
|
||||
charts (volatile! (or initial-charts {}))]
|
||||
(fn
|
||||
([] (rf))
|
||||
([result]
|
||||
;; Flush any remaining buffered content
|
||||
(let [flushed (flush-state @state)]
|
||||
(if (seq flushed)
|
||||
(rf (rf result {:type :text :text flushed}))
|
||||
(rf result))))
|
||||
([result part]
|
||||
;; Accumulate state from tool outputs
|
||||
(when (= (:type part) :tool-output)
|
||||
(when-let [{:keys [query-id query chart-id]} (get-structured-output (:result part))]
|
||||
(when (and query-id query)
|
||||
(log/debug "Accumulating query in resolve-xf" {:query-id query-id})
|
||||
(vswap! queries assoc query-id query))
|
||||
(when (and chart-id query-id)
|
||||
(log/debug "Accumulating chart in resolve-xf" {:chart-id chart-id})
|
||||
(vswap! charts assoc chart-id (get-structured-output (:result part)))))
|
||||
;; Update state with new context
|
||||
(vswap! state with-context @queries @charts))
|
||||
|
||||
;; Process text parts through link buffer
|
||||
(if (= (:type part) :text)
|
||||
(let [[new-state processed-text] (step @state (:text part))]
|
||||
(vreset! state new-state)
|
||||
(if (seq processed-text)
|
||||
(rf result (assoc part :text processed-text))
|
||||
result))
|
||||
(rf result part)))))))
|
||||
@@ -0,0 +1,172 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.memory
|
||||
"Memory and state management for the agent loop.
|
||||
Tracks conversation history, tool execution steps, and conversation state."
|
||||
(:require
|
||||
[clojure.string :as str]))
|
||||
|
||||
(defn initialize
|
||||
"Initialize memory from input messages and initial state.
|
||||
State structure:
|
||||
{:queries {query-id query-map}
|
||||
:charts {chart-id chart-map}
|
||||
:todos [...]
|
||||
:transforms {...}}"
|
||||
([messages state]
|
||||
(initialize messages state nil))
|
||||
([messages state context]
|
||||
{:input-messages messages
|
||||
:steps-taken []
|
||||
:context context
|
||||
:state (or state {:queries {} :charts {} :todos [] :transforms {}})}))
|
||||
|
||||
(defn add-step
|
||||
"Add a completed agent step to memory.
|
||||
A step includes the LLM response parts (text, tool calls, tool results)."
|
||||
[memory parts]
|
||||
(update memory :steps-taken conj {:parts parts}))
|
||||
|
||||
(defn get-state
|
||||
"Extract conversation state from memory.
|
||||
State includes queries, charts, todos, transforms, etc."
|
||||
[memory]
|
||||
(:state memory))
|
||||
|
||||
(defn update-state
|
||||
"Update conversation state with new entities."
|
||||
[memory state-updates]
|
||||
(update memory :state merge state-updates))
|
||||
|
||||
(defn get-steps
|
||||
"Get all steps taken by the agent so far."
|
||||
[memory]
|
||||
(:steps-taken memory))
|
||||
|
||||
(defn get-input-messages
|
||||
"Get the original input messages."
|
||||
[memory]
|
||||
(:input-messages memory))
|
||||
|
||||
(defn iteration-count
|
||||
"Get the number of iterations completed."
|
||||
[memory]
|
||||
(count (:steps-taken memory)))
|
||||
|
||||
;;; Query Management
|
||||
|
||||
(defn remember-query
|
||||
"Store a query in memory state by its query-id.
|
||||
Query should be an MBQL/MLv2 query map."
|
||||
[memory query-id query]
|
||||
(update-in memory [:state :queries] assoc query-id query))
|
||||
|
||||
(defn find-query
|
||||
"Retrieve a query from memory state by its query-id.
|
||||
Throws if query not found."
|
||||
[memory query-id]
|
||||
(let [queries (get-in memory [:state :queries] {})]
|
||||
(if-let [query (get queries query-id)]
|
||||
query
|
||||
(throw (ex-info (str "Query with ID " query-id " not found in memory. "
|
||||
"Available queries: [" (str/join ", " (keys queries)) "]. "
|
||||
"Are you sure you were referring to a query ID?")
|
||||
{:agent-error? true
|
||||
:query-id query-id
|
||||
:available-queries (keys queries)})))))
|
||||
|
||||
(defn get-queries
|
||||
"Get all stored queries from memory state."
|
||||
[memory]
|
||||
(get-in memory [:state :queries] {}))
|
||||
|
||||
;;; Chart Management
|
||||
|
||||
(defn store-chart
|
||||
"Store a chart configuration in memory state by its chart-id."
|
||||
[memory chart-id chart]
|
||||
(update-in memory [:state :charts] assoc chart-id chart))
|
||||
|
||||
(defn find-chart
|
||||
"Retrieve a chart from memory state by its chart-id.
|
||||
Throws if chart not found."
|
||||
[memory chart-id]
|
||||
(let [charts (get-in memory [:state :charts] {})]
|
||||
(if-let [chart (get charts chart-id)]
|
||||
chart
|
||||
(throw (ex-info (str "Chart with ID " chart-id " not found in memory. "
|
||||
"Available charts: [" (str/join ", " (keys charts)) "]")
|
||||
{:agent-error? true
|
||||
:chart-id chart-id
|
||||
:available-charts (keys charts)})))))
|
||||
|
||||
(defn get-charts
|
||||
"Get all stored charts from memory state."
|
||||
[memory]
|
||||
(get-in memory [:state :charts] {}))
|
||||
|
||||
;;; Transform Management
|
||||
|
||||
(defn remember-transform
|
||||
"Store a transform in memory state by its ID."
|
||||
[memory transform-id transform]
|
||||
(update-in memory [:state :transforms] assoc (str transform-id) transform))
|
||||
|
||||
(defn find-transform
|
||||
"Retrieve a transform from memory by its ID.
|
||||
Throws if transform not found."
|
||||
[memory transform-id]
|
||||
(let [transforms (get-in memory [:state :transforms] {})]
|
||||
(if-let [transform (get transforms (str transform-id))]
|
||||
transform
|
||||
(throw (ex-info (str "Transform with ID " transform-id " not found in memory. "
|
||||
"Available transforms: [" (str/join ", " (keys transforms)) "]")
|
||||
{:agent-error? true
|
||||
:transform-id transform-id
|
||||
:available-transforms (keys transforms)})))))
|
||||
|
||||
(defn get-transforms
|
||||
"Get all stored transforms from memory state."
|
||||
[memory]
|
||||
(get-in memory [:state :transforms] {}))
|
||||
|
||||
;;; Todo Management
|
||||
|
||||
(defn set-todos
|
||||
"Set the todo list in memory state.
|
||||
Todos should be a vector of todo item maps."
|
||||
[memory todos]
|
||||
(assoc-in memory [:state :todos] (vec todos)))
|
||||
|
||||
(defn get-todos
|
||||
"Get the current todo list from memory state."
|
||||
[memory]
|
||||
(get-in memory [:state :todos] []))
|
||||
|
||||
;;; State Loading
|
||||
|
||||
(defn load-queries-from-state
|
||||
"Load queries from incoming state into memory."
|
||||
[memory state]
|
||||
(if-let [queries (:queries state)]
|
||||
(reduce-kv remember-query memory queries)
|
||||
memory))
|
||||
|
||||
(defn load-charts-from-state
|
||||
"Load charts from incoming state into memory."
|
||||
[memory state]
|
||||
(if-let [charts (:charts state)]
|
||||
(reduce-kv store-chart memory charts)
|
||||
memory))
|
||||
|
||||
(defn load-transforms-from-state
|
||||
"Load transforms from incoming state into memory."
|
||||
[memory state]
|
||||
(if-let [transforms (:transforms state)]
|
||||
(reduce-kv remember-transform memory transforms)
|
||||
memory))
|
||||
|
||||
(defn load-todos-from-state
|
||||
"Load todos from incoming state into memory."
|
||||
[memory state]
|
||||
(if-let [todos (:todos state)]
|
||||
(set-todos memory todos)
|
||||
memory))
|
||||
@@ -0,0 +1,177 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.messages
|
||||
"Message formatting and history construction for the agent loop.
|
||||
|
||||
The agent stores conversation history as AISDK parts — the format-neutral
|
||||
internal representation used throughout the agent loop. Each LLM adapter
|
||||
is responsible for converting these parts into its own wire format (Claude
|
||||
messages, Chat Completions messages, OpenAI Responses input items, etc.)."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.agent.memory :as memory]
|
||||
[metabase-enterprise.metabot-v3.agent.prompts :as prompts]
|
||||
[metabase-enterprise.metabot-v3.agent.user-context :as user-context]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.log :as log]))
|
||||
|
||||
;;; ──────────────────────────────────────────────────────────────────
|
||||
;;; Input message → AISDK part normalisation
|
||||
;;; ──────────────────────────────────────────────────────────────────
|
||||
|
||||
(defn- normalize-role
|
||||
"Normalize role to keyword for comparison.
|
||||
Handles both keyword roles (:user) and string roles (\"user\")."
|
||||
[role]
|
||||
(if (keyword? role)
|
||||
role
|
||||
(keyword role)))
|
||||
|
||||
(defn- decode-tool-arguments
|
||||
"Decode tool call arguments from JSON when provided as a string.
|
||||
Falls back to the raw string if decoding fails."
|
||||
[arguments]
|
||||
(if (string? arguments)
|
||||
(try
|
||||
(json/decode+kw arguments)
|
||||
(catch Exception e
|
||||
(log/warn e "Failed to decode tool call arguments" {:arguments arguments})
|
||||
arguments))
|
||||
arguments))
|
||||
|
||||
(defn- tool-result-content
|
||||
"Normalise tool result content to a string suitable for :tool-output."
|
||||
[content]
|
||||
(cond
|
||||
(string? content) content
|
||||
(map? content) (or (:output content) (pr-str content))
|
||||
:else (str content)))
|
||||
|
||||
;; TODO (Sanya 2026-02-17): when we will use stored messages for context for AI agents we should be able to
|
||||
;; simplify (or even drop) this significantly if we're storing in the same AISDK parts format.
|
||||
(defn input-message->parts
|
||||
"Convert an input message (from the frontend / API) into a sequence of AISDK parts.
|
||||
|
||||
Supported input shapes:
|
||||
{:role :user, :content \"...\"}
|
||||
{:role :assistant, :content \"...\"}
|
||||
{:role :assistant, :content \"...\", :tool_calls [...]}
|
||||
{:role :assistant, :content [{:type \"tool_use\" ...}]}
|
||||
{:role :tool, :tool_call_id \"...\", :content \"...\"}
|
||||
|
||||
Returns a sequence of AISDK parts (may be >1 for messages with tool calls)."
|
||||
[{:keys [content tool_calls] :as msg}]
|
||||
(let [role (normalize-role (:role msg))]
|
||||
(case role
|
||||
:user
|
||||
;; User messages that contain tool_result content blocks (pre-formatted
|
||||
;; from conversation history) are expanded into :tool-output parts.
|
||||
(if (and (sequential? content)
|
||||
(every? #(= "tool_result" (:type %)) content))
|
||||
(mapv (fn [{:keys [tool_use_id content]}]
|
||||
{:type :tool-output
|
||||
:id tool_use_id
|
||||
:result {:output (tool-result-content content)}})
|
||||
content)
|
||||
[{:role :user :content (if (sequential? content)
|
||||
;; Mixed content blocks: join text parts
|
||||
(->> content
|
||||
(filter #(= "text" (:type %)))
|
||||
(map :text)
|
||||
(apply str))
|
||||
(or content ""))}])
|
||||
|
||||
:assistant
|
||||
;; Assistant messages may have text, tool_use content blocks, or
|
||||
;; OpenAI-style :tool_calls. Normalise all into AISDK parts.
|
||||
(let [ ;; Extract text — either plain string or from content blocks
|
||||
text (cond
|
||||
(string? content) content
|
||||
(sequential? content) (let [texts (->> content
|
||||
(filter #(= "text" (:type %)))
|
||||
(map :text))]
|
||||
(when (seq texts) (apply str texts))))
|
||||
;; Extract tool calls — from content blocks or :tool_calls key
|
||||
tool-inputs (cond
|
||||
(sequential? content)
|
||||
(->> content
|
||||
(filter #(= "tool_use" (:type %)))
|
||||
(mapv (fn [{:keys [id name input]}]
|
||||
{:type :tool-input
|
||||
:id id
|
||||
:function name
|
||||
:arguments (decode-tool-arguments input)})))
|
||||
|
||||
(seq tool_calls)
|
||||
(mapv (fn [{:keys [id name arguments]}]
|
||||
{:type :tool-input
|
||||
:id id
|
||||
:function name
|
||||
:arguments (decode-tool-arguments arguments)})
|
||||
tool_calls))]
|
||||
(cond-> []
|
||||
text (conj {:type :text :text text})
|
||||
(seq tool-inputs) (into tool-inputs)))
|
||||
|
||||
:tool
|
||||
[{:type :tool-output
|
||||
:id (:tool_call_id msg)
|
||||
:result {:output (tool-result-content content)}}]
|
||||
|
||||
;; system — pass through as-is (not really an AISDK part, but adapters
|
||||
;; handle it separately via the :system option anyway)
|
||||
:system
|
||||
[{:role :system :content content}]
|
||||
|
||||
;; Fallback
|
||||
(do
|
||||
(log/warn "Unknown message role, passing through" {:role role})
|
||||
[{:role role :content content}]))))
|
||||
|
||||
;;; ──────────────────────────────────────────────────────────────────
|
||||
;;; History construction
|
||||
;;; ──────────────────────────────────────────────────────────────────
|
||||
|
||||
(defn- step->parts
|
||||
"Extract AISDK parts from a step, filtering out non-message types."
|
||||
[step]
|
||||
(->> (:parts step)
|
||||
(filter #(#{:text :tool-input :tool-output} (:type %)))))
|
||||
|
||||
(defn build-message-history
|
||||
"Build the conversation history as a flat sequence of AISDK parts.
|
||||
|
||||
Returns a vector of items that are either:
|
||||
- `{:role :user, :content \"...\"}` for user messages
|
||||
- `{:type :text, :text \"...\"}` for assistant text
|
||||
- `{:type :tool-input, :id ..., :function ..., :arguments ...}` for tool calls
|
||||
- `{:type :tool-output, :id ..., :result ...}` for tool results
|
||||
|
||||
Each LLM adapter converts this to its own wire format."
|
||||
[memory]
|
||||
(let [input-messages (memory/get-input-messages memory)
|
||||
steps (memory/get-steps memory)
|
||||
input-parts (mapcat input-message->parts input-messages)
|
||||
step-parts (mapcat step->parts steps)
|
||||
result (vec (concat input-parts step-parts))]
|
||||
(log/info "Building message history"
|
||||
{:input-message-count (count input-messages)
|
||||
:step-count (count steps)
|
||||
:total-parts (count result)})
|
||||
result))
|
||||
|
||||
;;; ──────────────────────────────────────────────────────────────────
|
||||
;;; System message
|
||||
;;; ──────────────────────────────────────────────────────────────────
|
||||
|
||||
(defn build-system-message
|
||||
"Build system message with templated prompt and enriched context.
|
||||
|
||||
Parameters:
|
||||
- context: Context map from API (with user_is_viewing, user_recently_viewed, etc.)
|
||||
- profile: Profile map with :prompt-template key
|
||||
- tools: Tool registry map (name -> var)
|
||||
|
||||
Returns message map with {:role \"system\" :content \"...\"}."
|
||||
[context profile tools]
|
||||
(let [enriched-context (user-context/enrich-context-for-template context)
|
||||
content (prompts/build-system-message-content profile enriched-context tools)]
|
||||
{:role "system"
|
||||
:content content}))
|
||||
@@ -0,0 +1,194 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.profiles
|
||||
"Profile configurations for different metabot use cases.
|
||||
Each profile defines model settings, iteration limits, and available tools."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[malli.error :as me]
|
||||
[metabase-enterprise.llm.settings :as llm]
|
||||
[metabase-enterprise.metabot-v3.agent.tools :as agent-tools]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.registry :as mr]))
|
||||
|
||||
(def ^:private tool-definition-schema
|
||||
[:map
|
||||
[:tool-name [:and :string [:fn #(not (str/blank? %))]]]
|
||||
[:schema [:fn some?]]
|
||||
[:doc {:optional true} [:maybe :string]]
|
||||
[:prompt {:optional true} [:maybe :string]]])
|
||||
|
||||
(def ^:private *profiles
|
||||
"Map of profile-id to profile configuration"
|
||||
(atom {}))
|
||||
|
||||
(defn- validate-tool-definition!
|
||||
[tool-var]
|
||||
(let [definition (select-keys (meta tool-var) [:tool-name :schema :doc :prompt])]
|
||||
(when-not (mr/validate tool-definition-schema definition)
|
||||
(throw (ex-info "Invalid tool definition metadata"
|
||||
{:tool tool-var
|
||||
:definition definition
|
||||
:errors (me/humanize (mu/explain tool-definition-schema definition))})))))
|
||||
|
||||
(mu/defn ^:private register-profile!
|
||||
"Register new profile configuration.
|
||||
|
||||
Each profile includes:
|
||||
- :name - Keyword identifier for the profile (e.g. :internal)
|
||||
- :prompt-template - Selmer template name from resources/metabot/prompts/system/
|
||||
- :max-iterations - Maximum agent loop iterations
|
||||
- :temperature - LLM temperature setting
|
||||
- :tools - Vector of tool vars available to this profile
|
||||
|
||||
Note: `:model` is resolved at runtime from the `ee-ai-metabot-provider` setting
|
||||
(see [[get-profile]]), not stored in the profile."
|
||||
[profile :- [:map
|
||||
[:name :keyword]
|
||||
[:prompt-template :string]
|
||||
[:max-iterations :int]
|
||||
[:temperature :float]
|
||||
[:tools [:vector :any]]]]
|
||||
(when-not (apply distinct? (map #(:tool-name (meta %)) (:tools profile)))
|
||||
(let [dups (->> (frequencies (map #(:tool-name (meta %)) (:tools profile)))
|
||||
(filter (fn [[_ cnt]] (< 1 cnt))))]
|
||||
(throw (ex-info "Duplicate tool names in profile" {:tool-names (map first dups)}))))
|
||||
(doseq [tool (:tools profile)]
|
||||
(validate-tool-definition! tool))
|
||||
(swap! *profiles assoc (:name profile) profile))
|
||||
|
||||
(register-profile!
|
||||
{:name :embedding_next
|
||||
:prompt-template "embedding-next.selmer"
|
||||
:max-iterations 10
|
||||
:temperature 0.3
|
||||
:tools [#'agent-tools/construct-notebook-query-tool
|
||||
#'agent-tools/read-resource-tool
|
||||
#'agent-tools/list-available-data-sources-tool]})
|
||||
|
||||
(register-profile!
|
||||
{:name :internal
|
||||
:prompt-template "internal.selmer"
|
||||
:max-iterations 10
|
||||
:temperature 0.3
|
||||
:tools [#'agent-tools/search-tool
|
||||
#'agent-tools/construct-notebook-query-tool
|
||||
#'agent-tools/read-resource-tool
|
||||
#'agent-tools/create-sql-query-tool
|
||||
#'agent-tools/edit-sql-query-tool
|
||||
#'agent-tools/replace-sql-query-tool
|
||||
#'agent-tools/edit-chart-tool
|
||||
#'agent-tools/navigate-user-tool
|
||||
#'agent-tools/create-chart-tool
|
||||
#'agent-tools/create-autogenerated-dashboard-tool
|
||||
#'agent-tools/create-dashboard-subscription-tool]})
|
||||
|
||||
(register-profile!
|
||||
{:name :transforms_codegen
|
||||
:prompt-template "transform-codegen.selmer"
|
||||
:max-iterations 30
|
||||
:temperature 0.3
|
||||
:tools [#'agent-tools/transform-search-tool
|
||||
#'agent-tools/get-transform-details-tool
|
||||
#'agent-tools/get-transform-python-library-details-tool
|
||||
#'agent-tools/write-transform-sql-tool
|
||||
#'agent-tools/write-transform-python-tool
|
||||
#'agent-tools/list-snippets-tool
|
||||
#'agent-tools/get-snippet-details-tool
|
||||
#'agent-tools/list-available-fields-tool
|
||||
#'agent-tools/get-field-values-tool
|
||||
#'agent-tools/todo-write-tool
|
||||
#'agent-tools/todo-read-tool]})
|
||||
|
||||
(register-profile!
|
||||
{:name :sql
|
||||
:prompt-template "sql-querying-only.selmer"
|
||||
:max-iterations 10
|
||||
:temperature 0.3
|
||||
:required-tool-call? true
|
||||
:tools [#'agent-tools/sql-search-tool
|
||||
#'agent-tools/read-resource-tool
|
||||
#'agent-tools/create-sql-query-code-edit-tool
|
||||
#'agent-tools/edit-sql-query-code-edit-tool
|
||||
#'agent-tools/replace-sql-query-code-edit-tool
|
||||
#'agent-tools/ask-for-sql-clarification-tool]})
|
||||
|
||||
(register-profile!
|
||||
{:name :nlq
|
||||
:prompt-template "natural-language-querying-only.selmer"
|
||||
:max-iterations 10
|
||||
:temperature 0.3
|
||||
:tools [#'agent-tools/nlq-search-tool
|
||||
#'agent-tools/read-resource-tool
|
||||
#'agent-tools/construct-notebook-query-tool
|
||||
#'agent-tools/navigate-user-tool
|
||||
#'agent-tools/create-chart-tool
|
||||
#'agent-tools/edit-chart-tool]})
|
||||
|
||||
(register-profile!
|
||||
{:name :document-generate-content
|
||||
:prompt-template "document-generate-content.selmer"
|
||||
:max-iterations 10
|
||||
:temperature 0.3
|
||||
:required-tool-call? true
|
||||
:tools [#'agent-tools/list-available-data-sources-tool
|
||||
#'agent-tools/list-available-fields-tool
|
||||
#'agent-tools/get-field-values-tool
|
||||
#'agent-tools/document-schema-collect-tool
|
||||
#'agent-tools/document-construct-model-chart-tool
|
||||
#'agent-tools/document-construct-sql-chart-tool]})
|
||||
|
||||
(def ^:private api-string->capability-keyword
|
||||
"Map from API capability strings (as sent by the frontend) to the keywords
|
||||
used in tool :capabilities metadata. Keywords pass through unchanged."
|
||||
{"frontend:navigate_user_v1" :frontend-navigate-user-v1
|
||||
"permission:save_questions" :permission-save-questions
|
||||
"permission:write_sql_queries" :permission-write-sql-queries
|
||||
"permission:write_transforms" :permission-write-transforms
|
||||
"feature:transforms" :feature-transforms
|
||||
"feature:transforms-python" :feature-transforms-python
|
||||
"feature:snippets" :feature-snippets
|
||||
"automagic-dashboards" :automagic-dashboards})
|
||||
|
||||
(defn- capability->keyword
|
||||
"Normalize a capability value to a keyword. Strings are looked up in the
|
||||
api-string->capability-keyword map; keywords are returned as-is."
|
||||
[cap]
|
||||
(if (keyword? cap)
|
||||
cap
|
||||
(or (api-string->capability-keyword cap)
|
||||
;; Fallback: keywordize unknown strings so new capabilities degrade
|
||||
;; gracefully without requiring a map update.
|
||||
(keyword (-> (str cap) (str/replace ":" "-") (str/replace "_" "-"))))))
|
||||
|
||||
(defn- filter-by-capabilities
|
||||
"Filter tool vars by user capabilities.
|
||||
Removes tools that require capabilities the user doesn't have.
|
||||
Capabilities from the API arrive as strings (e.g. \"frontend:navigate_user_v1\")
|
||||
while tool metadata uses keywords (e.g. :frontend-navigate-user-v1), so we
|
||||
normalize to keywords before comparing."
|
||||
[tool-vars capabilities]
|
||||
(let [capabilities-set (into #{} (map capability->keyword) capabilities)]
|
||||
(filter (fn [tool-var]
|
||||
(every? capabilities-set (:capabilities (meta tool-var))))
|
||||
tool-vars)))
|
||||
|
||||
(defn- tool-map
|
||||
[tools]
|
||||
(into {} (map (juxt #(:tool-name (meta %)) identity) tools)))
|
||||
|
||||
;;; API
|
||||
|
||||
(defn get-profile
|
||||
"Get profile configuration by profile-id keyword.
|
||||
The `:model` in the returned profile is resolved from the `ee-ai-metabot-provider`
|
||||
setting at call time, so it always reflects the current admin configuration."
|
||||
[profile-id]
|
||||
(when-let [profile (get @*profiles profile-id)]
|
||||
(assoc profile :model (llm/ee-ai-metabot-provider))))
|
||||
|
||||
(defn get-tools-for-profile
|
||||
"Get tool registry filtered by profile configuration and user capabilities."
|
||||
[profile-id capabilities]
|
||||
(some-> (get-profile profile-id)
|
||||
:tools
|
||||
(filter-by-capabilities capabilities)
|
||||
tool-map))
|
||||
@@ -0,0 +1,242 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.prompts
|
||||
"System for loading and rendering prompt templates using Selmer.
|
||||
|
||||
Handles:
|
||||
- System prompt templates from resources/metabot/prompts/system/
|
||||
- SQL dialect instructions from resources/metabot/prompts/dialects/
|
||||
- Tool-specific prompts from resources/metabot/prompts/tools/
|
||||
- Template rendering with context variables
|
||||
- Template caching for performance"
|
||||
(:require
|
||||
[clojure.java.io :as io]
|
||||
[metabase.util.log :as log]
|
||||
[selmer.parser :as selmer]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;;; Template Loading
|
||||
|
||||
(defn- load-resource
|
||||
"Load a resource file as a string."
|
||||
[resource-path]
|
||||
(when-let [resource (io/resource resource-path)]
|
||||
(slurp resource)))
|
||||
|
||||
(defn load-system-prompt-template
|
||||
"Load a system prompt template from resources/metabot/prompts/system/.
|
||||
|
||||
Example: (load-system-prompt-template \"internal.selmer\")"
|
||||
[template-name]
|
||||
(let [path (str "metabot/prompts/system/" template-name)]
|
||||
(or (load-resource path)
|
||||
(do
|
||||
(log/warn "System prompt template not found:" path)
|
||||
nil))))
|
||||
|
||||
(defn load-dialect-instructions
|
||||
"Load SQL dialect instructions from resources/metabot/prompts/dialects/.
|
||||
|
||||
Example: (load-dialect-instructions \"postgresql\")
|
||||
Returns nil if dialect not found."
|
||||
[dialect-name]
|
||||
(when dialect-name
|
||||
(let [path (str "metabot/prompts/dialects/" dialect-name ".md")]
|
||||
(or (load-resource path)
|
||||
(do
|
||||
(log/debug "Dialect instructions not found:" path)
|
||||
nil)))))
|
||||
|
||||
(defn load-tool-prompt-template
|
||||
"Load a tool-specific prompt template from resources/metabot/prompts/tools/.
|
||||
|
||||
Example: (load-tool-prompt-template \"query-datasource/system.selmer\")"
|
||||
[template-path]
|
||||
(let [path (str "metabot/prompts/tools/" template-path)]
|
||||
(or (load-resource path)
|
||||
(do
|
||||
(log/debug "Tool prompt template not found:" path)
|
||||
nil))))
|
||||
|
||||
(defn load-llm-representations-template
|
||||
"Load the LLM representations template from resources/metabot/prompts/."
|
||||
[template-name]
|
||||
(let [path (str "metabot/prompts/" template-name)]
|
||||
(or (load-resource path)
|
||||
(do
|
||||
(log/warn "LLM representations template not found:" path)
|
||||
nil))))
|
||||
|
||||
;;; Template Rendering
|
||||
|
||||
(defn render-system-prompt
|
||||
"Render a system prompt template with context variables using Selmer.
|
||||
|
||||
Parameters:
|
||||
- template: Template string (from load-system-prompt-template)
|
||||
- context: Map of template variables
|
||||
|
||||
Common context variables:
|
||||
- :current-time - User's current time string
|
||||
- :first-day-of-week - Calendar week start (default \"Sunday\")
|
||||
- :sql-dialect - SQL dialect name
|
||||
- :sql-dialect-instructions - Dialect-specific guidance (markdown)
|
||||
- :tool-instructions - Vector of tool instruction maps
|
||||
- :viewing-context - Formatted user viewing context
|
||||
- :recent-views - Formatted recent views"
|
||||
[template context]
|
||||
(try
|
||||
(selmer/render template context)
|
||||
(catch Exception e
|
||||
(log/error e "Error rendering system prompt template")
|
||||
;; Return template as-is on error
|
||||
template)))
|
||||
|
||||
(defn render-tool-prompt
|
||||
"Render a tool-specific prompt template with context variables.
|
||||
|
||||
Similar to render-system-prompt but for tool-level prompts."
|
||||
[template context]
|
||||
(try
|
||||
(selmer/render template context)
|
||||
(catch Exception e
|
||||
(log/error e "Error rendering tool prompt template")
|
||||
template)))
|
||||
|
||||
;;; Template Caching
|
||||
|
||||
(def ^:private template-cache
|
||||
"In-memory cache for loaded templates.
|
||||
Map of resource-path -> {:template string :loaded-at instant}"
|
||||
(atom {}))
|
||||
|
||||
(defn- cache-key
|
||||
"Generate cache key for a template."
|
||||
[resource-type resource-name]
|
||||
(str resource-type ":" resource-name))
|
||||
|
||||
(defn cached-load-template
|
||||
"Load template with caching. Cache is simple in-memory map.
|
||||
|
||||
In development, you can clear the cache to reload templates:
|
||||
(reset! metabot-v3.agent.prompts/template-cache {})"
|
||||
[resource-type resource-name loader-fn]
|
||||
(let [key (cache-key resource-type resource-name)]
|
||||
(or (get @template-cache key)
|
||||
(when-let [template (loader-fn resource-name)]
|
||||
(let [entry {:template template
|
||||
:loaded-at (java.time.Instant/now)}]
|
||||
(swap! template-cache assoc key entry)
|
||||
entry)))))
|
||||
|
||||
(defn get-cached-system-prompt
|
||||
"Get system prompt template from cache or load it.
|
||||
Returns template string or nil."
|
||||
[template-name]
|
||||
(:template (cached-load-template "system" template-name load-system-prompt-template)))
|
||||
|
||||
(defn get-cached-dialect-instructions
|
||||
"Get dialect instructions from cache or load them.
|
||||
Returns instructions string or nil."
|
||||
[dialect-name]
|
||||
(when dialect-name
|
||||
(:template (cached-load-template "dialect" dialect-name load-dialect-instructions))))
|
||||
|
||||
(defn get-cached-tool-prompt
|
||||
"Get tool prompt template from cache or load it.
|
||||
Returns template string or nil."
|
||||
[template-path]
|
||||
(:template (cached-load-template "tool" template-path load-tool-prompt-template)))
|
||||
|
||||
(defn get-cached-llm-representations-template
|
||||
"Get the LLM representations template from cache or load it."
|
||||
[]
|
||||
(:template (cached-load-template "llm-representations"
|
||||
"llm_representations.selmer"
|
||||
load-llm-representations-template)))
|
||||
|
||||
(defn clear-cache!
|
||||
"Clear the template cache. Useful for development/testing."
|
||||
[]
|
||||
(reset! template-cache {}))
|
||||
|
||||
;;; Tool Instructions Extraction
|
||||
|
||||
(defn extract-tool-instructions
|
||||
"Extract system instructions from tool definitions by loading prompt files.
|
||||
|
||||
For each tool, loads a markdown prompt file from `resources/metabot/prompts/tools/`.
|
||||
The filename is determined by:
|
||||
1. `:prompt` key in the tool's metadata (if present), or
|
||||
2. `\"<tool-name>.md\"` as default.
|
||||
|
||||
Only tools with a corresponding prompt resource file are included.
|
||||
|
||||
Returns vector of maps: [{:tool_name \"search\" :instructions \"...\"}]"
|
||||
[tools]
|
||||
(vec
|
||||
(for [[tool-name tool] tools
|
||||
:let [fname (or (-> tool meta :prompt)
|
||||
(str tool-name ".md"))
|
||||
prompt (some-> (io/resource (str "metabot/prompts/tools/" fname))
|
||||
slurp)]
|
||||
:when prompt]
|
||||
{:tool_name tool-name
|
||||
:instructions prompt})))
|
||||
|
||||
;;; High-Level API
|
||||
|
||||
(defn build-system-message-content
|
||||
"Build complete system message content from profile and context.
|
||||
|
||||
Parameters:
|
||||
- profile: Profile map with :prompt-template key
|
||||
- context: Agent context map with user info, viewing context, etc.
|
||||
- tools: Tool registry map
|
||||
|
||||
Returns rendered system message string."
|
||||
[{:keys [prompt-template]} context tools]
|
||||
(let [template-name (or prompt-template "internal.selmer")
|
||||
template (get-cached-system-prompt template-name)]
|
||||
(if template
|
||||
(let [sql-dialect (or (get context :sql_dialect)
|
||||
(get context :sql-dialect))
|
||||
dialect-instructions (when sql-dialect
|
||||
(get-cached-dialect-instructions sql-dialect))
|
||||
tool-instructions (extract-tool-instructions tools)
|
||||
current-time (or (get context :current_time)
|
||||
(get context :current-time))
|
||||
first-day-of-week (or (get context :first_day_of_week)
|
||||
(get context :first-day-of-week "Sunday"))
|
||||
viewing-context (or (get context :viewing_context)
|
||||
(get context :viewing-context))
|
||||
recent-views (or (get context :recent_views)
|
||||
(get context :recent-views))
|
||||
template-context {:current_time current-time
|
||||
:first_day_of_week first-day-of-week
|
||||
:sql_dialect sql-dialect
|
||||
:sql_dialect_instructions dialect-instructions
|
||||
:tool_instructions tool-instructions
|
||||
:viewing_context viewing-context
|
||||
:recent_views recent-views}]
|
||||
(render-system-prompt template template-context))
|
||||
;; Fallback if template not found
|
||||
(do
|
||||
(log/error "System prompt template not found:" template-name)
|
||||
"You are Metabot, a data analysis assistant for Metabase."))))
|
||||
|
||||
(comment
|
||||
;; Development examples
|
||||
|
||||
;; Load and render a template
|
||||
(def template (load-system-prompt-template "internal.selmer"))
|
||||
(render-system-prompt template {:current-time "2024-01-15 14:30:00"
|
||||
:sql-dialect "postgresql"})
|
||||
|
||||
;; Load dialect instructions
|
||||
(load-dialect-instructions "postgresql")
|
||||
|
||||
;; Clear cache during development
|
||||
(clear-cache!)
|
||||
|
||||
;; Extract tool instructions
|
||||
(extract-tool-instructions {"search" #'some-tool-var}))
|
||||
@@ -0,0 +1,147 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.streaming
|
||||
"Streaming helpers for the agent loop.
|
||||
Provides utilities for creating AI-SDK data parts, including navigation,
|
||||
and transducers for stream processing."
|
||||
(:require
|
||||
[buddy.core.codecs :as codecs]
|
||||
[metabase-enterprise.metabot-v3.agent.markdown-link-buffer :as markdown-link-buffer]
|
||||
[metabase.util.json :as json]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;;; AI-SDK Data Types
|
||||
;;
|
||||
;; These match the Python AI Service's AISDKDataTypes enum
|
||||
|
||||
(def navigate-to-type "AI-SDK data type for navigation links." "navigate_to")
|
||||
(def state-type "AI-SDK data type for state updates." "state")
|
||||
(def todo-list-type "AI-SDK data type for todo lists." "todo_list")
|
||||
(def code-edit-type "AI-SDK data type for code edits." "code_edit")
|
||||
(def transform-suggestion-type "AI-SDK data type for transform suggestions." "transform_suggestion")
|
||||
|
||||
;;; Query URL Encoding
|
||||
|
||||
(defn query->url-hash
|
||||
"Convert an MLv2/MBQL query to a base64-encoded URL hash.
|
||||
Used for /question# URLs."
|
||||
[query]
|
||||
(-> {:dataset_query query}
|
||||
json/encode
|
||||
(.getBytes "UTF-8")
|
||||
codecs/bytes->b64-str))
|
||||
|
||||
(defn query->question-url
|
||||
"Convert a query to a /question# URL."
|
||||
[query]
|
||||
(str "/question#" (query->url-hash query)))
|
||||
|
||||
;;; Data Part Constructors
|
||||
|
||||
(defn navigate-to-part
|
||||
"Create a NAVIGATE_TO data part for streaming.
|
||||
The URL should be a path like '/question#...' or '/model/123'.
|
||||
|
||||
This matches Python AI Service's:
|
||||
ai_sdk.create_data_part(data_type=AISDKDataTypes.NAVIGATE_TO, version=1, value=path)"
|
||||
[url]
|
||||
{:type :data
|
||||
:data-type navigate-to-type
|
||||
:data url})
|
||||
|
||||
(defn state-part
|
||||
"Create a STATE data part for streaming."
|
||||
[state-map]
|
||||
{:type :data
|
||||
:data-type state-type
|
||||
:data state-map})
|
||||
|
||||
(defn todo-list-part
|
||||
"Create a TODO_LIST data part for streaming.
|
||||
Todos should be a vector of todo item maps with :id, :content, :status, :priority keys.
|
||||
|
||||
This matches Python AI Service's:
|
||||
ai_sdk.create_data_part(data_type=AISDKDataTypes.TODO_LIST, version=1, value=todos)"
|
||||
[todos]
|
||||
{:type :data
|
||||
:data-type todo-list-type
|
||||
:version 1
|
||||
:data todos})
|
||||
|
||||
(defn code-edit-part
|
||||
"Create a CODE_EDIT data part for streaming.
|
||||
Edit-data should be a map describing the code edit operation.
|
||||
|
||||
This matches Python AI Service's:
|
||||
ai_sdk.create_data_part(data_type=AISDKDataTypes.CODE_EDIT, version=1, value=edit_data)"
|
||||
[edit-data]
|
||||
{:type :data
|
||||
:data-type code-edit-type
|
||||
:version 1
|
||||
:data edit-data})
|
||||
|
||||
(defn transform-suggestion-part
|
||||
"Create a TRANSFORM_SUGGESTION data part for streaming.
|
||||
Suggestion should be a map containing the suggested transform definition.
|
||||
|
||||
This matches Python AI Service's:
|
||||
ai_sdk.create_data_part(data_type=AISDKDataTypes.TRANSFORM_SUGGESTION, version=1, value=suggestion)"
|
||||
[suggestion]
|
||||
{:type :data
|
||||
:data-type transform-suggestion-type
|
||||
:version 1
|
||||
:data suggestion})
|
||||
|
||||
;;; Reaction Conversion
|
||||
|
||||
(defn reactions->data-parts
|
||||
"Convert tool reactions to AI-SDK data parts for streaming.
|
||||
|
||||
Reactions are metadata returned by tools that trigger side effects:
|
||||
- :metabot.reaction/redirect -> navigate_to data part
|
||||
|
||||
Returns a vector of data parts (may be empty if no relevant reactions)."
|
||||
[reactions]
|
||||
(into []
|
||||
(keep (fn [{:keys [type url]}]
|
||||
(case type
|
||||
:metabot.reaction/redirect (navigate-to-part url)
|
||||
nil)))
|
||||
reactions))
|
||||
|
||||
;;; Stream Processing Transducers
|
||||
|
||||
(def expand-reactions-xf
|
||||
"Stateless transducer that expands :reactions from tool-output parts into data parts.
|
||||
Passes through all parts unchanged, then appends any reaction data parts after tool-output parts."
|
||||
(mapcat (fn [part]
|
||||
(if (= (:type part) :tool-output)
|
||||
(cons part (reactions->data-parts (get-in part [:result :reactions])))
|
||||
[part]))))
|
||||
|
||||
(def expand-data-parts-xf
|
||||
"Stateless transducer that expands :data-parts from tool-output results.
|
||||
Passes through all parts unchanged, then appends any data parts after tool-output parts."
|
||||
(mapcat (fn [part]
|
||||
(if (= (:type part) :tool-output)
|
||||
(cons part (get-in part [:result :data-parts]))
|
||||
[part]))))
|
||||
|
||||
(defn post-process-xf
|
||||
"Composed transducer for post-processing agent output.
|
||||
|
||||
Applies in order:
|
||||
1. expand-data-parts-xf - Extract data-parts from tool outputs
|
||||
2. expand-reactions-xf - Extract reactions from tool outputs as data parts
|
||||
3. resolve-links-xf - Resolve metabase:// links in text parts
|
||||
|
||||
Note: expand-data-parts-xf comes first so its output appears before reactions,
|
||||
matching the original stream-parts-to-output! behavior.
|
||||
|
||||
Parameters:
|
||||
- initial-queries: Initial map of query-id to query data
|
||||
- initial-charts: Initial map of chart-id to chart data"
|
||||
[initial-queries initial-charts]
|
||||
(comp
|
||||
expand-data-parts-xf
|
||||
expand-reactions-xf
|
||||
(markdown-link-buffer/resolve-xf initial-queries initial-charts)))
|
||||
@@ -0,0 +1,106 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools
|
||||
"Tool registry for the agent loop.
|
||||
Provides a unified namespace for tool vars and state-aware wrapping."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.agent.tools.autogen-dashboard :as tools.autogen-dashboard]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.charts :as tools.charts]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.clarification :as tools.clarification]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.construct :as tools.construct]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.document :as tools.document]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.metadata :as tools.metadata]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.navigation :as tools.navigation]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.resources :as tools.resources]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.search :as tools.search]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.snippets :as tools.snippets]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.sql :as tools.sql]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.subscriptions :as tools.subscriptions]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.todo :as tools.todo]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.transforms :as tools.transforms]
|
||||
[potemkin :as p]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(p/import-vars
|
||||
[tools.search search-tool
|
||||
sql-search-tool
|
||||
nlq-search-tool
|
||||
transform-search-tool]
|
||||
[tools.construct
|
||||
construct-notebook-query-tool]
|
||||
[tools.document
|
||||
document-schema-collect-tool
|
||||
document-construct-sql-chart-tool
|
||||
document-construct-model-chart-tool]
|
||||
[tools.metadata
|
||||
list-available-data-sources-tool
|
||||
list-available-fields-tool
|
||||
get-field-values-tool]
|
||||
[tools.transforms
|
||||
get-transform-details-tool
|
||||
get-transform-python-library-details-tool
|
||||
write-transform-sql-tool
|
||||
write-transform-python-tool]
|
||||
[tools.sql
|
||||
create-sql-query-tool
|
||||
create-sql-query-code-edit-tool
|
||||
edit-sql-query-tool
|
||||
edit-sql-query-code-edit-tool
|
||||
replace-sql-query-tool
|
||||
replace-sql-query-code-edit-tool]
|
||||
[tools.charts
|
||||
create-chart-tool
|
||||
edit-chart-tool]
|
||||
[tools.resources
|
||||
read-resource-tool]
|
||||
[tools.todo
|
||||
todo-write-tool
|
||||
todo-read-tool]
|
||||
[tools.navigation
|
||||
navigate-user-tool]
|
||||
[tools.snippets
|
||||
list-snippets-tool
|
||||
get-snippet-details-tool]
|
||||
[tools.clarification
|
||||
ask-for-sql-clarification-tool]
|
||||
[tools.autogen-dashboard
|
||||
create-autogenerated-dashboard-tool]
|
||||
[tools.subscriptions
|
||||
create-dashboard-subscription-tool])
|
||||
|
||||
(def ^:dynamic ^:private *memory-atom* nil)
|
||||
|
||||
(def ^:private state-dependent-tools
|
||||
"Set of tool names that require access to agent state."
|
||||
#{"create_chart" "edit_chart" "todo_write" "todo_read" "navigate_user"
|
||||
"write_transform_sql" "write_transform_python" "create_autogenerated_dashboard"
|
||||
"create_sql_query" "edit_sql_query" "replace_sql_query"
|
||||
"document_schema_collect" "document_construct_sql_chart" "document_construct_model_chart"})
|
||||
|
||||
(defn wrap-tools-with-state
|
||||
"Wrap state-dependent tools with access to the memory atom.
|
||||
Tools in `state-dependent-tools` will have access to the memory atom
|
||||
via the dynamic `*memory-atom*` binding.
|
||||
|
||||
Returns a new tools map where state-dependent tools are wrapped.
|
||||
Wrapped tools are returned as maps with :doc, :schema, :prompt, and :fn keys
|
||||
to satisfy the Claude API schema validation. Tool-specific instructions are
|
||||
loaded from `resources/metabot/prompts/tools/` by `extract-tool-instructions`
|
||||
in `prompts.clj`, keyed by `:prompt` metadata or `<tool-name>.md` by default."
|
||||
[tools memory-atom]
|
||||
(reduce-kv
|
||||
(fn [acc tool-name tool-var]
|
||||
(if (contains? state-dependent-tools tool-name)
|
||||
(let [{:keys [doc schema prompt decode]} (meta tool-var)
|
||||
wrapped-fn (fn [args]
|
||||
(binding [shared/*memory-atom* memory-atom
|
||||
*memory-atom* memory-atom]
|
||||
(tool-var args)))]
|
||||
(assoc acc tool-name (cond-> {:doc doc
|
||||
:schema schema
|
||||
:prompt prompt
|
||||
:fn wrapped-fn}
|
||||
decode (assoc :decode decode))))
|
||||
(assoc acc tool-name tool-var)))
|
||||
{}
|
||||
tools))
|
||||
@@ -0,0 +1,71 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.analytics
|
||||
"Analytics tool wrappers for outlier detection."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
|
||||
[metabase-enterprise.metabot-v3.tools.find-outliers :as outlier-tools]
|
||||
[metabase-enterprise.metabot-v3.util :as metabot.u]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- resolve-query-from-memory
|
||||
"Resolve a query-id from agent memory into a dataset_query."
|
||||
[query-id]
|
||||
(when-let [query (get (shared/current-queries-state) query-id)]
|
||||
query))
|
||||
|
||||
(defn- format-outliers-output
|
||||
"Format outlier results as XML for the agent."
|
||||
[outliers]
|
||||
(if (empty? outliers)
|
||||
"<result>No outliers detected in the data.</result>"
|
||||
(metabot.u/xml
|
||||
[:outliers {:count (count outliers)}
|
||||
(for [{:keys [dimension value]} outliers]
|
||||
[:outlier {:dimension (str dimension) :value (str value)}])])))
|
||||
|
||||
(mu/defn ^{:tool-name "find_outliers"}
|
||||
find-outliers-tool
|
||||
"Find outliers for a metric, or the numeric column of a query or report result.
|
||||
Use this if the user explicitly asks for outliers, unusual mins/maxes,
|
||||
whether values are within a normal range, or records that stand out in results."
|
||||
[{:keys [data_source]}
|
||||
:- [:map {:closed true}
|
||||
[:data_source [:map
|
||||
[:query_id {:optional true} [:maybe :string]]
|
||||
[:result_field_id {:optional true} [:maybe :string]]
|
||||
[:report_id {:optional true} [:maybe :int]]
|
||||
[:metric_id {:optional true} [:maybe :int]]
|
||||
[:table_id {:optional true} [:maybe :string]]]]]]
|
||||
(try
|
||||
(let [;; If query_id is provided, resolve it from memory to an actual query
|
||||
data-source (if-let [query-id (:query_id data_source)]
|
||||
(if-let [query (resolve-query-from-memory query-id)]
|
||||
(-> data_source
|
||||
(assoc :query query)
|
||||
(dissoc :query_id))
|
||||
(throw (ex-info (str "Query not found in memory: " query-id)
|
||||
{:agent-error? true :query-id query-id})))
|
||||
data_source)
|
||||
;; Normalize keys for the backend tool
|
||||
data-source (cond-> data-source
|
||||
(:report_id data-source) (-> (assoc :report-id (:report_id data-source))
|
||||
(dissoc :report_id))
|
||||
(:metric_id data-source) (-> (assoc :metric-id (:metric_id data-source))
|
||||
(dissoc :metric_id))
|
||||
(:table_id data-source) (-> (assoc :table-id (:table_id data-source))
|
||||
(dissoc :table_id))
|
||||
(:result_field_id data-source) (-> (assoc :result-field-id (:result_field_id data-source))
|
||||
(dissoc :result_field_id)))
|
||||
result (outlier-tools/find-outliers {:data-source data-source})
|
||||
outliers (:structured-output result)]
|
||||
(if outliers
|
||||
{:output (format-outliers-output outliers)
|
||||
:structured-output outliers}
|
||||
result))
|
||||
(catch Exception e
|
||||
(log/error e "Error finding outliers")
|
||||
(if (:agent-error? (ex-data e))
|
||||
{:output (ex-message e)}
|
||||
{:output (str "Failed to find outliers: " (or (ex-message e) "Unknown error"))}))))
|
||||
@@ -0,0 +1,49 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.autogen-dashboard
|
||||
"Autogenerated dashboard tool wrappers."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
|
||||
[metabase-enterprise.metabot-v3.tools.create-autogenerated-dashboard :as autogen-dashboard-tools]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(mu/defn ^{:tool-name "create_autogenerated_dashboard"
|
||||
:capabilities #{:automagic-dashboards}}
|
||||
create-autogenerated-dashboard-tool
|
||||
"Create an automatically generated dashboard using Metabase's X-ray feature.
|
||||
|
||||
X-ray builds complete dashboards with multiple visualizations that showcase
|
||||
different aspects of your data - distributions, breakdowns by categories,
|
||||
trends over time, and relationships between fields.
|
||||
|
||||
Specify one source type:
|
||||
- table_id: Create dashboard for a table
|
||||
- model_id: Create dashboard for a model
|
||||
- metric_id: Create dashboard for a metric
|
||||
- query_id: Create dashboard for query results from this conversation
|
||||
- report_id: Create dashboard for an existing report/question"
|
||||
[{:keys [source]}
|
||||
:- [:map {:closed true}
|
||||
[:source [:map
|
||||
[:table_id {:optional true} [:maybe :int]]
|
||||
[:model_id {:optional true} [:maybe :int]]
|
||||
[:metric_id {:optional true} [:maybe :int]]
|
||||
[:query_id {:optional true} [:maybe :string]]
|
||||
[:report_id {:optional true} [:maybe :int]]]]]]
|
||||
(try
|
||||
(let [result (autogen-dashboard-tools/create-autogenerated-dashboard
|
||||
{:source source
|
||||
:memory-atom shared/*memory-atom*})
|
||||
structured (:structured-output result)
|
||||
reactions (:reactions result)]
|
||||
(cond-> {:output (:message structured)
|
||||
:structured-output structured
|
||||
:final-response? (:final-response? result)
|
||||
:instructions (:instructions result)}
|
||||
(seq reactions) (assoc :reactions reactions)))
|
||||
(catch Exception e
|
||||
(log/error e "Error creating autogenerated dashboard")
|
||||
(if (:agent-error? (ex-data e))
|
||||
{:output (ex-message e)}
|
||||
{:output (str "Failed to create autogenerated dashboard: " (or (ex-message e) "Unknown error"))}))))
|
||||
@@ -0,0 +1,72 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.charts
|
||||
"Chart tool wrappers."
|
||||
(:require
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
|
||||
[metabase-enterprise.metabot-v3.tools.create-chart :as create-chart-tools]
|
||||
[metabase-enterprise.metabot-v3.tools.edit-chart :as edit-chart-tools]
|
||||
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
|
||||
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- format-chart-output
|
||||
[{:keys [chart-id] :as structured}]
|
||||
(let [chart-xml (llm-rep/chart->xml structured)]
|
||||
(str "<result>\n" chart-xml "\n</result>\n"
|
||||
"<instructions>\n" (instructions/chart-created-instructions chart-id) "\n</instructions>")))
|
||||
|
||||
(mu/defn ^{:tool-name "create_chart"} create-chart-tool
|
||||
"Create a chart from a query.
|
||||
|
||||
Provide a query_id in data_source and a chart_type in viz_settings."
|
||||
[{:keys [data_source viz_settings]}
|
||||
:- [:map {:closed true}
|
||||
[:data_source [:map {:closed true}
|
||||
[:query_id :string]]]
|
||||
[:viz_settings [:map {:closed true}
|
||||
[:chart_type [:enum "table" "bar" "line" "pie" "sunburst" "area" "combo"
|
||||
"row" "pivot" "scatter" "waterfall" "sankey" "scalar"
|
||||
"smartscalar" "gauge" "progress" "funnel" "object" "map"]]]]]]
|
||||
(try
|
||||
(let [result (create-chart-tools/create-chart
|
||||
{:query-id (get data_source :query_id)
|
||||
:chart-type (keyword (get viz_settings :chart_type))
|
||||
:queries-state (shared/current-queries-state)})
|
||||
reactions (:reactions result)
|
||||
structured (assoc (dissoc result :reactions) :result-type :chart)]
|
||||
(-> {:output (format-chart-output structured)
|
||||
:structured-output structured}
|
||||
(m/assoc-some :reactions (not-empty reactions))))
|
||||
(catch Exception e
|
||||
(log/error e "Error creating chart")
|
||||
(if (:agent-error? (ex-data e))
|
||||
{:output (ex-message e)}
|
||||
{:output (str "Failed to create chart: " (or (ex-message e) "Unknown error"))}))))
|
||||
|
||||
(mu/defn ^{:tool-name "edit_chart"} edit-chart-tool
|
||||
"Edit an existing chart's visualization type.
|
||||
|
||||
Provide a new chart_type in new_viz_settings."
|
||||
[{:keys [chart_id new_viz_settings]}
|
||||
:- [:map {:closed true}
|
||||
[:chart_id :string]
|
||||
[:new_viz_settings [:map {:closed true}
|
||||
[:chart_type [:enum "table" "bar" "line" "pie" "sunburst" "area" "combo"
|
||||
"row" "pivot" "scatter" "waterfall" "sankey" "scalar"
|
||||
"smartscalar" "gauge" "progress" "funnel" "object" "map"]]]]]]
|
||||
(try
|
||||
(let [result (edit-chart-tools/edit-chart
|
||||
{:chart-id chart_id
|
||||
:new-chart-type (keyword (get new_viz_settings :chart_type))
|
||||
:charts-state (shared/current-charts-state)})
|
||||
structured (assoc result :result-type :chart)]
|
||||
{:output (format-chart-output structured)
|
||||
:structured-output structured})
|
||||
(catch Exception e
|
||||
(log/error e "Error editing chart")
|
||||
(if (:agent-error? (ex-data e))
|
||||
{:output (ex-message e)}
|
||||
{:output (str "Failed to edit chart: " (or (ex-message e) "Unknown error"))}))))
|
||||
@@ -0,0 +1,30 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.clarification
|
||||
"Clarification tool wrappers."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.metabot-v3.tools.ask-clarification :as ask-clarification-tools]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- format-clarification-output
|
||||
[{:keys [question options]}]
|
||||
(str question
|
||||
(when (seq options)
|
||||
(str "\n\nOptions:\n" (str/join "\n" (map #(str "- " %) options))))))
|
||||
|
||||
(mu/defn ^{:tool-name "ask_for_sql_clarification"} ask-for-sql-clarification-tool
|
||||
"Ask the user for clarification about their SQL query request.
|
||||
|
||||
Use this tool when you need more information from the user to properly
|
||||
complete their SQL request. The agent will stop and wait for the user's response."
|
||||
[{:keys [question options]}
|
||||
:- [:map {:closed true}
|
||||
[:question :string]
|
||||
[:options {:optional true} [:maybe [:sequential :string]]]]]
|
||||
(try
|
||||
(let [result (ask-clarification-tools/ask-for-sql-clarification {:question question
|
||||
:options options})]
|
||||
(assoc result :output (format-clarification-output (:structured-output result))))
|
||||
(catch Exception e
|
||||
{:output (str "Failed to ask clarification: " (or (ex-message e) "Unknown error"))})))
|
||||
@@ -0,0 +1,568 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.construct
|
||||
"Notebook query construction tool wrappers."
|
||||
(:require
|
||||
[clojure.walk :as walk]
|
||||
[malli.core :as mc]
|
||||
[malli.transform :as mtx]
|
||||
[metabase-enterprise.metabot-v3.agent.streaming :as streaming]
|
||||
[metabase-enterprise.metabot-v3.tmpl :as te]
|
||||
[metabase-enterprise.metabot-v3.tools.create-chart :as create-chart-tools]
|
||||
[metabase-enterprise.metabot-v3.tools.filters :as filter-tools]
|
||||
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
|
||||
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
|
||||
[metabase-enterprise.metabot-v3.util :as metabot-u]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- normalize-ai-args
|
||||
"Normalize nested tool arguments to kebab-case keys and keyword enums."
|
||||
[value]
|
||||
(let [normalized (when value
|
||||
(metabot-u/recursive-update-keys value metabot-u/safe->kebab-case-en))
|
||||
enum-keys #{:operation :bucket :function :sort-order :direction :field-granularity :filter-type :operator}
|
||||
normalize-enum (fn [v]
|
||||
(cond
|
||||
(keyword? v) (-> v name metabot-u/safe->kebab-case-en keyword)
|
||||
(string? v) (let [normalized-v (metabot-u/safe->kebab-case-en v)]
|
||||
(case normalized-v
|
||||
"ascending" :asc
|
||||
"descending" :desc
|
||||
(keyword normalized-v)))
|
||||
:else v))]
|
||||
(walk/postwalk
|
||||
(fn [x]
|
||||
(if (map? x)
|
||||
(reduce-kv (fn [m k v]
|
||||
(assoc m k (if (enum-keys k) (normalize-enum v) v)))
|
||||
{}
|
||||
x)
|
||||
x))
|
||||
normalized)))
|
||||
|
||||
;; Query construction is handled by construct_notebook_query for parity with ai-service.
|
||||
|
||||
(def ^:private construct-field-schema
|
||||
[:map {:closed true}
|
||||
[:field_id :string]
|
||||
[:field_granularity {:optional true} [:maybe :string]]
|
||||
[:bucket {:optional true} [:maybe :string]]])
|
||||
|
||||
(def ^:private construct-field-aggregation-schema
|
||||
[:map {:closed true}
|
||||
[:field_id {:optional true} :string]
|
||||
;; expression_ref allows aggregating on a named expression (calculated column)
|
||||
[:expression_ref {:optional true} :string]
|
||||
[:function [:enum "count" "count-distinct" "distinct" "sum" "min" "max" "avg"
|
||||
;; Advanced aggregations
|
||||
"median" "stddev" "var" "percentile"
|
||||
"cum-sum" "cum-count" "share"]]
|
||||
[:sort_order {:optional true} [:maybe [:enum "ascending" "descending"]]]
|
||||
[:bucket {:optional true} [:maybe :string]]
|
||||
;; For percentile function (0-1 range, e.g., 0.95 for 95th percentile)
|
||||
[:percentile_value {:optional true} [:maybe number?]]])
|
||||
|
||||
(def ^:private construct-measure-aggregation-schema
|
||||
[:map {:closed true}
|
||||
[:measure_id :int]
|
||||
[:sort_order {:optional true} [:maybe :string]]])
|
||||
|
||||
(def ^:private construct-order-by-schema
|
||||
[:map {:closed true}
|
||||
[:field construct-field-schema]
|
||||
[:direction :string]])
|
||||
|
||||
;; Canonical operation values per filter_type.
|
||||
;; These appear as enum constraints in the JSON Schema sent to the LLM.
|
||||
(def ^:private multi-value-operations
|
||||
["equals" "not-equals"
|
||||
"string-starts-with" "string-ends-with"
|
||||
"string-contains" "string-not-contains"
|
||||
;; aliases the LLM may send from system-instruction shorthand
|
||||
"contains" "not-contains" "does-not-contain"
|
||||
"starts-with" "ends-with"])
|
||||
|
||||
(def ^:private single-value-operations
|
||||
["greater-than" "greater-than-or-equal"
|
||||
"less-than" "less-than-or-equal"
|
||||
;; LLM may use equals/not-equals with a single value instead of multi_value
|
||||
"equals" "not-equals"])
|
||||
|
||||
(def ^:private no-value-operations
|
||||
["is-null" "is-not-null"
|
||||
"string-is-empty" "string-is-not-empty"
|
||||
"is-true" "is-false"
|
||||
;; aliases
|
||||
"is-empty" "is-not-empty"])
|
||||
|
||||
(def ^:private construct-multi-value-filter-schema
|
||||
[:map {:closed true
|
||||
:decode/tool filter-tools/decode-temporal-filter}
|
||||
[:filter_type [:enum "multi_value"]]
|
||||
[:field_id :string]
|
||||
[:operation (into [:enum] multi-value-operations)]
|
||||
[:bucket {:optional true} [:maybe :string]]
|
||||
[:value {:optional true} [:maybe :any]]
|
||||
[:values {:optional true} [:maybe [:sequential :any]]]])
|
||||
|
||||
(def ^:private construct-single-value-filter-schema
|
||||
[:map {:closed true
|
||||
:decode/tool filter-tools/decode-temporal-filter}
|
||||
[:filter_type [:enum "single_value"]]
|
||||
[:field_id :string]
|
||||
[:operation (into [:enum] single-value-operations)]
|
||||
[:bucket {:optional true} [:maybe :string]]
|
||||
[:value {:optional true} [:maybe :any]]
|
||||
[:values {:optional true} [:maybe [:sequential :any]]]])
|
||||
|
||||
(def ^:private construct-no-value-filter-schema
|
||||
[:map {:closed true}
|
||||
[:filter_type [:enum "no_value"]]
|
||||
[:field_id :string]
|
||||
[:operation (into [:enum] no-value-operations)]])
|
||||
|
||||
(def ^:private construct-segment-filter-schema
|
||||
[:map {:closed true}
|
||||
[:filter_type [:enum "segment"]]
|
||||
[:segment_id :int]])
|
||||
|
||||
(def ^:private construct-between-filter-schema
|
||||
[:map {:closed true
|
||||
:decode/tool filter-tools/decode-temporal-filter}
|
||||
[:filter_type [:enum "between"]]
|
||||
[:field_id :string]
|
||||
[:lower_value :any]
|
||||
[:upper_value :any]
|
||||
[:bucket {:optional true} [:maybe :string]]])
|
||||
|
||||
;; Non-compound filter schema (used inside compound filters to avoid infinite recursion)
|
||||
(def ^:private construct-leaf-filter-schema
|
||||
"Filter schema without compound - used as children of compound filters."
|
||||
[:or
|
||||
construct-multi-value-filter-schema
|
||||
construct-single-value-filter-schema
|
||||
construct-no-value-filter-schema
|
||||
construct-segment-filter-schema
|
||||
construct-between-filter-schema])
|
||||
|
||||
;; Recursive filter schema so compound filters can be nested.
|
||||
(def ^:private construct-filter-registry
|
||||
{::construct-filter
|
||||
[:or
|
||||
construct-leaf-filter-schema
|
||||
[:map {:closed true}
|
||||
[:filter_type [:enum "compound"]]
|
||||
[:operator [:enum "and" "or"]]
|
||||
[:filters [:sequential {:min 1} [:ref ::construct-filter]]]]]})
|
||||
|
||||
(def ^:private construct-filter-schema
|
||||
[:schema {:registry construct-filter-registry}
|
||||
[:ref ::construct-filter]])
|
||||
|
||||
;; Simple filter schema for conditional aggregation conditions.
|
||||
;; Conditions don't need compound (AND/OR) filters - those are for top-level filters only.
|
||||
(def ^:private construct-simple-condition-filter-schema
|
||||
"Non-compound filter schema for use in conditional aggregation conditions."
|
||||
construct-leaf-filter-schema)
|
||||
|
||||
(def ^:private construct-conditional-aggregation-schema
|
||||
"Schema for conditional aggregations like count-where, sum-where, distinct-where."
|
||||
[:map {:closed true}
|
||||
[:field_id {:optional true} :string]
|
||||
[:function [:enum "count-where" "sum-where" "distinct-where"]]
|
||||
[:condition construct-simple-condition-filter-schema]
|
||||
[:sort_order {:optional true} [:maybe [:enum "ascending" "descending"]]]
|
||||
[:bucket {:optional true} [:maybe :string]]])
|
||||
|
||||
(def ^:private construct-metric-schema
|
||||
[:or
|
||||
construct-field-aggregation-schema
|
||||
construct-measure-aggregation-schema
|
||||
construct-conditional-aggregation-schema])
|
||||
|
||||
(def ^:private construct-source-metric-schema
|
||||
[:map {:closed true}
|
||||
[:metric_id :int]])
|
||||
|
||||
(def ^:private construct-source-model-schema
|
||||
[:map {:closed true}
|
||||
[:model_id :int]])
|
||||
|
||||
(def ^:private construct-source-table-schema
|
||||
[:map {:closed true}
|
||||
[:table_id :int]])
|
||||
|
||||
(def ^:private construct-visualization-schema
|
||||
[:map {:closed true}
|
||||
[:chart_type :string]])
|
||||
|
||||
;;; Expression schemas for calculated columns
|
||||
;;
|
||||
;; Expressions support nested sub-expressions as arguments, e.g.:
|
||||
;; {:operation "divide"
|
||||
;; :arguments [{:operation "subtract" :arguments [...]} ; nested expression
|
||||
;; {:field_id "t5-3"}]} ; field reference
|
||||
|
||||
(def ^:private expression-operation-enum
|
||||
"Valid operations for expressions."
|
||||
[:enum
|
||||
;; Math operations (binary - take multiple args)
|
||||
"add" "subtract" "multiply" "divide"
|
||||
;; Math operations (unary)
|
||||
"abs" "round" "ceil" "floor" "sqrt" "log" "exp" "power"
|
||||
;; String operations
|
||||
"concat" "upper" "lower" "trim" "length" "substring"
|
||||
;; Date extraction
|
||||
"get-year" "get-month" "get-day" "get-hour" "get-minute" "get-second"
|
||||
"get-quarter" "get-day-of-week"
|
||||
;; Date arithmetic
|
||||
"datetime-add" "datetime-subtract"
|
||||
;; Coalesce
|
||||
"coalesce"])
|
||||
|
||||
;; Inline sub-expression (used as an argument, doesn't have a name)
|
||||
(def ^:private construct-inline-expression-schema
|
||||
"Schema for an inline/nested expression used as an argument to another expression."
|
||||
[:map {:closed true}
|
||||
[:operation expression-operation-enum]
|
||||
[:arguments [:sequential [:ref ::expression-argument]]]
|
||||
;; Optional fields for specific operations
|
||||
[:unit {:optional true} [:maybe [:enum "year" "quarter" "month" "week" "day"
|
||||
"hour" "minute" "second"]]]
|
||||
[:start {:optional true} [:maybe :int]]
|
||||
[:end {:optional true} [:maybe :int]]
|
||||
[:exponent {:optional true} [:maybe number?]]])
|
||||
|
||||
;; Use Malli registry for recursive schema
|
||||
(def ^:private expression-argument-registry
|
||||
{::expression-argument
|
||||
[:or
|
||||
[:map {:closed true} [:field_id :string]]
|
||||
[:map {:closed true} [:value :any]]
|
||||
[:map {:closed true} [:expression_ref :string]]
|
||||
construct-inline-expression-schema]})
|
||||
|
||||
(def ^:private construct-expression-schema
|
||||
"Schema for defining calculated columns/expressions (top-level, must have a name)."
|
||||
[:map {:closed true}
|
||||
[:name :string]
|
||||
[:operation expression-operation-enum]
|
||||
[:arguments [:schema {:registry expression-argument-registry}
|
||||
[:sequential [:ref ::expression-argument]]]]
|
||||
;; For datetime-add/subtract operations
|
||||
[:unit {:optional true} [:maybe [:enum "year" "quarter" "month" "week" "day"
|
||||
"hour" "minute" "second"]]]
|
||||
;; For substring operation
|
||||
[:start {:optional true} [:maybe :int]]
|
||||
[:end {:optional true} [:maybe :int]]
|
||||
;; For power operation
|
||||
[:exponent {:optional true} [:maybe number?]]])
|
||||
|
||||
;;; Post-aggregation filter schema (HAVING equivalent)
|
||||
|
||||
(def ^:private construct-leaf-post-filter-schema
|
||||
"Schema for a single post-aggregation filter. References aggregations by their 0-based index."
|
||||
[:map {:closed true}
|
||||
[:aggregation_index :int]
|
||||
[:operation [:enum "greater-than" "less-than" "equals" "not-equals"
|
||||
"greater-than-or-equal" "less-than-or-equal"]]
|
||||
[:value :any]])
|
||||
|
||||
(def ^:private construct-compound-post-filter-schema
|
||||
"Schema for combining multiple post-aggregation filters with AND/OR."
|
||||
[:map {:closed true}
|
||||
[:filter_type [:enum "compound"]]
|
||||
[:operator [:enum "and" "or"]]
|
||||
[:filters [:sequential {:min 1} construct-leaf-post-filter-schema]]])
|
||||
|
||||
(def ^:private construct-post-filter-schema
|
||||
"Schema for post-aggregation filtering. Can be a simple filter or compound (AND/OR)."
|
||||
[:or
|
||||
construct-leaf-post-filter-schema
|
||||
construct-compound-post-filter-schema])
|
||||
|
||||
;; The LLM sometimes nests visualization inside query, so we accept it as optional
|
||||
;; in each query variant and pull it out during normalization.
|
||||
(def ^:private construct-query-metric-schema
|
||||
[:map {:closed true}
|
||||
[:query_type [:enum "metric"]]
|
||||
[:source construct-source-metric-schema]
|
||||
[:filters [:sequential construct-filter-schema]]
|
||||
[:group_by [:sequential construct-field-schema]]
|
||||
[:visualization {:optional true} construct-visualization-schema]])
|
||||
|
||||
(def ^:private construct-query-aggregate-schema
|
||||
[:map {:closed true}
|
||||
[:query_type [:enum "aggregate"]]
|
||||
[:source [:or construct-source-model-schema construct-source-table-schema]]
|
||||
[:expressions {:optional true} [:sequential construct-expression-schema]]
|
||||
[:aggregations [:sequential construct-metric-schema]]
|
||||
[:filters {:optional true} [:maybe [:sequential construct-filter-schema]]]
|
||||
[:group_by [:sequential construct-field-schema]]
|
||||
[:limit {:optional true} [:maybe :int]]
|
||||
[:post_filters {:optional true} [:sequential construct-post-filter-schema]]
|
||||
[:visualization {:optional true} construct-visualization-schema]])
|
||||
|
||||
(def ^:private construct-query-raw-schema
|
||||
[:map {:closed true}
|
||||
[:query_type [:enum "raw"]]
|
||||
[:source [:or construct-source-model-schema construct-source-table-schema]]
|
||||
[:expressions {:optional true} [:sequential construct-expression-schema]]
|
||||
[:filters [:sequential construct-filter-schema]]
|
||||
[:fields [:sequential construct-field-schema]]
|
||||
[:order_by [:sequential construct-order-by-schema]]
|
||||
[:limit [:maybe :int]]
|
||||
[:visualization {:optional true} construct-visualization-schema]])
|
||||
|
||||
(def ^:private construct-query-schema
|
||||
[:or construct-query-metric-schema construct-query-aggregate-schema construct-query-raw-schema])
|
||||
|
||||
(def ^:private construct-operation-aliases
|
||||
{"contains" :string-contains
|
||||
"not-contains" :string-not-contains
|
||||
"does-not-contain" :string-not-contains
|
||||
"starts-with" :string-starts-with
|
||||
"ends-with" :string-ends-with
|
||||
"is-empty" :string-is-empty
|
||||
"is-not-empty" :string-is-not-empty})
|
||||
|
||||
(defn- normalize-construct-operation
|
||||
[operation]
|
||||
(let [op (cond
|
||||
(keyword? operation) operation
|
||||
(string? operation) (keyword operation)
|
||||
:else operation)]
|
||||
(get construct-operation-aliases (name op) op)))
|
||||
|
||||
(defn- normalize-construct-filter
|
||||
[filter]
|
||||
(let [normalized (normalize-ai-args filter)
|
||||
filter-type (:filter-type normalized)
|
||||
;; Only normalize operation if it exists (compound, segment, between don't have operations)
|
||||
normalized (if (:operation normalized)
|
||||
(assoc normalized :operation (normalize-construct-operation (:operation normalized)))
|
||||
normalized)]
|
||||
(case filter-type
|
||||
:multi-value (-> normalized
|
||||
(dissoc :filter-type :value)
|
||||
(update :values (fn [vals]
|
||||
(cond
|
||||
(sequential? vals) (vec vals)
|
||||
(some? (:value normalized)) [(:value normalized)]
|
||||
:else []))))
|
||||
:single-value (-> normalized
|
||||
(dissoc :filter-type :values)
|
||||
(assoc :value (or (:value normalized)
|
||||
(first (:values normalized)))))
|
||||
:no-value (-> normalized
|
||||
(dissoc :filter-type :value :values))
|
||||
:segment (-> normalized
|
||||
(dissoc :filter-type :field-id :operation :bucket :value :values))
|
||||
:between (-> normalized
|
||||
(dissoc :filter-type)
|
||||
(assoc :filter-kind :between))
|
||||
:compound (-> normalized
|
||||
(dissoc :filter-type)
|
||||
(assoc :filter-kind :compound)
|
||||
(update :filters #(mapv normalize-construct-filter %)))
|
||||
(dissoc normalized :filter-type))))
|
||||
|
||||
(defn- normalize-construct-filters
|
||||
[filters]
|
||||
(mapv normalize-construct-filter filters))
|
||||
|
||||
(defn- normalize-post-filter
|
||||
"Normalize a post-filter, handling both leaf filters and compound (AND/OR) filters."
|
||||
[post-filter]
|
||||
(let [normalized (normalize-ai-args post-filter)]
|
||||
(if (= :compound (:filter-type normalized))
|
||||
;; Compound post-filter
|
||||
(-> normalized
|
||||
(dissoc :filter-type)
|
||||
(assoc :filter-kind :compound)
|
||||
(update :filters #(mapv normalize-post-filter %)))
|
||||
;; Leaf post-filter - just normalize the operation to keyword
|
||||
(update normalized :operation #(cond
|
||||
(keyword? %) %
|
||||
(string? %) (keyword %)
|
||||
:else %)))))
|
||||
|
||||
(defn- normalize-post-filters
|
||||
"Normalize post-filters array."
|
||||
[post-filters]
|
||||
(when (seq post-filters)
|
||||
(mapv normalize-post-filter post-filters)))
|
||||
|
||||
(defn- chart-type->keyword
|
||||
[chart-type]
|
||||
(cond
|
||||
(keyword? chart-type) chart-type
|
||||
(string? chart-type) (keyword chart-type)
|
||||
:else chart-type))
|
||||
|
||||
(defn- query-type->keyword
|
||||
[query-type]
|
||||
(cond
|
||||
(keyword? query-type) query-type
|
||||
(string? query-type) (keyword query-type)
|
||||
:else query-type))
|
||||
|
||||
(def ^:private construct-notebook-query-args-schema
|
||||
"Schema for the `construct_notebook_query` tool arguments.
|
||||
Filter sub-schemas carry `:decode/tool` transforms for temporal value coercion."
|
||||
[:map {:closed true}
|
||||
[:reasoning {:optional true} :string]
|
||||
[:query construct-query-schema]
|
||||
[:visualization {:optional true} construct-visualization-schema]])
|
||||
|
||||
(defn- structured->query-data
|
||||
"Convert tool structured output to a map suitable for [[llm-rep/query->xml]].
|
||||
Converts the pMBQL query to legacy MBQL, JSON-encodes it, and wraps result columns."
|
||||
[{:keys [query-id query result-columns]}]
|
||||
(let [legacy-query (when (and (map? query) (:lib/type query))
|
||||
#_{:clj-kondo/ignore [:discouraged-var]}
|
||||
(lib/->legacy-MBQL query))]
|
||||
{:query-type "notebook"
|
||||
:query-id query-id
|
||||
:database_id (:database legacy-query)
|
||||
:query-content (when legacy-query (json/encode legacy-query))
|
||||
:result (when (seq result-columns)
|
||||
{:result_columns result-columns})}))
|
||||
|
||||
(defn- structured->chart-xml
|
||||
"Render the full chart XML (matching Python Visualization.llm_representation)
|
||||
for the construct_notebook_query tool result."
|
||||
[structured chart-id chart-type]
|
||||
(llm-rep/visualization->xml
|
||||
{:chart-id chart-id
|
||||
:queries [(structured->query-data structured)]
|
||||
:visualization_settings {:chart_type (if chart-type (name chart-type) "table")}}))
|
||||
|
||||
(def ^:private tool-arg-transformer
|
||||
"Malli transformer that applies `:decode/tool` transforms declared on sub-schemas.
|
||||
Used by [[decode-tool-args]] to coerce LLM arguments (e.g., date strings → integers
|
||||
for extraction buckets) before the tool function runs."
|
||||
(mtx/transformer {:name :tool}))
|
||||
|
||||
(defn- decode-tool-args
|
||||
"Decode tool arguments through the Malli schema, applying `:decode/tool` transforms.
|
||||
This is attached as `:decode` metadata on the tool var so that [[run-tool]] calls it
|
||||
before invoking the tool function."
|
||||
[args]
|
||||
(mc/decode construct-notebook-query-args-schema args tool-arg-transformer))
|
||||
|
||||
(mu/defn ^{:tool-name "construct_notebook_query"
|
||||
:decode decode-tool-args}
|
||||
construct-notebook-query-tool
|
||||
"Constructs a query using either a **single model OR a single metric OR a single table** as the data source, never more than one.
|
||||
|
||||
**Capabilities:**
|
||||
- **Aggregations**: Basic (sum, avg, min, max, count, count-distinct) and advanced (median, stddev, var, percentile, cum-sum, cum-count, share)
|
||||
- **Conditional aggregations**: count-where, sum-where, distinct-where with filter conditions
|
||||
- **Filters**: AND/OR compound filters, between ranges, segments, and standard comparisons
|
||||
- **Expressions**: Calculated columns with math, string, and date operations
|
||||
- **Post-aggregation filters**: HAVING-equivalent filtering on aggregated results
|
||||
- **Grouping/breakout**: With temporal bucketing support
|
||||
|
||||
**Limitations:**
|
||||
- No joins, unions, subqueries, or cross-model/metric queries
|
||||
- No window functions (except cumulative aggregations), cohort analysis
|
||||
- All fields used must exist in the selected model, metric, or table
|
||||
|
||||
**Usage:**
|
||||
- Always use an existing business metric (`metric_id`) when it matches the intended aggregate—never rebuild a metric from scratch.
|
||||
- For multiple metrics: make separate tool calls, one per metric
|
||||
- For custom calculations or detailed record exploration on a specific model use the model (`model_id`). The query will operate on this single model. Only fields belonging to THIS model can be used.
|
||||
- For raw table data exploration use the table (`table_id`). The query will operate on this single table. Only fields belonging to THIS table can be used.
|
||||
- For categorical filters, only use values validated as present in the data.
|
||||
- Use `expressions` to create calculated columns (e.g., profit margin = revenue / cost)
|
||||
- Use `post_filters` to filter on aggregated results (e.g., categories with total > $10,000)
|
||||
|
||||
**Important Note about Metrics:**
|
||||
When using metrics, be aware that they often contain built-in filters and business logic that don't need to be explicitly added. For example, metrics named with states like \"active users\", \"converted customers\", or \"paid subscribers\" already include the relevant filtering conditions (activity, conversion, payment status) in their definitions and do not require additional filters for these criteria.
|
||||
|
||||
Never use this tool if the user's request explicitly asks for a SQL query or requires inter-row logic (comparing different rows), or joins. Use SQL tools instead if available.
|
||||
|
||||
If the request is out of scope, do not use this tool — suggest alternatives or refer to a more advanced context if available"
|
||||
[{:keys [_reasoning query visualization]} :- construct-notebook-query-args-schema]
|
||||
(try
|
||||
(let [;; LLM sometimes nests visualization inside query — pull it out
|
||||
effective-viz (or visualization (:visualization query))
|
||||
normalized-query (normalize-ai-args (dissoc query :visualization))
|
||||
normalized-visualization (normalize-ai-args effective-viz)
|
||||
query-type (query-type->keyword (:query-type normalized-query))
|
||||
chart-type (or (chart-type->keyword (:chart-type normalized-visualization))
|
||||
:table)
|
||||
_ (log/debug "construct_notebook_query request"
|
||||
{:query-type query-type
|
||||
:chart-type chart-type})
|
||||
query-result (case query-type
|
||||
:metric
|
||||
(filter-tools/query-metric
|
||||
{:metric-id (get-in normalized-query [:source :metric-id])
|
||||
:filters (normalize-construct-filters (:filters normalized-query))
|
||||
:group-by (normalize-ai-args (:group-by normalized-query))})
|
||||
:aggregate
|
||||
(filter-tools/query-datasource
|
||||
{:model-id (get-in normalized-query [:source :model-id])
|
||||
:table-id (get-in normalized-query [:source :table-id])
|
||||
:expressions (normalize-ai-args (:expressions normalized-query))
|
||||
:aggregations (normalize-ai-args (:aggregations normalized-query))
|
||||
:filters (normalize-construct-filters (:filters normalized-query))
|
||||
:group-by (normalize-ai-args (:group-by normalized-query))
|
||||
:limit (:limit normalized-query)
|
||||
:post-filters (normalize-post-filters (:post-filters normalized-query))})
|
||||
:raw
|
||||
(filter-tools/query-datasource
|
||||
{:model-id (get-in normalized-query [:source :model-id])
|
||||
:table-id (get-in normalized-query [:source :table-id])
|
||||
:expressions (normalize-ai-args (:expressions normalized-query))
|
||||
:fields (normalize-ai-args (:fields normalized-query))
|
||||
:filters (normalize-construct-filters (:filters normalized-query))
|
||||
:order-by (normalize-ai-args (:order-by normalized-query))
|
||||
:limit (:limit normalized-query)})
|
||||
{:output (str "Unsupported query_type: " query-type)})
|
||||
structured (or (:structured-output query-result) (:structured_output query-result))]
|
||||
(if (and structured (:query-id structured) (:query structured))
|
||||
(let [chart-result (create-chart-tools/create-chart
|
||||
{:query-id (:query-id structured)
|
||||
:chart-type chart-type
|
||||
:queries-state {(:query-id structured) (:query structured)}})
|
||||
navigate-url (get-in chart-result [:reactions 0 :url])
|
||||
full-structured (assoc structured
|
||||
:result-type :query
|
||||
:chart-id (:chart-id chart-result)
|
||||
:chart-type (:chart-type chart-result)
|
||||
:chart-link (:chart-link chart-result)
|
||||
:chart-content (:chart-content chart-result))
|
||||
instruction-text
|
||||
(let [link (te/link "Chart" "metabase://chart/" (:chart-id chart-result))]
|
||||
(te/lines
|
||||
"Your query and chart have been created successfully."
|
||||
""
|
||||
"Next steps to present the chart to the user:"
|
||||
(str "- Always provide a direct link using: `" link "` where Chart is a meaningful link text")
|
||||
"- If creating multiple charts, present all chart links"))
|
||||
chart-xml (structured->chart-xml structured (:chart-id chart-result) chart-type)]
|
||||
{:output (str "<result>\n" chart-xml "\n</result>\n"
|
||||
"<instructions>\n" instruction-text "\n</instructions>")
|
||||
:data-parts (when navigate-url
|
||||
[(streaming/navigate-to-part navigate-url)])
|
||||
:structured-output full-structured
|
||||
:instructions instruction-text})
|
||||
;; query-result may already have :output (error) or only :structured-output
|
||||
(if-let [s (or (:structured-output query-result) (:structured_output query-result))]
|
||||
(let [query-xml (llm-rep/query->xml (structured->query-data s))
|
||||
instruction-text (instructions/query-created-instructions-for (:query-id s))]
|
||||
(assoc query-result
|
||||
:output (str "<result>\n" query-xml "\n</result>\n"
|
||||
"<instructions>\n" instruction-text "\n</instructions>")))
|
||||
query-result)))
|
||||
(catch Exception e
|
||||
(log/error e "Failed to construct notebook query")
|
||||
(if (:agent-error? (ex-data e))
|
||||
{:output (ex-message e)}
|
||||
{:output (str "Failed to construct notebook query: " (or (ex-message e) "Unknown error"))}))))
|
||||
@@ -0,0 +1,166 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.document
|
||||
"Document-generation specific tool wrappers."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.agent.tools.construct :as construct-tools]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
|
||||
[metabase-enterprise.metabot-v3.table-utils :as table-utils]
|
||||
[metabase-enterprise.metabot-v3.tools.create-sql-query :as create-sql-query-tools]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.warehouses.core :as warehouses]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def ^:private chart-type-enum
|
||||
[:enum "table" "bar" "line" "pie" "sunburst" "area" "combo"
|
||||
"row" "pivot" "scatter" "waterfall" "sankey" "scalar"
|
||||
"smartscalar" "gauge" "progress" "funnel" "object" "map"])
|
||||
|
||||
(defn- parse-reference
|
||||
[[k v]]
|
||||
(let [reference (name k)]
|
||||
(when-let [[_ reference-type reference-id] (re-matches #"([^:]+):(.+)" reference)]
|
||||
{:reference reference
|
||||
:type reference-type
|
||||
:id reference-id
|
||||
:name v})))
|
||||
|
||||
(defn- parse-int
|
||||
[value]
|
||||
(cond
|
||||
(int? value) value
|
||||
(and (string? value) (re-matches #"\d+" value)) (Integer/parseInt value)
|
||||
:else nil))
|
||||
|
||||
(defn- context-references
|
||||
[]
|
||||
(or (get (shared/current-context) :references) {}))
|
||||
|
||||
(defn- referenced-database-ids
|
||||
[references]
|
||||
(into []
|
||||
(comp (map parse-reference)
|
||||
(filter #(= "database" (:type %)))
|
||||
(map (comp parse-int :id))
|
||||
(remove nil?))
|
||||
references))
|
||||
|
||||
(mu/defn ^{:tool-name "document_schema_collect"}
|
||||
document-schema-collect-tool
|
||||
"Collects the schema of a database in order to construct a SQL query.
|
||||
|
||||
**Usage:**
|
||||
- Use this tool when the user is explicitly requesting to use SQL
|
||||
- Use this tool when an existing model or metric does not answer the user's question.
|
||||
- Do NOT use this tool if there is a model or metric that can answer the user's question
|
||||
unless the user is requesting SQL."
|
||||
[_args :- [:maybe [:map {:closed true}]]]
|
||||
(try
|
||||
(let [refs (context-references)
|
||||
database-ids (referenced-database-ids refs)]
|
||||
(cond
|
||||
(empty? database-ids)
|
||||
{:output "You must `@` mention a database to use when not querying an existing model"}
|
||||
|
||||
(< 1 (count database-ids))
|
||||
{:output "You can only `@` mention one database when generating SQL"}
|
||||
|
||||
:else
|
||||
(let [database-id (first database-ids)
|
||||
db (warehouses/get-database database-id)
|
||||
schema-ddl (table-utils/schema-full database-id)
|
||||
output (str "<result>\n"
|
||||
"The database the user selected has this schema:\n"
|
||||
"<database_schema>\n"
|
||||
schema-ddl
|
||||
"\n</database_schema>\n"
|
||||
"</result>\n"
|
||||
"<instructions>\n"
|
||||
"NEXT: construct a SQL query based on the user's instructions and this schema.\n"
|
||||
"THEN: call `document_construct_sql_chart` with SQL and chart settings.\n"
|
||||
"SQL engine: " (:engine db) ".\n"
|
||||
"</instructions>")]
|
||||
{:output output
|
||||
:structured-output {:database_id database-id
|
||||
:sql_engine (:engine db)}})))
|
||||
(catch Exception e
|
||||
(log/error e "Error collecting document schema")
|
||||
{:output (str "Failed to collect schema: " (or (ex-message e) "Unknown error"))})))
|
||||
|
||||
(mu/defn ^{:tool-name "document_construct_sql_chart"}
|
||||
document-construct-sql-chart-tool
|
||||
"Construct SQL-backed chart draft payload for document insertion."
|
||||
[{:keys [database_id name description analysis approach sql viz_settings]}
|
||||
:- [:map {:closed true}
|
||||
[:database_id :int]
|
||||
[:name :string]
|
||||
[:description :string]
|
||||
[:analysis :string]
|
||||
[:approach :string]
|
||||
[:sql :string]
|
||||
[:viz_settings [:map {:closed true}
|
||||
[:chart_type chart-type-enum]]]]]
|
||||
(try
|
||||
(let [result (create-sql-query-tools/create-sql-query {:database-id database_id
|
||||
:sql sql})
|
||||
chart-type (get viz_settings :chart_type)
|
||||
query-id (:query-id result)
|
||||
query (:query result)
|
||||
structured {:tool "document_construct_sql_chart"
|
||||
:name name
|
||||
:description description
|
||||
:analysis analysis
|
||||
:approach approach
|
||||
:dataset_query query
|
||||
:display chart-type
|
||||
:chart_type chart-type
|
||||
:query_id query-id
|
||||
:query query
|
||||
:result-type :chart-draft}]
|
||||
{:output "Draft chart payload generated from SQL query."
|
||||
:structured-output structured
|
||||
:final-response? true})
|
||||
(catch Exception e
|
||||
(log/error e "Error constructing SQL chart draft")
|
||||
(if (:agent-error? (ex-data e))
|
||||
{:output (ex-message e)}
|
||||
{:output (str "Failed to construct SQL chart draft: " (or (ex-message e) "Unknown error"))}))))
|
||||
|
||||
(mu/defn ^{:tool-name "document_construct_model_chart"}
|
||||
document-construct-model-chart-tool
|
||||
"Construct notebook/model-backed chart draft payload for document insertion."
|
||||
[{:keys [name description query viz_settings]}
|
||||
:- [:map {:closed true}
|
||||
[:name :string]
|
||||
[:description :string]
|
||||
[:query :map]
|
||||
[:viz_settings [:map {:closed true}
|
||||
[:chart_type chart-type-enum]]]]]
|
||||
(try
|
||||
(let [chart-type (get viz_settings :chart_type)
|
||||
result (construct-tools/construct-notebook-query-tool
|
||||
{:query query
|
||||
:visualization {:chart_type chart-type}})
|
||||
structured (or (:structured-output result) (:structured_output result))
|
||||
query-id (:query-id structured)
|
||||
dataset-query (:query structured)]
|
||||
(if (map? dataset-query)
|
||||
{:output "Draft chart payload generated from model/notebook query."
|
||||
:structured-output {:tool "document_construct_model_chart"
|
||||
:name name
|
||||
:description description
|
||||
:dataset_query dataset-query
|
||||
:display chart-type
|
||||
:chart_type chart-type
|
||||
:query_id query-id
|
||||
:query dataset-query
|
||||
:result-type :chart-draft}
|
||||
:final-response? true}
|
||||
;; Preserve tool error messaging from construct_notebook_query path.
|
||||
(or result
|
||||
{:output "Failed to construct model chart draft."})))
|
||||
(catch Exception e
|
||||
(log/error e "Error constructing model chart draft")
|
||||
(if (:agent-error? (ex-data e))
|
||||
{:output (ex-message e)}
|
||||
{:output (str "Failed to construct model chart draft: " (or (ex-message e) "Unknown error"))}))))
|
||||
@@ -0,0 +1,80 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.metadata
|
||||
"Metadata tool wrappers."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
|
||||
[metabase-enterprise.metabot-v3.tools.entity-details :as entity-details-tools]
|
||||
[metabase-enterprise.metabot-v3.tools.field-stats :as field-stats-tools]
|
||||
[metabase-enterprise.metabot-v3.tools.get-metadata :as metadata-tools]
|
||||
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
|
||||
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- format-with-instructions
|
||||
[data instruction-text]
|
||||
(str "<result>\n" data "\n</result>\n"
|
||||
"<instructions>\n" instruction-text "\n</instructions>"))
|
||||
|
||||
(defn- format-answer-sources-output
|
||||
[{:keys [metrics models]}]
|
||||
(let [content (str (when (seq metrics)
|
||||
(str "<metrics>\n"
|
||||
(str/join "\n" (map llm-rep/metric->xml metrics))
|
||||
"\n</metrics>\n"))
|
||||
(when (seq models)
|
||||
(str "<metabase-models>\n"
|
||||
(str/join "\n" (map llm-rep/model->xml models))
|
||||
"\n</metabase-models>")))]
|
||||
(format-with-instructions content instructions/answer-sources-instructions)))
|
||||
|
||||
(defn- format-metadata-output
|
||||
[structured]
|
||||
(llm-rep/get-metadata-result->xml structured))
|
||||
|
||||
(defn- format-field-metadata-output
|
||||
;; NOTE: keep in sync with read_resource.clj/format-content :field-metadata branch
|
||||
[{:keys [field_id value_metadata]}]
|
||||
(format-with-instructions
|
||||
(llm-rep/field-metadata->xml {:field_id field_id :value_metadata value_metadata})
|
||||
instructions/field-metadata-instructions))
|
||||
|
||||
(defn- add-output
|
||||
"Add :output to a tool result that has :structured-output, using the given format-fn."
|
||||
[result format-fn]
|
||||
(m/assoc-some result :output (some-> result :structured-output format-fn)))
|
||||
|
||||
(mu/defn ^{:tool-name "list_available_data_sources"} list-available-data-sources-tool
|
||||
"List all data sources (metrics and models) available to the metabot instance."
|
||||
[_args :- [:maybe [:map {:closed true}]]]
|
||||
(add-output
|
||||
(entity-details-tools/answer-sources {:metabot-id metabot-v3.config/embedded-metabot-id
|
||||
:with-field-values? false})
|
||||
format-answer-sources-output))
|
||||
|
||||
(mu/defn ^{:tool-name "list_available_fields"} list-available-fields-tool
|
||||
"Retrieve metadata for tables, models, and metrics."
|
||||
[{:keys [table_ids model_ids metric_ids]} :- [:map {:closed true}
|
||||
[:table_ids [:sequential :int]]
|
||||
[:model_ids [:sequential :int]]
|
||||
[:metric_ids [:sequential :int]]]]
|
||||
(add-output
|
||||
(metadata-tools/get-metadata {:table-ids table_ids
|
||||
:model-ids model_ids
|
||||
:metric-ids metric_ids})
|
||||
format-metadata-output))
|
||||
|
||||
(mu/defn ^{:tool-name "get_field_values"} get-field-values-tool
|
||||
"Return metadata for a given field of a given data source."
|
||||
[{:keys [data_source source_id field_id]} :- [:map {:closed true}
|
||||
[:data_source [:enum "table" "model" "metric"]]
|
||||
[:source_id :int]
|
||||
[:field_id :string]]]
|
||||
(add-output
|
||||
(field-stats-tools/field-values {:entity-type data_source
|
||||
:entity-id source_id
|
||||
:field-id field_id
|
||||
:limit nil})
|
||||
format-field-metadata-output))
|
||||
@@ -0,0 +1,47 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.navigation
|
||||
"Navigation tool wrappers."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
|
||||
[metabase-enterprise.metabot-v3.tools.navigate :as navigate-tools]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(mu/defn ^{:tool-name "navigate_user"
|
||||
:capabilities #{:frontend-navigate-user-v1}}
|
||||
navigate-user-tool
|
||||
"Navigate the user to a specific page or entity in Metabase.
|
||||
|
||||
Use this tool to direct users to:
|
||||
- Pages: notebook_editor, metrics_browser, model_browser, database_browser, sql_editor
|
||||
- Entities: table, model, question, metric, dashboard
|
||||
- Query results or charts from the current conversation"
|
||||
[{:keys [destination]}
|
||||
:- [:map {:closed true}
|
||||
[:destination [:or
|
||||
[:map {:closed true}
|
||||
[:page [:enum "notebook_editor" "metrics_browser" "model_browser" "database_browser"]]]
|
||||
[:map {:closed true}
|
||||
[:page [:enum "sql_editor"]]
|
||||
[:database_id :int]]
|
||||
[:map {:closed true}
|
||||
[:entity_type [:enum "table" "model" "question" "metric" "dashboard"]]
|
||||
[:entity_id :int]]
|
||||
[:map {:closed true}
|
||||
[:query_id :string]]
|
||||
[:map {:closed true}
|
||||
[:chart_id :string]]]]]]
|
||||
(try
|
||||
(let [result (navigate-tools/navigate {:destination destination
|
||||
:memory-atom shared/*memory-atom*})
|
||||
structured (:structured-output result)
|
||||
reactions (:reactions result)]
|
||||
(cond-> {:output (:message structured)
|
||||
:structured-output structured}
|
||||
(seq reactions) (assoc :reactions reactions)))
|
||||
(catch Exception e
|
||||
(log/error e "Error navigating")
|
||||
(if (:agent-error? (ex-data e))
|
||||
{:output (ex-message e)}
|
||||
{:output (str "Failed to navigate: " (or (ex-message e) "Unknown error"))}))))
|
||||
@@ -0,0 +1,26 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.resources
|
||||
"Resource reading tool wrappers."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.tools.read-resource :as read-resource-tools]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(mu/defn ^{:tool-name "read_resource"} read-resource-tool
|
||||
"Read detailed information about Metabase resources via URI patterns.
|
||||
|
||||
Supports fetching multiple resources in parallel using metabase:// URIs:
|
||||
- metabase://table/{id}/fields - Get table structure with fields
|
||||
- metabase://model/{id}/fields/{field_id} - Get specific field details
|
||||
- metabase://metric/{id}/dimensions - Get metric dimensions
|
||||
- metabase://transform/{id} - Get transform details
|
||||
- metabase://dashboard/{id} - Get dashboard details"
|
||||
[{:keys [uris]}
|
||||
:- [:map {:closed true}
|
||||
[:uris [:sequential [:string {:description "Metabase resource URIs to fetch"}]]]]]
|
||||
(try
|
||||
(read-resource-tools/read-resource {:uris uris})
|
||||
(catch Exception e
|
||||
(log/error e "Error in read_resource tool")
|
||||
{:output (str "Failed to read resources: " (or (ex-message e) "Unknown error"))})))
|
||||
@@ -0,0 +1,126 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.search
|
||||
"Search tool wrappers for Metabot v3."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.tmpl :as te]
|
||||
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
|
||||
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
|
||||
[metabase-enterprise.metabot-v3.tools.search :as search-tools]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- format-search-output
|
||||
"Format search results as an LLM-ready string."
|
||||
[results]
|
||||
(let [results-xml (llm-rep/search-results->xml results)]
|
||||
(te/lines
|
||||
"<result>"
|
||||
results-xml
|
||||
""
|
||||
(str "Total results: " (count results))
|
||||
"</result>"
|
||||
"<instructions>"
|
||||
instructions/search-result-instructions "</instructions>")))
|
||||
|
||||
(defn- invalid-entity-types
|
||||
[entity-types allowed]
|
||||
(when (seq entity-types)
|
||||
(seq (remove allowed entity-types))))
|
||||
|
||||
(mu/defn ^{:tool-name "search"} search-tool
|
||||
"Search for tables, models, metrics, dashboards, and saved questions."
|
||||
[{:keys [semantic_queries keyword_queries entity_types]} :- [:map {:closed true}
|
||||
[:semantic_queries [:sequential :string]]
|
||||
[:keyword_queries [:sequential :string]]
|
||||
[:entity_types {:optional true}
|
||||
[:maybe [:sequential [:enum "table" "model" "metric" "dashboard" "question"]]]]]]
|
||||
(if-let [invalid (invalid-entity-types entity_types #{"table" "model" "metric" "dashboard" "question"})]
|
||||
{:output (str "Invalid entity_types for search: " (pr-str (vec invalid))
|
||||
". Allowed types: table, model, metric, dashboard, question.")}
|
||||
(try
|
||||
(let [results (search-tools/search {:semantic-queries semantic_queries
|
||||
:term-queries keyword_queries
|
||||
:entity-types entity_types
|
||||
:limit 10})]
|
||||
{:output (format-search-output results)
|
||||
:structured-output {:result-type :search
|
||||
:data results
|
||||
:total_count (count results)}})
|
||||
(catch Exception e
|
||||
(log/error e "Error in search")
|
||||
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))
|
||||
|
||||
(mu/defn ^{:tool-name "search" :prompt "sql_search.md"} sql-search-tool
|
||||
"Search for SQL-queryable data sources (tables and models) within a database."
|
||||
[{:keys [semantic_queries keyword_queries entity_types database_id]} :- [:map {:closed true}
|
||||
[:semantic_queries [:sequential :string]]
|
||||
[:keyword_queries [:sequential :string]]
|
||||
[:database_id :int]
|
||||
[:entity_types {:optional true}
|
||||
[:maybe [:sequential [:enum "table" "model"]]]]]]
|
||||
(if-let [invalid (invalid-entity-types entity_types #{"table" "model"})]
|
||||
{:output (str "Invalid entity_types for SQL search: " (pr-str (vec invalid))
|
||||
". Allowed types: table, model.")}
|
||||
(try
|
||||
(let [results (search-tools/search {:semantic-queries semantic_queries
|
||||
:term-queries keyword_queries
|
||||
:entity-types entity_types
|
||||
:database-id database_id
|
||||
:limit 10})]
|
||||
{:output (format-search-output results)
|
||||
:structured-output {:result-type :search
|
||||
:data results
|
||||
:total_count (count results)}})
|
||||
(catch Exception e
|
||||
(log/error e "Error in SQL search")
|
||||
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))
|
||||
|
||||
(mu/defn ^{:tool-name "search" :prompt "nql_search.md"} nlq-search-tool
|
||||
"Search for NLQ-queryable data sources (models, metrics, tables)."
|
||||
[{:keys [semantic_queries keyword_queries entity_types]} :- [:map {:closed true}
|
||||
[:semantic_queries [:sequential :string]]
|
||||
[:keyword_queries [:sequential :string]]
|
||||
[:entity_types {:optional true}
|
||||
[:maybe [:sequential [:enum "model" "metric" "table"]]]]]]
|
||||
(if-let [invalid (invalid-entity-types entity_types #{"model" "metric" "table"})]
|
||||
{:output (str "Invalid entity_types for NLQ search: " (pr-str (vec invalid))
|
||||
". Allowed types: model, metric, table.")}
|
||||
(try
|
||||
(let [results (search-tools/search {:semantic-queries semantic_queries
|
||||
:term-queries keyword_queries
|
||||
:entity-types entity_types
|
||||
:profile-id "nlq"
|
||||
:limit 10})]
|
||||
{:output (format-search-output results)
|
||||
:structured-output {:result-type :search
|
||||
:data results
|
||||
:total_count (count results)}})
|
||||
(catch Exception e
|
||||
(log/error e "Error in NLQ search")
|
||||
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))
|
||||
|
||||
(mu/defn ^{:tool-name "search" :prompt "transform_search"} transform-search-tool
|
||||
"Search for transforms, tables, and models."
|
||||
[{:keys [semantic_queries keyword_queries entity_types search_native_query]} :- [:map {:closed true}
|
||||
[:semantic_queries [:sequential :string]]
|
||||
[:keyword_queries [:sequential :string]]
|
||||
[:search_native_query {:optional true} [:maybe :boolean]]
|
||||
[:entity_types {:optional true}
|
||||
[:maybe [:sequential [:enum "table" "model" "transform"]]]]]]
|
||||
(if-let [invalid (invalid-entity-types entity_types #{"table" "model" "transform"})]
|
||||
{:output (str "Invalid entity_types for transform search: " (pr-str (vec invalid))
|
||||
". Allowed types: table, model, transform.")}
|
||||
(try
|
||||
(let [results (search-tools/search {:semantic-queries semantic_queries
|
||||
:term-queries keyword_queries
|
||||
:entity-types entity_types
|
||||
:search-native-query search_native_query
|
||||
:limit 10})]
|
||||
{:output (format-search-output results)
|
||||
:structured-output {:result-type :search
|
||||
:data results
|
||||
:total_count (count results)}})
|
||||
(catch Exception e
|
||||
(log/error e "Error in transform search")
|
||||
{:output (str "Search failed: " (or (ex-message e) "Unknown error"))}))))
|
||||
@@ -0,0 +1,40 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.shared
|
||||
"Shared tool helpers and state accessors."
|
||||
(:require
|
||||
[metabase.util.log :as log]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(def ^:dynamic *memory-atom*
|
||||
"Dynamic memory atom bound for tools that need access to agent state."
|
||||
nil)
|
||||
|
||||
(defn current-memory
|
||||
"Returns the current agent memory map, or nil if not in an agent context."
|
||||
[]
|
||||
(when *memory-atom*
|
||||
@*memory-atom*))
|
||||
|
||||
(defn current-queries-state
|
||||
"Returns the current queries state map from agent memory."
|
||||
[]
|
||||
(get-in (current-memory) [:state :queries] {}))
|
||||
|
||||
(defn current-charts-state
|
||||
"Returns the current charts state map from agent memory."
|
||||
[]
|
||||
(get-in (current-memory) [:state :charts] {}))
|
||||
|
||||
(defn current-context
|
||||
"Returns the current agent context from memory."
|
||||
[]
|
||||
(get (current-memory) :context))
|
||||
|
||||
(defn with-memory
|
||||
"Helper for debugging memory-bound tools when needed."
|
||||
[f]
|
||||
(if *memory-atom*
|
||||
(f)
|
||||
(do
|
||||
(log/warn "Tool called without bound memory atom")
|
||||
(f))))
|
||||
@@ -0,0 +1,55 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.snippets
|
||||
"Snippet tool wrappers."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.metabot-v3.tools.snippets :as snippet-tools]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- format-snippet-list-output
|
||||
[snippets]
|
||||
(if (seq snippets)
|
||||
(str "<snippets>\n"
|
||||
(str/join "\n" (map (fn [{:keys [id name description]}]
|
||||
(str "<snippet id=\"" id "\" name=\"" name "\">"
|
||||
(when description description)
|
||||
"</snippet>"))
|
||||
snippets))
|
||||
"\n</snippets>")
|
||||
"No snippets available."))
|
||||
|
||||
(defn- format-snippet-details-output
|
||||
[{:keys [id name description content]}]
|
||||
(str "<snippet id=\"" id "\" name=\"" name "\">\n"
|
||||
(when description (str "<description>" description "</description>\n"))
|
||||
(when content (str "<content>\n" content "\n</content>\n"))
|
||||
"</snippet>"))
|
||||
|
||||
(defn- add-output
|
||||
"Add :output to a tool result. Handles both :structured_output and :structured-output."
|
||||
[result format-fn]
|
||||
(if-let [structured (or (:structured_output result) (:structured-output result))]
|
||||
(assoc result :output (format-fn structured))
|
||||
result))
|
||||
|
||||
(mu/defn ^{:tool-name "list_snippets"
|
||||
:capabilities #{:feature-snippets}} list-snippets-tool
|
||||
"List all SQL snippets available in the Metabase instance.
|
||||
|
||||
Use this tool before editing or creating SQL transforms to understand what
|
||||
snippets are available. If any snippets may be relevant to the user's request,
|
||||
fetch their content using the get_snippet_details tool."
|
||||
[_args :- [:maybe [:map {:closed true}]]]
|
||||
(add-output (snippet-tools/get-snippets {}) format-snippet-list-output))
|
||||
|
||||
(mu/defn ^{:tool-name "get_snippet_details"
|
||||
:capabilities #{:feature-snippets}} get-snippet-details-tool
|
||||
"Get the full details of a SQL snippet including its content.
|
||||
|
||||
Use this tool to retrieve the actual SQL content of a snippet after identifying
|
||||
it with list_snippets."
|
||||
[{:keys [snippet_id]}
|
||||
:- [:map {:closed true}
|
||||
[:snippet_id :int]]]
|
||||
(add-output (snippet-tools/get-snippet-details {:snippet-id snippet_id}) format-snippet-details-output))
|
||||
@@ -0,0 +1,222 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.sql
|
||||
"SQL tool wrappers."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.agent.streaming :as streaming]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
|
||||
[metabase-enterprise.metabot-v3.tmpl :as te]
|
||||
[metabase-enterprise.metabot-v3.tools.create-sql-query :as create-sql-query-tools]
|
||||
[metabase-enterprise.metabot-v3.tools.edit-sql-query :as edit-sql-query-tools]
|
||||
[metabase-enterprise.metabot-v3.tools.instructions :as instructions]
|
||||
[metabase-enterprise.metabot-v3.tools.llm-representations :as llm-rep]
|
||||
[metabase-enterprise.metabot-v3.tools.replace-sql-query :as replace-sql-query-tools]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- normalize-context-type
|
||||
[type-val]
|
||||
(cond
|
||||
(keyword? type-val) (name type-val)
|
||||
(string? type-val) (u/lower-case-en type-val)
|
||||
:else ""))
|
||||
|
||||
(defn- first-code-editor-buffer-id
|
||||
[]
|
||||
(let [memory (shared/current-memory)]
|
||||
(when memory
|
||||
(let [viewing (get-in memory [:context :user_is_viewing] [])]
|
||||
(some->> viewing
|
||||
(filter #(= "code_editor" (normalize-context-type (:type %))))
|
||||
first
|
||||
:buffers
|
||||
first
|
||||
:id)))))
|
||||
|
||||
(defn- format-query-output
|
||||
"Format a query result for LLM consumption.
|
||||
`preamble` is optional text placed before the query XML inside <result>
|
||||
(e.g. \"SQL query successfully constructed.\")."
|
||||
[structured instruction-text & {:keys [preamble?]}]
|
||||
(let [query-xml (llm-rep/query->xml structured)]
|
||||
(te/lines
|
||||
"<result>"
|
||||
(when preamble?
|
||||
(te/lines
|
||||
"SQL query successfully constructed."
|
||||
(str "New query ID: " (:query-id structured))
|
||||
"The complete query is shown below:"))
|
||||
query-xml
|
||||
"</result>"
|
||||
"<instructions>"
|
||||
instruction-text
|
||||
"</instructions>")))
|
||||
|
||||
(defn- code-edit-part
|
||||
[buffer-id sql]
|
||||
(streaming/code-edit-part {:buffer_id buffer-id
|
||||
:mode "rewrite"
|
||||
:value sql}))
|
||||
|
||||
(mu/defn ^{:tool-name "create_sql_query"
|
||||
:capabilities #{:permission-write-sql-queries}}
|
||||
create-sql-query-tool
|
||||
"Create a new SQL query."
|
||||
[{:keys [database_id sql_query]}
|
||||
:- [:map {:closed true}
|
||||
[:database_id :int]
|
||||
[:sql_query :string]]]
|
||||
(try
|
||||
(let [result (create-sql-query-tools/create-sql-query
|
||||
{:database-id database_id
|
||||
:sql sql_query})
|
||||
structured (assoc result :result-type :query)
|
||||
query-id (:query-id result)
|
||||
instr (instructions/query-created-instructions-for query-id)
|
||||
results-url (streaming/query->question-url (:query result))]
|
||||
{:output (format-query-output structured instr {:preamble? true})
|
||||
:structured-output structured
|
||||
:instructions instr
|
||||
:data-parts [(streaming/navigate-to-part results-url)]})
|
||||
(catch Exception e
|
||||
(log/error e "Error creating SQL query")
|
||||
(if (:agent-error? (ex-data e))
|
||||
{:output (ex-message e)}
|
||||
{:output (str "Failed to create SQL query: " (or (ex-message e) "Unknown error"))}))))
|
||||
|
||||
(mu/defn ^{:tool-name "create_sql_query"
|
||||
:capabilities #{:permission-write-sql-queries}}
|
||||
create-sql-query-code-edit-tool
|
||||
"Create a new SQL query and update the code editor buffer."
|
||||
[{:keys [database_id sql_query]}
|
||||
:- [:map {:closed true}
|
||||
[:database_id :int]
|
||||
[:sql_query :string]]]
|
||||
(let [result (create-sql-query-tools/create-sql-query
|
||||
{:database-id database_id
|
||||
:sql sql_query})
|
||||
structured (assoc result :result-type :query)
|
||||
query-id (:query-id result)
|
||||
instr (instructions/query-created-instructions-for query-id)
|
||||
buffer-id (first-code-editor-buffer-id)]
|
||||
(if buffer-id
|
||||
{:output (format-query-output structured instr {:preamble? true})
|
||||
:structured-output structured
|
||||
:instructions instr
|
||||
:data-parts [(code-edit-part buffer-id (:query-content result))]}
|
||||
{:output "No active code editor buffer found for SQL editing."})))
|
||||
|
||||
(mu/defn ^{:tool-name "edit_sql_query"
|
||||
:capabilities #{:permission-write-sql-queries}}
|
||||
edit-sql-query-tool
|
||||
"Edit an existing SQL query using structured edits."
|
||||
[{:keys [query_id edits checklist]}
|
||||
:- [:map {:closed true}
|
||||
[:query_id [:or :string :int]]
|
||||
[:checklist :string]
|
||||
[:edits [:sequential [:map {:closed true}
|
||||
[:old_string :string]
|
||||
[:new_string :string]
|
||||
[:replace_all {:optional true} [:maybe :boolean]]]]]]]
|
||||
(try
|
||||
(let [result (edit-sql-query-tools/edit-sql-query
|
||||
{:query-id query_id
|
||||
:edits edits
|
||||
:checklist checklist
|
||||
:queries-state (shared/current-queries-state)})
|
||||
structured (assoc result :result-type :query)
|
||||
qid (:query-id result)
|
||||
instr (instructions/edit-sql-query-instructions-for qid)
|
||||
results-url (streaming/query->question-url (:query result))]
|
||||
{:output (format-query-output structured instr)
|
||||
:structured-output structured
|
||||
:instructions instr
|
||||
:data-parts [(streaming/navigate-to-part results-url)]})
|
||||
(catch Exception e
|
||||
(log/error e "Error editing SQL query")
|
||||
(if (:agent-error? (ex-data e))
|
||||
{:output (ex-message e)}
|
||||
{:output (str "Failed to edit SQL query: " (or (ex-message e) "Unknown error"))}))))
|
||||
|
||||
(mu/defn ^{:tool-name "edit_sql_query"
|
||||
:capabilities #{:permission-write-sql-queries}}
|
||||
edit-sql-query-code-edit-tool
|
||||
"Edit an existing SQL query and update the code editor buffer."
|
||||
[{:keys [query_id edits checklist]}
|
||||
:- [:map {:closed true}
|
||||
[:query_id [:or :string :int]]
|
||||
[:checklist :string]
|
||||
[:edits [:sequential [:map {:closed true}
|
||||
[:old_string :string]
|
||||
[:new_string :string]
|
||||
[:replace_all {:optional true} [:maybe :boolean]]]]]]]
|
||||
(let [result (edit-sql-query-tools/edit-sql-query
|
||||
{:query-id query_id
|
||||
:edits edits
|
||||
:checklist checklist
|
||||
:queries-state (shared/current-queries-state)})
|
||||
structured (assoc result :result-type :query)
|
||||
qid (:query-id result)
|
||||
instr (instructions/edit-sql-query-instructions-for qid)
|
||||
buffer-id (first-code-editor-buffer-id)]
|
||||
(if buffer-id
|
||||
{:output (format-query-output structured instr)
|
||||
:structured-output structured
|
||||
:instructions instr
|
||||
:data-parts [(code-edit-part buffer-id (:query-content result))]}
|
||||
{:output "No active code editor buffer found for SQL editing."})))
|
||||
|
||||
(mu/defn ^{:tool-name "replace_sql_query"
|
||||
:capabilities #{:permission-write-sql-queries}}
|
||||
replace-sql-query-tool
|
||||
"Replace the SQL content of an existing query entirely."
|
||||
[{:keys [query_id new_query checklist]}
|
||||
:- [:map {:closed true}
|
||||
[:query_id [:or :string :int]]
|
||||
[:checklist :string]
|
||||
[:new_query :string]]]
|
||||
(try
|
||||
(let [result (replace-sql-query-tools/replace-sql-query
|
||||
{:query-id query_id
|
||||
:sql new_query
|
||||
:checklist checklist
|
||||
:queries-state (shared/current-queries-state)})
|
||||
structured (assoc result :result-type :query)
|
||||
qid (:query-id result)
|
||||
instr (instructions/replace-sql-query-instructions-for qid)
|
||||
results-url (streaming/query->question-url (:query result))]
|
||||
{:output (format-query-output structured instr)
|
||||
:structured-output structured
|
||||
:instructions instr
|
||||
:data-parts [(streaming/navigate-to-part results-url)]})
|
||||
(catch Exception e
|
||||
(log/error e "Error replacing SQL query")
|
||||
(if (:agent-error? (ex-data e))
|
||||
{:output (ex-message e)}
|
||||
{:output (str "Failed to replace SQL query: " (or (ex-message e) "Unknown error"))}))))
|
||||
|
||||
(mu/defn ^{:tool-name "replace_sql_query"
|
||||
:capabilities #{:permission-write-sql-queries}}
|
||||
replace-sql-query-code-edit-tool
|
||||
"Replace an SQL query and update the code editor buffer."
|
||||
[{:keys [query_id new_query checklist]}
|
||||
:- [:map {:closed true}
|
||||
[:query_id [:or :string :int]]
|
||||
[:checklist :string]
|
||||
[:new_query :string]]]
|
||||
(let [result (replace-sql-query-tools/replace-sql-query
|
||||
{:query-id query_id
|
||||
:sql new_query
|
||||
:checklist checklist
|
||||
:queries-state (shared/current-queries-state)})
|
||||
structured (assoc result :result-type :query)
|
||||
qid (:query-id result)
|
||||
instr (instructions/replace-sql-query-instructions-for qid)
|
||||
buffer-id (first-code-editor-buffer-id)]
|
||||
(if buffer-id
|
||||
{:output (format-query-output structured instr)
|
||||
:structured-output structured
|
||||
:instructions instr
|
||||
:data-parts [(code-edit-part buffer-id (:query-content result))]}
|
||||
{:output "No active code editor buffer found for SQL editing."})))
|
||||
@@ -0,0 +1,44 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.subscriptions
|
||||
"Dashboard subscription tool wrapper."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription :as subscription-tools]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(mu/defn ^{:tool-name "create_dashboard_subscription"}
|
||||
create-dashboard-subscription-tool
|
||||
"Create a dashboard subscription to send regular email updates.
|
||||
|
||||
Use when a user wants to receive or send regular updates on a dashboard's contents.
|
||||
Requires a valid dashboard ID, email address, and schedule.
|
||||
|
||||
Do NOT infer email addresses from usernames or other information.
|
||||
If the email address is incomplete or missing a part like the TLD,
|
||||
ask the user for clarification before proceeding."
|
||||
[{:keys [dashboard_id email schedule]}
|
||||
:- [:map {:closed true}
|
||||
[:dashboard_id :int]
|
||||
[:email :string]
|
||||
[:schedule [:map
|
||||
[:frequency [:enum "hourly" "daily" "weekly" "monthly"]]
|
||||
[:hour {:optional true} [:maybe :int]]
|
||||
[:day_of_week {:optional true} [:maybe :string]]
|
||||
[:day_of_month {:optional true} [:maybe :string]]]]]]
|
||||
(try
|
||||
(subscription-tools/create-dashboard-subscription
|
||||
{:dashboard-id dashboard_id
|
||||
:email email
|
||||
:schedule (-> schedule
|
||||
(update :frequency keyword)
|
||||
(cond->
|
||||
(:day_of_week schedule) (-> (assoc :day-of-week (keyword (:day_of_week schedule)))
|
||||
(dissoc :day_of_week))
|
||||
(:day_of_month schedule) (-> (assoc :day-of-month (keyword (:day_of_month schedule)))
|
||||
(dissoc :day_of_month))))})
|
||||
(catch Exception e
|
||||
(log/error e "Error creating dashboard subscription")
|
||||
(if (:agent-error? (ex-data e))
|
||||
{:output (ex-message e)}
|
||||
{:output (str "Failed to create dashboard subscription: " (or (ex-message e) "Unknown error"))}))))
|
||||
@@ -0,0 +1,65 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.todo
|
||||
"Todo tool wrappers."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
|
||||
[metabase-enterprise.metabot-v3.tools.todo :as todo-tools]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- format-todo-write-output
|
||||
[{:keys [todo_count]}]
|
||||
(str "Todo list updated successfully. " todo_count " item(s)."))
|
||||
|
||||
(defn- format-todo-read-output
|
||||
[{:keys [todos todo_count]}]
|
||||
(if (seq todos)
|
||||
(str "Current todo list (" todo_count " items):\n"
|
||||
(str/join "\n" (map (fn [{:keys [id content status priority]}]
|
||||
(str "- [" status "] " content " (id=" id ", priority=" priority ")"))
|
||||
todos)))
|
||||
"No todos found."))
|
||||
|
||||
(defn- add-output
|
||||
[result format-fn]
|
||||
(if-let [structured (:structured-output result)]
|
||||
(assoc result :output (format-fn structured))
|
||||
result))
|
||||
|
||||
(mu/defn ^{:tool-name "todo_write"} todo-write-tool
|
||||
"Create and manage a structured task list.
|
||||
Write or update the todo list with tasks for tracking progress.
|
||||
|
||||
Each todo item must have:
|
||||
- id: Unique identifier for the task
|
||||
- content: Description of what needs to be done
|
||||
- status: One of 'pending', 'in_progress', 'completed', 'cancelled'
|
||||
- priority: One of 'high', 'medium', 'low'"
|
||||
[{:keys [todos]}
|
||||
:- [:map {:closed true}
|
||||
[:todos [:sequential [:map {:closed true}
|
||||
[:id :string]
|
||||
[:content :string]
|
||||
[:status [:enum "pending" "in_progress" "completed" "cancelled"]]
|
||||
[:priority [:enum "high" "medium" "low"]]]]]]]
|
||||
(try
|
||||
(add-output
|
||||
(todo-tools/todo-write {:todos todos
|
||||
:memory-atom shared/*memory-atom*})
|
||||
format-todo-write-output)
|
||||
(catch Exception e
|
||||
(if (:agent-error? (ex-data e))
|
||||
{:output (ex-message e)}
|
||||
{:output (str "Failed to update todo list: " (or (ex-message e) "Unknown error"))}))))
|
||||
|
||||
(mu/defn ^{:tool-name "todo_read"} todo-read-tool
|
||||
"Read the current todo list from memory.
|
||||
Returns the list of todos that have been created during this conversation."
|
||||
[_args :- [:maybe [:map {:closed true}]]]
|
||||
(try
|
||||
(add-output
|
||||
(todo-tools/todo-read {:memory-atom shared/*memory-atom*})
|
||||
format-todo-read-output)
|
||||
(catch Exception e
|
||||
{:output (str "Failed to read todo list: " (or (ex-message e) "Unknown error"))})))
|
||||
@@ -0,0 +1,185 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.tools.transforms
|
||||
"Transform tool wrappers."
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.agent.tools.shared :as shared]
|
||||
[metabase-enterprise.metabot-v3.tools.dependencies :as deps]
|
||||
[metabase-enterprise.metabot-v3.tools.transforms :as transform-tools]
|
||||
[metabase-enterprise.metabot-v3.tools.transforms-write :as transforms-write-tools]
|
||||
[metabase-enterprise.metabot-v3.util :as metabot.u]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- format-transform-details-output
|
||||
[{:keys [id name description source target] :as _transform}]
|
||||
(metabot.u/xml
|
||||
[:transform {:id id :name name}
|
||||
(when description [:description description])
|
||||
(when source
|
||||
[:source {:type (:type source)}
|
||||
(when (:query source) [:query (:query source)])
|
||||
(when (:source-database source) [:database (:source-database source)])])
|
||||
(when target [:target (pr-str target)])]))
|
||||
|
||||
(defn- format-python-library-output
|
||||
[{:keys [path] :as lib}]
|
||||
(metabot.u/xml
|
||||
[:python-library {:path path}
|
||||
(when-let [content (:content lib)] [:content content])]))
|
||||
|
||||
(defn- format-transform-write-output
|
||||
[{:keys [message]}]
|
||||
(or message "Transform updated successfully."))
|
||||
|
||||
(defn- add-output
|
||||
"Add :output to a tool result. Handles both :structured_output and :structured-output."
|
||||
[result format-fn]
|
||||
(if-let [structured (or (:structured_output result) (:structured-output result))]
|
||||
(assoc result :output (format-fn structured))
|
||||
result))
|
||||
|
||||
(defn- check-dependencies
|
||||
"Check for downstream breakages after a SQL transform edit.
|
||||
Returns nil if no issues found or the check fails, otherwise a map with
|
||||
:bad_transforms and :bad_questions."
|
||||
[transform-id source]
|
||||
(when transform-id
|
||||
(try
|
||||
(let [{:keys [structured_output]} (deps/check-transform-dependencies
|
||||
{:id transform-id :source source})]
|
||||
(when (and structured_output
|
||||
(or (seq (:bad_transforms structured_output))
|
||||
(seq (:bad_questions structured_output))))
|
||||
structured_output))
|
||||
(catch Exception e
|
||||
(log/error e "Dependency check failed for transform" transform-id)
|
||||
nil))))
|
||||
|
||||
(defn- format-dependency-warnings
|
||||
"Format dependency check results into instructions for the agent."
|
||||
[{:keys [bad_transforms bad_questions]}]
|
||||
(str "\n\n**Dependency issues detected.**\n"
|
||||
"Your proposed changes would break downstream dependencies.\n\n"
|
||||
(when (seq bad_transforms)
|
||||
(str "Broken transforms:\n"
|
||||
(apply str (for [{:keys [transform]} bad_transforms]
|
||||
(str "- [" (:name transform) "](metabase://transform/" (:id transform) ")\n")))))
|
||||
(when (seq bad_questions)
|
||||
(str "Broken questions:\n"
|
||||
(apply str (for [{:keys [question]} bad_questions]
|
||||
(str "- [" (:name question) "](metabase://question/" (:id question) ")\n")))))
|
||||
"\nYou must summarize these dependency issues for the user. "
|
||||
"Do not propose or apply fixes yourself."))
|
||||
|
||||
(mu/defn ^{:tool-name "get_transform_details"} get-transform-details-tool
|
||||
"Get information about a transform."
|
||||
[{:keys [transform_id]} :- [:map {:closed true}
|
||||
[:transform_id :int]]]
|
||||
(add-output (transform-tools/get-transform-details {:transform-id transform_id})
|
||||
format-transform-details-output))
|
||||
|
||||
(mu/defn ^{:tool-name "get_transform_python_library_details"} get-transform-python-library-details-tool
|
||||
"Get information about a Python library by path."
|
||||
[{:keys [path]} :- [:map {:closed true}
|
||||
[:path :string]]]
|
||||
(add-output (transform-tools/get-transform-python-library-details {:path path})
|
||||
format-python-library-output))
|
||||
|
||||
(mu/defn ^{:tool-name "write_transform_sql"
|
||||
:capabilities #{:feature-transforms :permission-write-transforms}}
|
||||
write-transform-sql-tool
|
||||
"Write new SQL queries or edit existing queries for transforms.
|
||||
|
||||
Supports two modes:
|
||||
- edit: Targeted string replacements with partial edits
|
||||
- replace: Replace entire query atomically
|
||||
|
||||
For edit mode, provide edits as an array of {old_string, new_string, replace_all} objects.
|
||||
For replace mode, provide new_content with the complete SQL."
|
||||
[{:keys [transform_id edit_action thinking transform_name transform_description
|
||||
source_database source_tables]}
|
||||
:- [:map {:closed true}
|
||||
[:transform_id {:optional true} [:maybe :int]]
|
||||
[:edit_action [:map
|
||||
[:mode [:enum "edit" "replace"]]
|
||||
[:edits {:optional true} [:maybe [:sequential [:map
|
||||
[:old_string :string]
|
||||
[:new_string :string]
|
||||
[:replace_all {:optional true} [:maybe :boolean]]]]]]
|
||||
[:new_content {:optional true} [:maybe :string]]]]
|
||||
[:thinking {:optional true} [:maybe :string]]
|
||||
[:transform_name {:optional true} [:maybe :string]]
|
||||
[:transform_description {:optional true} [:maybe :string]]
|
||||
[:source_database {:optional true} [:maybe :int]]
|
||||
[:source_tables {:optional true} [:maybe :map]]]]
|
||||
(try
|
||||
(let [result (add-output
|
||||
(transforms-write-tools/write-transform-sql
|
||||
{:transform_id transform_id
|
||||
:edit_action edit_action
|
||||
:thinking thinking
|
||||
:transform_name transform_name
|
||||
:transform_description transform_description
|
||||
:source_database source_database
|
||||
:source_tables source_tables
|
||||
:memory-atom shared/*memory-atom*
|
||||
:context (shared/current-context)})
|
||||
format-transform-write-output)
|
||||
transform (get-in result [:structured-output :transform])
|
||||
dep-issues (check-dependencies transform_id (:source transform))]
|
||||
(cond-> result
|
||||
dep-issues (update :instructions str (format-dependency-warnings dep-issues))))
|
||||
(catch Exception e
|
||||
(if (:agent-error? (ex-data e))
|
||||
{:output (ex-message e)}
|
||||
{:output (str "Failed to write SQL transform: " (or (ex-message e) "Unknown error"))}))))
|
||||
|
||||
(mu/defn ^{:tool-name "write_transform_python"
|
||||
:capabilities #{:feature-transforms :feature-transforms-python :permission-write-transforms}}
|
||||
write-transform-python-tool
|
||||
"Write new Python code or edit existing code for transforms.
|
||||
|
||||
Supports two modes:
|
||||
- edit: Targeted string replacements with partial edits
|
||||
- replace: Replace entire code atomically
|
||||
|
||||
For edit mode, provide edits as an array of {old_string, new_string, replace_all} objects.
|
||||
For replace mode, provide new_content with the complete Python code.
|
||||
|
||||
Use `get_transform_python_library_details` before writing any Python code to inspect the shared library.
|
||||
Use the shared library in your code by adding `import common` at the top of the file.
|
||||
Keep `import common` at the top of the file even if it is currently unused."
|
||||
[{:keys [transform_id edit_action thinking transform_name transform_description
|
||||
source_database source_tables]}
|
||||
:- [:map {:closed true}
|
||||
[:transform_id {:optional true} [:maybe :int]]
|
||||
[:edit_action [:map
|
||||
[:mode [:enum "edit" "replace"]]
|
||||
[:edits {:optional true} [:maybe [:sequential [:map
|
||||
[:old_string :string]
|
||||
[:new_string :string]
|
||||
[:replace_all {:optional true} [:maybe :boolean]]]]]]
|
||||
[:new_content {:optional true} [:maybe :string]]]]
|
||||
[:thinking {:optional true} [:maybe :string]]
|
||||
[:transform_name {:optional true} [:maybe :string]]
|
||||
[:transform_description {:optional true} [:maybe :string]]
|
||||
[:source_database {:optional true} [:maybe :int]]
|
||||
[:source_tables {:optional true} [:maybe :map]]]]
|
||||
(try
|
||||
(add-output
|
||||
(transforms-write-tools/write-transform-python
|
||||
{:transform_id transform_id
|
||||
:edit_action edit_action
|
||||
:thinking thinking
|
||||
:transform_name transform_name
|
||||
:transform_description transform_description
|
||||
:source_database source_database
|
||||
:source_tables source_tables
|
||||
:memory-atom shared/*memory-atom*
|
||||
:context (shared/current-context)})
|
||||
format-transform-write-output)
|
||||
(catch Exception e
|
||||
(if (:agent-error? (ex-data e))
|
||||
{:output (ex-message e)}
|
||||
{:output (str "Failed to write Python transform: " (or (ex-message e) "Unknown error"))}))))
|
||||
@@ -0,0 +1,254 @@
|
||||
(ns metabase-enterprise.metabot-v3.agent.user-context
|
||||
"User context enrichment and formatting for agent system messages.
|
||||
|
||||
Handles formatting of viewing context (what the user is currently looking at),
|
||||
recent views, user time formatting, and SQL dialect extraction from context."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.metabot-v3.tmpl :as te]
|
||||
[metabase.lib-be.core :as lib-be]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.log :as log])
|
||||
(:import
|
||||
(java.time OffsetDateTime)
|
||||
(java.time.format DateTimeFormatter)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;;; Time Formatting
|
||||
|
||||
(defn format-current-time
|
||||
"Format user's current time from context.
|
||||
Expects :current_user_time (preferred) or :current_time_with_timezone in context as ISO-8601 string."
|
||||
[context]
|
||||
(try
|
||||
(cond
|
||||
(string? (:current_user_time context))
|
||||
(:current_user_time context)
|
||||
|
||||
(string? (:current_time_with_timezone context))
|
||||
(let [offset-time (OffsetDateTime/parse (:current_time_with_timezone context))
|
||||
formatter DateTimeFormatter/ISO_LOCAL_DATE_TIME]
|
||||
(.format formatter offset-time))
|
||||
|
||||
(string? (:current_time context))
|
||||
(:current_time context)
|
||||
|
||||
:else
|
||||
(.format DateTimeFormatter/ISO_LOCAL_DATE_TIME (OffsetDateTime/now)))
|
||||
(catch Exception e
|
||||
(log/error e "Error formatting current time")
|
||||
(.format DateTimeFormatter/ISO_LOCAL_DATE_TIME (OffsetDateTime/now)))))
|
||||
|
||||
;;; SQL Dialect Extraction
|
||||
|
||||
(declare normalize-context-type native-query-item? effective-context-type)
|
||||
|
||||
(defn extract-sql-dialect
|
||||
"Extract SQL dialect name from context.
|
||||
|
||||
Looks for dialect in native SQL editor context (:sql_engine field).
|
||||
Handles both explicit `type: \"native\"` items and `type: \"adhoc\"` items
|
||||
whose inner dataset-query type is `\"native\"`.
|
||||
|
||||
Returns lowercase dialect name suitable for loading dialect instructions."
|
||||
[context]
|
||||
(when-let [viewing (:user_is_viewing context)]
|
||||
(some (fn [item]
|
||||
(when (= "native" (effective-context-type item))
|
||||
(some-> (:sql_engine item) u/lower-case-en)))
|
||||
viewing)))
|
||||
|
||||
;;; Context Normalization
|
||||
|
||||
(defn- normalize-context-type
|
||||
"Normalize context :type to lowercase string."
|
||||
[type-val]
|
||||
(cond
|
||||
(keyword? type-val) (name type-val)
|
||||
(string? type-val) type-val
|
||||
(nil? type-val) nil
|
||||
:else (str type-val)))
|
||||
|
||||
(defn- native-query-item?
|
||||
"True when the viewing-context item represents a native SQL query.
|
||||
|
||||
The frontend sends `type: \"adhoc\"` for *both* notebook (MBQL) and native SQL
|
||||
queries. We distinguish them by inspecting the query: a dataset-query with
|
||||
`{:type \"native\"}` (or `:native`) is a native SQL query, as is an MLv2/pMBQL
|
||||
query with a single native stage."
|
||||
[item]
|
||||
(let [query (:query item)]
|
||||
(or (= "native" (normalize-context-type (:type query)))
|
||||
;; MLv2/pMBQL: normalize and use lib to detect native queries
|
||||
(when (and (map? query) (:database query))
|
||||
(try
|
||||
(lib/native-only-query? (lib-be/normalize-query query))
|
||||
(catch Exception _ false))))))
|
||||
|
||||
(defn- effective-context-type
|
||||
"Return the effective context type for a viewing-context item.
|
||||
|
||||
Handles the fact that the frontend sends `type: \"adhoc\"` for both notebook
|
||||
and native SQL queries by inspecting the inner dataset-query type."
|
||||
[item]
|
||||
(let [t (normalize-context-type (:type item))]
|
||||
(if (and (= "adhoc" t) (native-query-item? item))
|
||||
"native"
|
||||
t)))
|
||||
|
||||
;;; Entity Formatting
|
||||
|
||||
(defn- fully-qualified-name
|
||||
"Build fully qualified table name (schema.table) when schema is available."
|
||||
[{:keys [name database_schema]}]
|
||||
(if (not-empty database_schema)
|
||||
(str database_schema "." name)
|
||||
name))
|
||||
|
||||
(defn- format-simple-entity
|
||||
[entity]
|
||||
(te/lines
|
||||
(te/field (:type entity) (str (fully-qualified-name entity) " (ID: " (or (:id entity) "-") ")"))
|
||||
(te/field "Description" (:description entity))
|
||||
(te/field "Fields" (some->> (:fields entity) (map :name) (str/join ", ")))))
|
||||
|
||||
(defn- dispatch-format-entity [entity] (effective-context-type entity))
|
||||
(defmulti format-entity "Format an entity for LLM representation." {:arglists '([entity])} dispatch-format-entity)
|
||||
|
||||
(defmethod format-entity :default [entity]
|
||||
(log/warn "Unknown viewing context type:" (:type entity))
|
||||
"")
|
||||
|
||||
(defmethod format-entity "table" [entity] (format-simple-entity entity))
|
||||
(defmethod format-entity "model" [entity] (format-simple-entity entity))
|
||||
(defmethod format-entity "question" [entity] (if (native-query-item? entity) (format-entity "native" entity) (format-simple-entity entity)))
|
||||
(defmethod format-entity "metric" [entity] (format-simple-entity entity))
|
||||
(defmethod format-entity "dashboard" [entity] (format-simple-entity entity))
|
||||
|
||||
;;; Viewing Context Formatting
|
||||
|
||||
;; Format adhoc query (notebook editor) viewing context.
|
||||
(defmethod format-entity "adhoc"
|
||||
[item]
|
||||
(te/lines "The user is currently in the notebook editor viewing a query."
|
||||
(te/field "Query ID" (:id item))
|
||||
(te/field "Database ID" (get-in item [:query :database]))
|
||||
(te/field "Tables used" (some->> (:used_tables item)
|
||||
(map format-entity)
|
||||
te/lines))))
|
||||
|
||||
;; Format native SQL query viewing context.
|
||||
;; The :query field can be either a plain SQL string (legacy / explicit `type: "native"`)
|
||||
;; or a dataset-query map (from the frontend with `type: "adhoc"`) where the actual SQL
|
||||
;; lives at [:native :query].
|
||||
(defmethod format-entity "native"
|
||||
[item]
|
||||
(let [query-val (:query item)
|
||||
sql-text (cond
|
||||
;; Plain SQL string (legacy)
|
||||
(string? query-val) query-val
|
||||
;; MLv2/pMBQL or legacy dataset-query map: normalize and use lib
|
||||
(and (map? query-val) (:database query-val))
|
||||
(try
|
||||
(lib/raw-native-query (lib-be/normalize-query query-val))
|
||||
(catch Exception _
|
||||
;; Fall back to manual extraction
|
||||
(or (some :native (:stages query-val))
|
||||
(get-in query-val [:native :query]))))
|
||||
;; Other map shapes
|
||||
(map? query-val) (get-in query-val [:native :query])
|
||||
:else nil)]
|
||||
(te/lines
|
||||
"The user is currently in the SQL editor."
|
||||
(te/field "Query ID" (:id item))
|
||||
(te/field "Current SQL query" (te/code sql-text "sql"))
|
||||
(te/field "Database SQL engine" (:sql_engine item))
|
||||
(te/field "Query error" (te/code (:error item)))
|
||||
(te/field "Tables used" (some->> (:used_tables item)
|
||||
(map format-entity)
|
||||
te/lines)))))
|
||||
|
||||
(defmethod format-entity "transform"
|
||||
[item]
|
||||
(te/lines "The user is currently viewing a Transform."
|
||||
(te/field "Transform ID" (:id item))
|
||||
(te/field "Transform name" (:name item))
|
||||
(te/field "Source type" (:source_type item))
|
||||
(te/field "Transform error" (te/code (:error item)))
|
||||
(te/field "Tables used" (some->> (:used_tables item)
|
||||
(map format-entity)
|
||||
te/lines))))
|
||||
|
||||
(defmethod format-entity "code_editor"
|
||||
[{:keys [buffers]}]
|
||||
(if (empty? buffers)
|
||||
"The user is in the code editor but no active buffers are available."
|
||||
(te/lines "The user is currently in the code editor with the following buffer(s):"
|
||||
(for [{:keys [source cursor selection] :as buffer} buffers]
|
||||
(te/lines
|
||||
(format "Buffer ID: %s | Language: %s | Database ID: %s"
|
||||
(:id buffer) (:language source) (:database_id source))
|
||||
(when cursor
|
||||
(format "Cursor: Line %s, Column %s" (:line cursor) (:column cursor)))
|
||||
(when-let [{:keys [start end text]} selection]
|
||||
(te/lines
|
||||
(te/field "Selected lines" (str (:line start) "-" (:line end)))
|
||||
(te/field "Selected text" text))))))))
|
||||
|
||||
(defn format-viewing-context
|
||||
"Format user's current viewing context for injection into system message.
|
||||
|
||||
Handles different context types:
|
||||
- adhoc: Notebook query editor
|
||||
- native: SQL editor with schema context
|
||||
- transform: Transform definition and code
|
||||
- code_editor: Code editor buffers with cursor position
|
||||
- table/model/question/metric/dashboard: Entity details
|
||||
|
||||
Returns formatted string for template variable {{viewing_context}}."
|
||||
[context]
|
||||
(str/join "\n\n"
|
||||
(for [item (:user_is_viewing context)]
|
||||
(try
|
||||
(format-entity item)
|
||||
(catch Exception e
|
||||
(log/error e "Error formatting viewing context item:" (:type item))
|
||||
"")))))
|
||||
|
||||
;;; Recent Views Formatting
|
||||
|
||||
(defn format-recent-views
|
||||
"Format user's recently viewed items for injection into system message.
|
||||
|
||||
Returns formatted string for template variable {{recent_views}}."
|
||||
[context]
|
||||
(if-not (:user_recently_viewed context)
|
||||
""
|
||||
(let [items (:user_recently_viewed context)]
|
||||
(te/lines "Here are some items the user has recently viewed:"
|
||||
(for [item items]
|
||||
(format-simple-entity (select-keys item [:type :id :name :description])))
|
||||
""
|
||||
"**Important:** These items might be relevant for answering the user's request."
|
||||
"If any item seems relevant, try to fetch its full details using the appropriate tool."
|
||||
"Otherwise, use the search tool to find relevant entities."))))
|
||||
|
||||
;;; Context Enrichment
|
||||
|
||||
(defn enrich-context-for-template
|
||||
"Enrich context with all necessary variables for system prompt template rendering.
|
||||
|
||||
Takes raw context from API and returns map suitable for template rendering:
|
||||
- :current_time - Formatted user time string
|
||||
- :first_day_of_week - Calendar week start (default 'Sunday')
|
||||
- :sql_dialect - SQL dialect name (lowercase)
|
||||
- :viewing_context - Formatted viewing context
|
||||
- :recent_views - Formatted recent views"
|
||||
[context]
|
||||
{:current_time (format-current-time context)
|
||||
:first_day_of_week (get context :first_day_of_week "Sunday")
|
||||
:sql_dialect (extract-sql-dialect context)
|
||||
:viewing_context (format-viewing-context context)
|
||||
:recent_views (format-recent-views context)})
|
||||
@@ -2,7 +2,9 @@
|
||||
"`/api/ee/metabot-v3/` routes"
|
||||
(:require
|
||||
[clj-http.client :as http]
|
||||
[clojure.core.async :as a]
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.metabot-v3.agent.core :as agent]
|
||||
[metabase-enterprise.metabot-v3.api.document]
|
||||
[metabase-enterprise.metabot-v3.api.metabot]
|
||||
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
|
||||
@@ -10,6 +12,7 @@
|
||||
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
|
||||
[metabase-enterprise.metabot-v3.context :as metabot-v3.context]
|
||||
[metabase-enterprise.metabot-v3.envelope :as metabot-v3.envelope]
|
||||
[metabase-enterprise.metabot-v3.self.core :as self.core]
|
||||
[metabase-enterprise.metabot-v3.settings :as metabot-v3.settings]
|
||||
[metabase-enterprise.metabot-v3.util :as metabot-v3.u]
|
||||
[metabase.api.common :as api]
|
||||
@@ -17,15 +20,23 @@
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.api.util.handlers :as handlers]
|
||||
[metabase.app-db.core :as app-db]
|
||||
[metabase.config.core :as config]
|
||||
[metabase.premium-features.core :as premium-features]
|
||||
[metabase.server.streaming-response :as sr]
|
||||
[metabase.store-api.core :as store-api]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[toucan2.core :as t2]))
|
||||
[toucan2.core :as t2])
|
||||
(:import
|
||||
(java.io OutputStream)))
|
||||
|
||||
(defn- store-message! [conversation-id profile-id messages]
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn- store-aiservice-messages!
|
||||
"Store messages that are going from ai-service"
|
||||
[conversation-id profile-id messages]
|
||||
(let [finish (let [m (u/last messages)]
|
||||
(when (= (:_type m) :FINISH_MESSAGE)
|
||||
m))
|
||||
@@ -50,7 +61,6 @@
|
||||
(filter map?)
|
||||
(map #(+ (:prompt %) (:completion %)))
|
||||
(apply +))})))
|
||||
|
||||
(defn- check-metabot-enabled!
|
||||
"Checks that the Metabot instance identified by `metabot-id` is enabled. Throws a 403 if it's not."
|
||||
[metabot-id]
|
||||
@@ -60,27 +70,170 @@
|
||||
(api/check (metabot-v3.settings/metabot-enabled?)
|
||||
[403 "Metabot is not enabled."])))
|
||||
|
||||
(defn- extract-usage
|
||||
"Extract usage from parts, taking the last `:usage` per model.
|
||||
|
||||
The agent loop emits cumulative usage — each `:usage` part subsumes all prior
|
||||
usage for that model — so we simply take the last one per model rather than
|
||||
summing. Returns a map keyed by model name:
|
||||
{\"model-name\" {:prompt X :completion Y}}"
|
||||
[parts]
|
||||
(transduce
|
||||
(filter #(= :usage (:type %)))
|
||||
(completing
|
||||
(fn [acc {:keys [usage model]}]
|
||||
(let [model (or model "unknown")]
|
||||
(assoc acc model {:prompt (:promptTokens usage 0)
|
||||
:completion (:completionTokens usage 0)}))))
|
||||
{}
|
||||
parts))
|
||||
|
||||
(defn- store-native-parts!
|
||||
"Store assistant response parts directly to the database.
|
||||
|
||||
Takes AI SDK parts (after aisdk-xf combining) and stores them in the native format,
|
||||
avoiding the intermediate 'aisdk messages' format.
|
||||
|
||||
Parts format: [{:type :text :text \"...\"} {:type :tool-input ...} ...]"
|
||||
[conversation-id profile-id parts]
|
||||
(let [state-part (u/seek #(and (= :data (:type %))
|
||||
(= "state" (:data-type %)))
|
||||
parts)
|
||||
usage (extract-usage parts)
|
||||
;; Filter out :start, :usage, :finish, :data - these are metadata, not message content
|
||||
;; :data is like `:navigate_to`
|
||||
content (->> parts
|
||||
(remove #(#{:start :usage :finish :data} (:type %)))
|
||||
vec)]
|
||||
(t2/with-transaction [_conn]
|
||||
(when state-part
|
||||
(app-db/update-or-insert! :model/MetabotConversation {:id conversation-id}
|
||||
(constantly {:user_id api/*current-user-id*
|
||||
:state (:data state-part)})))
|
||||
(t2/insert! :model/MetabotMessage
|
||||
{:conversation_id conversation-id
|
||||
:data content
|
||||
:usage usage
|
||||
:role :assistant
|
||||
:profile_id profile-id
|
||||
:total_tokens (->> (vals usage)
|
||||
(map #(+ (:prompt %) (:completion %)))
|
||||
(reduce + 0))}))))
|
||||
|
||||
(defn- streaming-writer-rf
|
||||
"Creates a reducing function that writes AI SDK lines to an OutputStream.
|
||||
|
||||
Lines are written immediately with a newline and flushed for real-time streaming.
|
||||
When `canceled-chan` is provided, polls it before each write and returns `reduced`
|
||||
to stop the pipeline when the client has disconnected. Also catches EofException
|
||||
(client closed connection) and converts it to `reduced` so the pipeline shuts down
|
||||
cleanly without triggering upstream retries."
|
||||
[^java.io.OutputStream os canceled-chan]
|
||||
(fn
|
||||
([] nil)
|
||||
([_] nil)
|
||||
([acc ^String line]
|
||||
(if (and canceled-chan (a/poll! canceled-chan))
|
||||
(reduced acc)
|
||||
(try
|
||||
(.write os (.getBytes (str line "\n") "UTF-8"))
|
||||
(.flush os)
|
||||
(catch org.eclipse.jetty.io.EofException _
|
||||
(reduced acc)))))))
|
||||
|
||||
(defn- combine-text-parts-xf []
|
||||
(fn [rf]
|
||||
(let [pending (volatile! nil)]
|
||||
(fn
|
||||
([] (rf))
|
||||
([result]
|
||||
(let [p @pending]
|
||||
(rf (if p (rf result p) result))))
|
||||
([result part]
|
||||
(let [prev @pending]
|
||||
(if (and prev (= :text (:type prev) (:type part)))
|
||||
(do (vswap! pending update :text str (:text part))
|
||||
result)
|
||||
(do (vreset! pending part)
|
||||
(if prev (rf result prev) result)))))))))
|
||||
|
||||
(defn- native-agent-streaming-request
|
||||
"Handle streaming request using native Clojure agent.
|
||||
|
||||
Streams AI SDK v4 line protocol to the client in real-time while simultaneously
|
||||
collecting parts for database storage. Text parts are combined before storage
|
||||
to consolidate streaming chunks into single text parts.
|
||||
|
||||
Monitors `canceled-chan` for client disconnection — when the client closes the
|
||||
connection, the pipeline stops via `reduced` and collected parts are still persisted.
|
||||
|
||||
When `:debug?` is true, enables debug logging which emits a `debug_log` data
|
||||
part at the end of the stream with full LLM request/response data per iteration."
|
||||
[{:keys [profile-id message context history conversation-id state debug?]}]
|
||||
(let [enriched-context (metabot-v3.context/create-context context)
|
||||
messages (concat history [message])]
|
||||
(sr/streaming-response {:content-type "text/event-stream"} [^OutputStream os canceled-chan]
|
||||
(let [parts-atom (atom [])
|
||||
;; Compose: collect parts AND convert to lines for streaming.
|
||||
;; In dev mode, emit usage parts in the SSE stream for debugging/benchmarking.
|
||||
xf (comp (u/tee-xf parts-atom)
|
||||
(self.core/aisdk-line-xf {:emit-usage? config/is-dev?}))]
|
||||
(try
|
||||
(transduce xf
|
||||
(streaming-writer-rf os canceled-chan)
|
||||
(agent/run-agent-loop
|
||||
(cond-> {:messages messages
|
||||
:state state
|
||||
:profile-id (keyword profile-id)
|
||||
:context enriched-context
|
||||
:tracking-opts {:session-id conversation-id
|
||||
:track-user-intent? true}}
|
||||
debug? (assoc :debug? true))))
|
||||
(catch org.eclipse.jetty.io.EofException _
|
||||
(log/debug "Client disconnected during native agent streaming"))
|
||||
(finally
|
||||
(store-native-parts! conversation-id profile-id (into [] (combine-text-parts-xf) @parts-atom))))))))
|
||||
|
||||
(defn streaming-request
|
||||
"Handles an incoming request, making all required tool invocation, LLM call loops, etc."
|
||||
[{:keys [metabot_id profile_id message context history conversation_id state]}]
|
||||
[{:keys [metabot_id profile_id message context history conversation_id state debug]}]
|
||||
(let [message (metabot-v3.envelope/user-message message)
|
||||
metabot-id (metabot-v3.config/resolve-dynamic-metabot-id metabot_id)
|
||||
_ (check-metabot-enabled! metabot-id)
|
||||
profile-id (metabot-v3.config/resolve-dynamic-profile-id profile_id metabot-id)
|
||||
session-id (metabot-v3.client/get-ai-service-token api/*current-user-id* metabot-id)]
|
||||
(store-message! conversation_id profile-id [message])
|
||||
(metabot-v3.client/streaming-request
|
||||
{:context (metabot-v3.context/create-context context)
|
||||
:metabot-id metabot-id
|
||||
:profile-id profile-id
|
||||
:session-id session-id
|
||||
:conversation-id conversation_id
|
||||
:message message
|
||||
:history history
|
||||
:state state
|
||||
:on-complete (fn [lines]
|
||||
(store-message! conversation_id profile-id (metabot-v3.u/aisdk->messages :assistant lines))
|
||||
:store-in-db)})))
|
||||
;; Only allow debug mode in dev — never in production
|
||||
debug? (and config/is-dev? (boolean debug))]
|
||||
(store-aiservice-messages! conversation_id profile-id [message])
|
||||
|
||||
(if (metabot-v3.settings/use-native-agent)
|
||||
;; Use native Clojure agent
|
||||
(do
|
||||
(log/info "Using native Clojure agent" {:profile-id profile-id :debug? debug?})
|
||||
(native-agent-streaming-request
|
||||
{:profile-id profile-id
|
||||
:message message
|
||||
:context context
|
||||
:history history
|
||||
:conversation-id conversation_id
|
||||
:state state
|
||||
:debug? debug?}))
|
||||
|
||||
;; Fallback to Python AI Service
|
||||
(let [session-id (metabot-v3.client/get-ai-service-token api/*current-user-id* metabot-id)]
|
||||
(log/info "Using Python AI Service" {:profile-id profile-id :debug? debug?})
|
||||
(metabot-v3.client/streaming-request
|
||||
{:context (metabot-v3.context/create-context context)
|
||||
:metabot-id metabot-id
|
||||
:profile-id profile-id
|
||||
:session-id session-id
|
||||
:conversation-id conversation_id
|
||||
:message message
|
||||
:history history
|
||||
:state state
|
||||
:debug? debug?
|
||||
:on-complete (fn [lines]
|
||||
(store-aiservice-messages! conversation_id profile-id (metabot-v3.u/aisdk->messages :assistant lines))
|
||||
:store-in-db)})))))
|
||||
|
||||
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
|
||||
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
|
||||
@@ -97,7 +250,8 @@
|
||||
[:context ::metabot-v3.context/context]
|
||||
[:conversation_id ms/UUIDString]
|
||||
[:history [:maybe ::metabot-v3.client.schema/messages]]
|
||||
[:state :map]]]
|
||||
[:state :map]
|
||||
[:debug {:optional true} [:maybe :boolean]]]]
|
||||
(metabot-v3.context/log body :llm.log/fe->be)
|
||||
(streaming-request body))
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"`/api/ee/metabot-v3/document` routes"
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.metabot-v3.agent.core :as metabot-v3.agent]
|
||||
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
|
||||
[metabase-enterprise.metabot-v3.context :as metabot-v3.context]
|
||||
[metabase-enterprise.metabot-v3.table-utils :as table-utils]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
@@ -32,6 +34,109 @@
|
||||
(catch Exception e
|
||||
(ex-message e))))
|
||||
|
||||
(defn- maybe-fix-native-sql
|
||||
[response references]
|
||||
(let [query (get-in response [:draft_card :dataset_query])]
|
||||
(if-let [error (when (= "native" (name (:type query))) (check-query query))]
|
||||
(let [db-id (-> references
|
||||
keys
|
||||
first
|
||||
name
|
||||
(#(str/split % #":"))
|
||||
last
|
||||
Integer/parseInt)
|
||||
db (warehouses/get-database db-id)
|
||||
schema-ddl (table-utils/schema-full db-id)]
|
||||
;; Attempt to fix SQL 5 times before returning.
|
||||
(loop [attempts-left 5
|
||||
sql (get-in query [:native :query])
|
||||
error error]
|
||||
(let [fixed-sql (fix-sql {:sql sql
|
||||
:dialect (:engine db)
|
||||
:error_message error
|
||||
:schema_ddl schema-ddl})
|
||||
;; Only re-check query if new SQL was produced.
|
||||
error (when fixed-sql
|
||||
(check-query (assoc-in query [:native :query] fixed-sql)))]
|
||||
(if (and error (> attempts-left 1))
|
||||
(recur (dec attempts-left) fixed-sql error)
|
||||
(assoc-in response [:draft_card :dataset_query :native :query] (or fixed-sql sql))))))
|
||||
response)))
|
||||
|
||||
(defn- part->structured-output
|
||||
[part]
|
||||
(or (get-in part [:result :structured-output])
|
||||
(get-in part [:result :structured_output])))
|
||||
|
||||
(defn- latest-chart-structured-output
|
||||
[parts]
|
||||
(->> parts
|
||||
(filter #(= :tool-output (:type %)))
|
||||
(keep part->structured-output)
|
||||
(filter map?)
|
||||
(filter #(or (:chart-id %)
|
||||
(= :chart-draft (:result-type %))
|
||||
(and (:dataset_query %) (:display %))))
|
||||
last))
|
||||
|
||||
(defn- draft-card-from-chart-output
|
||||
[chart-output]
|
||||
(let [chart-name (:name chart-output)
|
||||
query (:dataset_query chart-output)
|
||||
chart-type (or (:display chart-output)
|
||||
(:chart_type chart-output))]
|
||||
(when (and chart-name (map? query) chart-type)
|
||||
{:name chart-name
|
||||
:display (name chart-type)
|
||||
:dataset_query query
|
||||
:database_id (:database query)
|
||||
:parameters []
|
||||
:visualization_settings {}})))
|
||||
|
||||
(defn- last-agent-message
|
||||
[parts]
|
||||
(let [text-groups (reduce (fn [groups part]
|
||||
(if (= :text (:type part))
|
||||
(update groups (dec (count groups)) conj (:text part))
|
||||
(conj groups [])))
|
||||
[[]]
|
||||
parts)
|
||||
last-text-message (some->> text-groups
|
||||
reverse
|
||||
(map #(str/trim (str/join "" %)))
|
||||
(remove str/blank?)
|
||||
first)]
|
||||
last-text-message))
|
||||
|
||||
(defn- native-generate-content
|
||||
[{:keys [instructions references]}]
|
||||
(let [context (assoc
|
||||
(metabot-v3.context/create-context {:capabilities #{"permission:write_sql_queries"}})
|
||||
:references references)
|
||||
parts (into [] (metabot-v3.agent/run-agent-loop
|
||||
{:messages [{:role :user
|
||||
:content instructions}]
|
||||
:profile-id :document-generate-content
|
||||
:state {}
|
||||
:context context
|
||||
:tracking-opts {:source "document_generate_content"
|
||||
:track-user-intent? true}}))
|
||||
chart-output (latest-chart-structured-output parts)
|
||||
draft-card (draft-card-from-chart-output chart-output)
|
||||
description (or (:description chart-output)
|
||||
(:name chart-output)
|
||||
(:name draft-card))]
|
||||
|
||||
(if draft-card
|
||||
(maybe-fix-native-sql {:draft_card draft-card
|
||||
:description description
|
||||
:error nil} references)
|
||||
{:draft_card nil
|
||||
:description nil
|
||||
:error (or
|
||||
(last-agent-message parts)
|
||||
"Unable to generate chart content.")})))
|
||||
|
||||
;; TODO (Cam 2025-11-25) please add a response schema to this API endpoint, it makes it easier for our customers to
|
||||
;; use our API + we will need it when we make auto-TypeScript-signature generation happen
|
||||
;;
|
||||
@@ -47,35 +152,26 @@
|
||||
(let [response (metabot-v3.client/document-generate-content {:instructions (:instructions body)
|
||||
:references (:references body)
|
||||
:user_id api/*current-user-id*
|
||||
:conversation_id (str (random-uuid))})
|
||||
query (get-in response [:draft_card :dataset_query])]
|
||||
(if (and query (= "native" (:type query)))
|
||||
(if-let [error (check-query query)]
|
||||
(let [db-id (-> (:references body)
|
||||
keys
|
||||
first
|
||||
name
|
||||
(#(str/split % #":"))
|
||||
last
|
||||
Integer/parseInt)
|
||||
db (warehouses/get-database db-id)
|
||||
schema-ddl (table-utils/schema-full db-id)]
|
||||
;; Attempt to fix SQL 5 times before returning.
|
||||
(loop [attempts-left 5
|
||||
sql (get-in query [:native :query])
|
||||
error error]
|
||||
(let [fixed-sql (fix-sql {:sql sql
|
||||
:dialect (:engine db)
|
||||
:error_message error
|
||||
:schema_ddl schema-ddl})
|
||||
;; Only re-check query if new SQL was produced.
|
||||
error (when fixed-sql
|
||||
(check-query (assoc-in query [:native :query] fixed-sql)))]
|
||||
(if (and error (> attempts-left 1))
|
||||
(recur (dec attempts-left) fixed-sql error)
|
||||
(assoc-in response [:draft_card :dataset_query :native :query] (or fixed-sql sql))))))
|
||||
response)
|
||||
response)))
|
||||
:conversation_id (str (random-uuid))})]
|
||||
(maybe-fix-native-sql response (:references body))))
|
||||
|
||||
(api.macros/defendpoint :post "/native-generate-content" :- [:map
|
||||
[:draft_card [:maybe [:map
|
||||
[:name ms/NonBlankString]
|
||||
[:dataset_query ms/Map]
|
||||
[:database_id ms/PositiveInt]
|
||||
[:parameters [:maybe [:sequential ms/Map]]]
|
||||
[:visualization_settings ms/Map]]]]
|
||||
[:error [:maybe ms/NonBlankString]]
|
||||
[:description [:maybe ms/NonBlankString]]]
|
||||
"Create a new piece of content to insert into the document using the native Clojure agent."
|
||||
[_route-params
|
||||
_query-params
|
||||
|
||||
body :- [:map
|
||||
[:instructions ms/NonBlankString]
|
||||
[:references {:optional true} ms/Map]]]
|
||||
(native-generate-content body))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/metabot-v3/document` routes."
|
||||
|
||||
@@ -94,9 +94,6 @@
|
||||
(defn- metric-selection-endpoint-url []
|
||||
(str (metabot-v3.settings/ai-service-base-url) "/v1/select-metric"))
|
||||
|
||||
(defn- find-outliers-endpoint-url []
|
||||
(str (metabot-v3.settings/ai-service-base-url) "/v1/find-outliers"))
|
||||
|
||||
(defn- fix-sql-endpoint []
|
||||
(str (metabot-v3.settings/ai-service-base-url) "/v1/sql/fix"))
|
||||
|
||||
@@ -115,9 +112,6 @@
|
||||
(defn- document-generate-content-endpoint []
|
||||
(str (metabot-v3.settings/ai-service-base-url) "/v1/document/generate-content"))
|
||||
|
||||
(defn- generate-embeddings-endpoint []
|
||||
(str (metabot-v3.settings/ai-service-base-url) "/v1/embeddings"))
|
||||
|
||||
(defn- quick-closing-body
|
||||
"Some requests come with body wrapped in ContentLengthInputStream, and that will never close the underlying stream.
|
||||
So we just close the client itself.
|
||||
@@ -147,7 +141,7 @@
|
||||
Response chunks are encoded in the format understood by the frontend and AI Service, Clojure backend doesn't
|
||||
know anything about it and just shuttles them over.
|
||||
"
|
||||
[{:keys [context message history profile-id conversation-id session-id state on-complete]}
|
||||
[{:keys [context message history profile-id conversation-id session-id state debug? on-complete]}
|
||||
:- [:map
|
||||
[:context :map]
|
||||
[:message ::metabot-v3.client.schema/message]
|
||||
@@ -156,16 +150,18 @@
|
||||
[:conversation-id :string]
|
||||
[:session-id :string]
|
||||
[:state :map]
|
||||
[:debug? {:optional true} [:maybe :boolean]]
|
||||
[:on-complete {:optional true} [:function [:=> [:cat :any] :any]]]]]
|
||||
(premium-features/assert-has-feature :metabot-v3 "MetaBot")
|
||||
(try
|
||||
(let [url (ai-url "/v2/agent/stream")
|
||||
body {:messages (conj (vec history) message)
|
||||
:context context
|
||||
:conversation_id conversation-id
|
||||
:profile_id profile-id
|
||||
:user_id api/*current-user-id*
|
||||
:state state}
|
||||
body (cond-> {:messages (conj (vec history) message)
|
||||
:context context
|
||||
:conversation_id conversation-id
|
||||
:profile_id profile-id
|
||||
:user_id api/*current-user-id*
|
||||
:state state}
|
||||
debug? (assoc :debug true))
|
||||
_ (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" "text/event-stream"
|
||||
@@ -241,21 +237,6 @@
|
||||
{}
|
||||
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)]
|
||||
(u/prog1 (check-response! response body)
|
||||
(log/debugf "Response:\n%s" (u/pprint-to-str <>))))
|
||||
(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
|
||||
@@ -373,14 +354,3 @@
|
||||
options (build-request-options payload)
|
||||
response (post! url options)]
|
||||
(check-response! response payload)))
|
||||
|
||||
(defn generate-embeddings
|
||||
"Generate vector embeddings for a batch of inputs questions for the given models and metrics."
|
||||
[model-name texts]
|
||||
(let [url (generate-embeddings-endpoint)
|
||||
body {:model model-name
|
||||
:input texts
|
||||
:encoding_format "base64"}
|
||||
options (build-request-options body)
|
||||
response (post! url options)]
|
||||
(check-response! response body)))
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"The name of the collection exposed by the answer-sources tool."
|
||||
{internal-metabot-id {:profile-id "internal"
|
||||
:entity-id "metabotmetabotmetabot"}
|
||||
embedded-metabot-id {:profile-id "embedding"
|
||||
embedded-metabot-id {:profile-id "embedding_next"
|
||||
:entity-id "embeddedmetabotmetabo"}})
|
||||
|
||||
(defn metabot-id->profile-id
|
||||
@@ -45,11 +45,11 @@
|
||||
|
||||
(defn resolve-dynamic-profile-id
|
||||
"Resolve the ultimate ai-service profile ID with logical fall backs
|
||||
Precedence: explicit profile_id > env profile_id > metabot-id->profile-id > default (embedded)"
|
||||
Precedence: explicit profile_id > env profile_id > metabot-id->profile-id > default (embedding_next)"
|
||||
([profile-id]
|
||||
(resolve-dynamic-profile-id profile-id (resolve-dynamic-metabot-id nil)))
|
||||
([profile-id metabot-id]
|
||||
(or profile-id
|
||||
(metabot-v3.settings/ai-service-profile-id)
|
||||
(metabot-id->profile-id metabot-id)
|
||||
"default")))
|
||||
"embedding_next")))
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
[metabase.util.json :as json]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.registry :as mr])
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[toucan2.core :as t2])
|
||||
(:import
|
||||
(java.time OffsetDateTime)
|
||||
(java.time.format DateTimeFormatter)))
|
||||
@@ -114,8 +115,47 @@
|
||||
(log/error e "Error getting Python transform tables for context")
|
||||
[])))
|
||||
|
||||
(defn- mbql-source-table-ids
|
||||
"Given a context item with an MBQL query, return [database-id [table-id ...]] if it has
|
||||
source-table references, or nil otherwise. Handles both MLv2/pMBQL and legacy formats."
|
||||
[item]
|
||||
(when (= "adhoc" (:type item))
|
||||
(let [query (:query item)
|
||||
database-id (:database query)
|
||||
normalized (try (lib-be/normalize-query query) (catch Exception _ nil))
|
||||
table-ids (->> (:stages normalized)
|
||||
(keep :source-table)
|
||||
(filter pos-int?)
|
||||
distinct
|
||||
vec)]
|
||||
(when (seq table-ids)
|
||||
[database-id table-ids]))))
|
||||
|
||||
(defn- mbql-source-tables-for-context
|
||||
"Get source tables for an MBQL query, formatted for metabot context (with :type, :display_name, :fields, etc.).
|
||||
|
||||
Uses a direct table lookup without native-query permission checks. The user is already
|
||||
viewing these tables in the notebook editor, so they have at least query-builder access.
|
||||
The standard `used-tables-from-ids` requires `:query-builder-and-native` permissions
|
||||
which is too restrictive for MBQL viewing context enrichment."
|
||||
[[database-id table-ids]]
|
||||
(try
|
||||
(let [raw-tables (t2/select [:model/Table :id :name :schema]
|
||||
:db_id database-id
|
||||
:id [:in table-ids]
|
||||
:active true
|
||||
:visibility_type nil)
|
||||
tables (when (seq raw-tables)
|
||||
(table-utils/enhanced-database-tables database-id
|
||||
{:priority-tables raw-tables
|
||||
:all-tables-limit (count raw-tables)}))]
|
||||
(m/distinct-by :id tables))
|
||||
(catch Exception e
|
||||
(log/error e "Error getting MBQL source tables for context")
|
||||
nil)))
|
||||
|
||||
(defn- enhance-context-with-schema
|
||||
"Enhance context by adding table schema information for native queries, SQL transforms, and Python transforms"
|
||||
"Enhance context by adding table schema information for native queries, MBQL queries, SQL transforms, and Python transforms."
|
||||
[context]
|
||||
(if-let [user-viewing (get context :user_is_viewing)]
|
||||
(let [enhanced-viewing
|
||||
@@ -126,6 +166,11 @@
|
||||
(when-let [tables (seq (database-tables-for-context {:query query}))]
|
||||
(assoc item :used_tables tables)))
|
||||
|
||||
;; Handle MBQL/notebook queries
|
||||
(when-let [db-and-table-ids (mbql-source-table-ids item)]
|
||||
(when-let [tables (seq (mbql-source-tables-for-context db-and-table-ids))]
|
||||
(assoc item :used_tables tables)))
|
||||
|
||||
;; Handle Python transforms
|
||||
(when-let [db-and-table-ids (python-transform-db-and-table-ids item)]
|
||||
(when-let [tables (seq (python-transform-tables-for-context db-and-table-ids))]
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
(ns metabase-enterprise.metabot-v3.example-question-generator
|
||||
"Native Clojure generator for example questions (prompts) for Metabot.
|
||||
|
||||
Replaces the Python ai-service `/v1/example-question-generation/batch` endpoint
|
||||
when `use-native-agent` is true.
|
||||
|
||||
Architecture mirrors the Python service:
|
||||
- Separate LLM call per table/metric (not one batch prompt)
|
||||
- Two distinct prompt templates: table.jinja → table.selmer, metric.jinja → metric.selmer
|
||||
- Parallel execution within batches of 10
|
||||
- 5 questions generated per item
|
||||
- Uses tool_choice pattern for structured output (matching Python's structured_completion)
|
||||
- Temperature 0.3 (matching Python default)
|
||||
- Response shape matches: `{:table_questions [{:questions [...]}] :metric_questions [{:questions [...]}]}`"
|
||||
(:require
|
||||
[clojure.java.io :as io]
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.llm.settings :as llm]
|
||||
[metabase-enterprise.metabot-v3.self :as self]
|
||||
[metabase.util.log :as log]
|
||||
[selmer.parser :as selmer]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
;;; Template loading (cached)
|
||||
|
||||
(def ^:private table-template-path "metabot/prompts/example_questions_table.selmer")
|
||||
(def ^:private metric-template-path "metabot/prompts/example_questions_metric.selmer")
|
||||
|
||||
(def ^:private template-cache
|
||||
"In-memory cache for loaded prompt templates. Map of path -> template string."
|
||||
(atom {}))
|
||||
|
||||
(defn- load-template [path]
|
||||
(or (get @template-cache path)
|
||||
(if-let [content (some-> (io/resource path) slurp)]
|
||||
(do (swap! template-cache assoc path content)
|
||||
content)
|
||||
(throw (ex-info "Prompt template not found" {:path path})))))
|
||||
|
||||
(defn clear-template-cache!
|
||||
"Clear the template cache. Useful for development/testing."
|
||||
[]
|
||||
(reset! template-cache {}))
|
||||
|
||||
;;; Column LLM name formatting (mirrors Python ColumnSchema.get_llm_name / get_llm_description)
|
||||
|
||||
(defn- llm-name
|
||||
"Format column name for LLM. If it has a table-reference, format as table_reference__name."
|
||||
[{:keys [name table-reference]}]
|
||||
(if (not (str/blank? table-reference))
|
||||
(str table-reference "__" name)
|
||||
name))
|
||||
|
||||
(defn- llm-description
|
||||
"Format column description for LLM. If it has a table-reference, prepend 'From the related X table.'"
|
||||
[{:keys [description table-reference]}]
|
||||
(cond
|
||||
(and (not (str/blank? table-reference)) (not (str/blank? description)))
|
||||
(str "From the related " table-reference " table. " description)
|
||||
|
||||
(not (str/blank? table-reference))
|
||||
(str "From the related " table-reference " table.")
|
||||
|
||||
:else
|
||||
(or description "")))
|
||||
|
||||
(defn- enrich-column
|
||||
"Add :llm_name and :llm_description to a column map for template rendering."
|
||||
[col]
|
||||
(assoc col
|
||||
:llm_name (llm-name col)
|
||||
:llm_description (llm-description col)))
|
||||
|
||||
;;; Output validation
|
||||
|
||||
(def ^:private questions-json-schema
|
||||
"JSON Schema for the structured output tool, matching Python's ExampleQuestionGenerationSchema."
|
||||
{:type "object"
|
||||
:properties {:questions {:type "array"
|
||||
:items {:type "string"}
|
||||
:description "List of example questions. No additional explanation."}}
|
||||
:required ["questions"]
|
||||
:additionalProperties false})
|
||||
|
||||
(defn- validate-questions-response!
|
||||
"Validate that LLM response has the expected shape: {:questions [string ...]}.
|
||||
Throws on invalid response so fallback can kick in."
|
||||
[response source-name]
|
||||
(when-not (and (map? response)
|
||||
(sequential? (:questions response))
|
||||
(every? string? (:questions response)))
|
||||
(throw (ex-info "Invalid LLM response shape for example question generation"
|
||||
{:response response
|
||||
:source source-name
|
||||
:expected "{:questions [\"string\" ...]}"}))))
|
||||
|
||||
;;; LLM call
|
||||
|
||||
(def ^:private temperature 0.3)
|
||||
|
||||
(defn- call-llm
|
||||
"Make a structured LLM call for example question generation.
|
||||
Delegates to the shared self/call-llm-structured infrastructure which provides
|
||||
retry logic, error handling, and OTel tracing."
|
||||
[rendered-prompt]
|
||||
(self/call-llm-structured
|
||||
(llm/ee-ai-metabot-provider)
|
||||
[{:role "user" :content rendered-prompt}]
|
||||
questions-json-schema
|
||||
temperature
|
||||
300
|
||||
{:request-id (str (random-uuid))
|
||||
;; example_question_generation_batch was the name of the old ai-service api endpoint
|
||||
:source "example_question_generation_batch"
|
||||
:tag "example-question-generation"}))
|
||||
|
||||
;;; Per-item generation (mirrors Python generate_table_example_questions / generate_metric_example_questions)
|
||||
|
||||
(defn- generate-table-questions
|
||||
"Generate example questions for a single table/model."
|
||||
[table]
|
||||
(let [template (load-template table-template-path)
|
||||
rendered (selmer/render template
|
||||
{:table_name (:name table)
|
||||
:table_description (or (:description table) "")
|
||||
:columns (map enrich-column (:fields table))})
|
||||
response (call-llm rendered)]
|
||||
(validate-questions-response! response (:name table))
|
||||
{:questions (vec (:questions response))}))
|
||||
|
||||
(defn- generate-metric-questions
|
||||
"Generate example questions for a single metric."
|
||||
[metric]
|
||||
(let [template (load-template metric-template-path)
|
||||
rendered (selmer/render template
|
||||
{:metric_name (:name metric)
|
||||
:metric_description (or (:description metric) "")
|
||||
:dimensions (map enrich-column
|
||||
(:queryable-dimensions metric))
|
||||
:default_time_dimension (some-> (:default-time-dimension metric)
|
||||
enrich-column)})
|
||||
response (call-llm rendered)]
|
||||
(validate-questions-response! response (:name metric))
|
||||
{:questions (vec (:questions response))}))
|
||||
|
||||
;;; Batch processing (mirrors Python generate_example_questions_batch with asyncio.gather)
|
||||
|
||||
(def ^:private max-batch-size 10)
|
||||
|
||||
(defn- process-batch-parallel
|
||||
"Process items in parallel batches, matching Python's asyncio.gather behavior."
|
||||
[items generate-fn]
|
||||
(vec
|
||||
(mapcat
|
||||
(fn [batch]
|
||||
(let [futures (mapv #(future (generate-fn %)) batch)]
|
||||
(mapv deref futures)))
|
||||
(partition-all max-batch-size items))))
|
||||
|
||||
(defn generate-example-questions
|
||||
"Generate example questions natively using LLM via OpenRouter.
|
||||
|
||||
Accepts the same payload shape as `metabot-v3.client/generate-example-questions`:
|
||||
{:tables [{:name ... :fields [...]}]
|
||||
:metrics [{:name ... :queryable-dimensions [...]}]}
|
||||
|
||||
Returns the same shape:
|
||||
{:table_questions [{:questions [...]}]
|
||||
:metric_questions [{:questions [...]}]}"
|
||||
[payload]
|
||||
(log/info "Generating example questions natively"
|
||||
{:table-count (count (:tables payload))
|
||||
:metric-count (count (:metrics payload))})
|
||||
(let [table-questions (if (seq (:tables payload))
|
||||
(process-batch-parallel (:tables payload) generate-table-questions)
|
||||
[])
|
||||
metric-questions (if (seq (:metrics payload))
|
||||
(process-batch-parallel (:metrics payload) generate-metric-questions)
|
||||
[])]
|
||||
(log/info "Native example question generation complete"
|
||||
{:table-results (count table-questions)
|
||||
:metric-results (count metric-questions)})
|
||||
{:table_questions table-questions
|
||||
:metric_questions metric-questions}))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user