diff --git a/.github/workflows/rill-cloud.yml b/.github/workflows/rill-cloud.yml index 910f156b6a1d..2b886701408e 100644 --- a/.github/workflows/rill-cloud.yml +++ b/.github/workflows/rill-cloud.yml @@ -73,6 +73,18 @@ jobs: with: go-version: 1.26.5 + - name: Set up NodeJS + uses: actions/setup-node@v4 + with: + node-version-file: '.nvmrc' + cache: 'npm' + + - name: Build and embed the MCP Apps chart widget + run: |- + npm ci + npm run build:i18n + make runtime.mcpapp.embed + - name: Login to GCR uses: docker/login-action@v3 with: diff --git a/.github/workflows/web-test-code-quality.yml b/.github/workflows/web-test-code-quality.yml index 2904d6399fd6..2caa7f19abf1 100644 --- a/.github/workflows/web-test-code-quality.yml +++ b/.github/workflows/web-test-code-quality.yml @@ -9,6 +9,7 @@ on: - "web-admin/**" - "web-common/**" - "web-local/**" + - "web-mcp-app/**" jobs: build: runs-on: ubuntu-22.04 @@ -30,6 +31,10 @@ jobs: common: - ".github/workflows/web-test.yml" - "web-common/**" + mcpapp: + - ".github/workflows/web-test.yml" + - "web-common/**" + - "web-mcp-app/**" - name: Set up NodeJS uses: actions/setup-node@v4 @@ -43,3 +48,4 @@ jobs: ADMIN: ${{ steps.filter.outputs.admin }} LOCAL: ${{ steps.filter.outputs.local }} COMMON: ${{ steps.filter.outputs.common }} + MCPAPP: ${{ steps.filter.outputs.mcpapp }} diff --git a/.github/workflows/web-test-unit-tests.yml b/.github/workflows/web-test-unit-tests.yml index d268b684f57d..bf9965124807 100644 --- a/.github/workflows/web-test-unit-tests.yml +++ b/.github/workflows/web-test-unit-tests.yml @@ -9,6 +9,7 @@ on: - "web-admin/**" - "web-common/**" - "web-local/**" + - "web-mcp-app/**" jobs: build: runs-on: ubuntu-22.04 @@ -34,6 +35,13 @@ jobs: - "package.json" - "package-lock.json" - "web-common/**" + mcpapp: + - ".github/workflows/web-test-unit-tests.yml" + - ".nvmrc" + - "package.json" + - "package-lock.json" + - "web-common/**" + - "web-mcp-app/**" - name: Set up NodeJS uses: actions/setup-node@v4 @@ -57,3 +65,19 @@ jobs: - name: Run web-admin unit tests if: steps.filter.outputs.admin == 'true' run: npm run test:unit -w web-admin + + - name: Build the MCP App widget + if: steps.filter.outputs.mcpapp == 'true' + run: npm run build -w web-mcp-app + + - name: Run MCP App widget unit tests + if: steps.filter.outputs.mcpapp == 'true' + run: npm run test -w web-mcp-app + + - name: Install Playwright browsers for the MCP App widget + if: steps.filter.outputs.mcpapp == 'true' + run: npx playwright install --with-deps chromium + + - name: Run MCP App widget browser tests + if: steps.filter.outputs.mcpapp == 'true' + run: npm run test:e2e -w web-mcp-app diff --git a/.gitignore b/.gitignore index b2b96db7a830..06dc80cb6110 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,8 @@ node_modules /cli/pkg/examples/embed/dist /cli/pkg/installscript/embed/install.sh /cli/pkg/web/embed/dist +/runtime/ai/mcpapp/embed/widget.html +/web-mcp-app/dist/ /runtime/drivers/duckdb/extensions/embed/*/* !/runtime/drivers/duckdb/extensions/embed/**/.gitkeep /dev-project* diff --git a/Makefile b/Makefile index 11fb16ae1dca..7bb70877fd23 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,7 @@ cli.prepare: runtime.examples.embed rm -rf cli/pkg/web/embed/dist || true mkdir -p cli/pkg/web/embed/dist cp -r web-local/build/* cli/pkg/web/embed/dist + $(MAKE) runtime.mcpapp.embed go run scripts/embed_duckdb_ext/main.go .PHONY: coverage.go @@ -57,6 +58,15 @@ proto.generate: npm run generate:runtime-client -w web-common npm run generate:client -w web-admin +# Builds the MCP Apps chart widget and copies it where the runtime embeds it (see runtime/ai/mcpapp/embed/README.md). +# Requires the npm dependencies to be installed and the i18n messages to be compiled (npm run build:i18n). +# The widget is optional: without the file, the runtime serves its MCP tools without the widget. +.PHONY: runtime.mcpapp.embed +runtime.mcpapp.embed: + npm run build -w web-mcp-app + mkdir -p runtime/ai/mcpapp/embed + cp web-mcp-app/dist/widget.html runtime/ai/mcpapp/embed/widget.html + KEEP_EXAMPLES := rill-openrtb-prog-ads rill-github-analytics rill-cost-monitoring .PHONY: runtime.examples.embed diff --git a/admin/server/mcp.go b/admin/server/mcp.go index ca788080c93b..39d1e0d530d1 100644 --- a/admin/server/mcp.go +++ b/admin/server/mcp.go @@ -19,6 +19,7 @@ import ( "github.com/rilldata/rill/admin/database" "github.com/rilldata/rill/admin/server/auth" "github.com/rilldata/rill/runtime/ai" + "github.com/rilldata/rill/runtime/ai/mcpapp" "github.com/rilldata/rill/runtime/pkg/httputil" "github.com/rilldata/rill/runtime/pkg/middleware" "github.com/rilldata/rill/runtime/pkg/observability" @@ -48,11 +49,15 @@ const ( // mcpForwardedTools are the runtime tools exposed on the MCP server. // The runtime exposes more tools than these, but the rest require runtime.EditRepo, // which is only granted for editable deployments, and a project's primary deployment is never editable. +// create_chart is always exposed: clients that do not get the MCP Apps widget receive its markdown fallback. +// chart_data may only be called by the widget, so it is only exposed when this server serves the widget (see mcpForwardedToolSpecs). var mcpForwardedTools = []string{ ai.ListMetricsViewsName, ai.GetMetricsViewName, ai.QueryMetricsViewSummaryName, ai.QueryMetricsViewName, + ai.CreateChartName, + ai.ChartDataName, ai.ListSkillsName, ai.LoadSkillName, } @@ -66,8 +71,14 @@ var mcpSessionNamespace = uuid.MustParse("6f4a1c94-3d1e-4f6b-9a02-1c0f6b7d5e8a") // Unlike the per-project runtime proxy, it serves a single endpoint for all the projects a caller has access to: // it answers the MCP protocol methods itself and forwards tool calls to the runtime of the project named in the tool arguments. func (s *Server) mcpHandler() (http.Handler, error) { + // The MCP Apps widget is served whenever the binary carries the widget bundle (see runtime/ai/mcpapp). + widget, ok := mcpapp.Widget() + if !ok { + s.logger.Warn("mcp: the MCP Apps widget bundle is not embedded in this binary; serving the chart tools without the widget") + } + adminTools := s.mcpAdminTools() - forwardedTools, err := mcpForwardedToolSpecs() + forwardedTools, err := mcpForwardedToolSpecs(widget) if err != nil { return nil, err } @@ -79,6 +90,11 @@ func (s *Server) mcpHandler() (http.Handler, error) { } } + var capabilities *mcp.ServerCapabilities + if widget != nil { + capabilities = mcpapp.ServerCapabilities() + } + return middleware.MCPSessionIDMiddleware(mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { srv := mcp.NewServer( &mcp.Implementation{ @@ -89,6 +105,7 @@ func (s *Server) mcpHandler() (http.Handler, error) { &mcp.ServerOptions{ Instructions: mcpInstructions, HasTools: true, + Capabilities: capabilities, }, ) @@ -111,6 +128,9 @@ func (s *Server) mcpHandler() (http.Handler, error) { for _, t := range forwardedTools { srv.AddTool(t, s.mcpForwardToolCall(t.Name, client)) } + if widget != nil { + mcpapp.Register(srv, widget) + } return srv }, &mcp.StreamableHTTPOptions{ @@ -162,7 +182,9 @@ type mcpAdminTool struct { // mcpForwardedToolSpecs returns the specs of the tools forwarded to a project's runtime, using the tool definitions in runtime/ai as the source of truth. // It injects the "project" argument into the input schema of each tool, which we intercept to route calls to the correct runtime. -func mcpForwardedToolSpecs() ([]*mcp.Tool, error) { +// The tools that take part in the MCP Apps widget get the matching _meta.ui: create_chart links to the widget when it is served, +// and app-only tools, which only the widget may call, declare their visibility and are left out when it is not served. +func mcpForwardedToolSpecs(widget *mcpapp.Asset) ([]*mcp.Tool, error) { specs := ai.MCPToolSpecs() res := make([]*mcp.Tool, 0, len(mcpForwardedTools)) @@ -172,6 +194,11 @@ func mcpForwardedToolSpecs() ([]*mcp.Tool, error) { return nil, fmt.Errorf("mcp: no spec for tool %q", name) } + if widget == nil && ai.IsMCPAppOnlyTool(name) { + continue + } + spec = mcpapp.ToolWithMeta(spec, ai.MCPAppToolMeta(name, widget)) + // Inject the "project" argument into the input schema. schema, ok := spec.InputSchema.(*jsonschema.Schema) if !ok { diff --git a/admin/server/mcp_test.go b/admin/server/mcp_test.go index c6d455de5298..ee21b8a25e26 100644 --- a/admin/server/mcp_test.go +++ b/admin/server/mcp_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" @@ -15,19 +17,25 @@ import ( "github.com/rilldata/rill/admin" "github.com/rilldata/rill/admin/database" "github.com/rilldata/rill/runtime/ai" + "github.com/rilldata/rill/runtime/ai/mcpapp" "github.com/stretchr/testify/require" ) func TestMCPForwardedToolSpecs(t *testing.T) { - tools, err := mcpForwardedToolSpecs() + // Without the widget, create_chart is still forwarded (clients get its text fallback) but chart_data, which only the widget + // may call, is left out; no tool carries UI metadata. + tools, err := mcpForwardedToolSpecs(nil) require.NoError(t, err) - require.Len(t, tools, len(mcpForwardedTools)) + require.Len(t, tools, len(mcpForwardedTools)-1) + byName := make(map[string]*mcp.Tool) for _, tool := range tools { + byName[tool.Name] = tool schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "tool %q", tool.Name) require.Contains(t, schema.Properties, mcpProjectArg, "tool %q", tool.Name) require.Contains(t, schema.Required, mcpProjectArg, "tool %q", tool.Name) + require.Nil(t, tool.Meta["ui"], "tool %q", tool.Name) // query_metrics_view has a hand-written schema, which must be extended and not replaced. if tool.Name == ai.QueryMetricsViewName { @@ -35,6 +43,63 @@ func TestMCPForwardedToolSpecs(t *testing.T) { require.NotEmpty(t, schema.Defs) } } + require.Contains(t, byName, ai.CreateChartName) + require.Equal(t, "Creating chart...", byName[ai.CreateChartName].Meta["openai/toolInvocation/invoking"], "the tool's own meta is kept") + require.NotContains(t, byName, ai.ChartDataName) + require.Contains(t, byName, ai.QueryMetricsViewName) + + // With the widget, create_chart links to it and chart_data is restricted to it. + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "widget.html"), []byte(""), 0o644)) + t.Setenv(mcpapp.DirEnv, dir) + widget, ok := mcpapp.Widget() + require.True(t, ok) + + tools, err = mcpForwardedToolSpecs(widget) + require.NoError(t, err) + require.Len(t, tools, len(mcpForwardedTools)) + byName = make(map[string]*mcp.Tool) + for _, tool := range tools { + byName[tool.Name] = tool + } + require.Equal(t, widget.URI, byName[ai.CreateChartName].Meta["ui"].(map[string]any)["resourceUri"]) + require.Equal(t, "Creating chart...", byName[ai.CreateChartName].Meta["openai/toolInvocation/invoking"], "existing meta is kept") + require.Equal(t, []any{"app"}, byName[ai.ChartDataName].Meta["ui"].(map[string]any)["visibility"]) + require.Nil(t, byName[ai.QueryMetricsViewName].Meta["ui"]) + require.Contains(t, byName[ai.CreateChartName].InputSchema.(*jsonschema.Schema).Properties, mcpProjectArg) +} + +// TestCallRuntimeToolPassthrough verifies that the content blocks, structured content and _meta a runtime tool returns +// reach the client unchanged, which the MCP Apps widget relies on. +func TestCallRuntimeToolPassthrough(t *testing.T) { + type args struct { + Name string `json:"name"` + } + type result struct { + Name string `json:"name"` + } + handler := mcp.NewStreamableHTTPHandler(func(r *http.Request) *mcp.Server { + srv := mcp.NewServer(&mcp.Implementation{Name: "runtime"}, &mcp.ServerOptions{HasTools: true}) + mcp.AddTool(srv, &mcp.Tool{Name: "echo"}, func(ctx context.Context, req *mcp.CallToolRequest, a args) (*mcp.CallToolResult, result, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "**Chart: " + a.Name + "**"}}, + Meta: map[string]any{"rill/chart": map[string]any{"rows": float64(1)}}, + }, result{Name: a.Name}, nil + }) + return srv + }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}) + rt := httptest.NewServer(handler) + t.Cleanup(rt.Close) + + s := &Server{} + depl := &database.Deployment{RuntimeHost: rt.URL, RuntimeInstanceID: "default"} + res, err := s.callRuntimeTool(t.Context(), depl, "jwt", &mcpClient{sessionID: "session", userAgent: "claude-ai"}, "echo", json.RawMessage(`{"name":"hello"}`)) + require.NoError(t, err) + require.False(t, res.IsError) + require.Len(t, res.Content, 1) + require.Equal(t, "**Chart: hello**", res.Content[0].(*mcp.TextContent).Text) + require.Equal(t, map[string]any{"name": "hello"}, res.StructuredContent) + require.Equal(t, map[string]any{"rill/chart": map[string]any{"rows": float64(1)}}, map[string]any(res.Meta)) } func TestTakeMCPProjectArg(t *testing.T) { diff --git a/docs/docs/developers/build/ai-configuration.md b/docs/docs/developers/build/ai-configuration.md index a609e1a3b306..627031cb60f5 100644 --- a/docs/docs/developers/build/ai-configuration.md +++ b/docs/docs/developers/build/ai-configuration.md @@ -205,7 +205,7 @@ Feature flags support the same templating as other project settings, so memory c ## Visualization Tips -When using the [Rill MCP Server](/guide/ai/mcp) with external AI clients like Claude, you can provide specific instructions on how to visualize data. Since the MCP server returns structured data, the AI client is responsible for rendering it. +When using the [Rill MCP Server](/guide/ai/mcp) with external AI clients, how results are visualized depends on the client. Clients that support MCP Apps, such as Claude and ChatGPT, render Rill's own interactive charts inline when the AI calls `create_chart`; see [Interactive charts in Claude and ChatGPT](/guide/ai/mcp#interactive-charts-in-claude-and-chatgpt). The tips below only apply to text-only clients, which receive structured data and leave the rendering to the AI, so you can tell it how to present results as text. :::note Visualization tips affect all AI interactions Visualization instructions added to `rill.yaml` will affect both [Rill Chat](/guide/ai/ai-chat) responses and external AI clients via the MCP Server. If you only want visualization tips to apply to external AI clients (like Claude Desktop), consider adding them to your client-specific configuration files instead: diff --git a/docs/docs/guide/ai/mcp.md b/docs/docs/guide/ai/mcp.md index fa0987cb2c1b..b6a4f17b92ce 100644 --- a/docs/docs/guide/ai/mcp.md +++ b/docs/docs/guide/ai/mcp.md @@ -179,7 +179,7 @@ Replace `org` and `project` with the ID of your organization and project. "command": "npx", "args": [ "mcp-remote", - "https://api.rilldata.com/v1/organizations/{org}/projects/{project}/runtime/mcp", + "https://api.rilldata.com/v1/orgs/{org}/projects/{project}/runtime/mcp", "--header", "Authorization:${AUTH_HEADER}" ], @@ -202,7 +202,7 @@ See [our demo page](https://ui.rilldata.com/demo) for public projects to test. "command": "npx", "args": [ "mcp-remote", - "https://api.rilldata.com/v1/organizations/demo/projects/rill-github-analytics/runtime/mcp" + "https://api.rilldata.com/v1/orgs/demo/projects/rill-github-analytics/runtime/mcp" ] } } @@ -260,6 +260,8 @@ You can look at one of our [example projects](https://github.com/rilldata/rill-e - __*Get metrics view spec*__ – Use `get_metrics_view` to fetch a metrics view's specification. This is important to understand all the dimensions and measures in a metrics view. - __*Query the time range*__ – Use `query_metrics_view_summary` to obtain the available time range for a metrics view. This is important to understand what time range the data spans. - __*Query the metrics*__ – Use `query_metrics_view` to run queries to get aggregated results. +- __*Create a chart*__ – Use `create_chart` to draw a chart of a metrics view query. Every client receives the chart's data as a table with an **Open in Rill** link; clients that support MCP Apps show it inline as an interactive Rill chart when the chart app is enabled. See [Interactive charts in Claude and ChatGPT](#interactive-charts-in-claude-and-chatgpt). +- __*Load chart data*__ – `chart_data` is called by the interactive chart to refetch its data when you change its time grain, limits or comparison. It is not meant to be called directly, and clients that support MCP Apps hide it from the model. - __*List skills*__ – Use `list_skills` to discover the [skills](/developers/build/ai-configuration#skills) defined in the project. It returns an empty list when the project defines no skills. - __*Load a skill*__ – Use `load_skill` to fetch a skill's full instructions before doing work its description covers. @@ -277,6 +279,25 @@ Using all the above concepts, you can ask the Rill MCP server questions like: - In the *last 7 days*, how many __auction requests were there from mobile vs desktop__? +## Interactive charts in Claude and ChatGPT + +The server ships a `rill-charts` skill that tells the AI which chart fits which question and what to check before charting; external clients receive it in the `ai_instructions` of `list_metrics_views` and can also fetch it with `load_skill`. When the AI calls `create_chart`, the Rill MCP server answers with the chart's data. Every client receives it as a markdown table of the first rows with an **Open in Rill** link, which opens an Explore dashboard with the chart's measures, dimensions, filters and time range applied. Charts require the `chat_charts` feature flag, which is on by default. + +The AI describes the chart the way [custom viz components](/developers/build/dashboards/canvas-widgets/custom-viz) do: a chart type and which field goes on which channel (`chart_spec`, with Flint's chart types such as `Bar Chart`, `Line Chart`, `Waterfall Chart`, `Slope Chart` or `Heatmap`) together with the metrics view, time range, filter and grain the data comes from (`data`). Rill derives the scales, axes, number formats, sorting and layout from the metrics view's semantics, so the AI never writes styling. + +Clients that support [MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps), such as Claude (web, desktop and mobile), ChatGPT, Cursor and VS Code, can instead show the chart inline in the conversation as an interactive Rill chart, with the same rendering, formatting and theme as custom viz components in Rill. In fullscreen, the chart offers data controls (time grain, top values, comparison with the previous period, each re-queried from Rill) and presentation controls (chart type, layout and chart properties, applied instantly), and tells the AI what it currently shows. Every Rill MCP endpoint serves the chart app whenever charts are enabled: project endpoints, the unified `https://api.rilldata.com/v1/mcp` endpoint and Rill Developer alike, with no configuration of its own. Clients that don't support MCP Apps, such as Claude Code, keep receiving the table and the link. + +:::tip Troubleshooting +If charts show up as a table, check whether the server advertises the chart app with the MCP Inspector: + +```bash +npx @modelcontextprotocol/inspector --cli --transport http --method resources/list +``` + +The output should list a `ui://rill/widget/...` resource. If it is missing, charts are disabled for the project (`chat_charts: false` under `features` in `rill.yaml`) or the deployment hasn't picked up a release that ships the chart app yet. If it is listed but your client still shows a table, the client doesn't support MCP Apps. +::: + + ## Conclusion While [Explore dashboards](/guide/dashboards/explore) are a great way to slice and dice to find insights, sometimes you just need a quick, overall summary of your data via a text conversation. The Rill MCP server enables this through external AI assistants like Claude Desktop. Since Rill MCP is built on top of your existing metrics, you can be confident that the returned data will be correct. diff --git a/eslint.config.js b/eslint.config.js index 7d95356e7f1d..e4732e29748f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -89,6 +89,9 @@ export default [ "web-local/build/*", "web-local/playwright-report/*", "web-local/playwright/*", + "web-mcp-app/dist/*", + "web-mcp-app/playwright-report/*", + "web-mcp-app/test-results/*", ], }, { diff --git a/package-lock.json b/package-lock.json index 6212faaa804c..9445cd70298c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,8 @@ "web-admin", "web-common", "web-integration", - "web-local" + "web-local", + "web-mcp-app" ], "devDependencies": { "@inlang/paraglide-js": "^2.10.0", @@ -7203,6 +7204,62 @@ "langium": "^4.0.0" } }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.3.1.tgz", + "integrity": "sha512-mIGZXpHsjnZ6lD+gD/WCMpR5k8yVQQ8nNFH1N0Srf7AvnwTUMYD6pvTY8ng2+He5FfV7YMZLmrjL7cLa/cc3dQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@modelcontextprotocol/core": "2.3.1", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.8", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/client/node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.3.1.tgz", + "integrity": "sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core/node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.2.tgz", @@ -17746,6 +17803,31 @@ "bare-events": "^2.7.0" } }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/execa": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", @@ -20911,6 +20993,17 @@ "@sideway/pinpoint": "^2.0.0" } }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-levenshtein": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", @@ -25721,6 +25814,17 @@ "node": ">= 6" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/pkg-dir": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", @@ -34430,6 +34534,28 @@ } } }, + "node_modules/vite-plugin-singlefile": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.3.tgz", + "integrity": "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">18.0.0" + }, + "peerDependencies": { + "rollup": "^4.59.0", + "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/vite/node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -34658,6 +34784,10 @@ "resolved": "web-integration", "link": true }, + "node_modules/web-mcp-app": { + "resolved": "web-mcp-app", + "link": true + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -36221,6 +36351,70 @@ "optional": true } } + }, + "web-mcp-app": { + "version": "0.0.0", + "devDependencies": { + "@modelcontextprotocol/ext-apps": "^2.0.3", + "@playwright/test": "^1.50.1", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "autoprefixer": "^10.4.20", + "jsdom": "^26.0.0", + "postcss": "^8.4.47", + "svelte": "^5.0.0", + "svelte-check": "^4.0.4", + "tailwindcss": "^3.4.13", + "vite": "^8.0.0", + "vite-plugin-singlefile": "^2.3.3", + "vitest": "^4.1.1", + "web-common": "*" + } + }, + "web-mcp-app/node_modules/@modelcontextprotocol/ext-apps": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-2.0.3.tgz", + "integrity": "sha512-Tn+4+cyhO4f4cQSDxNyLiIfEE+qlTTuIbWBJB8JTinEXOhDYgCT4wDTQplxD9MBYZH5pCYGC59GaxiwIT4bu8w==", + "dev": true, + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "dependencies": { + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/core": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^4.2.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/server": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "web-mcp-app/node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 9eb9e5ca96d8..c1a23e1dd815 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "web-admin", "web-common", "web-integration", - "web-local" + "web-local", + "web-mcp-app" ], "scripts": { "build:i18n": "paraglide-js compile --project web-common/src/lib/i18n/project.inlang --outdir web-common/src/lib/i18n/gen", diff --git a/runtime/ai/ai.go b/runtime/ai/ai.go index cc8e7e1ae1ad..e127c4a0bfa8 100644 --- a/runtime/ai/ai.go +++ b/runtime/ai/ai.go @@ -18,6 +18,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" aiv1 "github.com/rilldata/rill/proto/gen/rill/ai/v1" "github.com/rilldata/rill/runtime" + "github.com/rilldata/rill/runtime/ai/mcpapp" "github.com/rilldata/rill/runtime/drivers" "github.com/rilldata/rill/runtime/pkg/activity" "github.com/rilldata/rill/runtime/pkg/ctxsync" @@ -63,6 +64,7 @@ func NewRunner(rt *runtime.Runtime, activity *activity.Client) *Runner { RegisterTool(r, &QueryMetricsViewSummary{Runtime: rt}) RegisterTool(r, &QueryMetricsView{Runtime: rt}) RegisterTool(r, &CreateChart{Runtime: rt}) + RegisterTool(r, &ChartData{Runtime: rt}) RegisterTool(r, &ListSkills{Runtime: rt}) RegisterTool(r, &LoadSkill{Runtime: rt}) @@ -91,8 +93,12 @@ type SessionOptions struct { // CreateIfNotExists creates the session if it does not exist. // If SessionID is set, the session is created with that ID. CreateIfNotExists bool - Claims *runtime.SecurityClaims - UserAgent string + // Transient makes a new session in-memory only: it is not inserted in the catalog and Flush is a no-op. + // It serves MCP requests that are not part of a conversation (such as resources/list or ping from a client + // that has not sent a session ID), which would otherwise leave orphan sessions behind. + Transient bool + Claims *runtime.SecurityClaims + UserAgent string } // Session creates or loads an AI session. @@ -129,15 +135,17 @@ func (r *Runner) Session(ctx context.Context, opts *SessionOptions) (res *Sessio CreatedOn: time.Now(), UpdatedOn: time.Now(), } - err = catalog.InsertAISession(ctx, session) - if err != nil { - // Concurrent requests addressing the same new session ID race to create it, - // e.g. parallel tool calls forwarded by the unified MCP server for a project it has not called before. - // If another request won the race, continue with the session it created. - var retryErr error - session, messages, retryErr = findSession(ctx, catalog, opts) - if retryErr != nil || session == nil { - return nil, fmt.Errorf("failed to create session: %w", errors.Join(err, retryErr)) + if !opts.Transient { + err = catalog.InsertAISession(ctx, session) + if err != nil { + // Concurrent requests addressing the same new session ID race to create it, + // e.g. parallel tool calls forwarded by the unified MCP server for a project it has not called before. + // If another request won the race, continue with the session it created. + var retryErr error + session, messages, retryErr = findSession(ctx, catalog, opts) + if retryErr != nil || session == nil { + return nil, fmt.Errorf("failed to create session: %w", errors.Join(err, retryErr)) + } } } } @@ -184,6 +192,7 @@ func (r *Runner) Session(ctx context.Context, opts *SessionOptions) (res *Sessio memoryEnabled: memEnabled, userMemories: memories, managedAI: instance.ResolveAIConnector() == instance.AdminConnector, + transient: opts.Transient, acquireLLM: func(ctx context.Context) (drivers.AIService, func(), error) { return r.Runtime.AI(ctx, opts.InstanceID) }, @@ -328,15 +337,52 @@ type Tool[In, Out any] interface { Handler(ctx context.Context, args In) (Out, error) } +// MCPResultDecorator is an optional interface for tools that customize the MCP representation of their result. +// The MCP SDK fills structuredContent from the result as usual; the decorator can set the content blocks +// (for example a markdown rendering for clients that cannot show the structured result) and the result's _meta. +// It only affects the MCP server: the result persisted in the session and sent to the in-app LLM is unchanged. +type MCPResultDecorator[Out any] interface { + DecorateMCPResult(ctx context.Context, out Out, res *mcp.CallToolResult) +} + +// MCPSpecProvider is an optional interface for tools whose definition on the MCP server differs from their in-app definition. +// The MCP server (and the admin service's unified MCP server, see MCPToolSpecs) advertises and validates against the spec it returns, +// while Spec stays what the in-app LLM sees (see AsProto). The returned spec inherits the in-app input and output schemas where it sets none. +type MCPSpecProvider interface { + MCPSpec() *mcp.Tool +} + +// MCPAppTool is an optional interface for tools that take part in the MCP Apps widget (see package mcpapp). +// The returned UI drives the _meta.ui the tool is registered with on the MCP server. +// App-only tools (see mcpapp.ToolUI.AppOnly) are called by the widget rather than the model, so their calls are plumbing: +// they are not added to the session's messages and are not counted as billable tool calls. +type MCPAppTool interface { + MCPAppUI() *mcpapp.ToolUI +} + // CompiledTool is the internal representation of a registered tool. type CompiledTool struct { - Name string - Spec *mcp.Tool - CheckAccess func(context.Context) (bool, error) - UnmarshalArgs func(content string) (any, error) - UnmarshalResult func(content string) (any, error) - JSONHandler func(ctx context.Context, input json.RawMessage) (json.RawMessage, error) - RegisterWithMCPServer func(srv *mcp.Server) + Name string + Spec *mcp.Tool + CheckAccess func(context.Context) (bool, error) + UnmarshalArgs func(content string) (any, error) + UnmarshalResult func(content string) (any, error) + JSONHandler func(ctx context.Context, input json.RawMessage) (json.RawMessage, error) + // RegisterWithMCPServer registers the tool on an MCP server. + // A non-nil meta is merged into the _meta of a shallow copy of the spec, so Spec itself (and AsProto) never carries it. + RegisterWithMCPServer func(srv *mcp.Server, meta map[string]any) + // MCPSpec is set for tools that implement MCPSpecProvider; see mcpSpec. + MCPSpec *mcp.Tool + // AppUI is set for tools that implement MCPAppTool. + AppUI *mcpapp.ToolUI +} + +// mcpSpec returns the definition the tool is registered with on MCP servers: its MCP spec when it has one, else its in-app spec. +func (t *CompiledTool) mcpSpec() *mcp.Tool { + if t.MCPSpec != nil { + return t.MCPSpec + } + return t.Spec } // AsProto converts the CompiledTool to a protocol buffer representation. @@ -407,10 +453,34 @@ func RegisterTool[In, Out any](s *Runner, t Tool[In, Out]) { } } + var appUI *mcpapp.ToolUI + if at, ok := any(t).(MCPAppTool); ok { + appUI = at.MCPAppUI() + } + decorator, _ := any(t).(MCPResultDecorator[Out]) + + // The definition advertised on MCP servers; the same as the in-app one unless the tool provides its own. + var mcpSpec *mcp.Tool + if p, ok := any(t).(MCPSpecProvider); ok { + mcpSpec = p.MCPSpec() + if mcpSpec.InputSchema == nil { + mcpSpec.InputSchema = spec.InputSchema + } + if mcpSpec.OutputSchema == nil { + mcpSpec.OutputSchema = spec.OutputSchema + } + } + registered := spec + if mcpSpec != nil { + registered = mcpSpec + } + s.Tools[spec.Name] = &CompiledTool{ Name: spec.Name, Spec: spec, + MCPSpec: mcpSpec, CheckAccess: t.CheckAccess, + AppUI: appUI, UnmarshalArgs: func(content string) (any, error) { var args In if err := json.Unmarshal([]byte(content), &args); err != nil { @@ -440,17 +510,24 @@ func RegisterTool[In, Out any](s *Runner, t Tool[In, Out]) { } return data, nil }, - RegisterWithMCPServer: func(srv *mcp.Server) { - mcp.AddTool(srv, spec, func(ctx context.Context, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error) { + RegisterWithMCPServer: func(srv *mcp.Server, meta map[string]any) { + mcp.AddTool(srv, mcpapp.ToolWithMeta(registered, meta), func(ctx context.Context, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error) { s := GetSession(ctx) - var res Out + var out Out _, err := s.CallToolWithOptions(ctx, &CallToolOptions{ - Role: RoleAssistant, - Tool: spec.Name, - Out: &res, - Args: args, + Role: RoleAssistant, + Tool: spec.Name, + Unwrap: appUI.AppOnly(), // Widget plumbing is not part of the conversation (see MCPAppTool) + Out: &out, + Args: args, }) - return nil, res, err + if err != nil || decorator == nil { + return nil, out, err + } + // The SDK keeps the content set here and still fills structuredContent from out. + res := &mcp.CallToolResult{} + decorator.DecorateMCPResult(ctx, out, res) + return res, out, nil }) }, } @@ -567,6 +644,7 @@ type BaseSession struct { memoryEnabled bool // true if memory applies to this session: it is available to the owner (see memoryEnabled) and the owner has it turned on userMemories []*drivers.AIMemory // the owner's memories, loaded at session creation and refreshed on writes managedAI bool // true if completions use the Rill-managed AI connector (billable tokens); false for bring-your-own-model + transient bool // true if the session is never persisted (see SessionOptions.Transient) acquireLLM func(ctx context.Context) (drivers.AIService, func(), error) acquireCatalog func(ctx context.Context) (drivers.CatalogStore, func(), error) @@ -583,6 +661,9 @@ type BaseSession struct { skillsMu ctxsync.RWMutex skillsLoaded bool skills []*Skill + + featureFlagsMu sync.Mutex + featureFlags map[string]bool } func (s *BaseSession) Flush(ctx context.Context) error { @@ -590,8 +671,8 @@ func (s *BaseSession) Flush(ctx context.Context) error { ctx, cancel := graceful.WithMinimumDuration(ctx, 5*time.Second) defer cancel() - // Exit early if nothing to flush - if !s.dtoDirty && !s.messagesDirty { + // Exit early if nothing to flush. Transient sessions are never persisted. + if s.transient || (!s.dtoDirty && !s.messagesDirty) { return nil } @@ -670,6 +751,22 @@ func (s *BaseSession) Claims() *runtime.SecurityClaims { return s.claims } +// FeatureFlags returns the feature flags resolved for the session's instance and claims. +// They are resolved on the first call and cached for the lifetime of the session (an MCP session lives for one request), +// since the MCP server and the access checks of several tools consult them. The returned map must not be modified. +func (s *BaseSession) FeatureFlags(ctx context.Context) (map[string]bool, error) { + s.featureFlagsMu.Lock() + defer s.featureFlagsMu.Unlock() + if s.featureFlags == nil { + ff, err := s.runner.Runtime.FeatureFlags(ctx, s.instanceID, s.claims) + if err != nil { + return nil, err + } + s.featureFlags = ff + } + return s.featureFlags, nil +} + func (s *BaseSession) Title() string { return s.dto.Title } @@ -1165,7 +1262,8 @@ func (s *Session) CallToolWithOptions(ctx context.Context, opts *CallToolOptions // Emit a billable tool_call metric. This is the shared point for all agent tool calls (chat, AI reports, // and the MCP server), so counting here covers them uniformly. Tool calls are tracked separately from api_calls // since they are agent actions rather than external API requests. High-level orchestration tools (the agents) - // are excluded since they don't do real work themselves. + // are excluded since they don't do real work themselves. App-only tools (see MCPAppTool) count like any other tool: + // they run real queries on behalf of the widget, even though their calls are not persisted in the session. if !nonBillableToolCalls[opts.Tool] { s.activity.RecordMetric(ctx, "tool_call", 1, attribute.String("tool", opts.Tool)) } diff --git a/runtime/ai/analyst_agent.go b/runtime/ai/analyst_agent.go index cf4a0a34eef2..d5ead64b8b63 100644 --- a/runtime/ai/analyst_agent.go +++ b/runtime/ai/analyst_agent.go @@ -94,7 +94,7 @@ func (t *AnalystAgent) CheckAccess(ctx context.Context) (bool, error) { } // Only allow for rill user agents since it's not useful in MCP contexts. - if !strings.HasPrefix(s.CatalogSession().UserAgent, "rill") { + if !isRillUserAgent(s) { return false, nil } diff --git a/runtime/ai/canvas_get.go b/runtime/ai/canvas_get.go index 7fa9e2caae5d..bc0e341f5df4 100644 --- a/runtime/ai/canvas_get.go +++ b/runtime/ai/canvas_get.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "strings" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/rilldata/rill/runtime" @@ -56,7 +55,7 @@ func (t *GetCanvas) CheckAccess(ctx context.Context) (bool, error) { } // Only allow for rill user agents since it's not useful in MCP contexts. - if !strings.HasPrefix(s.CatalogSession().UserAgent, "rill") { + if !isRillUserAgent(s) { return false, nil } diff --git a/runtime/ai/chart_data.go b/runtime/ai/chart_data.go new file mode 100644 index 000000000000..a9ac4b23e8ad --- /dev/null +++ b/runtime/ai/chart_data.go @@ -0,0 +1,91 @@ +package ai + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/rilldata/rill/runtime" + "github.com/rilldata/rill/runtime/ai/mcpapp" +) + +const ChartDataName = "chart_data" + +// ChartData returns the data of a chart without creating a chart in the conversation. +// It is an app-only tool: the MCP Apps widget calls it (through the host) to refetch data when the user changes +// the time grain, limits or comparison of a chart created with create_chart. Its _meta.ui marks it app-only, +// so hosts that support MCP Apps hide it from the model; its description tells other clients not to call it directly. +type ChartData struct { + Runtime *runtime.Runtime +} + +var _ Tool[ChartDataArgs, *ChartDataResult] = (*ChartData)(nil) + +var _ MCPAppTool = (*ChartData)(nil) + +// ChartDataArgs are the arguments of chart_data, which are the same as those of create_chart. +type ChartDataArgs map[string]any + +func (t *ChartData) Spec() *mcp.Tool { + return &mcp.Tool{ + Name: ChartDataName, + Title: "Chart data", + Description: `Return the data of a chart configuration without creating a chart in the conversation. +It is called by the chart widget to refetch a chart's data when the user changes its time grain, limits or comparison, +and is not meant to be called directly: use create_chart to show a chart. +It takes the same arguments as create_chart and returns the chart-ready rows, field metadata and resolved time ranges.`, + Annotations: &mcp.ToolAnnotations{ + DestructiveHint: boolPtr(false), + IdempotentHint: true, + OpenWorldHint: boolPtr(false), + ReadOnlyHint: true, + }, + InputSchema: chartsInputSchema(), + } +} + +// MCPSpec is the definition advertised on the MCP server: the same Flint shape of the arguments as create_chart (see CreateChart.MCPSpec). +func (t *ChartData) MCPSpec() *mcp.Tool { + spec := t.Spec() + spec.Description = `Return the data of a chart without creating a chart in the conversation. +It is called by the chart widget to refetch a chart's data when the user changes its time grain, top-N limits or comparison, +and is not meant to be called directly: use create_chart to show a chart. +It takes the same arguments as create_chart (chart_spec and data) and returns the chart-ready rows, field metadata, +semantic types and resolved time ranges.` + spec.InputSchema = flintChartsInputSchema() + return spec +} + +func (t *ChartData) CheckAccess(ctx context.Context) (bool, error) { + // Like create_chart, it requires ReadMetrics and the chat_charts feature flag. + // Only external MCP clients get it: Rill's own clients fetch chart data themselves. + s := GetSession(ctx) + if !s.Claims().Can(runtime.ReadMetrics) || isRillUserAgent(s) { + return false, nil + } + ff, err := s.FeatureFlags(ctx) + if err != nil { + return false, err + } + return ff["chat_charts"], nil +} + +func (t *ChartData) Handler(ctx context.Context, args ChartDataArgs) (*ChartDataResult, error) { + s := GetSession(ctx) + req, err := parseChartRequest(ctx, t.Runtime, s, args) + if err != nil { + return nil, err + } + instance, err := t.Runtime.Instance(ctx, s.InstanceID()) + if err != nil { + return nil, fmt.Errorf("failed to get instance: %w", err) + } + res, _, err := queryChartData(ctx, t.Runtime, s, instance, req) + return res, err +} + +// MCPAppUI restricts chart_data to the widget: hosts hide tools with app visibility from the model. +// Being app-only also makes its calls plumbing rather than conversation turns (see MCPAppTool). +func (t *ChartData) MCPAppUI() *mcpapp.ToolUI { + return &mcpapp.ToolUI{Visibility: []string{mcpapp.VisibilityApp}} +} diff --git a/runtime/ai/chart_flint.go b/runtime/ai/chart_flint.go new file mode 100644 index 000000000000..637ee858aab9 --- /dev/null +++ b/runtime/ai/chart_flint.go @@ -0,0 +1,382 @@ +package ai + +import ( + "context" + "fmt" + "maps" + "slices" + "strings" + "sync" + + "github.com/google/jsonschema-go/jsonschema" + runtimev1 "github.com/rilldata/rill/proto/gen/rill/runtime/v1" + "github.com/rilldata/rill/runtime" + "github.com/rilldata/rill/runtime/ai/chartquery" +) + +// The Flint shape of create_chart and chart_data, served to external MCP clients (see CreateChart.MCPSpec): +// a Flint chart spec (chartType, encodings, chartProperties) bound to a metrics view through a data block, +// as opposed to the in-app shape (chart_type and spec) that Rill's own chat renders itself. +// The chart types come from the embedded Flint catalog (see chartquery.Catalog). + +// ChartInput is the effective Flint input echoed in a create_chart result: +// the chart spec with the applied top-N limits and the data binding with the resolved grain. +type ChartInput struct { + ChartSpec map[string]any `json:"chart_spec"` + Data map[string]any `json:"data"` +} + +// flintChartNotes are the rules of the chart types that need more than their channel list, shown in the tool description. +var flintChartNotes = map[string]string{ + "Bar Chart": "one dimension on x and one measure on y; a second dimension on color stacks segments; put the measure on x and the dimension on y for horizontal bars", + "Grouped Bar Chart": "side-by-side bars: the clustering dimension goes on group, not on color", + "Stacked Bar Chart": "parts of a whole: the stacked dimension on color; chartProperties.stackMode is stacked, normalize or layered", + "Lollipop Chart": "a bar chart drawn as stems with dots", + "Waterfall Chart": "x is the step (the time field or a dimension), y the signed change; leave color unbound (it is reserved for a start/delta/end type field); chartProperties.totals is first, last or both", + "Bullet Chart": "y the dimension, x the measure, goal the target measure (required)", + "Pie Chart": "the measure on size, the dimension on color; chartProperties.innerRadius above 0 makes it a donut", + "Donut Chart": "the measure on size, the dimension on color", + "Rose Chart": "polar bars: the dimension on x, the measure on y", + "Radar Chart": "the dimension on x (one axis per value), the measure on y, an optional series dimension on color", + "Heatmap": "two dimensions on x and y (one may be the time field), the measure on color", + "Bar Table": "the dimension on y, the measure on x: compact bars with value labels", + "Line Chart": "the time field on x, the measure on y, an optional series dimension on color; several measures on y draw one line each", + "Area Chart": "like a line chart with filled areas; chartProperties.stackMode stacks a series dimension", + "Sparkline": "one small trend line per value of the series dimension (color)", + "Bump Chart": "rank over time: the time field on x, the measure on y, the ranked dimension on color", + "Slope Chart": "two periods: the time field on x at a grain that yields two buckets, the measure on y, the dimension on color", + "Streamgraph": "stacked areas around a center line: the time field on x, the measure on y, the dimension on color", + "Range Area Chart": "a band between two measures: the low measure on y and the high one on y2", + "Pyramid Chart": "back-to-back bars: the measure on x, the band dimension on y, a two-valued dimension on color", + "Ranged Dot Plot": "a dumbbell per category: the measure on x, the dimension on y, a two-valued dimension on color", + "Candlestick Chart": "the time field on x and four measures on open, high, low and close", + "Gantt Chart": "needs a start on x and an end on x2; rarely expressible with a metrics view", + "Scatter Plot": "two measures on x and y, one point per value of the dimension on detail; optional color and size", + "Regression": "a scatter plot with a fitted line: two measures on x and y, the dimension on detail", + "Connected Scatter Plot": "two measures on x and y connected in the order of the time field on order", + "Histogram": "the measure on x, binned over one row per value of the dimension on detail", + "Density Plot": "the measure on x, estimated over one row per value of the dimension on detail", + "ECDF Plot": "the measure on x, cumulated over one row per value of the dimension on detail", + "Boxplot": "the dimension on x, the measure on y, one observation per value of the dimension on detail", + "Violin Plot": "the dimension on x, the measure on y, one observation per value of the dimension on detail", + "Strip Plot": "the dimension on x, the measure on y, one point per value of the dimension on detail", +} + +// flintChartsInputSchema returns the JSON schema of the Flint shape of the chart arguments. +// It reuses the $defs of the in-app schema (Expression, TimeGrain, ...) for the data block. +func flintChartsInputSchema() *jsonschema.Schema { + encoding := &jsonschema.Schema{ + Type: "object", + Required: []string{"field"}, + Description: "A field bound to a channel with optional sorting and top-N.", + Properties: map[string]*jsonschema.Schema{ + "field": {Type: "string", Description: "A measure, dimension or the time dimension of the metrics view."}, + "type": {Type: "string", Enum: []any{"quantitative", "nominal", "ordinal", "temporal"}, Description: "Overrides the type derived from the field; rarely needed."}, + "aggregate": {Type: "string", Enum: []any{"count", "sum", "average", "mean"}, Description: "Rarely needed: measures are already aggregated."}, + "sortOrder": {Type: "string", Enum: []any{"ascending", "descending"}, Description: "Direction of the sort; descending by default for a sort by a measure."}, + "sortBy": {Type: "string", Description: "Channel (e.g. \"y\") or field to sort this channel's values by; the first measure by default."}, + "scheme": {Type: "string", Description: "Color scheme for the color channel, e.g. viridis or tableau10."}, + "limit": {Type: "integer", Minimum: ptr(1.0), Description: "Top-N limit for a dimension on this channel: defaults to 20 on x and y, 10 on color, group, shape and strokeDash, 6 on column and row."}, + }, + } + encodingValue := &jsonschema.Schema{ + AnyOf: []*jsonschema.Schema{ + {Type: "string", Description: "A field name."}, + {Ref: "#/$defs/FlintEncoding"}, + {Type: "array", Items: &jsonschema.Schema{AnyOf: []*jsonschema.Schema{{Type: "string"}, {Ref: "#/$defs/FlintEncoding"}}}, Description: "Several fields on one channel, e.g. several measures on y as one series each."}, + }, + } + chartSpec := &jsonschema.Schema{ + Type: "object", + Required: []string{"chartType", "encodings"}, + Description: "What to draw: the chart type and which field goes on which channel. Rill derives scales, axes, formats and colors.", + Properties: map[string]*jsonschema.Schema{ + "chartType": {Type: "string", Enum: anyStrings(chartquery.Catalog().Names()), Description: "The chart type, exactly as listed in the tool description."}, + "title": {Type: "string", Description: "Headline shown above the chart; optional."}, + "subtitle": {Type: "string", Description: "What is measured, of whom, when; optional."}, + "encodings": {Type: "object", AdditionalProperties: encodingValue, Description: "Channel -> field, e.g. {\"x\": \"advertiser\", \"y\": {\"field\": \"total_bids\"}}. The channels of each chart type are listed in the tool description; detail binds a dimension that sets the row grain without being drawn."}, + "chartProperties": {Type: "object", Description: "Per-chart-type tuning, e.g. {\"stackMode\": \"normalize\"} or {\"innerRadius\": 50}; the keys of each chart type are listed in the tool description."}, + }, + } + legacy := chartsInputSchema() + data := &jsonschema.Schema{ + Type: "object", + Required: []string{"metrics_view", "time_range"}, + Description: "The data binding: which metrics view to query and over which time range. Rill runs the query; never pass rows.", + Properties: map[string]*jsonschema.Schema{ + "metrics_view": {Type: "string", Description: "The metrics view to query."}, + "time_range": {Type: "object", Required: []string{"start", "end"}, Properties: map[string]*jsonschema.Schema{"start": {Type: "string", Description: "ISO 8601 start, inclusive."}, "end": {Type: "string", Description: "ISO 8601 end, exclusive."}, "time_zone": {Type: "string", Description: "IANA time zone; defaults to UTC."}}, Description: "The period to chart."}, + "comparison_time_range": {Type: "object", Required: []string{"start", "end"}, Properties: map[string]*jsonschema.Schema{"start": {Type: "string"}, "end": {Type: "string"}}, Description: "Optional period to compare with: every measure also returns its value in this period, drawn as a second series."}, + "where": {Ref: "#/$defs/Expression", Description: "Optional filter, with the same structure as in query_metrics_view."}, + "time_grain": {Ref: "#/$defs/TimeGrain", Description: "Bucket size of the time field when one is bound; defaults to TIME_GRAIN_DAY."}, + }, + } + defs := maps.Clone(legacy.Defs) + defs["FlintEncoding"] = encoding + // The root requires neither shape: hosts cache a connector's tool list across conversations, so a client that still holds + // the previous shape (chart_type and spec) keeps passing validation and gets the handler's guidance instead of a protocol error. + // Both of its properties are described as deprecated so a client that reads this schema uses the Flint shape. + deprecated := "Deprecated: the previous shape of this tool, kept for clients with a cached tool list. Use chart_spec and data instead." + return &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "chart_spec": chartSpec, + "data": data, + "chart_type": {Type: "string", Description: deprecated}, + "spec": {Ref: "#/$defs/ChartSpec", Description: deprecated}, + "semantic_types": {Type: "object", AdditionalProperties: &jsonschema.Schema{Type: "string"}, Description: "Optional overrides of the semantic type Rill derives per field (e.g. {\"revenue\": \"Amount\"}); only needed when the derived type is wrong."}, + }, + Defs: defs, + } +} + +// flintCreateChartDescription is the description of create_chart for external MCP clients, built from the catalog. +var flintCreateChartDescription = sync.OnceValue(func() string { + var b strings.Builder + b.WriteString(`# Chart Visualization Tool + +Create a chart from a metrics view. The chart is rendered interactively in clients that support it; other clients receive the chart's data as a table together with a link. Whenever the result includes an ` + "`open_url`" + `, surface it to the user so they can open the chart in Rill. + +Query the data with query_metrics_view first, then describe the chart: the chart type and which field goes on which channel. Rill runs the query, derives scales, axes, number formats, sorting, stacking, colors and layout, and returns the rows. Do not pass colors, formats or axis settings, and reference only measures, dimensions and the time dimension of the metrics view. The rill-charts skill (in the ai_instructions of list_metrics_views, or via load_skill) says which chart fits which question and what to check first. + +## Arguments + +` + "```json" + ` +{ + "chart_spec": { + "chartType": "Bar Chart", + "title": "Bids by advertiser", + "encodings": { + "x": {"field": "advertiser_name", "limit": 10, "sortBy": "y", "sortOrder": "descending"}, + "y": "total_bids", + "color": "device_os" + }, + "chartProperties": {"cornerRadius": 2} + }, + "data": { + "metrics_view": "bids_metrics", + "time_range": {"start": "2024-01-01T00:00:00Z", "end": "2024-02-01T00:00:00Z"}, + "time_grain": "TIME_GRAIN_DAY" + } +} +` + "```" + ` + +- ` + "`chart_spec.encodings`" + ` maps a channel to a field name or to an object with ` + "`field`" + ` and optionally ` + "`sortOrder`" + ` (ascending or descending), ` + "`sortBy`" + ` (a channel such as "y", or a field) and ` + "`limit`" + ` (top-N for a dimension; defaults to 20 on an axis, 10 on a series channel, 6 on a facet). An array of fields on one channel draws one series per field. +- ` + "`detail`" + ` may be bound on any chart type to a dimension that sets the row grain without being drawn: one row per value. Charts of individual observations (scatter, histogram, box plots) need it. +- ` + "`data.time_range`" + ` is required (ISO 8601 ` + "`start`" + ` inclusive, ` + "`end`" + ` exclusive, optional ` + "`time_zone`" + `). ` + "`data.time_grain`" + ` buckets the time field (default TIME_GRAIN_DAY). ` + "`data.where`" + ` filters with the same expression structure as query_metrics_view. ` + "`data.comparison_time_range`" + ` adds the previous period of every measure as a second series; use it with a time field on x or a dimension axis. +- Results are capped (at most 1500 rows); the result says when data was cut and how to narrow the chart. + +## Chart types and their channels + +`) + for _, ct := range chartquery.Catalog().ChartTypes { + fmt.Fprintf(&b, "- %s: %s", ct.Name, strings.Join(ct.Channels, ", ")) + if note, ok := flintChartNotes[ct.Name]; ok { + b.WriteString(". ") + b.WriteString(note) + } + if keys := propertyKeys(ct); len(keys) > 0 { + fmt.Fprintf(&b, ". chartProperties: %s", strings.Join(keys, ", ")) + } + b.WriteString("\n") + } + b.WriteString(` +Rule of thumb for bars: a single series is a Bar Chart; parts of a whole are a Stacked Bar Chart with the parts on color; side-by-side values are a Grouped Bar Chart with the clustering dimension on group. +`) + return b.String() +}) + +// propertyKeys lists the tunable properties of a chart type, leaving out the axis settings Rill derives. +func propertyKeys(ct *chartquery.FlintChartType) []string { + var keys []string + for _, p := range ct.Properties { + switch p.Key { + case "independentYAxis", "logScale_x", "logScale_y", "includeZero_x", "includeZero_y", "xAxisType", "yAxisType": + continue + } + keys = append(keys, p.Key) + } + return keys +} + +// parseFlintChartRequest validates the Flint shape of the chart arguments against the metrics view the caller has access to. +func parseFlintChartRequest(ctx context.Context, rt *runtime.Runtime, s *Session, args map[string]any) (*chartRequest, error) { + chartSpec, ok := args["chart_spec"].(map[string]any) + if !ok { + return nil, fmt.Errorf("chart_spec is required and must be an object") + } + data, ok := args["data"].(map[string]any) + if !ok { + return nil, fmt.Errorf("data is required and must be an object with 'metrics_view' and 'time_range'") + } + metricsView, ok := data["metrics_view"].(string) + if !ok || metricsView == "" { + return nil, fmt.Errorf("data must contain a 'metrics_view' field") + } + mv, err := loadMetricsView(ctx, rt, s, metricsView) + if err != nil { + return nil, err + } + + parsed, err := chartquery.ParseFlintSpec(chartSpec, data, fieldKinds(mv)) + if err != nil { + return nil, err + } + semanticTypes, _ := args["semantic_types"].(map[string]any) + + return &chartRequest{ + chartType: parsed.ChartType, + flint: parsed, + input: &ChartInput{ChartSpec: chartSpec, Data: data}, + semanticTypes: semanticTypes, + mv: mv, + }, nil +} + +// fieldKinds classifies the fields of a metrics view: its time dimension and dimensions of the time type are time fields. +func fieldKinds(mv *runtimev1.MetricsViewSpec) map[string]chartquery.FieldKind { + kinds := make(map[string]chartquery.FieldKind, len(mv.Measures)+len(mv.Dimensions)+1) + for _, m := range mv.Measures { + kinds[m.Name] = chartquery.FieldKindMeasure + } + for _, d := range mv.Dimensions { + if d.Type == runtimev1.MetricsViewSpec_DIMENSION_TYPE_TIME { + kinds[d.Name] = chartquery.FieldKindTime + } else { + kinds[d.Name] = chartquery.FieldKindDimension + } + } + if mv.TimeDimension != "" { + kinds[mv.TimeDimension] = chartquery.FieldKindTime + } + return kinds +} + +// flintInputWithLimits returns the effective input: the chart spec with the applied top-N limit of each channel filled in +// (as a copy; the input maps are not modified) and the data block with the resolved time grain. +func flintInputWithLimits(input *ChartInput, limits map[string]int, timeGrain string) *ChartInput { + chartSpec := maps.Clone(input.ChartSpec) + if encodings, ok := input.ChartSpec["encodings"].(map[string]any); ok && len(limits) > 0 { + encodings = maps.Clone(encodings) + for channel, limit := range limits { + value, ok := encodings[channel] + if !ok { + continue + } + encodings[channel] = encodingWithLimit(value, limit) + } + chartSpec["encodings"] = encodings + } + data := maps.Clone(input.Data) + if timeGrain != "" { + data["time_grain"] = timeGrain + } + return &ChartInput{ChartSpec: chartSpec, Data: data} +} + +// encodingWithLimit returns the encoding value with the limit set: a field name becomes an object, +// an object is copied, and an array gets the limit on its first field, which is the one the top-N selected. +func encodingWithLimit(value any, limit int) any { + switch v := value.(type) { + case string: + return map[string]any{"field": v, "limit": limit} + case map[string]any: + res := maps.Clone(v) + res["limit"] = limit + return res + case []any: + if len(v) == 0 { + return v + } + res := slices.Clone(v) + res[0] = encodingWithLimit(res[0], limit) + return res + default: + return value + } +} + +// flintTitle builds the title of a Flint chart from its headline, or from the bound fields' display names: +// " over Time" or " by ", plus "split by " when a series dimension is bound. +func flintTitle(parsed *chartquery.FlintSpec, chartSpec map[string]any, fields map[string]*ChartField) string { + if title, ok := chartSpec["title"].(string); ok && strings.TrimSpace(title) != "" { + return strings.TrimSpace(title) + } + label := func(name string) string { + if f, ok := fields[name]; ok { + if f.Kind == "time" { + return chartTimeDisplayName + } + if f.DisplayName != "" { + return f.DisplayName + } + } + return name + } + labels := func(names []string) string { + res := make([]string, len(names)) + for i, n := range names { + res[i] = label(n) + } + return strings.Join(res, ", ") + } + + measures := parsed.Measures() + // The category of the chart: the first dimension or time field on an axis, else on any channel other than a series or facet one; + // without one, the detail dimension (the row grain) or the series dimension names it. + var category, series, detail *chartquery.FlintEncoding + for _, e := range parsed.Encodings { + if e.Kind == chartquery.FieldKindMeasure { + continue + } + switch e.Channel { + case "color", "group", "shape", "strokeDash": + if series == nil { + series = e + } + case chartquery.DetailChannel: + if detail == nil { + detail = e + } + case "column", "row", "opacity": + default: + if category == nil { + category = e + } + } + } + preposition := "by" + switch { + case category != nil && category.Kind == chartquery.FieldKindTime: + preposition = "over" + case category == nil && detail != nil: + category, preposition = detail, "for" + case category == nil && series != nil: + category, series = series, nil + } + + // Two measures on the axes (scatter plots) read as "x vs y"; otherwise the measures are listed. + title := labels(measures) + if x, y := parsed.FieldOn("x"), parsed.FieldOn("y"); x != nil && y != nil && x.Kind == chartquery.FieldKindMeasure && y.Kind == chartquery.FieldKindMeasure { + title = label(x.Field) + " vs " + label(y.Field) + } + if category != nil { + title = fmt.Sprintf("%s %s %s", title, preposition, label(category.Field)) + } + if series != nil { + title += " split by " + label(series.Field) + } + return title +} + +func anyStrings(values []string) []any { + res := make([]any, len(values)) + for i, v := range values { + res[i] = v + } + return res +} + +func ptr[T any](v T) *T { + return &v +} diff --git a/runtime/ai/chart_flint_test.go b/runtime/ai/chart_flint_test.go new file mode 100644 index 000000000000..0fee0509726a --- /dev/null +++ b/runtime/ai/chart_flint_test.go @@ -0,0 +1,79 @@ +package ai + +import ( + "strings" + "testing" + + "github.com/rilldata/rill/runtime/ai/chartquery" + "github.com/stretchr/testify/require" +) + +// TestFlintChartNotesMatchCatalog keeps the hand-written chart notes of the external description tied to the generated catalog: +// every noted chart type exists, and every chart type is described. +func TestFlintChartNotesMatchCatalog(t *testing.T) { + catalog := chartquery.Catalog() + for name := range flintChartNotes { + require.NotNil(t, catalog.ChartType(name), "note for a chart type that is not in the catalog: %q", name) + } + description := flintCreateChartDescription() + for _, ct := range catalog.ChartTypes { + require.Contains(t, description, "- "+ct.Name+": ", "chart type missing from the description") + } + require.NotContains(t, description, "KPI Card") + require.Less(t, len(description), 12_000, "the description is sent with every tool list") + + schema := flintChartsInputSchema() + require.Empty(t, schema.Required, "both shapes must pass validation") + require.Contains(t, schema.Properties, "chart_type") + require.Contains(t, schema.Properties, "spec") + require.Len(t, schema.Properties["chart_spec"].Properties["chartType"].Enum, len(catalog.ChartTypes)) + require.Contains(t, schema.Defs, "FlintEncoding") + require.Contains(t, schema.Defs, "Expression") +} + +func TestFlintInputWithLimits(t *testing.T) { + input := &ChartInput{ + ChartSpec: map[string]any{ + "chartType": "Line Chart", + "encodings": map[string]any{"x": "__time", "y": []any{"bids", "clicks"}, "color": map[string]any{"field": "device", "sortBy": "y"}}, + }, + Data: map[string]any{"metrics_view": "mv"}, + } + res := flintInputWithLimits(input, map[string]int{"color": 10, "y": 3}, "TIME_GRAIN_DAY") + + encodings := res.ChartSpec["encodings"].(map[string]any) + require.Equal(t, map[string]any{"field": "device", "sortBy": "y", "limit": 10}, encodings["color"]) + require.Equal(t, []any{map[string]any{"field": "bids", "limit": 3}, "clicks"}, encodings["y"], "an array gets the limit on its first field") + require.Equal(t, "__time", encodings["x"], "channels without a limit are echoed as given") + require.Equal(t, "TIME_GRAIN_DAY", res.Data["time_grain"]) + + // The input maps are not modified. + require.Equal(t, map[string]any{"field": "device", "sortBy": "y"}, input.ChartSpec["encodings"].(map[string]any)["color"]) + require.NotContains(t, input.Data, "time_grain") +} + +func TestFlintTitle(t *testing.T) { + kinds := map[string]chartquery.FieldKind{"__time": chartquery.FieldKindTime, "advertiser": chartquery.FieldKindDimension, "device": chartquery.FieldKindDimension, "bids": chartquery.FieldKindMeasure, "clicks": chartquery.FieldKindMeasure} + fields := map[string]*ChartField{ + "__time": {Name: "__time", Kind: "time", DisplayName: "__time"}, + "advertiser": {Name: "advertiser", Kind: "dimension", DisplayName: "Advertiser"}, + "device": {Name: "device", Kind: "dimension", DisplayName: "Device"}, + "bids": {Name: "bids", Kind: "measure", DisplayName: "Bids"}, + "clicks": {Name: "clicks", Kind: "measure", DisplayName: "Clicks"}, + } + data := map[string]any{"metrics_view": "mv", "time_range": map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-02-01T00:00:00Z"}} + title := func(chartSpec map[string]any) string { + parsed, err := chartquery.ParseFlintSpec(chartSpec, data, kinds) + require.NoError(t, err) + return flintTitle(parsed, chartSpec, fields) + } + + require.Equal(t, "Bids by Advertiser", title(map[string]any{"chartType": "Bar Chart", "encodings": map[string]any{"x": "advertiser", "y": "bids"}})) + require.Equal(t, "Bids, Clicks over Time split by Device", title(map[string]any{"chartType": "Line Chart", "encodings": map[string]any{"x": "__time", "y": []any{"bids", "clicks"}, "color": "device"}})) + require.Equal(t, "Bids by Device", title(map[string]any{"chartType": "Pie Chart", "encodings": map[string]any{"size": "bids", "color": "device"}}), "a series channel is the category when nothing else is") + require.Equal(t, "Bids by Advertiser", title(map[string]any{"chartType": "Bar Chart", "encodings": map[string]any{"y": "advertiser", "x": "bids"}}), "horizontal layout") + require.Equal(t, "Bids by Device", title(map[string]any{"chartType": "Heatmap", "encodings": map[string]any{"x": "device", "y": "advertiser", "color": "bids"}})) + require.Equal(t, "Bids vs Clicks for Advertiser", title(map[string]any{"chartType": "Scatter Plot", "encodings": map[string]any{"x": "bids", "y": "clicks", "detail": "advertiser"}}), "the detail dimension is the grain of a scatter plot") + require.Equal(t, "My headline", title(map[string]any{"chartType": "Bar Chart", "title": " My headline ", "encodings": map[string]any{"x": "advertiser", "y": "bids"}})) + require.True(t, strings.HasPrefix(title(map[string]any{"chartType": "Bar Chart", "encodings": map[string]any{"x": "advertiser", "y": "bids", "column": "device"}}), "Bids by Advertiser"), "facets do not name the chart") +} diff --git a/runtime/ai/chart_semantics.go b/runtime/ai/chart_semantics.go new file mode 100644 index 000000000000..c8838c1c31be --- /dev/null +++ b/runtime/ai/chart_semantics.go @@ -0,0 +1,107 @@ +package ai + +import ( + "strings" + + "github.com/rilldata/rill/runtime/ai/chartquery" +) + +// Flint semantic types of chart fields, derived from the metrics view the same way the canvas custom viz derives them. +// This is a port of deriveFlintFields in web-common/src/features/custom-viz/flint/semantic-types.ts: keep the two in sync. +// A semantic type tells the chart compiler how to format, scale and color a field; a wrong one degrades to plain rendering, +// so only names of Flint's own registry are emitted. + +// chartTimeDisplayName labels the time dimension, which carries no meaningful display name of its own. +// Keep in sync with TIME_DISPLAY_NAME in semantic-types.ts and timeDimensionDisplayName in runtime/canvas/fields.go. +const chartTimeDisplayName = "Time" + +// chartTimeSemanticTypes maps the grain a time field is bucketed at to Flint's temporal type; finer grains fall back to DateTime. +var chartTimeSemanticTypes = map[string]string{ + "TIME_GRAIN_YEAR": "Year", + "TIME_GRAIN_QUARTER": "YearQuarter", + "TIME_GRAIN_MONTH": "YearMonth", + "TIME_GRAIN_WEEK": "YearWeek", + "TIME_GRAIN_DAY": "Date", +} + +// chartIntegerTypes are the type codes of integer measures, which are counts often enough that Flint's integer tick strategy is the better default. +var chartIntegerTypes = map[string]bool{ + "INT8": true, "INT16": true, "INT32": true, "INT64": true, "INT128": true, "INT256": true, + "UINT8": true, "UINT16": true, "UINT32": true, "UINT64": true, "UINT128": true, "UINT256": true, +} + +// chartSemantics derives the Flint semantic type and display name of every field of a chart result. +// The comparison value of a measure (_prev) takes the measure's type and a "(previous period)" display name. +// overrides are the caller's semantic_types: a type name per field, or an annotation object with a semanticType. +func chartSemantics(fields map[string]*ChartField, timeGrain string, comparison bool, overrides map[string]any) (semanticTypes, displayNames map[string]string) { + semanticTypes = make(map[string]string, len(fields)) + displayNames = make(map[string]string, len(fields)) + for name, f := range fields { + semanticTypes[name] = fieldSemanticType(f, timeGrain) + displayNames[name] = f.DisplayName + if f.Kind == "time" { + displayNames[name] = chartTimeDisplayName + } + if displayNames[name] == "" { + displayNames[name] = name + } + } + for name, override := range overrides { + if _, ok := fields[name]; !ok { + continue + } + switch v := override.(type) { + case string: + if v != "" { + semanticTypes[name] = v + } + case map[string]any: + if t, ok := v["semanticType"].(string); ok && t != "" { + semanticTypes[name] = t + } + } + } + if comparison { + for name, f := range fields { + if f.Kind != "measure" { + continue + } + prev := name + chartquery.PrevSuffix + semanticTypes[prev] = semanticTypes[name] + displayNames[prev] = displayNames[name] + " (previous period)" + } + } + return semanticTypes, displayNames +} + +// fieldSemanticType picks the type of a field from its role: measures by how they are formatted (the preset encodes the intent: +// money, ratio, elapsed time), time fields by the grain they are bucketed at, and dimensions are categories. +func fieldSemanticType(f *ChartField, timeGrain string) string { + switch f.Kind { + case "time": + if t, ok := chartTimeSemanticTypes[timeGrain]; ok { + return t + } + return "DateTime" + case "dimension": + return "Category" + } + switch f.FormatPreset { + case "currency_usd", "currency_eur": + return "Amount" + case "percentage": + return "Percentage" + case "interval_ms": + return "Duration" + } + if strings.Contains(f.FormatD3, "$") { + return "Amount" + } + if strings.Contains(f.FormatD3, "%") { + return "Percentage" + } + if chartIntegerTypes[f.Type] { + return "Count" + } + return "Number" +} diff --git a/runtime/ai/chartquery/build.go b/runtime/ai/chartquery/build.go new file mode 100644 index 000000000000..53f0d35a079e --- /dev/null +++ b/runtime/ai/chartquery/build.go @@ -0,0 +1,683 @@ +package chartquery + +import ( + "fmt" + + "github.com/rilldata/rill/runtime/metricsview" +) + +// Budgets for the chart data. They bound the number of categories, rows and bytes a chart result may carry, +// since MCP hosts cap tool results and the widget renders every row it receives. +const ( + // MaxNominalValues caps the values of a nominal x axis, funnel stage or circular slice dimension. + MaxNominalValues = 50 + // MaxColorValues caps the values of a color split. + MaxColorValues = 20 + // MaxHeatmapAxisValues caps the values of each heatmap axis. + MaxHeatmapAxisValues = 40 + // MaxRows caps the rows of the main query. + MaxRows = 1500 + // MaxDataBytes caps the JSON size of the rows; rows are trimmed from the tail to fit. + MaxDataBytes = 100 << 10 +) + +// Default limits applied when the spec does not set one. They match the frontend chart providers. +const ( + defaultNominalLimit = 20 + defaultColorLimit = 10 + defaultSliceLimit = 20 + defaultStageLimit = 15 + defaultHeatmapLimit = 40 +) + +// Suffixes of the derived measures, shared with the frontend. +const ( + // PrevSuffix names the comparison period value of a measure. + PrevSuffix = "_prev" + // deltaSuffix names the absolute change of a measure used for delta sorting. + deltaSuffix = "_delta" +) + +// Plan is the set of queries that fetch a chart's data. +type Plan struct { + Spec *Spec + // TopN are pre-queries that select the values of a dimension (the x axis, color split, funnel stage or heatmap axes). + // They run in order before the main query, and their values filter it and order its categories. + TopN []TopNQuery + // Main builds the main query from the selected domain values (keyed by field name). + Main func(domain map[string][]any) *metricsview.Query + // Extra are queries whose single row is returned as totals (donut and pie charts), keyed by name. + Extra map[string]*metricsview.Query + // RowLimit caps the rows returned from the main query; the query itself asks for one more row to detect truncation. + RowLimit int + // TimeField is the temporal field the main query is ordered by, newest first, when the chart has a time axis. + // Ordering newest first makes the row cap and the byte budget drop the oldest rows; Execute restores ascending order. + TimeField string + // MeasureDomain lists the measure names to report as the domain of a "rill_measures" color split. + MeasureDomain []string +} + +// TopNQuery selects the values of one dimension. +type TopNQuery struct { + // Channel is the spec channel whose values are selected (x, y, color or stage), as named in the input spec. + Channel string `json:"channel"` + // Field is the dimension whose values are selected. + Field string `json:"field"` + // Limit is the number of values to keep. The query asks for one more row to detect truncation. + Limit int `json:"limit"` + Query *metricsview.Query `json:"query"` +} + +// Build plans the queries for a chart spec. +func Build(s *Spec) (*Plan, error) { + switch s.ChartType { + case BarChart, LineChart, AreaChart, StackedBar, StackedBarNormalized: + return buildCartesian(s) + case DonutChart, PieChart: + return buildCircular(s) + case FunnelChart: + return buildFunnel(s) + case Heatmap: + return buildHeatmap(s) + case ComboChart: + return buildCombo(s) + case ScatterPlot: + return buildScatter(s) + default: + return nil, fmt.Errorf("unsupported chart type %q", s.ChartType) + } +} + +// buildCartesian mirrors CartesianChartProvider: a top-N query for a nominal x axis (sorted by the measure, or by its +// period-over-period delta), a top-N query for the color split, and a main query that filters to those values, +// floors temporal x values to the grain and adds a _prev column for each measure when a comparison is set. +func buildCartesian(s *Spec) (*Plan, error) { + measures := s.Y.measures() + if len(measures) == 0 { + return nil, fmt.Errorf("%s requires a quantitative y field", s.ChartType) + } + if s.X == nil || s.X.Field == "" { + return nil, fmt.Errorf("%s requires an x field", s.ChartType) + } + multiMeasure := len(s.Y.Fields) > 0 + comparison := s.ComparisonTimeRange != nil + + plan := &Plan{Spec: s, RowLimit: MaxRows} + + // Resolve the x sort against the vertical layout. Delta sorts need a comparison, otherwise they fall back to the measure. + xSort := s.X.Sort + if xSort == "" || len(s.X.SortValues) > 0 { + xSort = "-y" + } + if !comparison { + switch xSort { + case "y_delta": + xSort = "y" + case "-y_delta": + xSort = "-y" + } + } + var topNSort, mainSort metricsview.Sort + var deltaSort bool + switch xSort { + case "x", "-x": + topNSort = metricsview.Sort{Name: s.X.Field, Desc: xSort == "-x"} + mainSort = topNSort + case "y_delta", "-y_delta": + deltaSort = true + topNSort = metricsview.Sort{Name: measures[0] + deltaSuffix, Desc: xSort == "-y_delta"} + mainSort = metricsview.Sort{Name: measures[0], Desc: xSort == "-y_delta"} + default: + topNSort = metricsview.Sort{Name: measures[0], Desc: xSort == "-y"} + mainSort = topNSort + } + + colorField := s.Color + if colorField != nil && (colorField.Field == "" || colorField.Field == MeasuresField || colorField.Type == TypeValue || multiMeasure) { + colorField = nil + } + if multiMeasure && s.Color != nil { + plan.MeasureDomain = measures + } + + // The x axis of a horizontal bar chart is the input's y channel (see Spec.Horizontal). + xChannel := "x" + if s.Horizontal { + xChannel = "y" + } + if s.X.isNominal() && len(s.X.SortValues) == 0 { + limit := capLimit(s.X.Limit, defaultNominalLimit, MaxNominalValues) + q := &metricsview.Query{ + MetricsView: s.MetricsView, + Dimensions: []metricsview.Dimension{{Name: s.X.Field}}, + Measures: namedMeasures(measures), + Sort: []metricsview.Sort{topNSort}, + Where: and(s.Where, nullExclusion(s.X)), + TimeRange: s.TimeRange.toQuery(), + TimeZone: s.TimeRange.TimeZone, + Limit: limitPtr(limit + 1), + } + if deltaSort { + q.Measures = append(q.Measures, metricsview.Measure{ + Name: measures[0] + deltaSuffix, + Compute: &metricsview.MeasureCompute{ComparisonDelta: &metricsview.MeasureComputeComparisonDelta{Measure: measures[0]}}, + }) + q.ComparisonTimeRange = s.ComparisonTimeRange.toQuery() + } + plan.TopN = append(plan.TopN, TopNQuery{Channel: xChannel, Field: s.X.Field, Limit: limit, Query: q}) + } + + if colorField != nil && len(colorField.SortValues) == 0 { + limit := capLimit(colorField.Limit, defaultColorLimit, MaxColorValues) + plan.TopN = append(plan.TopN, TopNQuery{ + Channel: "color", + Field: colorField.Field, + Limit: limit, + Query: &metricsview.Query{ + MetricsView: s.MetricsView, + Dimensions: []metricsview.Dimension{{Name: colorField.Field}}, + Measures: namedMeasures(measures), + Sort: []metricsview.Sort{{Name: measures[0], Desc: true}}, + Where: and(s.Where, nullExclusion(colorField)), + TimeRange: s.TimeRange.toQuery(), + TimeZone: s.TimeRange.TimeZone, + Limit: limitPtr(limit + 1), + }, + }) + } + + // Temporal charts are queried newest first, so that the row cuts in Execute keep the most recent rows. + if s.X.isTemporal() { + plan.TimeField = s.X.Field + } + plan.Main = func(domain map[string][]any) *metricsview.Query { + q := &metricsview.Query{ + MetricsView: s.MetricsView, + Dimensions: []metricsview.Dimension{dimensionFor(s.X, s.TimeGrain)}, + Measures: namedMeasures(measures), + Where: and(s.Where, nullExclusion(s.X), inFilter(s.X, domain)), + TimeRange: s.TimeRange.toQuery(), + ComparisonTimeRange: s.ComparisonTimeRange.toQuery(), + TimeZone: s.TimeRange.TimeZone, + Limit: limitPtr(plan.RowLimit + 1), + } + if comparison { + q.Measures = withPrevMeasures(measures) + } + if s.X.isTemporal() { + q.Sort = []metricsview.Sort{{Name: s.X.Field, Desc: true}} + } else { + q.Sort = []metricsview.Sort{mainSort} + } + if colorField != nil { + q.Dimensions = append(q.Dimensions, metricsview.Dimension{Name: colorField.Field}) + q.Where = and(q.Where, inFilter(colorField, domain)) + q.Sort = append(q.Sort, metricsview.Sort{Name: colorField.Field}) + } + return q + } + + return plan, nil +} + +// buildCircular mirrors CircularChartProvider: a top-N query for the slice dimension sorted by the measure, +// a main query filtered to those values, and a total query for the center label and percent-of-total tooltip. +func buildCircular(s *Spec) (*Plan, error) { + if s.Measure == nil || s.Measure.Field == "" { + return nil, fmt.Errorf("%s requires a measure field", s.ChartType) + } + measure := s.Measure.Field + color := s.Color + if color != nil && color.Field == "" { + color = nil + } + + plan := &Plan{Spec: s, RowLimit: MaxRows, Extra: map[string]*metricsview.Query{}} + + var sort []metricsview.Sort + if color != nil { + limit := capLimit(color.Limit, defaultSliceLimit, MaxNominalValues) + switch color.Sort { + case "color", "-color": + sort = []metricsview.Sort{{Name: color.Field, Desc: color.Sort == "-color"}} + case "measure": + sort = []metricsview.Sort{{Name: measure}} + default: + sort = []metricsview.Sort{{Name: measure, Desc: true}} + } + if len(color.SortValues) == 0 { + plan.TopN = append(plan.TopN, TopNQuery{ + Channel: "color", + Field: color.Field, + Limit: limit, + Query: &metricsview.Query{ + MetricsView: s.MetricsView, + Dimensions: []metricsview.Dimension{{Name: color.Field}}, + Measures: namedMeasures([]string{measure}), + Sort: sort, + Where: and(s.Where, nullExclusion(color)), + TimeRange: s.TimeRange.toQuery(), + TimeZone: s.TimeRange.TimeZone, + Limit: limitPtr(limit + 1), + }, + }) + } + plan.RowLimit = limit + } + + plan.Extra["total"] = &metricsview.Query{ + MetricsView: s.MetricsView, + Measures: namedMeasures([]string{measure}), + Where: and(s.Where, nullExclusion(color)), + TimeRange: s.TimeRange.toQuery(), + TimeZone: s.TimeRange.TimeZone, + } + + plan.Main = func(domain map[string][]any) *metricsview.Query { + q := &metricsview.Query{ + MetricsView: s.MetricsView, + Measures: namedMeasures([]string{measure}), + Where: s.Where, + Sort: sort, + TimeRange: s.TimeRange.toQuery(), + TimeZone: s.TimeRange.TimeZone, + Limit: limitPtr(plan.RowLimit + 1), + } + if color != nil { + q.Dimensions = []metricsview.Dimension{{Name: color.Field}} + q.Where = and(s.Where, inFilter(color, domain)) + } + return q + } + + return plan, nil +} + +// buildFunnel mirrors FunnelChartProvider: in dimension mode, a top-N query for the stage dimension sorted by the measure +// and a main query filtered to those stages; in measures mode, a single query that returns every measure as a stage. +func buildFunnel(s *Spec) (*Plan, error) { + measures := s.Measure.measures() + if len(measures) == 0 { + return nil, fmt.Errorf("%s requires a measure field", s.ChartType) + } + multiMeasure := s.BreakdownMode == "measures" || (s.Stage == nil && len(measures) > 1) + + plan := &Plan{Spec: s, RowLimit: MaxRows} + + if multiMeasure { + plan.RowLimit = 1 + plan.Main = func(map[string][]any) *metricsview.Query { + return &metricsview.Query{ + MetricsView: s.MetricsView, + Measures: namedMeasures(measures), + Where: s.Where, + TimeRange: s.TimeRange.toQuery(), + TimeZone: s.TimeRange.TimeZone, + Limit: limitPtr(plan.RowLimit + 1), + } + } + return plan, nil + } + + if s.Stage == nil || s.Stage.Field == "" { + return nil, fmt.Errorf("%s requires a stage field in dimension mode", s.ChartType) + } + measure := measures[0] + limit := capLimit(s.Stage.Limit, defaultStageLimit, MaxNominalValues) + sort := []metricsview.Sort{{Name: measure, Desc: s.Stage.Sort != "y"}} + plan.RowLimit = limit + + if len(s.Stage.SortValues) == 0 { + plan.TopN = append(plan.TopN, TopNQuery{ + Channel: "stage", + Field: s.Stage.Field, + Limit: limit, + Query: &metricsview.Query{ + MetricsView: s.MetricsView, + Dimensions: []metricsview.Dimension{{Name: s.Stage.Field}}, + Measures: namedMeasures([]string{measure}), + Sort: sort, + Where: and(s.Where, nullExclusion(s.Stage)), + TimeRange: s.TimeRange.toQuery(), + TimeZone: s.TimeRange.TimeZone, + Limit: limitPtr(limit + 1), + }, + }) + } + + plan.Main = func(domain map[string][]any) *metricsview.Query { + return &metricsview.Query{ + MetricsView: s.MetricsView, + Dimensions: []metricsview.Dimension{{Name: s.Stage.Field}}, + Measures: namedMeasures([]string{measure}), + Where: and(s.Where, nullExclusion(s.Stage), inFilter(s.Stage, domain)), + Sort: sort, + TimeRange: s.TimeRange.toQuery(), + TimeZone: s.TimeRange.TimeZone, + Limit: limitPtr(plan.RowLimit + 1), + } + } + + return plan, nil +} + +// buildHeatmap mirrors HeatmapChartProvider: a top-N query per nominal axis sorted by the color measure, +// and a main query over both axes filtered to the selected values. +func buildHeatmap(s *Spec) (*Plan, error) { + if s.Color == nil || s.Color.Field == "" { + return nil, fmt.Errorf("%s requires a quantitative color field", s.ChartType) + } + if s.X == nil || s.X.Field == "" || s.Y == nil || s.Y.Field == "" { + return nil, fmt.Errorf("%s requires x and y fields", s.ChartType) + } + measure := s.Color.Field + + plan := &Plan{Spec: s, RowLimit: MaxRows} + + axisSort := func(axis *Field) metricsview.Sort { + switch axis.Sort { + case "x", "-x": + return metricsview.Sort{Name: s.X.Field, Desc: axis.Sort == "-x"} + case "y", "-y": + return metricsview.Sort{Name: s.Y.Field, Desc: axis.Sort == "-y"} + case "color": + return metricsview.Sort{Name: measure} + default: + return metricsview.Sort{Name: measure, Desc: true} + } + } + for _, axis := range []struct { + channel string + field *Field + }{{"x", s.X}, {"y", s.Y}} { + if !axis.field.isNominal() || len(axis.field.SortValues) > 0 { + continue + } + limit := capLimit(axis.field.Limit, defaultHeatmapLimit, MaxHeatmapAxisValues) + plan.TopN = append(plan.TopN, TopNQuery{ + Channel: axis.channel, + Field: axis.field.Field, + Limit: limit, + Query: &metricsview.Query{ + MetricsView: s.MetricsView, + Dimensions: []metricsview.Dimension{{Name: axis.field.Field}}, + Measures: namedMeasures([]string{measure}), + Sort: []metricsview.Sort{axisSort(axis.field)}, + Where: and(s.Where, nullExclusion(axis.field)), + TimeRange: s.TimeRange.toQuery(), + TimeZone: s.TimeRange.TimeZone, + Limit: limitPtr(limit + 1), + }, + }) + } + + if s.X.isTemporal() { + plan.TimeField = s.X.Field + } + plan.Main = func(domain map[string][]any) *metricsview.Query { + q := &metricsview.Query{ + MetricsView: s.MetricsView, + Dimensions: []metricsview.Dimension{dimensionFor(s.X, s.TimeGrain), dimensionFor(s.Y, s.TimeGrain)}, + Measures: namedMeasures([]string{measure}), + Where: and(s.Where, inFilter(s.X, domain), inFilter(s.Y, domain)), + TimeRange: s.TimeRange.toQuery(), + TimeZone: s.TimeRange.TimeZone, + Limit: limitPtr(plan.RowLimit + 1), + } + // Sorting x descending keeps the newest buckets when a temporal heatmap is truncated; + // for a nominal x the widget orders the cells by the top-N domain, so the row order is irrelevant. + q.Sort = []metricsview.Sort{{Name: s.X.Field, Desc: true}} + return q + } + + return plan, nil +} + +// buildCombo mirrors ComboChartProvider: a top-N query for a nominal x axis sorted by the first measure, +// and a main query with both measures. +func buildCombo(s *Spec) (*Plan, error) { + var measures []string + for _, f := range []*Field{s.Y1, s.Y2} { + if f != nil && f.Field != "" && f.Type == TypeQuantitative { + measures = append(measures, f.Field) + } + } + if len(measures) == 0 { + return nil, fmt.Errorf("%s requires quantitative y1 and y2 fields", s.ChartType) + } + if s.X == nil || s.X.Field == "" { + return nil, fmt.Errorf("%s requires an x field", s.ChartType) + } + + plan := &Plan{Spec: s, RowLimit: MaxRows} + if s.Color != nil && s.Color.Field != "" { + plan.MeasureDomain = measures + } + + var sort metricsview.Sort + switch s.X.Sort { + case "x", "-x": + sort = metricsview.Sort{Name: s.X.Field, Desc: s.X.Sort == "-x"} + case "y": + sort = metricsview.Sort{Name: measures[0]} + default: + sort = metricsview.Sort{Name: measures[0], Desc: true} + } + + if s.X.isNominal() { + limit := capLimit(s.X.Limit, defaultNominalLimit, MaxNominalValues) + plan.RowLimit = limit + if len(s.X.SortValues) == 0 { + plan.TopN = append(plan.TopN, TopNQuery{ + Channel: "x", + Field: s.X.Field, + Limit: limit, + Query: &metricsview.Query{ + MetricsView: s.MetricsView, + Dimensions: []metricsview.Dimension{{Name: s.X.Field}}, + Measures: namedMeasures(measures[:1]), + Sort: []metricsview.Sort{sort}, + Where: and(s.Where, nullExclusion(s.X)), + TimeRange: s.TimeRange.toQuery(), + TimeZone: s.TimeRange.TimeZone, + Limit: limitPtr(limit + 1), + }, + }) + } + } + + if s.X.isTemporal() { + plan.TimeField = s.X.Field + } + plan.Main = func(domain map[string][]any) *metricsview.Query { + q := &metricsview.Query{ + MetricsView: s.MetricsView, + Dimensions: []metricsview.Dimension{dimensionFor(s.X, s.TimeGrain)}, + Measures: namedMeasures(measures), + Where: and(s.Where, nullExclusion(s.X), inFilter(s.X, domain)), + TimeRange: s.TimeRange.toQuery(), + TimeZone: s.TimeRange.TimeZone, + Limit: limitPtr(plan.RowLimit + 1), + } + if s.X.isTemporal() { + q.Sort = []metricsview.Sort{{Name: s.X.Field, Desc: true}} + } else { + q.Sort = []metricsview.Sort{sort} + } + return q + } + + return plan, nil +} + +// buildScatter mirrors ScatterPlotChartProvider: a top-N query for the color split sorted by the y measure, +// and a main query with the x, y and size measures per point dimension (and color). +func buildScatter(s *Spec) (*Plan, error) { + var measures []string + var dims []*Field + for _, f := range []*Field{s.X, s.Y} { + switch { + case f == nil || f.Field == "": + return nil, fmt.Errorf("%s requires x and y fields", s.ChartType) + case f.Type == TypeQuantitative: + measures = append(measures, f.Field) + case f.Type == TypeTemporal: + dims = append(dims, f) + } + } + if s.Size != nil && s.Size.Field != "" && s.Size.Type == TypeQuantitative { + measures = append(measures, s.Size.Field) + } + if len(measures) == 0 { + return nil, fmt.Errorf("%s requires quantitative x and y fields", s.ChartType) + } + if s.Dimension.isNominal() { + dims = append(dims, s.Dimension) + } + color := s.Color + if color != nil && color.Field == "" { + color = nil + } + if color != nil { + dims = append(dims, color) + } + + plan := &Plan{Spec: s, RowLimit: MaxRows} + + var sort []metricsview.Sort + if s.Y != nil && s.Y.Type == TypeQuantitative { + sort = []metricsview.Sort{{Name: s.Y.Field, Desc: true}} + } + + if color != nil && len(color.SortValues) == 0 { + limit := capLimit(color.Limit, defaultColorLimit, MaxColorValues) + plan.TopN = append(plan.TopN, TopNQuery{ + Channel: "color", + Field: color.Field, + Limit: limit, + Query: &metricsview.Query{ + MetricsView: s.MetricsView, + Dimensions: []metricsview.Dimension{{Name: color.Field}}, + Measures: namedMeasures(measures), + Sort: sort, + Where: and(s.Where, nullExclusion(color)), + TimeRange: s.TimeRange.toQuery(), + TimeZone: s.TimeRange.TimeZone, + Limit: limitPtr(limit + 1), + }, + }) + } + + plan.Main = func(domain map[string][]any) *metricsview.Query { + q := &metricsview.Query{ + MetricsView: s.MetricsView, + Measures: namedMeasures(measures), + Where: and(s.Where, nullExclusion(s.Dimension), inFilter(color, domain)), + Sort: sort, + TimeRange: s.TimeRange.toQuery(), + TimeZone: s.TimeRange.TimeZone, + Limit: limitPtr(plan.RowLimit + 1), + } + for _, d := range dims { + q.Dimensions = append(q.Dimensions, dimensionFor(d, s.TimeGrain)) + } + return q + } + + return plan, nil +} + +// capLimit applies the default and the cap to a spec limit. +func capLimit(limit, def, maximum int) int { + if limit <= 0 { + limit = def + } + return min(limit, maximum) +} + +func limitPtr(n int) *int64 { + v := int64(n) + return &v +} + +func (tr *TimeRange) toQuery() *metricsview.TimeRange { + if tr == nil { + return nil + } + return &metricsview.TimeRange{Start: tr.Start, End: tr.End} +} + +func namedMeasures(names []string) []metricsview.Measure { + res := make([]metricsview.Measure, len(names)) + for i, n := range names { + res[i] = metricsview.Measure{Name: n} + } + return res +} + +// withPrevMeasures returns each measure followed by its comparison period value under _prev. +func withPrevMeasures(names []string) []metricsview.Measure { + res := make([]metricsview.Measure, 0, 2*len(names)) + for _, n := range names { + res = append(res, + metricsview.Measure{Name: n}, + metricsview.Measure{Name: n + PrevSuffix, Compute: &metricsview.MeasureCompute{ComparisonValue: &metricsview.MeasureComputeComparisonValue{Measure: n}}}, + ) + } + return res +} + +// dimensionFor returns the query dimension for a field, flooring temporal fields to the grain. +func dimensionFor(f *Field, grain metricsview.TimeGrain) metricsview.Dimension { + if f.isTemporal() && grain != metricsview.TimeGrainUnspecified { + return metricsview.Dimension{ + Name: f.Field, + Compute: &metricsview.DimensionCompute{TimeFloor: &metricsview.DimensionComputeTimeFloor{Dimension: f.Field, Grain: grain}}, + } + } + return metricsview.Dimension{Name: f.Field} +} + +// nullExclusion returns a filter that drops null values of a nominal field, unless the spec asks to show them. +func nullExclusion(f *Field) *metricsview.Expression { + if !f.isNominal() || f.ShowNull { + return nil + } + return &metricsview.Expression{Condition: &metricsview.Condition{ + Operator: metricsview.OperatorNin, + Expressions: []*metricsview.Expression{{Name: f.Field}, {Value: []any{nil}}}, + }} +} + +// inFilter returns a filter that keeps only the selected values of a field, or nil when none were selected. +func inFilter(f *Field, domain map[string][]any) *metricsview.Expression { + if f == nil || f.Field == "" { + return nil + } + values, ok := domain[f.Field] + if !ok || len(values) == 0 { + return nil + } + return &metricsview.Expression{Condition: &metricsview.Condition{ + Operator: metricsview.OperatorIn, + Expressions: []*metricsview.Expression{{Name: f.Field}, {Value: values}}, + }} +} + +// and combines filters with AND, skipping nil ones. +func and(exprs ...*metricsview.Expression) *metricsview.Expression { + var res []*metricsview.Expression + for _, e := range exprs { + if e != nil { + res = append(res, e) + } + } + switch len(res) { + case 0: + return nil + case 1: + return res[0] + default: + return &metricsview.Expression{Condition: &metricsview.Condition{Operator: metricsview.OperatorAnd, Expressions: res}} + } +} diff --git a/runtime/ai/chartquery/build_test.go b/runtime/ai/chartquery/build_test.go new file mode 100644 index 000000000000..87ea516ee1bc --- /dev/null +++ b/runtime/ai/chartquery/build_test.go @@ -0,0 +1,302 @@ +package chartquery + +import ( + "encoding/json" + "flag" + "os" + "path/filepath" + "testing" + + "github.com/rilldata/rill/runtime/metricsview" + "github.com/stretchr/testify/require" +) + +var update = flag.Bool("update", false, "update the golden files") + +// TestBuildGolden checks the generated queries for every chart type against golden files in testdata. +// The main query is built with a fixed domain so the top-N filters are visible. Run with -update to regenerate. +func TestBuildGolden(t *testing.T) { + timeRange := map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-02-01T00:00:00Z"} + comparisonTimeRange := map[string]any{"start": "2023-12-01T00:00:00Z", "end": "2024-01-01T00:00:00Z"} + where := map[string]any{"cond": map[string]any{"op": "eq", "exprs": []any{map[string]any{"name": "country"}, map[string]any{"val": "US"}}}} + + cases := []struct { + name string + chartType string + spec map[string]any + domain map[string][]any + }{ + { + name: "bar_chart", + chartType: BarChart, + spec: map[string]any{ + "metrics_view": "mv", "time_range": timeRange, "where": where, "color": "primary", + "x": map[string]any{"field": "advertiser", "type": "nominal", "limit": 10, "sort": "-y"}, + "y": map[string]any{"field": "bids", "type": "quantitative", "zeroBasedOrigin": true}, + }, + domain: map[string][]any{"advertiser": {"A", "B"}}, + }, + { + name: "bar_chart_horizontal", + chartType: BarChart, + spec: map[string]any{ + "metrics_view": "mv", "time_range": timeRange, "color": "primary", + "x": map[string]any{"field": "bids", "type": "quantitative"}, + "y": map[string]any{"field": "advertiser", "type": "nominal", "limit": 100, "sort": "-x"}, + }, + domain: map[string][]any{"advertiser": {"A", "B"}}, + }, + { + name: "bar_chart_delta_sort", + chartType: BarChart, + spec: map[string]any{ + "metrics_view": "mv", "time_range": timeRange, "comparison_time_range": comparisonTimeRange, "color": "primary", + "x": map[string]any{"field": "advertiser", "type": "nominal", "limit": 5, "sort": "-y_delta"}, + "y": map[string]any{"field": "bids", "type": "quantitative"}, + }, + domain: map[string][]any{"advertiser": {"A", "B"}}, + }, + { + name: "bar_chart_show_null", + chartType: BarChart, + spec: map[string]any{ + "metrics_view": "mv", "time_range": timeRange, "color": "primary", + "x": map[string]any{"field": "advertiser", "type": "nominal", "showNull": true, "sort": "x"}, + "y": map[string]any{"field": "bids", "type": "quantitative"}, + }, + domain: map[string][]any{"advertiser": {"A", nil}}, + }, + { + name: "line_chart", + chartType: LineChart, + spec: map[string]any{ + "metrics_view": "mv", "time_range": timeRange, "time_grain": "TIME_GRAIN_MONTH", + "color": map[string]any{"field": "device", "type": "nominal", "limit": 3}, + "x": map[string]any{"field": "__time", "type": "temporal", "limit": 20, "sort": "-y"}, + "y": map[string]any{"field": "bids", "type": "quantitative"}, + }, + domain: map[string][]any{"device": {"iOS", "Android"}}, + }, + { + name: "line_chart_comparison", + chartType: LineChart, + spec: map[string]any{ + "metrics_view": "mv", "time_range": timeRange, "comparison_time_range": comparisonTimeRange, "color": "primary", + "x": map[string]any{"field": "__time", "type": "temporal"}, + "y": map[string]any{"field": "bids", "type": "quantitative"}, + }, + }, + { + name: "area_chart", + chartType: AreaChart, + spec: map[string]any{ + "metrics_view": "mv", "time_range": map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-02-01T00:00:00Z", "time_zone": "America/New_York"}, + "color": map[string]any{"field": "app_or_site", "type": "nominal"}, + "x": map[string]any{"field": "__time", "type": "temporal", "showNull": true}, + "y": map[string]any{"field": "requests", "type": "quantitative"}, + }, + domain: map[string][]any{"app_or_site": {"app"}}, + }, + { + name: "stacked_bar_multi_measure", + chartType: StackedBar, + spec: map[string]any{ + "metrics_view": "mv", "time_range": timeRange, "time_grain": "TIME_GRAIN_WEEK", + "color": map[string]any{"field": "rill_measures", "type": "value"}, + "x": map[string]any{"field": "__time", "type": "temporal"}, + "y": map[string]any{"field": "clicks", "type": "quantitative", "fields": []any{"clicks", "impressions"}}, + }, + }, + { + name: "stacked_bar_normalized", + chartType: StackedBarNormalized, + spec: map[string]any{ + "metrics_view": "mv", "time_range": timeRange, + "color": map[string]any{"field": "username", "type": "nominal", "limit": 3}, + "x": map[string]any{"field": "date", "type": "temporal"}, + "y": map[string]any{"field": "commits", "type": "quantitative"}, + }, + domain: map[string][]any{"username": {"alice", "bob"}}, + }, + { + name: "donut_chart", + chartType: DonutChart, + spec: map[string]any{ + "metrics_view": "mv", "time_range": timeRange, + "color": map[string]any{"field": "username", "type": "nominal", "limit": 20}, + "measure": map[string]any{"field": "commits", "type": "quantitative", "showTotal": true}, + }, + domain: map[string][]any{"username": {"alice", "bob"}}, + }, + { + name: "pie_chart_custom_order", + chartType: PieChart, + spec: map[string]any{ + "metrics_view": "mv", "time_range": timeRange, + "color": map[string]any{"field": "weekday", "type": "nominal", "sort": []any{"Mon", "Tue", "Wed"}}, + "measure": map[string]any{"field": "commits", "type": "quantitative"}, + }, + domain: map[string][]any{"weekday": {"Mon", "Tue", "Wed"}}, + }, + { + name: "funnel_chart_dimension", + chartType: FunnelChart, + spec: map[string]any{ + "metrics_view": "mv", "time_range": timeRange, "breakdownMode": "dimension", "color": "stage", "mode": "width", + "measure": map[string]any{"field": "users", "type": "quantitative"}, + "stage": map[string]any{"field": "stage", "type": "nominal", "limit": 15}, + }, + domain: map[string][]any{"stage": {"visit", "signup"}}, + }, + { + name: "funnel_chart_measures", + chartType: FunnelChart, + spec: map[string]any{ + "metrics_view": "mv", "time_range": timeRange, "breakdownMode": "measures", "color": "name", + "measure": map[string]any{"field": "impressions", "type": "quantitative", "fields": []any{"impressions", "video_starts", "video_completes"}}, + }, + }, + { + name: "heatmap", + chartType: Heatmap, + spec: map[string]any{ + "metrics_view": "mv", "time_range": timeRange, + "color": map[string]any{"field": "bids", "type": "quantitative"}, + "x": map[string]any{"field": "day", "type": "nominal", "limit": 10, "sort": []any{"Mon", "Tue"}}, + "y": map[string]any{"field": "hour", "type": "nominal", "limit": 24, "sort": "-color"}, + }, + domain: map[string][]any{"day": {"Mon", "Tue"}, "hour": {"0", "1"}}, + }, + { + name: "heatmap_temporal", + chartType: Heatmap, + spec: map[string]any{ + "metrics_view": "mv", "time_range": timeRange, "time_grain": "TIME_GRAIN_DAY", + "color": map[string]any{"field": "bids", "type": "quantitative"}, + "x": map[string]any{"field": "__time", "type": "temporal"}, + "y": map[string]any{"field": "device", "type": "nominal", "limit": 100}, + }, + domain: map[string][]any{"device": {"iOS"}}, + }, + { + name: "combo_chart_temporal", + chartType: ComboChart, + spec: map[string]any{ + "metrics_view": "mv", "time_range": timeRange, "time_grain": "TIME_GRAIN_DAY", + "color": map[string]any{"field": "rill_measures", "type": "value"}, + "x": map[string]any{"field": "__time", "type": "temporal"}, + "y1": map[string]any{"field": "qps", "type": "quantitative", "mark": "bar"}, + "y2": map[string]any{"field": "requests", "type": "quantitative", "mark": "line"}, + }, + }, + { + name: "combo_chart_nominal", + chartType: ComboChart, + spec: map[string]any{ + "metrics_view": "mv", "time_range": timeRange, + "x": map[string]any{"field": "country", "type": "nominal", "limit": 5}, + "y1": map[string]any{"field": "qps", "type": "quantitative", "mark": "bar"}, + "y2": map[string]any{"field": "requests", "type": "quantitative", "mark": "line"}, + }, + domain: map[string][]any{"country": {"US", "CA"}}, + }, + { + name: "scatter_plot", + chartType: ScatterPlot, + spec: map[string]any{ + "metrics_view": "mv", "time_range": timeRange, + "x": map[string]any{"field": "bids", "type": "quantitative"}, + "y": map[string]any{"field": "clicks", "type": "quantitative"}, + "dimension": map[string]any{"field": "advertiser", "type": "nominal", "limit": 10}, + "size": map[string]any{"field": "impressions", "type": "quantitative"}, + "color": map[string]any{"field": "device", "type": "nominal", "limit": 5}, + }, + domain: map[string][]any{"device": {"iOS", "Android"}}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + spec, err := ParseSpec(tc.chartType, tc.spec) + require.NoError(t, err) + plan, err := Build(spec) + require.NoError(t, err) + + got := struct { + TimeGrain string `json:"time_grain,omitempty"` + Horizontal bool `json:"horizontal,omitempty"` + RowLimit int `json:"row_limit"` + TopN []TopNQuery `json:"top_n,omitempty"` + Main *metricsview.Query `json:"main"` + Extra map[string]*metricsview.Query `json:"extra,omitempty"` + Measures []string `json:"measure_domain,omitempty"` + }{ + TimeGrain: spec.TimeGrainName(), + Horizontal: spec.Horizontal, + RowLimit: plan.RowLimit, + TopN: plan.TopN, + Main: plan.Main(tc.domain), + Extra: plan.Extra, + Measures: plan.MeasureDomain, + } + gotJSON, err := json.MarshalIndent(got, "", " ") + require.NoError(t, err) + gotJSON = append(gotJSON, '\n') + + path := filepath.Join("testdata", tc.name+".json") + if *update { + require.NoError(t, os.WriteFile(path, gotJSON, 0o644)) + } + want, err := os.ReadFile(path) + require.NoError(t, err, "missing golden file; run with -update") + require.JSONEq(t, string(want), string(gotJSON)) + }) + } +} + +func TestParseSpecErrors(t *testing.T) { + timeRange := map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-02-01T00:00:00Z"} + + _, err := ParseSpec("sparkline", map[string]any{"metrics_view": "mv", "time_range": timeRange}) + require.ErrorContains(t, err, "unsupported chart type") + + _, err = ParseSpec(BarChart, map[string]any{"time_range": timeRange}) + require.ErrorContains(t, err, "metrics_view") + + _, err = ParseSpec(BarChart, map[string]any{"metrics_view": "mv"}) + require.ErrorContains(t, err, "time_range") + + _, err = ParseSpec(LineChart, map[string]any{ + "metrics_view": "mv", "time_range": timeRange, "time_grain": "TIME_GRAIN_FORTNIGHT", + "x": map[string]any{"field": "__time", "type": "temporal"}, + "y": map[string]any{"field": "bids", "type": "quantitative"}, + }) + require.ErrorContains(t, err, "invalid time_grain") + + // A grain without a temporal field is ignored rather than reported. + spec, err := ParseSpec(BarChart, map[string]any{ + "metrics_view": "mv", "time_range": timeRange, "time_grain": "TIME_GRAIN_MONTH", + "x": map[string]any{"field": "advertiser", "type": "nominal"}, + "y": map[string]any{"field": "bids", "type": "quantitative"}, + }) + require.NoError(t, err) + require.Empty(t, spec.TimeGrainName()) + + // Build rejects specs that lack the fields the chart type needs. + spec, err = ParseSpec(DonutChart, map[string]any{"metrics_view": "mv", "time_range": timeRange}) + require.NoError(t, err) + _, err = Build(spec) + require.ErrorContains(t, err, "measure") +} + +func TestSpecFields(t *testing.T) { + spec, err := ParseSpec(StackedBar, map[string]any{ + "metrics_view": "mv", "time_range": map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-02-01T00:00:00Z"}, + "color": map[string]any{"field": "rill_measures", "type": "value"}, + "x": map[string]any{"field": "__time", "type": "temporal"}, + "y": map[string]any{"field": "clicks", "type": "quantitative", "fields": []any{"clicks", "impressions"}}, + }) + require.NoError(t, err) + require.Equal(t, []string{"__time", "clicks", "impressions"}, spec.Fields()) + require.Equal(t, "TIME_GRAIN_DAY", spec.TimeGrainName()) +} diff --git a/runtime/ai/chartquery/execute.go b/runtime/ai/chartquery/execute.go new file mode 100644 index 000000000000..8bac69a6d6c7 --- /dev/null +++ b/runtime/ai/chartquery/execute.go @@ -0,0 +1,242 @@ +package chartquery + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + "time" + + "github.com/rilldata/rill/runtime/metricsview" +) + +// Runner runs a metrics view query. It is implemented by the AI tools on top of the runtime's metrics resolver. +type Runner interface { + Query(ctx context.Context, q *metricsview.Query) (*Result, error) +} + +// Result is the outcome of one query. +type Result struct { + // Rows are the result rows keyed by column name. + Rows []map[string]any + // Types maps column names to their type code without the CODE_ prefix (e.g. STRING, INT64, TIMESTAMP). + Types map[string]string + // TimeRange and ComparisonTimeRange are the time ranges the query resolved to. + TimeRange *TimeRange + ComparisonTimeRange *TimeRange +} + +// Output is the chart-ready data produced by Execute. +type Output struct { + // TimeGrain is the TIME_GRAIN_* name used to floor temporal fields, or empty when none is used. + TimeGrain string + // Data are the rows of the main query keyed by field name. + // Comparison values are under _prev and temporal values are RFC3339 UTC strings of the bucket start. + Data []map[string]any + // DomainValues lists the ordered values selected for each nominal axis, split, stage or heatmap axis, + // and the measure names for a "rill_measures" color split. + DomainValues map[string][]any + // Limits is the effective top-N limit of each spec channel (x, y, color or stage, as named in the input spec) + // after defaults and caps, so that a renderer can show the limit that was applied. + Limits map[string]int + // Totals holds the total of each measure for donut and pie charts. + Totals map[string]any + // Types maps the columns of the main query to their type codes. + Types map[string]string + ResolvedTimeRange *TimeRange + ResolvedComparisonTimeRange *TimeRange + // Truncated reports whether any budget cut the data; TruncationWarning explains what was cut. + // RowsTruncated reports whether the rows of the main query were cut (by the row cap or the byte budget) as opposed to a top-N cut; + // a temporal chart then keeps its newest buckets, so its time axis is incomplete at the start. + Truncated bool + RowsTruncated bool + TruncationWarning string +} + +// Execute runs the plan's queries in order and assembles the chart data within the budgets. +func Execute(ctx context.Context, plan *Plan, runner Runner) (*Output, error) { + out := &Output{ + TimeGrain: plan.Spec.TimeGrainName(), + DomainValues: make(map[string][]any), + Limits: make(map[string]int, len(plan.TopN)), + } + var warnings []string + + // Select the values of each top-N dimension. The spec may also pin the values explicitly. + domain := make(map[string][]any) + for _, f := range plan.Spec.fieldsWithSortValues() { + values := make([]any, len(f.SortValues)) + for i, v := range f.SortValues { + values[i] = v + } + domain[f.Field] = values + out.DomainValues[f.Field] = values + } + for _, topN := range plan.TopN { + out.Limits[topN.Channel] = topN.Limit + res, err := runner.Query(ctx, topN.Query) + if err != nil { + return nil, fmt.Errorf("top-N query for %q failed: %w", topN.Field, err) + } + rows := res.Rows + if len(rows) > topN.Limit { + rows = rows[:topN.Limit] + out.Truncated = true + warnings = append(warnings, fmt.Sprintf("only the top %d values of %q are shown", topN.Limit, topN.Field)) + } + values := make([]any, len(rows)) + for i, row := range rows { + values[i] = normalizeValue(row[topN.Field]) + } + domain[topN.Field] = values + out.DomainValues[topN.Field] = values + } + if plan.Spec.Color != nil && len(plan.MeasureDomain) > 0 { + values := make([]any, len(plan.MeasureDomain)) + for i, m := range plan.MeasureDomain { + values[i] = m + } + out.DomainValues[plan.Spec.Color.Field] = values + } + + // Run the main query. Temporal charts are queried newest first (see Plan.TimeField), so the row cuts below keep the most recent rows. + res, err := runner.Query(ctx, plan.Main(domain)) + if err != nil { + return nil, fmt.Errorf("chart query failed: %w", err) + } + rows := res.Rows + if len(rows) > plan.RowLimit { + rows = rows[:plan.RowLimit] + out.RowsTruncated = true + } + for _, row := range rows { + for k, v := range row { + row[k] = normalizeValue(v) + } + } + out.Data = rows + out.Types = res.Types + out.ResolvedTimeRange = res.TimeRange + out.ResolvedComparisonTimeRange = res.ComparisonTimeRange + if out.ResolvedTimeRange != nil && plan.Spec.TimeRange != nil { + out.ResolvedTimeRange.TimeZone = plan.Spec.TimeRange.TimeZone + } + + // Trim rows from the tail until the data fits the byte budget. + data, err := json.Marshal(out.Data) + if err != nil { + return nil, fmt.Errorf("failed to marshal chart data: %w", err) + } + overBudget := len(data) > MaxDataBytes + for len(data) > MaxDataBytes && len(out.Data) > 1 { + keep := len(out.Data) * MaxDataBytes / len(data) + if keep >= len(out.Data) { + keep = len(out.Data) - 1 + } + out.Data = out.Data[:max(keep, 1)] + data, err = json.Marshal(out.Data) + if err != nil { + return nil, fmt.Errorf("failed to marshal chart data: %w", err) + } + out.RowsTruncated = true + } + + // The rows of a temporal chart arrive newest first. When rows were cut, the oldest bucket may be partial + // (a time series with a color split has several rows per bucket), so it is dropped unless it is the only one. + // The rows are then returned in ascending time order, keeping the order within each bucket. + var periods int + if plan.TimeField != "" { + buckets := timeBuckets(out.Data, plan.TimeField) + if out.RowsTruncated && len(buckets) > 1 { + buckets = buckets[:len(buckets)-1] + } + slices.Reverse(buckets) + out.Data = slices.Concat(buckets...) + periods = len(buckets) + } + if out.RowsTruncated { + out.Truncated = true + warning := fmt.Sprintf("the data was limited to %d rows", len(out.Data)) + if plan.TimeField != "" { + warning = fmt.Sprintf("only the most recent %d periods (%d rows) are shown", periods, len(out.Data)) + } + if overBudget { + warning += " to fit the response size budget" + } + warnings = append(warnings, warning) + } + + // Data is never nil, so that the result carries data: [] when there are no rows and a renderer can tell an empty chart from missing data. + if out.Data == nil { + out.Data = []map[string]any{} + } + + // Run the extra queries (totals). + for name, q := range plan.Extra { + res, err := runner.Query(ctx, q) + if err != nil { + return nil, fmt.Errorf("%s query failed: %w", name, err) + } + if len(res.Rows) == 0 { + continue + } + if out.Totals == nil { + out.Totals = make(map[string]any) + } + for _, m := range q.Measures { + out.Totals[m.Name] = normalizeValue(res.Rows[0][m.Name]) + } + } + + if out.Truncated { + remedy := "lower the field limits or add filters" + if out.TimeGrain != "" { + remedy = "use a coarser time_grain, lower the field limits, or add filters" + } + out.TruncationWarning = fmt.Sprintf("The chart data is incomplete: %s. To see the complete picture, %s.", strings.Join(warnings, "; "), remedy) + } + return out, nil +} + +// timeBuckets splits rows that are ordered by a time field into runs of rows with the same time value. +func timeBuckets(rows []map[string]any, field string) [][]map[string]any { + var res [][]map[string]any + for i, row := range rows { + if i == 0 || row[field] != rows[i-1][field] { + res = append(res, nil) + } + res[len(res)-1] = append(res[len(res)-1], row) + } + return res +} + +// fieldsWithSortValues returns the fields whose values are pinned by the spec. +func (s *Spec) fieldsWithSortValues() []*Field { + var res []*Field + for _, f := range []*Field{s.X, s.Y, s.Color, s.Stage} { + if f != nil && f.Field != "" && len(f.SortValues) > 0 { + res = append(res, f) + } + } + return res +} + +// normalizeValue makes a value JSON-friendly for the widget: timestamps become RFC3339 UTC strings. +func normalizeValue(v any) any { + switch t := v.(type) { + case time.Time: + t = t.UTC() + if t.Nanosecond() != 0 { + return t.Format(time.RFC3339Nano) + } + return t.Format(time.RFC3339) + case *time.Time: + if t == nil { + return nil + } + return normalizeValue(*t) + default: + return v + } +} diff --git a/runtime/ai/chartquery/execute_test.go b/runtime/ai/chartquery/execute_test.go new file mode 100644 index 000000000000..01c870545e4a --- /dev/null +++ b/runtime/ai/chartquery/execute_test.go @@ -0,0 +1,288 @@ +package chartquery + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/rilldata/rill/runtime/metricsview" + "github.com/stretchr/testify/require" +) + +// fakeRunner answers queries from a function and records the queries it received. +type fakeRunner struct { + queries []*metricsview.Query + answer func(q *metricsview.Query) *Result +} + +func (r *fakeRunner) Query(ctx context.Context, q *metricsview.Query) (*Result, error) { + r.queries = append(r.queries, q) + return r.answer(q), nil +} + +func rows(field string, n int) []map[string]any { + res := make([]map[string]any, n) + for i := range res { + res[i] = map[string]any{field: fmt.Sprintf("v%d", i), "bids": float64(100 - i)} + } + return res +} + +func TestExecuteCartesian(t *testing.T) { + spec, err := ParseSpec(BarChart, map[string]any{ + "metrics_view": "mv", "time_range": map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-02-01T00:00:00Z"}, + "comparison_time_range": map[string]any{"start": "2023-12-01T00:00:00Z", "end": "2024-01-01T00:00:00Z"}, + "color": map[string]any{"field": "device", "type": "nominal", "limit": 2}, + "x": map[string]any{"field": "advertiser", "type": "nominal", "limit": 3, "sort": "-y"}, + "y": map[string]any{"field": "bids", "type": "quantitative"}, + }) + require.NoError(t, err) + plan, err := Build(spec) + require.NoError(t, err) + + runner := &fakeRunner{answer: func(q *metricsview.Query) *Result { + switch { + case len(q.Dimensions) == 1 && q.Dimensions[0].Name == "advertiser": + // One more row than the limit: the x axis is truncated. + return &Result{Rows: rows("advertiser", 4)} + case len(q.Dimensions) == 1 && q.Dimensions[0].Name == "device": + return &Result{Rows: rows("device", 2)} + default: + return &Result{ + Rows: []map[string]any{ + {"advertiser": "v0", "device": "v0", "bids": float64(10), "bids_prev": float64(8)}, + {"advertiser": "v0", "device": "v1", "bids": float64(5), "bids_prev": nil}, + }, + Types: map[string]string{"advertiser": "STRING", "device": "STRING", "bids": "INT64", "bids_prev": "INT64"}, + TimeRange: &TimeRange{Start: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), End: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC)}, + ComparisonTimeRange: &TimeRange{Start: time.Date(2023, 12, 1, 0, 0, 0, 0, time.UTC), End: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}, + } + } + }} + + out, err := Execute(t.Context(), plan, runner) + require.NoError(t, err) + + // Two top-N queries then the main query, which filters to the selected values. + require.Len(t, runner.queries, 3) + main := runner.queries[2] + require.Equal(t, []any{"v0", "v1", "v2"}, out.DomainValues["advertiser"]) + require.Equal(t, []any{"v0", "v1"}, out.DomainValues["device"]) + mainJSON, err := json.Marshal(main.Where) + require.NoError(t, err) + require.Contains(t, string(mainJSON), `{"name":"advertiser"},{"val":["v0","v1","v2"]}`) + require.Contains(t, string(mainJSON), `{"name":"device"},{"val":["v0","v1"]}`) + require.Len(t, main.Measures, 2) + require.Equal(t, "bids_prev", main.Measures[1].Name) + + require.Len(t, out.Data, 2) + require.Equal(t, "INT64", out.Types["bids"]) + require.Equal(t, map[string]int{"x": 3, "color": 2}, out.Limits) + require.True(t, out.Truncated) + require.False(t, out.RowsTruncated, "only a top-N was cut") + require.Contains(t, out.TruncationWarning, `only the top 3 values of "advertiser" are shown`) + require.NotContains(t, out.TruncationWarning, "rows") + require.Equal(t, "2024-01-01T00:00:00Z", out.ResolvedTimeRange.Start.Format(time.RFC3339)) + require.NotNil(t, out.ResolvedComparisonTimeRange) + require.Empty(t, out.TimeGrain) +} + +func TestExecuteTemporalAndRowLimit(t *testing.T) { + spec, err := ParseSpec(LineChart, map[string]any{ + "metrics_view": "mv", "time_range": map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-02-01T00:00:00Z", "time_zone": "America/New_York"}, + "time_grain": "TIME_GRAIN_HOUR", "color": "primary", + "x": map[string]any{"field": "__time", "type": "temporal"}, + "y": map[string]any{"field": "bids", "type": "quantitative"}, + }) + require.NoError(t, err) + plan, err := Build(spec) + require.NoError(t, err) + + // The query asks for the rows newest first (and one more than the cap), so the fake returns hours MaxRows down to 0. + start := time.Date(2024, 1, 1, 0, 0, 0, 0, time.FixedZone("EST", -5*3600)) + runner := &fakeRunner{answer: func(q *metricsview.Query) *Result { + res := &Result{TimeRange: &TimeRange{Start: start, End: start.AddDate(0, 1, 0)}} + for i := MaxRows; i >= 0; i-- { + res.Rows = append(res.Rows, map[string]any{"__time": start.Add(time.Duration(i) * time.Hour), "bids": float64(i)}) + } + return res + }} + + out, err := Execute(t.Context(), plan, runner) + require.NoError(t, err) + require.Len(t, runner.queries, 1) + require.Equal(t, "America/New_York", runner.queries[0].TimeZone) + require.Equal(t, metricsview.TimeGrainHour, runner.queries[0].Dimensions[0].Compute.TimeFloor.Grain) + require.Equal(t, []metricsview.Sort{{Name: "__time", Desc: true}}, runner.queries[0].Sort, "temporal charts are queried newest first") + + // The cap drops the oldest row (hour 0), then the oldest remaining bucket (hour 1) is dropped as possibly partial, + // and the rows come back in ascending order: hours 2 to MaxRows. + require.Equal(t, "TIME_GRAIN_HOUR", out.TimeGrain) + require.Len(t, out.Data, MaxRows-1) + require.Equal(t, "2024-01-01T07:00:00Z", out.Data[0]["__time"], "temporal values are RFC3339 in UTC") + require.Equal(t, start.Add(MaxRows*time.Hour).UTC().Format(time.RFC3339), out.Data[len(out.Data)-1]["__time"], "the newest row survives") + require.True(t, out.Truncated) + require.True(t, out.RowsTruncated) + require.Contains(t, out.TruncationWarning, "only the most recent 1499 periods (1499 rows) are shown") + require.Contains(t, out.TruncationWarning, "use a coarser time_grain") + require.Equal(t, "America/New_York", out.ResolvedTimeRange.TimeZone) + require.Empty(t, out.Limits) +} + +// TestExecuteTemporalKeepsNewestBuckets checks a row-truncated time series with a color split: the newest buckets survive, +// including a newest bucket that is genuinely partial, the oldest bucket (partial after the cut) is dropped whole, +// and the rows are returned in ascending time order with the order within each bucket kept. +func TestExecuteTemporalKeepsNewestBuckets(t *testing.T) { + spec, err := ParseSpec(LineChart, map[string]any{ + "metrics_view": "mv", "time_range": map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-04-01T00:00:00Z"}, + "time_grain": "TIME_GRAIN_HOUR", + "color": map[string]any{"field": "device", "type": "nominal", "sort": []any{"a", "b"}}, + "x": map[string]any{"field": "__time", "type": "temporal"}, + "y": map[string]any{"field": "bids", "type": "quantitative"}, + }) + require.NoError(t, err) + plan, err := Build(spec) + require.NoError(t, err) + + // Newest first: bucket 0 has only device "a", buckets 1 to 750 have both devices; MaxRows+1 rows in total. + newest := time.Date(2024, 3, 31, 23, 0, 0, 0, time.UTC) + bucket := func(k int) time.Time { return newest.Add(-time.Duration(k) * time.Hour) } + runner := &fakeRunner{answer: func(q *metricsview.Query) *Result { + res := &Result{} + res.Rows = append(res.Rows, map[string]any{"__time": bucket(0), "device": "a", "bids": float64(1)}) + for k := 1; k <= 750; k++ { + for _, d := range []string{"a", "b"} { + res.Rows = append(res.Rows, map[string]any{"__time": bucket(k), "device": d, "bids": float64(k)}) + } + } + require.Len(t, res.Rows, MaxRows+1) + return res + }} + + out, err := Execute(t.Context(), plan, runner) + require.NoError(t, err) + require.True(t, out.RowsTruncated) + + // The cap cuts bucket 750 to one row, so the whole bucket is dropped: buckets 749 down to 0 remain. + require.Len(t, out.Data, 1+749*2) + require.Equal(t, bucket(749).Format(time.RFC3339), out.Data[0]["__time"]) + require.Equal(t, "a", out.Data[0]["device"]) + require.Equal(t, "b", out.Data[1]["device"], "the order within a bucket is kept") + require.Equal(t, bucket(0).Format(time.RFC3339), out.Data[len(out.Data)-1]["__time"], "the partial newest bucket survives") + for _, row := range out.Data { + require.NotEqual(t, bucket(750).Format(time.RFC3339), row["__time"], "the partial oldest bucket is dropped whole") + } + require.Contains(t, out.TruncationWarning, "only the most recent 750 periods (1499 rows) are shown") +} + +func TestExecuteByteBudget(t *testing.T) { + spec, err := ParseSpec(LineChart, map[string]any{ + "metrics_view": "mv", "time_range": map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-02-01T00:00:00Z"}, "color": "primary", + "x": map[string]any{"field": "__time", "type": "temporal"}, + "y": map[string]any{"field": "bids", "type": "quantitative"}, + }) + require.NoError(t, err) + plan, err := Build(spec) + require.NoError(t, err) + + // 1000 rows of ~1 KiB each, newest first, exceed the 100 KiB budget without hitting the row limit. + runner := &fakeRunner{answer: func(q *metricsview.Query) *Result { + res := &Result{} + for i := 999; i >= 0; i-- { + res.Rows = append(res.Rows, map[string]any{"__time": fmt.Sprintf("2024-01-01T%03d", i), "bids": strings.Repeat("x", 1024)}) + } + return res + }} + + out, err := Execute(t.Context(), plan, runner) + require.NoError(t, err) + require.True(t, out.Truncated) + require.True(t, out.RowsTruncated) + require.Less(t, len(out.Data), 1000) + require.NotEmpty(t, out.Data) + data, err := json.Marshal(out.Data) + require.NoError(t, err) + require.LessOrEqual(t, len(data), MaxDataBytes) + require.Contains(t, out.TruncationWarning, fmt.Sprintf("only the most recent %d periods (%d rows) are shown to fit the response size budget", len(out.Data), len(out.Data))) + // The oldest rows are trimmed and the rest returned in ascending order, so the newest row is last. + require.Equal(t, "2024-01-01T999", out.Data[len(out.Data)-1]["__time"]) + require.Less(t, out.Data[0]["__time"].(string), out.Data[1]["__time"].(string)) +} + +func TestExecuteDonutTotals(t *testing.T) { + spec, err := ParseSpec(DonutChart, map[string]any{ + "metrics_view": "mv", "time_range": map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-02-01T00:00:00Z"}, + "color": map[string]any{"field": "username", "type": "nominal", "limit": 2}, + "measure": map[string]any{"field": "commits", "type": "quantitative"}, + }) + require.NoError(t, err) + plan, err := Build(spec) + require.NoError(t, err) + + runner := &fakeRunner{answer: func(q *metricsview.Query) *Result { + if len(q.Dimensions) == 0 { + return &Result{Rows: []map[string]any{{"commits": float64(42)}}} + } + return &Result{Rows: []map[string]any{ + {"username": "alice", "commits": float64(20)}, + {"username": "bob", "commits": float64(12)}, + }} + }} + + out, err := Execute(t.Context(), plan, runner) + require.NoError(t, err) + require.Len(t, runner.queries, 3) + require.Equal(t, map[string]any{"commits": float64(42)}, out.Totals) + require.Equal(t, []any{"alice", "bob"}, out.DomainValues["username"]) + require.Equal(t, map[string]int{"color": 2}, out.Limits) + require.False(t, out.Truncated) + require.Empty(t, out.TruncationWarning) +} + +func TestExecuteMeasureDomainAndPinnedValues(t *testing.T) { + spec, err := ParseSpec(StackedBar, map[string]any{ + "metrics_view": "mv", "time_range": map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-02-01T00:00:00Z"}, + "color": map[string]any{"field": "rill_measures", "type": "value"}, + "x": map[string]any{"field": "weekday", "type": "nominal", "sort": []any{"Mon", "Tue"}}, + "y": map[string]any{"field": "clicks", "type": "quantitative", "fields": []any{"clicks", "impressions"}}, + }) + require.NoError(t, err) + plan, err := Build(spec) + require.NoError(t, err) + require.Empty(t, plan.TopN, "pinned values replace the top-N query") + + runner := &fakeRunner{answer: func(q *metricsview.Query) *Result { return &Result{} }} + out, err := Execute(t.Context(), plan, runner) + require.NoError(t, err) + require.Len(t, runner.queries, 1) + whereJSON, err := json.Marshal(runner.queries[0].Where) + require.NoError(t, err) + require.Contains(t, string(whereJSON), `{"name":"weekday"},{"val":["Mon","Tue"]}`) + require.Equal(t, []any{"Mon", "Tue"}, out.DomainValues["weekday"]) + require.Equal(t, []any{"clicks", "impressions"}, out.DomainValues["rill_measures"]) + require.Empty(t, out.Limits, "pinned values apply no limit") + require.NotNil(t, out.Data, "no rows is an empty slice, not nil") + require.Empty(t, out.Data) +} + +// TestExecuteLimitsHorizontal checks that the limit of a horizontal bar chart is reported on the input's y channel, +// which holds the dimension, and that the default limit is reported when the spec sets none. +func TestExecuteLimitsHorizontal(t *testing.T) { + spec, err := ParseSpec(BarChart, map[string]any{ + "metrics_view": "mv", "time_range": map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-02-01T00:00:00Z"}, "color": "primary", + "x": map[string]any{"field": "bids", "type": "quantitative"}, + "y": map[string]any{"field": "advertiser", "type": "nominal", "sort": "-x"}, + }) + require.NoError(t, err) + require.True(t, spec.Horizontal) + plan, err := Build(spec) + require.NoError(t, err) + + runner := &fakeRunner{answer: func(q *metricsview.Query) *Result { return &Result{} }} + out, err := Execute(t.Context(), plan, runner) + require.NoError(t, err) + require.Equal(t, map[string]int{"y": defaultNominalLimit}, out.Limits) +} diff --git a/runtime/ai/chartquery/flint.go b/runtime/ai/chartquery/flint.go new file mode 100644 index 000000000000..c2bec22cb761 --- /dev/null +++ b/runtime/ai/chartquery/flint.go @@ -0,0 +1,515 @@ +package chartquery + +import ( + "encoding/json" + "fmt" + "maps" + "slices" + "strings" + "sync" + + "github.com/rilldata/rill/runtime/metricsview" + "github.com/rilldata/rill/runtime/pkg/mapstructureutil" + + _ "embed" +) + +// Budgets for the facet channels of a Flint chart. Axes take the budget of a nominal x axis and series channels +// that of a color split (see the constants in build.go); small multiples stay small. +const ( + defaultFacetLimit = 6 + // MaxFacetValues caps the values of a column or row facet channel. + MaxFacetValues = 8 +) + +// DetailChannel is a Rill extension to Flint's channels: a dimension bound to it sets the grain of the rows +// (one row per value) without being drawn, which charts of individual observations need on aggregated data. +// Chart types that have their own detail channel keep its meaning; for the others the widget drops it before compiling. +const DetailChannel = "detail" + +//go:embed flint_catalog.json +var flintCatalogJSON []byte + +// FlintCatalog lists the Flint chart types create_chart accepts, with the channels and properties of each. +// It is generated from the installed flint-chart package by web-mcp-app/scripts/generate-flint-catalog.mjs, +// so the server validates against the same compiler the widget renders with. +type FlintCatalog struct { + FlintVersion string `json:"flint_version"` + ChartTypes []*FlintChartType `json:"chart_types"` +} + +// FlintChartType is a chart type of the catalog. +type FlintChartType struct { + Name string `json:"name"` + Channels []string `json:"channels"` + Properties []*FlintProperty `json:"properties"` +} + +// FlintProperty is a tunable property of a chart type (a key of chartProperties in a chart spec). +type FlintProperty struct { + Key string `json:"key"` + Label string `json:"label"` + // Type is "continuous", "discrete" or "binary". + Type string `json:"type"` + Min *float64 `json:"min,omitempty"` + Max *float64 `json:"max,omitempty"` + Step *float64 `json:"step,omitempty"` + Options []*FlintPropertyOption `json:"options,omitempty"` + Default any `json:"default,omitempty"` +} + +// FlintPropertyOption is a value of a discrete property. +type FlintPropertyOption struct { + Value any `json:"value"` + Label string `json:"label"` +} + +var flintCatalog = sync.OnceValue(func() *FlintCatalog { + c := &FlintCatalog{} + if err := json.Unmarshal(flintCatalogJSON, c); err != nil { + panic(fmt.Errorf("chartquery: invalid flint_catalog.json: %w", err)) + } + return c +}) + +// Catalog returns the embedded Flint chart catalog. +func Catalog() *FlintCatalog { + return flintCatalog() +} + +// ChartType returns the catalog entry of a chart type, or nil when the name is unknown. +func (c *FlintCatalog) ChartType(name string) *FlintChartType { + for _, ct := range c.ChartTypes { + if ct.Name == name { + return ct + } + } + return nil +} + +// Names returns the chart type names in catalog order. +func (c *FlintCatalog) Names() []string { + res := make([]string, len(c.ChartTypes)) + for i, ct := range c.ChartTypes { + res[i] = ct.Name + } + return res +} + +// AcceptsChannel reports whether a chart spec may bind the channel: one of the chart type's own channels, or the detail extension. +func (ct *FlintChartType) AcceptsChannel(channel string) bool { + return channel == DetailChannel || slices.Contains(ct.Channels, channel) +} + +// FieldKind is the role of a metrics view field in a chart: a measure, a dimension, or a time dimension. +type FieldKind string + +const ( + FieldKindMeasure FieldKind = "measure" + FieldKindDimension FieldKind = "dimension" + FieldKindTime FieldKind = "time" +) + +// FlintSpec is a parsed Flint chart spec (chartType and encodings) bound to a metrics view through the data block of create_chart. +// Unlike Spec, it is not normalized per chart type: the queries follow from the kinds of the bound fields alone. +type FlintSpec struct { + ChartType string + MetricsView string + TimeRange *TimeRange + ComparisonTimeRange *TimeRange + Where *metricsview.Expression + // TimeGrain floors the bound time fields. It is set only when one is bound, defaulting to day. + TimeGrain metricsview.TimeGrain + // Encodings are the channel bindings in the channel order of the chart type (the detail extension last); + // a channel bound to an array of fields has one entry per field. + Encodings []*FlintEncoding +} + +// FlintEncoding is one field bound to a channel. +type FlintEncoding struct { + Channel string + Field string + Kind FieldKind + // Limit is the top-N limit the spec asks for on a dimension channel, or 0 for the default. + Limit int + // SortOrder is "ascending", "descending" or empty; SortBy names the channel or the field the values are sorted by. + SortOrder string + SortBy string +} + +// rawFlintData is the wire shape of the data block of a Flint chart call. +type rawFlintData struct { + MetricsView string `mapstructure:"metrics_view"` + TimeRange *rawTimeRange `mapstructure:"time_range"` + ComparisonTimeRange *rawTimeRange `mapstructure:"comparison_time_range"` + Where *metricsview.Expression `mapstructure:"where"` + TimeGrain string `mapstructure:"time_grain"` +} + +// rawFlintEncoding is the wire shape of an encoding object: Flint's own keys plus Rill's limit. +type rawFlintEncoding struct { + Field string `mapstructure:"field"` + Type string `mapstructure:"type"` + Aggregate string `mapstructure:"aggregate"` + SortOrder string `mapstructure:"sortOrder"` + SortBy string `mapstructure:"sortBy"` + Scheme string `mapstructure:"scheme"` + Limit int `mapstructure:"limit"` +} + +// flintEncodingKeys are the keys an encoding object may carry. +var flintEncodingKeys = []string{"field", "type", "aggregate", "sortOrder", "sortBy", "scheme", "limit"} + +// ParseFlintSpec parses the chart_spec and data arguments of a Flint chart call. +// kinds classifies the metrics view's fields; a field the chart binds must be in it. +// It validates the chart type and channels against the catalog, and what the queries need from the data block. +func ParseFlintSpec(chartSpec, data map[string]any, kinds map[string]FieldKind) (*FlintSpec, error) { + chartType, _ := chartSpec["chartType"].(string) + ct := Catalog().ChartType(chartType) + if ct == nil { + return nil, fmt.Errorf("unknown chart type %q; the chart types are: %s", chartType, strings.Join(Catalog().Names(), ", ")) + } + + raw := &rawFlintData{} + if err := mapstructureutil.WeakDecode(data, raw); err != nil { + return nil, fmt.Errorf("invalid data: %w", err) + } + if raw.MetricsView == "" { + return nil, fmt.Errorf("data must contain a 'metrics_view' field") + } + if raw.TimeRange == nil || raw.TimeRange.Start.IsZero() || raw.TimeRange.End.IsZero() { + return nil, fmt.Errorf("data must contain a 'time_range' field with 'start' and 'end' properties") + } + + encodings, ok := chartSpec["encodings"].(map[string]any) + if !ok || len(encodings) == 0 { + return nil, fmt.Errorf("chart_spec must contain an 'encodings' object that binds fields to channels") + } + for _, channel := range slices.Sorted(maps.Keys(encodings)) { + if !ct.AcceptsChannel(channel) { + return nil, fmt.Errorf("chart type %q does not accept channel %q; its channels are: %s", chartType, channel, strings.Join(ct.Channels, ", ")) + } + } + + res := &FlintSpec{ + ChartType: chartType, + MetricsView: raw.MetricsView, + TimeRange: raw.TimeRange.toTimeRange(), + Where: raw.Where, + } + if raw.ComparisonTimeRange != nil && !raw.ComparisonTimeRange.Start.IsZero() && !raw.ComparisonTimeRange.End.IsZero() { + res.ComparisonTimeRange = raw.ComparisonTimeRange.toTimeRange() + } + + // Channels are visited in the order the chart type declares them, which keeps the plan stable regardless of the input's key order. + channels := ct.Channels + if !slices.Contains(channels, DetailChannel) { + channels = append(slices.Clone(channels), DetailChannel) + } + for _, channel := range channels { + value, ok := encodings[channel] + if !ok { + continue + } + entries, err := parseFlintEncoding(channel, value) + if err != nil { + return nil, err + } + for _, e := range entries { + kind, ok := kinds[e.Field] + if !ok { + return nil, fmt.Errorf("field %q on channel %q not found in metrics view %q", e.Field, channel, raw.MetricsView) + } + e.Kind = kind + res.Encodings = append(res.Encodings, e) + } + } + if len(res.Encodings) == 0 { + return nil, fmt.Errorf("chart_spec must bind at least one field") + } + + // The grain only matters when a time field is bound, in which case it defaults to day. + if res.TimeField() != "" { + grain, err := parseTimeGrain(raw.TimeGrain) + if err != nil { + return nil, err + } + if grain == metricsview.TimeGrainUnspecified { + grain = metricsview.TimeGrainDay + } + res.TimeGrain = grain + } + + return res, nil +} + +// parseFlintEncoding parses the value of a channel: a field name, an encoding object, or an array of either. +func parseFlintEncoding(channel string, value any) ([]*FlintEncoding, error) { + switch v := value.(type) { + case string: + if v == "" { + return nil, fmt.Errorf("channel %q binds an empty field name", channel) + } + return []*FlintEncoding{{Channel: channel, Field: v}}, nil + case map[string]any: + for _, key := range slices.Sorted(maps.Keys(v)) { + if !slices.Contains(flintEncodingKeys, key) { + return nil, fmt.Errorf("channel %q has an unknown encoding key %q; the keys are: %s", channel, key, strings.Join(flintEncodingKeys, ", ")) + } + } + raw := &rawFlintEncoding{} + if err := mapstructureutil.WeakDecode(v, raw); err != nil { + return nil, fmt.Errorf("channel %q has an invalid encoding: %w", channel, err) + } + if raw.Field == "" { + return nil, fmt.Errorf("channel %q must name a field", channel) + } + switch raw.SortOrder { + case "", "ascending", "descending": + default: + return nil, fmt.Errorf("channel %q has an invalid sortOrder %q; use ascending or descending", channel, raw.SortOrder) + } + return []*FlintEncoding{{Channel: channel, Field: raw.Field, Limit: raw.Limit, SortOrder: raw.SortOrder, SortBy: raw.SortBy}}, nil + case []any: + if len(v) == 0 { + return nil, fmt.Errorf("channel %q binds an empty array", channel) + } + var res []*FlintEncoding + for _, item := range v { + entries, err := parseFlintEncoding(channel, item) + if err != nil { + return nil, err + } + res = append(res, entries...) + } + return res, nil + default: + return nil, fmt.Errorf("channel %q must bind a field name, an encoding object, or an array of them, got %T", channel, value) + } +} + +// BuildFlint plans the queries for a Flint chart spec from the kinds of its bound fields: +// a top-N query for each dimension on an axis, series or facet channel, and a main query that groups by every bound dimension +// (time fields floored to the grain), selects every bound measure (with its comparison value when a comparison is set), +// filters to the top-N values and keeps the row cap. +func BuildFlint(s *FlintSpec) (*Plan, error) { + measures := s.Measures() + if len(measures) == 0 { + return nil, fmt.Errorf("%s needs at least one measure bound to a channel", s.ChartType) + } + dims := s.Dimensions() + timeFields := s.timeFields() + + plan := &Plan{Spec: s.executeSpec(), RowLimit: MaxRows} + if len(timeFields) > 0 { + plan.TimeField = timeFields[0] + } + + // One top-N query per dimension channel with a budget; a dimension bound twice is selected on its first channel. + topN := make(map[string]bool) + for _, e := range dims { + def, maximum, ok := flintChannelBudget(e.Channel) + if !ok || topN[e.Field] { + continue + } + topN[e.Field] = true + limit := capLimit(e.Limit, def, maximum) + plan.TopN = append(plan.TopN, TopNQuery{ + Channel: e.Channel, + Field: e.Field, + Limit: limit, + Query: &metricsview.Query{ + MetricsView: s.MetricsView, + Dimensions: []metricsview.Dimension{{Name: e.Field}}, + Measures: namedMeasures(measures), + Sort: []metricsview.Sort{s.topNSort(e, measures[0])}, + Where: and(s.Where, nullExclusion(nominalField(e.Field))), + TimeRange: s.TimeRange.toQuery(), + TimeZone: s.TimeRange.TimeZone, + Limit: limitPtr(limit + 1), + }, + }) + } + + // Temporal charts are queried newest first, so that the row cuts in Execute keep the most recent rows. + plan.Main = func(domain map[string][]any) *metricsview.Query { + q := &metricsview.Query{ + MetricsView: s.MetricsView, + Measures: namedMeasures(measures), + Where: s.Where, + TimeRange: s.TimeRange.toQuery(), + ComparisonTimeRange: s.ComparisonTimeRange.toQuery(), + TimeZone: s.TimeRange.TimeZone, + Limit: limitPtr(plan.RowLimit + 1), + } + if s.ComparisonTimeRange != nil { + q.Measures = withPrevMeasures(measures) + } + for _, name := range timeFields { + q.Dimensions = append(q.Dimensions, dimensionFor(temporalField(name), s.TimeGrain)) + } + seen := make(map[string]bool) + for _, e := range dims { + if seen[e.Field] { + continue + } + seen[e.Field] = true + q.Dimensions = append(q.Dimensions, metricsview.Dimension{Name: e.Field}) + if topN[e.Field] { + q.Where = and(q.Where, nullExclusion(nominalField(e.Field)), inFilter(nominalField(e.Field), domain)) + } + } + if plan.TimeField != "" { + q.Sort = []metricsview.Sort{{Name: plan.TimeField, Desc: true}} + } else { + q.Sort = []metricsview.Sort{{Name: measures[0], Desc: true}} + } + return q + } + + return plan, nil +} + +// flintChannelBudget returns the default and maximum top-N limit of a dimension channel, and whether the channel has a budget at all. +// Axes take the budget of a nominal x axis, series channels that of a color split, facets stay small; +// detail and the remaining channels are bounded by the row cap instead. +func flintChannelBudget(channel string) (def, maximum int, ok bool) { + switch channel { + case "x", "y": + return defaultNominalLimit, MaxNominalValues, true + case "color", "group", "shape", "strokeDash": + return defaultColorLimit, MaxColorValues, true + case "column", "row": + return defaultFacetLimit, MaxFacetValues, true + default: + return 0, 0, false + } +} + +// topNSort resolves the sort of a dimension's top-N query: sortBy names a channel or a field, defaulting to the first measure; +// a sort by the dimension itself orders its values, anything else orders by that measure. sortOrder flips the direction, +// which defaults to descending for measures and ascending for the dimension's own values. +func (s *FlintSpec) topNSort(e *FlintEncoding, firstMeasure string) metricsview.Sort { + target := firstMeasure + if e.SortBy != "" { + if f := s.FieldOn(e.SortBy); f != nil { + target = f.Field + } else if s.fieldNamed(e.SortBy) != nil { + target = e.SortBy + } + } + sort := metricsview.Sort{Name: target, Desc: true} + if target == e.Field { + sort.Desc = false + } else if s.fieldNamed(target).Kind != FieldKindMeasure { + // Sorting by another dimension is not expressible in a single-dimension query; fall back to the measure. + sort = metricsview.Sort{Name: firstMeasure, Desc: true} + } + switch e.SortOrder { + case "ascending": + sort.Desc = false + case "descending": + sort.Desc = true + } + return sort +} + +// FieldOn returns the first field bound to a channel, or nil. +func (s *FlintSpec) FieldOn(channel string) *FlintEncoding { + for _, e := range s.Encodings { + if e.Channel == channel { + return e + } + } + return nil +} + +// fieldNamed returns the first binding of a field, or nil. +func (s *FlintSpec) fieldNamed(name string) *FlintEncoding { + for _, e := range s.Encodings { + if e.Field == name { + return e + } + } + return nil +} + +// Fields returns the names of the metrics view fields the chart binds, in channel order and without duplicates. +func (s *FlintSpec) Fields() []string { + var res []string + seen := make(map[string]bool) + for _, e := range s.Encodings { + if !seen[e.Field] { + seen[e.Field] = true + res = append(res, e.Field) + } + } + return res +} + +// Measures returns the bound measures in channel order, without duplicates. +func (s *FlintSpec) Measures() []string { + var res []string + seen := make(map[string]bool) + for _, e := range s.Encodings { + if e.Kind == FieldKindMeasure && !seen[e.Field] { + seen[e.Field] = true + res = append(res, e.Field) + } + } + return res +} + +// Dimensions returns the bindings of dimensions other than time fields, in channel order. A dimension bound to several channels appears once per channel. +func (s *FlintSpec) Dimensions() []*FlintEncoding { + var res []*FlintEncoding + for _, e := range s.Encodings { + if e.Kind == FieldKindDimension { + res = append(res, e) + } + } + return res +} + +// TimeField returns the first bound time field, or an empty string. +func (s *FlintSpec) TimeField() string { + if fields := s.timeFields(); len(fields) > 0 { + return fields[0] + } + return "" +} + +func (s *FlintSpec) timeFields() []string { + var res []string + for _, e := range s.Encodings { + if e.Kind == FieldKindTime && !slices.Contains(res, e.Field) { + res = append(res, e.Field) + } + } + return res +} + +// TimeGrainName returns the TIME_GRAIN_* name of the resolved grain, or an empty string when no time field is bound. +func (s *FlintSpec) TimeGrainName() string { + return s.executeSpec().TimeGrainName() +} + +// executeSpec is the Spec view of a Flint spec that Execute reads: the time grain and the query scope; it binds no fields. +func (s *FlintSpec) executeSpec() *Spec { + return &Spec{ + ChartType: s.ChartType, + MetricsView: s.MetricsView, + TimeRange: s.TimeRange, + ComparisonTimeRange: s.ComparisonTimeRange, + Where: s.Where, + TimeGrain: s.TimeGrain, + } +} + +func nominalField(name string) *Field { + return &Field{Field: name, Type: TypeNominal} +} + +func temporalField(name string) *Field { + return &Field{Field: name, Type: TypeTemporal} +} diff --git a/runtime/ai/chartquery/flint_catalog.json b/runtime/ai/chartquery/flint_catalog.json new file mode 100644 index 000000000000..a5c6452e3a46 --- /dev/null +++ b/runtime/ai/chartquery/flint_catalog.json @@ -0,0 +1,1707 @@ +{ + "flint_version": "0.4.1", + "chart_types": [ + { + "name": "Scatter Plot", + "channels": [ + "x", + "y", + "color", + "size", + "shape", + "opacity", + "column", + "row" + ], + "properties": [ + { + "key": "opacity", + "label": "Opacity", + "type": "continuous", + "min": 0.1, + "max": 1, + "step": 0.1, + "default": 1 + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + }, + { + "key": "logScale_x", + "label": "Log X", + "type": "binary", + "default": false + }, + { + "key": "logScale_y", + "label": "Log Y", + "type": "binary", + "default": false + }, + { + "key": "includeZero_x", + "label": "Zero X", + "type": "binary" + }, + { + "key": "includeZero_y", + "label": "Zero Y", + "type": "binary" + } + ] + }, + { + "name": "Regression", + "channels": [ + "x", + "y", + "size", + "color", + "column", + "row" + ], + "properties": [ + { + "key": "regressionMethod", + "label": "Method", + "type": "discrete", + "options": [ + { + "value": "linear", + "label": "Linear" + }, + { + "value": "log", + "label": "Logarithmic" + }, + { + "value": "exp", + "label": "Exponential" + }, + { + "value": "pow", + "label": "Power" + }, + { + "value": "quad", + "label": "Quadratic" + }, + { + "value": "poly", + "label": "Polynomial" + } + ], + "default": "linear" + }, + { + "key": "polyOrder", + "label": "Poly Order", + "type": "continuous", + "min": 2, + "max": 10, + "step": 1, + "default": 3 + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + }, + { + "key": "logScale_x", + "label": "Log X", + "type": "binary", + "default": false + }, + { + "key": "logScale_y", + "label": "Log Y", + "type": "binary", + "default": false + }, + { + "key": "includeZero_x", + "label": "Zero X", + "type": "binary" + }, + { + "key": "includeZero_y", + "label": "Zero Y", + "type": "binary" + } + ] + }, + { + "name": "Connected Scatter Plot", + "channels": [ + "x", + "y", + "order", + "color", + "detail", + "column", + "row" + ], + "properties": [ + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + }, + { + "key": "logScale_x", + "label": "Log X", + "type": "binary", + "default": false + }, + { + "key": "logScale_y", + "label": "Log Y", + "type": "binary", + "default": false + }, + { + "key": "includeZero_x", + "label": "Zero X", + "type": "binary" + }, + { + "key": "includeZero_y", + "label": "Zero Y", + "type": "binary" + } + ] + }, + { + "name": "Ranged Dot Plot", + "channels": [ + "x", + "y", + "color" + ], + "properties": [ + { + "key": "logScale_x", + "label": "Log X", + "type": "binary", + "default": false + }, + { + "key": "logScale_y", + "label": "Log Y", + "type": "binary", + "default": false + }, + { + "key": "includeZero_x", + "label": "Zero X", + "type": "binary" + }, + { + "key": "includeZero_y", + "label": "Zero Y", + "type": "binary" + } + ] + }, + { + "name": "Strip Plot", + "channels": [ + "x", + "y", + "color", + "size", + "column", + "row" + ], + "properties": [ + { + "key": "stepWidth", + "label": "Jitter", + "type": "continuous", + "min": 10, + "max": 100, + "step": 5, + "default": 20 + }, + { + "key": "pointSize", + "label": "Size", + "type": "continuous", + "min": 0, + "max": 150, + "step": 5, + "default": 0 + }, + { + "key": "opacity", + "label": "Opacity", + "type": "continuous", + "min": 0, + "max": 1, + "step": 0.1, + "default": 0 + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + }, + { + "key": "logScale_x", + "label": "Log X", + "type": "binary", + "default": false + }, + { + "key": "logScale_y", + "label": "Log Y", + "type": "binary", + "default": false + }, + { + "key": "includeZero_x", + "label": "Zero X", + "type": "binary" + }, + { + "key": "includeZero_y", + "label": "Zero Y", + "type": "binary" + } + ] + }, + { + "name": "Bar Chart", + "channels": [ + "x", + "y", + "color", + "opacity", + "column", + "row" + ], + "properties": [ + { + "key": "cornerRadius", + "label": "Corners", + "type": "continuous", + "min": 0, + "max": 15, + "step": 1, + "default": 0 + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + }, + { + "key": "xAxisType", + "label": "X as", + "type": "discrete", + "options": [ + { + "value": "temporal", + "label": "Temporal" + }, + { + "value": "nominal", + "label": "Discrete" + } + ] + }, + { + "key": "yAxisType", + "label": "Y as", + "type": "discrete", + "options": [ + { + "value": "temporal", + "label": "Temporal" + }, + { + "value": "nominal", + "label": "Discrete" + } + ] + } + ] + }, + { + "name": "Grouped Bar Chart", + "channels": [ + "x", + "y", + "group", + "column", + "row" + ], + "properties": [ + { + "key": "dodge", + "label": "Dodge", + "type": "discrete", + "options": [ + { + "value": "auto", + "label": "Auto" + }, + { + "value": "local", + "label": "Local (compact)" + }, + { + "value": "global", + "label": "Global (aligned)" + } + ], + "default": "auto" + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + } + ] + }, + { + "name": "Stacked Bar Chart", + "channels": [ + "x", + "y", + "color", + "column", + "row" + ], + "properties": [ + { + "key": "stackMode", + "label": "Stack", + "type": "discrete", + "options": [ + { + "label": "Stacked (default)" + }, + { + "value": "normalize", + "label": "Normalize (100%)" + }, + { + "value": "center", + "label": "Center" + } + ] + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + } + ] + }, + { + "name": "Lollipop Chart", + "channels": [ + "x", + "y", + "color", + "column", + "row" + ], + "properties": [ + { + "key": "dotSize", + "label": "Dot Size", + "type": "continuous", + "min": 20, + "max": 300, + "step": 10, + "default": 80 + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + }, + { + "key": "xAxisType", + "label": "X as", + "type": "discrete", + "options": [ + { + "value": "temporal", + "label": "Temporal" + }, + { + "value": "nominal", + "label": "Discrete" + } + ] + }, + { + "key": "yAxisType", + "label": "Y as", + "type": "discrete", + "options": [ + { + "value": "temporal", + "label": "Temporal" + }, + { + "value": "nominal", + "label": "Discrete" + } + ] + } + ] + }, + { + "name": "Waterfall Chart", + "channels": [ + "x", + "y", + "color", + "column", + "row" + ], + "properties": [ + { + "key": "cornerRadius", + "label": "Corners", + "type": "continuous", + "min": 0, + "max": 8, + "step": 1, + "default": 0 + }, + { + "key": "totals", + "label": "Totals", + "type": "discrete", + "options": [ + { + "value": "auto", + "label": "Auto" + }, + { + "value": "none", + "label": "None" + }, + { + "value": "first", + "label": "First" + }, + { + "value": "last", + "label": "Last" + }, + { + "value": "both", + "label": "Both" + } + ], + "default": "auto" + }, + { + "key": "showTextLabels", + "label": "Labels", + "type": "binary", + "default": false + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + } + ] + }, + { + "name": "Gantt Chart", + "channels": [ + "y", + "x", + "x2", + "color", + "detail", + "column", + "row" + ], + "properties": [ + { + "key": "taskHeight", + "label": "Task height", + "type": "continuous", + "min": 40, + "max": 90, + "step": 5, + "default": 70 + }, + { + "key": "cornerRadius", + "label": "Corners", + "type": "continuous", + "min": 0, + "max": 8, + "step": 1, + "default": 2 + }, + { + "key": "intervalLabels", + "label": "Labels", + "type": "binary", + "default": false + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + }, + { + "key": "logScale_x", + "label": "Log X", + "type": "binary", + "default": false + }, + { + "key": "logScale_y", + "label": "Log Y", + "type": "binary", + "default": false + }, + { + "key": "includeZero_x", + "label": "Zero X", + "type": "binary" + }, + { + "key": "includeZero_y", + "label": "Zero Y", + "type": "binary" + } + ] + }, + { + "name": "Bullet Chart", + "channels": [ + "y", + "x", + "goal", + "color", + "column", + "row" + ], + "properties": [ + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + } + ] + }, + { + "name": "Histogram", + "channels": [ + "x", + "color", + "column", + "row" + ], + "properties": [ + { + "key": "binCount", + "label": "Max Bins", + "type": "continuous", + "min": 5, + "max": 50, + "step": 1, + "default": 0 + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + } + ] + }, + { + "name": "Density Plot", + "channels": [ + "x", + "color", + "column", + "row" + ], + "properties": [ + { + "key": "bandwidth", + "label": "Bandwidth", + "type": "continuous", + "min": 0.05, + "max": 2, + "step": 0.05, + "default": 0 + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + } + ] + }, + { + "name": "ECDF Plot", + "channels": [ + "x", + "color", + "detail", + "column", + "row" + ], + "properties": [ + { + "key": "showPoints", + "label": "Points", + "type": "binary", + "default": false + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + }, + { + "key": "logScale_x", + "label": "Log X", + "type": "binary", + "default": false + }, + { + "key": "logScale_y", + "label": "Log Y", + "type": "binary", + "default": false + }, + { + "key": "includeZero_x", + "label": "Zero X", + "type": "binary" + }, + { + "key": "includeZero_y", + "label": "Zero Y", + "type": "binary" + } + ] + }, + { + "name": "Violin Plot", + "channels": [ + "x", + "y", + "color", + "row" + ], + "properties": [ + { + "key": "bandwidth", + "label": "Bandwidth", + "type": "continuous", + "min": 0.05, + "max": 2, + "step": 0.05, + "default": 0 + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + } + ] + }, + { + "name": "Boxplot", + "channels": [ + "x", + "y", + "color", + "opacity", + "column", + "row" + ], + "properties": [ + { + "key": "whiskerMethod", + "label": "Whiskers", + "type": "discrete", + "options": [ + { + "value": "iqr", + "label": "Tukey (1.5 × IQR)" + }, + { + "value": "minmax", + "label": "Min–Max" + } + ], + "default": "iqr" + }, + { + "key": "showOutliers", + "label": "Outliers", + "type": "binary", + "default": true + }, + { + "key": "dodge", + "label": "Dodge", + "type": "discrete", + "options": [ + { + "value": "auto", + "label": "Auto" + }, + { + "value": "local", + "label": "Local (compact)" + }, + { + "value": "global", + "label": "Global (aligned)" + } + ], + "default": "auto" + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + }, + { + "key": "logScale_x", + "label": "Log X", + "type": "binary", + "default": false + }, + { + "key": "logScale_y", + "label": "Log Y", + "type": "binary", + "default": false + }, + { + "key": "includeZero_x", + "label": "Zero X", + "type": "binary" + }, + { + "key": "includeZero_y", + "label": "Zero Y", + "type": "binary" + } + ] + }, + { + "name": "Pyramid Chart", + "channels": [ + "x", + "y", + "color" + ], + "properties": [] + }, + { + "name": "Candlestick Chart", + "channels": [ + "x", + "open", + "high", + "low", + "close", + "column", + "row" + ], + "properties": [ + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + }, + { + "key": "logScale_x", + "label": "Log X", + "type": "binary", + "default": false + }, + { + "key": "logScale_y", + "label": "Log Y", + "type": "binary", + "default": false + }, + { + "key": "includeZero_x", + "label": "Zero X", + "type": "binary" + }, + { + "key": "includeZero_y", + "label": "Zero Y", + "type": "binary" + } + ] + }, + { + "name": "Line Chart", + "channels": [ + "x", + "y", + "color", + "strokeDash", + "detail", + "opacity", + "column", + "row" + ], + "properties": [ + { + "key": "interpolate", + "label": "Curve", + "type": "discrete", + "options": [ + { + "label": "Default (linear)" + }, + { + "value": "linear", + "label": "Linear" + }, + { + "value": "monotone", + "label": "Monotone (smooth)" + }, + { + "value": "step", + "label": "Step" + }, + { + "value": "step-before", + "label": "Step Before" + }, + { + "value": "step-after", + "label": "Step After" + }, + { + "value": "basis", + "label": "Basis (smooth)" + }, + { + "value": "cardinal", + "label": "Cardinal" + }, + { + "value": "catmull-rom", + "label": "Catmull-Rom" + } + ] + }, + { + "key": "showPoints", + "label": "Points", + "type": "binary", + "default": false + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + }, + { + "key": "logScale_x", + "label": "Log X", + "type": "binary", + "default": false + }, + { + "key": "logScale_y", + "label": "Log Y", + "type": "binary", + "default": false + }, + { + "key": "includeZero_x", + "label": "Zero X", + "type": "binary" + }, + { + "key": "includeZero_y", + "label": "Zero Y", + "type": "binary" + }, + { + "key": "xAxisType", + "label": "X as", + "type": "discrete", + "options": [ + { + "value": "temporal", + "label": "Temporal" + }, + { + "value": "nominal", + "label": "Discrete" + } + ] + }, + { + "key": "yAxisType", + "label": "Y as", + "type": "discrete", + "options": [ + { + "value": "temporal", + "label": "Temporal" + }, + { + "value": "nominal", + "label": "Discrete" + } + ] + } + ] + }, + { + "name": "Sparkline", + "channels": [ + "x", + "y", + "color", + "detail", + "row", + "column" + ], + "properties": [ + { + "key": "interpolate", + "label": "Curve", + "type": "discrete", + "options": [ + { + "label": "Default (linear)" + }, + { + "value": "linear", + "label": "Linear" + }, + { + "value": "monotone", + "label": "Monotone (smooth)" + }, + { + "value": "step", + "label": "Step" + }, + { + "value": "step-before", + "label": "Step Before" + }, + { + "value": "step-after", + "label": "Step After" + }, + { + "value": "basis", + "label": "Basis (smooth)" + }, + { + "value": "cardinal", + "label": "Cardinal" + }, + { + "value": "catmull-rom", + "label": "Catmull-Rom" + } + ] + }, + { + "key": "baseline", + "label": "Reference line", + "type": "discrete", + "options": [ + { + "value": "mean", + "label": "Average" + }, + { + "value": "zero", + "label": "Zero" + }, + { + "value": "median", + "label": "Median" + }, + { + "value": "none", + "label": "None" + } + ], + "default": "mean" + }, + { + "key": "trendWidth", + "label": "Sparkline width", + "type": "continuous", + "min": 80, + "max": 600, + "step": 10, + "default": 240 + }, + { + "key": "logScale_x", + "label": "Log X", + "type": "binary", + "default": false + }, + { + "key": "logScale_y", + "label": "Log Y", + "type": "binary", + "default": false + }, + { + "key": "includeZero_x", + "label": "Zero X", + "type": "binary" + }, + { + "key": "includeZero_y", + "label": "Zero Y", + "type": "binary" + } + ] + }, + { + "name": "Bump Chart", + "channels": [ + "x", + "y", + "color", + "detail", + "column", + "row" + ], + "properties": [ + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + }, + { + "key": "logScale_x", + "label": "Log X", + "type": "binary", + "default": false + }, + { + "key": "logScale_y", + "label": "Log Y", + "type": "binary", + "default": false + }, + { + "key": "includeZero_x", + "label": "Zero X", + "type": "binary" + }, + { + "key": "includeZero_y", + "label": "Zero Y", + "type": "binary" + } + ] + }, + { + "name": "Slope Chart", + "channels": [ + "x", + "y", + "color", + "detail", + "column", + "row" + ], + "properties": [ + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + }, + { + "key": "logScale_x", + "label": "Log X", + "type": "binary", + "default": false + }, + { + "key": "logScale_y", + "label": "Log Y", + "type": "binary", + "default": false + }, + { + "key": "includeZero_x", + "label": "Zero X", + "type": "binary" + }, + { + "key": "includeZero_y", + "label": "Zero Y", + "type": "binary" + } + ] + }, + { + "name": "Area Chart", + "channels": [ + "x", + "y", + "color", + "opacity", + "column", + "row" + ], + "properties": [ + { + "key": "interpolate", + "label": "Curve", + "type": "discrete", + "options": [ + { + "label": "Default (linear)" + }, + { + "value": "linear", + "label": "Linear" + }, + { + "value": "monotone", + "label": "Monotone (smooth)" + }, + { + "value": "step", + "label": "Step" + }, + { + "value": "step-before", + "label": "Step Before" + }, + { + "value": "step-after", + "label": "Step After" + }, + { + "value": "basis", + "label": "Basis (smooth)" + }, + { + "value": "cardinal", + "label": "Cardinal" + }, + { + "value": "catmull-rom", + "label": "Catmull-Rom" + } + ] + }, + { + "key": "opacity", + "label": "Opacity", + "type": "continuous", + "min": 0.1, + "max": 1, + "step": 0.1, + "default": 0.7 + }, + { + "key": "stackMode", + "label": "Stack", + "type": "discrete", + "options": [ + { + "label": "Stacked (default)" + }, + { + "value": "normalize", + "label": "Normalize (100%)" + }, + { + "value": "center", + "label": "Center" + }, + { + "value": "layered", + "label": "Layered (overlap)" + } + ] + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + }, + { + "key": "xAxisType", + "label": "X as", + "type": "discrete", + "options": [ + { + "value": "temporal", + "label": "Temporal" + }, + { + "value": "nominal", + "label": "Discrete" + } + ] + }, + { + "key": "yAxisType", + "label": "Y as", + "type": "discrete", + "options": [ + { + "value": "temporal", + "label": "Temporal" + }, + { + "value": "nominal", + "label": "Discrete" + } + ] + } + ] + }, + { + "name": "Streamgraph", + "channels": [ + "x", + "y", + "color", + "column", + "row" + ], + "properties": [ + { + "key": "interpolate", + "label": "Curve", + "type": "discrete", + "options": [ + { + "label": "Default (linear)" + }, + { + "value": "linear", + "label": "Linear" + }, + { + "value": "monotone", + "label": "Monotone (smooth)" + }, + { + "value": "step", + "label": "Step" + }, + { + "value": "step-before", + "label": "Step Before" + }, + { + "value": "step-after", + "label": "Step After" + }, + { + "value": "basis", + "label": "Basis (smooth)" + }, + { + "value": "cardinal", + "label": "Cardinal" + }, + { + "value": "catmull-rom", + "label": "Catmull-Rom" + } + ] + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + } + ] + }, + { + "name": "Range Area Chart", + "channels": [ + "x", + "y", + "y2", + "color", + "column", + "row" + ], + "properties": [ + { + "key": "interpolate", + "label": "Curve", + "type": "discrete", + "options": [ + { + "label": "Default (linear)" + }, + { + "value": "linear", + "label": "Linear" + }, + { + "value": "monotone", + "label": "Monotone (smooth)" + }, + { + "value": "step", + "label": "Step" + }, + { + "value": "step-before", + "label": "Step Before" + }, + { + "value": "step-after", + "label": "Step After" + }, + { + "value": "basis", + "label": "Basis (smooth)" + } + ] + }, + { + "key": "opacity", + "label": "Opacity", + "type": "continuous", + "min": 0.1, + "max": 1, + "step": 0.1, + "default": 0.5 + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + } + ] + }, + { + "name": "Pie Chart", + "channels": [ + "size", + "color", + "column", + "row" + ], + "properties": [ + { + "key": "innerRadius", + "label": "Donut", + "type": "continuous", + "min": 0, + "max": 100, + "step": 5, + "default": 0 + }, + { + "key": "sortSlices", + "label": "Sort slices", + "type": "discrete", + "options": [ + { + "value": "none", + "label": "Data order" + }, + { + "value": "descending", + "label": "Largest first" + }, + { + "value": "ascending", + "label": "Smallest first" + } + ], + "default": "none" + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + } + ] + }, + { + "name": "Donut Chart", + "channels": [ + "size", + "color", + "column", + "row" + ], + "properties": [ + { + "key": "innerRadius", + "label": "Donut", + "type": "continuous", + "min": 0, + "max": 100, + "step": 5, + "default": 0 + }, + { + "key": "sortSlices", + "label": "Sort slices", + "type": "discrete", + "options": [ + { + "value": "none", + "label": "Data order" + }, + { + "value": "descending", + "label": "Largest first" + }, + { + "value": "ascending", + "label": "Smallest first" + } + ], + "default": "none" + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + } + ] + }, + { + "name": "Rose Chart", + "channels": [ + "x", + "y", + "color", + "column", + "row" + ], + "properties": [ + { + "key": "padAngle", + "label": "Gap", + "type": "continuous", + "min": 0, + "max": 0.1, + "step": 0.005, + "default": 0 + }, + { + "key": "alignment", + "label": "Alignment", + "type": "discrete", + "options": [ + { + "value": "left", + "label": "Left (default)" + }, + { + "value": "center", + "label": "Center" + } + ] + }, + { + "key": "sortSlices", + "label": "Sort slices", + "type": "discrete", + "options": [ + { + "value": "none", + "label": "Data order" + }, + { + "value": "descending", + "label": "Largest first" + }, + { + "value": "ascending", + "label": "Smallest first" + } + ], + "default": "none" + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + } + ] + }, + { + "name": "Radar Chart", + "channels": [ + "x", + "y", + "color", + "column", + "row" + ], + "properties": [ + { + "key": "filled", + "label": "Filled", + "type": "binary", + "default": true + }, + { + "key": "fillOpacity", + "label": "Fill Opacity", + "type": "continuous", + "min": 0, + "max": 0.5, + "step": 0.1, + "default": 0.15 + }, + { + "key": "strokeWidth", + "label": "Line Width", + "type": "continuous", + "min": 0.5, + "max": 4, + "step": 0.5, + "default": 1.5 + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + }, + { + "key": "logScale_x", + "label": "Log X", + "type": "binary", + "default": false + }, + { + "key": "logScale_y", + "label": "Log Y", + "type": "binary", + "default": false + }, + { + "key": "includeZero_x", + "label": "Zero X", + "type": "binary" + }, + { + "key": "includeZero_y", + "label": "Zero Y", + "type": "binary" + } + ] + }, + { + "name": "Heatmap", + "channels": [ + "x", + "y", + "color", + "column", + "row" + ], + "properties": [ + { + "key": "showTextLabels", + "label": "Labels", + "type": "binary", + "default": false + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + }, + { + "key": "xAxisType", + "label": "X as", + "type": "discrete", + "options": [ + { + "value": "temporal", + "label": "Temporal" + }, + { + "value": "nominal", + "label": "Discrete" + } + ] + }, + { + "key": "yAxisType", + "label": "Y as", + "type": "discrete", + "options": [ + { + "value": "temporal", + "label": "Temporal" + }, + { + "value": "nominal", + "label": "Discrete" + } + ] + } + ] + }, + { + "name": "Bar Table", + "channels": [ + "y", + "x", + "color", + "column", + "row" + ], + "properties": [ + { + "key": "maxRows", + "label": "Max Rows", + "type": "continuous", + "min": 5, + "max": 100, + "step": 1, + "default": 20 + }, + { + "key": "showPercent", + "label": "% of total", + "type": "binary", + "default": false + }, + { + "key": "independentYAxis", + "label": "Independent Y", + "type": "binary" + } + ] + } + ] +} diff --git a/runtime/ai/chartquery/flint_test.go b/runtime/ai/chartquery/flint_test.go new file mode 100644 index 000000000000..d8fa81942fc5 --- /dev/null +++ b/runtime/ai/chartquery/flint_test.go @@ -0,0 +1,281 @@ +package chartquery + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/rilldata/rill/runtime/metricsview" + "github.com/stretchr/testify/require" +) + +// flintTestKinds classifies the fields of the metrics view the Flint tests bind. +var flintTestKinds = map[string]FieldKind{ + "__time": FieldKindTime, + "advertiser": FieldKindDimension, + "device": FieldKindDimension, + "country": FieldKindDimension, + "campaign_id": FieldKindDimension, + "bids": FieldKindMeasure, + "clicks": FieldKindMeasure, + "spend": FieldKindMeasure, + "target": FieldKindMeasure, +} + +func flintTestData(extra map[string]any) map[string]any { + data := map[string]any{ + "metrics_view": "mv", + "time_range": map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-02-01T00:00:00Z"}, + } + for k, v := range extra { + data[k] = v + } + return data +} + +// TestBuildFlintGolden checks the generated queries of Flint chart specs against golden files in testdata (flint_*.json). +// The main query is built with a fixed domain so the top-N filters are visible. Run with -update to regenerate. +func TestBuildFlintGolden(t *testing.T) { + comparisonTimeRange := map[string]any{"start": "2023-12-01T00:00:00Z", "end": "2024-01-01T00:00:00Z"} + where := map[string]any{"cond": map[string]any{"op": "eq", "exprs": []any{map[string]any{"name": "country"}, map[string]any{"val": "US"}}}} + + cases := []struct { + name string + chartSpec map[string]any + data map[string]any + domain map[string][]any + }{ + { + name: "flint_bar_chart", + chartSpec: map[string]any{ + "chartType": "Bar Chart", + "encodings": map[string]any{ + "x": map[string]any{"field": "advertiser", "limit": 10, "sortBy": "y", "sortOrder": "descending"}, + "y": "bids", + }, + "chartProperties": map[string]any{"cornerRadius": 2}, + }, + data: flintTestData(map[string]any{"where": where}), + domain: map[string][]any{"advertiser": {"A", "B"}}, + }, + { + name: "flint_bar_chart_sorted_by_name", + chartSpec: map[string]any{ + "chartType": "Bar Chart", + "encodings": map[string]any{ + "x": map[string]any{"field": "advertiser", "sortBy": "x", "sortOrder": "descending"}, + "y": "bids", + }, + }, + data: flintTestData(nil), + domain: map[string][]any{"advertiser": {"B", "A"}}, + }, + { + name: "flint_line_chart_comparison", + chartSpec: map[string]any{ + "chartType": "Line Chart", + "encodings": map[string]any{ + "x": "__time", + "y": "bids", + "color": map[string]any{"field": "device", "limit": 3}, + }, + }, + data: flintTestData(map[string]any{"comparison_time_range": comparisonTimeRange, "time_grain": "TIME_GRAIN_MONTH"}), + domain: map[string][]any{"device": {"iOS", "Android"}}, + }, + { + name: "flint_grouped_bar_chart", + chartSpec: map[string]any{ + "chartType": "Grouped Bar Chart", + "encodings": map[string]any{"x": "advertiser", "y": "bids", "group": "device"}, + }, + data: flintTestData(nil), + domain: map[string][]any{"advertiser": {"A", "B"}, "device": {"iOS", "Android"}}, + }, + { + name: "flint_stacked_bar_chart", + chartSpec: map[string]any{ + "chartType": "Stacked Bar Chart", + "encodings": map[string]any{"x": "__time", "y": "bids", "color": "device"}, + "chartProperties": map[string]any{"stackMode": "normalize"}, + }, + data: flintTestData(map[string]any{"time_grain": "TIME_GRAIN_WEEK"}), + domain: map[string][]any{"device": {"iOS"}}, + }, + { + name: "flint_waterfall_chart", + chartSpec: map[string]any{ + "chartType": "Waterfall Chart", + "encodings": map[string]any{"x": "__time", "y": "spend"}, + "chartProperties": map[string]any{"totals": "both"}, + }, + data: flintTestData(map[string]any{"time_grain": "TIME_GRAIN_MONTH"}), + }, + { + name: "flint_pie_chart", + chartSpec: map[string]any{ + "chartType": "Pie Chart", + "encodings": map[string]any{"size": "bids", "color": map[string]any{"field": "device", "limit": 5}}, + "chartProperties": map[string]any{"innerRadius": 40}, + }, + data: flintTestData(nil), + domain: map[string][]any{"device": {"iOS", "Android"}}, + }, + { + name: "flint_heatmap", + chartSpec: map[string]any{ + "chartType": "Heatmap", + "encodings": map[string]any{"x": "device", "y": map[string]any{"field": "advertiser", "limit": 100}, "color": "bids"}, + }, + data: flintTestData(nil), + domain: map[string][]any{"device": {"iOS"}, "advertiser": {"A"}}, + }, + { + name: "flint_slope_chart", + chartSpec: map[string]any{ + "chartType": "Slope Chart", + "encodings": map[string]any{"x": "__time", "y": "bids", "color": "advertiser"}, + }, + data: flintTestData(map[string]any{"time_grain": "TIME_GRAIN_YEAR"}), + domain: map[string][]any{"advertiser": {"A", "B"}}, + }, + { + name: "flint_scatter_plot_detail", + chartSpec: map[string]any{ + "chartType": "Scatter Plot", + "encodings": map[string]any{"x": "bids", "y": "clicks", "size": "spend", "detail": "campaign_id", "color": "device"}, + }, + data: flintTestData(nil), + domain: map[string][]any{"device": {"iOS", "Android"}}, + }, + { + name: "flint_bar_chart_facet", + chartSpec: map[string]any{ + "chartType": "Bar Chart", + "encodings": map[string]any{"x": "advertiser", "y": "bids", "column": "country"}, + }, + data: flintTestData(nil), + domain: map[string][]any{"advertiser": {"A", "B"}, "country": {"US", "CA"}}, + }, + { + name: "flint_multi_measure_fold", + chartSpec: map[string]any{ + "chartType": "Line Chart", + "encodings": map[string]any{"x": "__time", "y": []any{"bids", "clicks"}}, + }, + data: flintTestData(map[string]any{"time_range": map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-02-01T00:00:00Z", "time_zone": "America/New_York"}}), + }, + { + name: "flint_bullet_chart", + chartSpec: map[string]any{ + "chartType": "Bullet Chart", + "encodings": map[string]any{"y": "advertiser", "x": "bids", "goal": "target"}, + }, + data: flintTestData(nil), + domain: map[string][]any{"advertiser": {"A"}}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + spec, err := ParseFlintSpec(tc.chartSpec, tc.data, flintTestKinds) + require.NoError(t, err) + plan, err := BuildFlint(spec) + require.NoError(t, err) + + got := struct { + ChartType string `json:"chart_type"` + TimeGrain string `json:"time_grain,omitempty"` + TimeField string `json:"time_field,omitempty"` + Fields []string `json:"fields"` + Measures []string `json:"measures"` + RowLimit int `json:"row_limit"` + TopN []TopNQuery `json:"top_n,omitempty"` + Main *metricsview.Query `json:"main"` + }{ + ChartType: spec.ChartType, + TimeGrain: spec.TimeGrainName(), + TimeField: plan.TimeField, + Fields: spec.Fields(), + Measures: spec.Measures(), + RowLimit: plan.RowLimit, + TopN: plan.TopN, + Main: plan.Main(tc.domain), + } + gotJSON, err := json.MarshalIndent(got, "", " ") + require.NoError(t, err) + gotJSON = append(gotJSON, '\n') + + path := filepath.Join("testdata", tc.name+".json") + if *update { + require.NoError(t, os.WriteFile(path, gotJSON, 0o644)) + } + want, err := os.ReadFile(path) + require.NoError(t, err, "missing golden file; run with -update") + require.JSONEq(t, string(want), string(gotJSON)) + }) + } +} + +func TestParseFlintSpecErrors(t *testing.T) { + data := flintTestData(nil) + + _, err := ParseFlintSpec(map[string]any{"chartType": "Sankey", "encodings": map[string]any{"x": "advertiser"}}, data, flintTestKinds) + require.ErrorContains(t, err, `unknown chart type "Sankey"`) + require.ErrorContains(t, err, "Bar Chart") + + _, err = ParseFlintSpec(map[string]any{"chartType": "Bar Chart", "encodings": map[string]any{"x": "advertiser", "y": "bids", "angle": "clicks"}}, data, flintTestKinds) + require.ErrorContains(t, err, `does not accept channel "angle"`) + require.ErrorContains(t, err, "x, y, color") + + _, err = ParseFlintSpec(map[string]any{"chartType": "Bar Chart", "encodings": map[string]any{"x": "publisher", "y": "bids"}}, data, flintTestKinds) + require.ErrorContains(t, err, `field "publisher" on channel "x" not found in metrics view "mv"`) + + _, err = ParseFlintSpec(map[string]any{"chartType": "Bar Chart", "encodings": map[string]any{"x": map[string]any{"field": "advertiser", "sort": "-y"}, "y": "bids"}}, data, flintTestKinds) + require.ErrorContains(t, err, `unknown encoding key "sort"`) + + _, err = ParseFlintSpec(map[string]any{"chartType": "Bar Chart", "encodings": map[string]any{"x": map[string]any{"field": "advertiser", "sortOrder": "desc"}, "y": "bids"}}, data, flintTestKinds) + require.ErrorContains(t, err, "invalid sortOrder") + + _, err = ParseFlintSpec(map[string]any{"chartType": "Bar Chart"}, data, flintTestKinds) + require.ErrorContains(t, err, "encodings") + + _, err = ParseFlintSpec(map[string]any{"chartType": "Bar Chart", "encodings": map[string]any{"x": "advertiser"}}, map[string]any{"time_range": data["time_range"]}, flintTestKinds) + require.ErrorContains(t, err, "metrics_view") + + _, err = ParseFlintSpec(map[string]any{"chartType": "Bar Chart", "encodings": map[string]any{"x": "advertiser"}}, map[string]any{"metrics_view": "mv"}, flintTestKinds) + require.ErrorContains(t, err, "time_range") + + _, err = ParseFlintSpec(map[string]any{"chartType": "Line Chart", "encodings": map[string]any{"x": "__time", "y": "bids"}}, flintTestData(map[string]any{"time_grain": "TIME_GRAIN_FORTNIGHT"}), flintTestKinds) + require.ErrorContains(t, err, "invalid time_grain") + + // A grain without a time field is ignored rather than reported. + spec, err := ParseFlintSpec(map[string]any{"chartType": "Bar Chart", "encodings": map[string]any{"x": "advertiser", "y": "bids"}}, flintTestData(map[string]any{"time_grain": "TIME_GRAIN_MONTH"}), flintTestKinds) + require.NoError(t, err) + require.Empty(t, spec.TimeGrainName()) + + // Build rejects a chart that binds no measure. + spec, err = ParseFlintSpec(map[string]any{"chartType": "Bar Chart", "encodings": map[string]any{"x": "advertiser", "color": "device"}}, data, flintTestKinds) + require.NoError(t, err) + _, err = BuildFlint(spec) + require.ErrorContains(t, err, "at least one measure") +} + +func TestFlintSpecFields(t *testing.T) { + spec, err := ParseFlintSpec(map[string]any{ + "chartType": "Line Chart", + "encodings": map[string]any{"color": "device", "y": []any{"bids", "clicks", "bids"}, "x": "__time", "detail": "campaign_id"}, + }, flintTestData(nil), flintTestKinds) + require.NoError(t, err) + // Channel order of the chart type (x, y, color, ..., detail), not the input's key order + require.Equal(t, []string{"__time", "bids", "clicks", "device", "campaign_id"}, spec.Fields()) + require.Equal(t, []string{"bids", "clicks"}, spec.Measures()) + require.Equal(t, "__time", spec.TimeField()) + require.Equal(t, "TIME_GRAIN_DAY", spec.TimeGrainName()) + + require.Equal(t, "0.4.1", Catalog().FlintVersion) + require.NotNil(t, Catalog().ChartType("Waterfall Chart")) + require.Nil(t, Catalog().ChartType("Map"), "geographic charts are left out of the catalog") + require.True(t, Catalog().ChartType("Bar Chart").AcceptsChannel(DetailChannel)) +} diff --git a/runtime/ai/chartquery/spec.go b/runtime/ai/chartquery/spec.go new file mode 100644 index 000000000000..27be928e28f1 --- /dev/null +++ b/runtime/ai/chartquery/spec.go @@ -0,0 +1,324 @@ +// Package chartquery turns a Rill chart configuration (the argument of the create_chart AI tool) into the metrics view +// queries that fetch its data, and runs them within fixed budgets. +// +// The query shapes mirror the frontend chart data providers in web-common/src/features/components/charts +// (top-N pre-queries for nominal axes and the color split, a main query with time flooring and comparison values, +// a total query for circular charts), so that a chart rendered outside the Rill app shows the same data as inside it. +// +// The package is pure: it builds metricsview.Query values and runs them through a caller-provided Runner. +package chartquery + +import ( + "fmt" + "strings" + "time" + + "github.com/rilldata/rill/runtime/metricsview" + "github.com/rilldata/rill/runtime/pkg/mapstructureutil" +) + +// Chart types supported by create_chart. +const ( + BarChart = "bar_chart" + LineChart = "line_chart" + AreaChart = "area_chart" + StackedBar = "stacked_bar" + StackedBarNormalized = "stacked_bar_normalized" + DonutChart = "donut_chart" + PieChart = "pie_chart" + FunnelChart = "funnel_chart" + Heatmap = "heatmap" + ComboChart = "combo_chart" + ScatterPlot = "scatter_plot" +) + +// Field types used in chart specs. +const ( + TypeNominal = "nominal" + TypeTemporal = "temporal" + TypeQuantitative = "quantitative" + TypeValue = "value" +) + +// MeasuresField is the pseudo field that colors a chart by measure instead of by a dimension. +const MeasuresField = "rill_measures" + +// Spec is a parsed and normalized chart configuration. +// Cartesian charts are normalized to the vertical layout (dimension on X, measure on Y), +// which is the layout the queries are written against; Horizontal records that the input had them swapped. +type Spec struct { + ChartType string + MetricsView string + TimeRange *TimeRange + ComparisonTimeRange *TimeRange + Where *metricsview.Expression + // TimeGrain is the grain used to floor temporal fields. It is set only when a temporal field is used, + // defaulting to day when the spec does not name one. + TimeGrain metricsview.TimeGrain + + X *Field + Y *Field + Y1 *Field + Y2 *Field + Color *Field + ColorValue string // Static color (e.g. "primary") when color is not a field + Measure *Field + Stage *Field + Dimension *Field + Size *Field + + BreakdownMode string + Horizontal bool +} + +// Field is a field configuration in a chart spec. +type Field struct { + Field string + Type string + Fields []string + Limit int + ShowNull bool + // Sort is the channel-relative sort ("x", "-y", "-y_delta", "-color", "-measure", ...), or empty. + Sort string + // SortValues is the explicit value order when the spec sorts by a list of values; it replaces the top-N query. + SortValues []string +} + +// TimeRange is an absolute time range with an optional IANA time zone. +type TimeRange struct { + Start time.Time `json:"start"` + End time.Time `json:"end"` + TimeZone string `json:"time_zone,omitempty"` +} + +// rawSpec is the wire shape of a chart spec. Unknown keys (visual options such as zeroBasedOrigin) are ignored. +type rawSpec struct { + MetricsView string `mapstructure:"metrics_view"` + TimeRange *rawTimeRange `mapstructure:"time_range"` + ComparisonTimeRange *rawTimeRange `mapstructure:"comparison_time_range"` + Where *metricsview.Expression `mapstructure:"where"` + TimeGrain string `mapstructure:"time_grain"` + X *rawField `mapstructure:"x"` + Y *rawField `mapstructure:"y"` + Y1 *rawField `mapstructure:"y1"` + Y2 *rawField `mapstructure:"y2"` + Color any `mapstructure:"color"` + Measure *rawField `mapstructure:"measure"` + Stage *rawField `mapstructure:"stage"` + Dimension *rawField `mapstructure:"dimension"` + Size *rawField `mapstructure:"size"` + BreakdownMode string `mapstructure:"breakdownMode"` +} + +type rawTimeRange struct { + Start time.Time `mapstructure:"start"` + End time.Time `mapstructure:"end"` + TimeZone string `mapstructure:"time_zone"` +} + +type rawField struct { + Field string `mapstructure:"field"` + Type string `mapstructure:"type"` + Fields []string `mapstructure:"fields"` + Limit int `mapstructure:"limit"` + ShowNull bool `mapstructure:"showNull"` + Sort any `mapstructure:"sort"` +} + +// ParseSpec parses the chart_type and spec arguments of create_chart. +// It validates only what the queries need; field existence is checked by the caller against the metrics view. +func ParseSpec(chartType string, spec map[string]any) (*Spec, error) { + switch chartType { + case BarChart, LineChart, AreaChart, StackedBar, StackedBarNormalized, DonutChart, PieChart, FunnelChart, Heatmap, ComboChart, ScatterPlot: + default: + return nil, fmt.Errorf("unsupported chart type %q", chartType) + } + + raw := &rawSpec{} + if err := mapstructureutil.WeakDecode(spec, raw); err != nil { + return nil, fmt.Errorf("invalid chart spec: %w", err) + } + if raw.MetricsView == "" { + return nil, fmt.Errorf("spec must contain a 'metrics_view' field") + } + if raw.TimeRange == nil || raw.TimeRange.Start.IsZero() || raw.TimeRange.End.IsZero() { + return nil, fmt.Errorf("spec must contain a 'time_range' field with 'start' and 'end' properties") + } + + res := &Spec{ + ChartType: chartType, + MetricsView: raw.MetricsView, + TimeRange: raw.TimeRange.toTimeRange(), + Where: raw.Where, + X: raw.X.toField(), + Y: raw.Y.toField(), + Y1: raw.Y1.toField(), + Y2: raw.Y2.toField(), + Measure: raw.Measure.toField(), + Stage: raw.Stage.toField(), + Dimension: raw.Dimension.toField(), + Size: raw.Size.toField(), + BreakdownMode: raw.BreakdownMode, + } + if raw.ComparisonTimeRange != nil && !raw.ComparisonTimeRange.Start.IsZero() && !raw.ComparisonTimeRange.End.IsZero() { + res.ComparisonTimeRange = raw.ComparisonTimeRange.toTimeRange() + } + + // Color is either a field configuration or a static color string (which may also be a funnel color mode). + switch c := raw.Color.(type) { + case nil: + case string: + res.ColorValue = c + case map[string]any: + f := &rawField{} + if err := mapstructureutil.WeakDecode(c, f); err != nil { + return nil, fmt.Errorf("invalid color field: %w", err) + } + res.Color = f.toField() + default: + return nil, fmt.Errorf("invalid color: expected an object or a string, got %T", raw.Color) + } + + // Horizontal bar charts put the measure on x and the dimension on y. Normalize to the vertical layout, + // rewriting channel-relative sorts so that they keep referring to the same field. + switch chartType { + case BarChart, StackedBar, StackedBarNormalized: + if res.X != nil && res.X.Type == TypeQuantitative { + res.X, res.Y = res.Y, res.X + res.X.swapSortChannel() + res.Y.swapSortChannel() + res.Horizontal = true + } + } + + // Resolve the time grain: it only matters when a temporal field is used, in which case it defaults to day. + if res.hasTemporalField() { + grain, err := parseTimeGrain(raw.TimeGrain) + if err != nil { + return nil, err + } + if grain == metricsview.TimeGrainUnspecified { + grain = metricsview.TimeGrainDay + } + res.TimeGrain = grain + } + + return res, nil +} + +func (r *rawTimeRange) toTimeRange() *TimeRange { + return &TimeRange{Start: r.Start, End: r.End, TimeZone: r.TimeZone} +} + +func (r *rawField) toField() *Field { + if r == nil { + return nil + } + f := &Field{ + Field: r.Field, + Type: r.Type, + Fields: r.Fields, + Limit: r.Limit, + ShowNull: r.ShowNull, + } + switch s := r.Sort.(type) { + case string: + f.Sort = s + case []any: + for _, v := range s { + f.SortValues = append(f.SortValues, fmt.Sprintf("%v", v)) + } + case []string: + f.SortValues = s + } + return f +} + +// sortChannelTranspose rewrites channel-relative sort values when the x and y channels are swapped. +var sortChannelTranspose = map[string]string{ + "x": "y", "-x": "-y", "y": "x", "-y": "-x", + "x_delta": "y_delta", "-x_delta": "-y_delta", "y_delta": "x_delta", "-y_delta": "-x_delta", +} + +func (f *Field) swapSortChannel() { + if f == nil { + return + } + if s, ok := sortChannelTranspose[f.Sort]; ok { + f.Sort = s + } +} + +func (f *Field) isNominal() bool { return f != nil && f.Field != "" && f.Type == TypeNominal } +func (f *Field) isTemporal() bool { return f != nil && f.Field != "" && f.Type == TypeTemporal } + +// measures returns the measures named by a quantitative field: the multi-measure list plus the primary field. +func (f *Field) measures() []string { + if f == nil { + return nil + } + var res []string + seen := make(map[string]bool) + for _, m := range f.Fields { + if m != "" && !seen[m] { + seen[m] = true + res = append(res, m) + } + } + if f.Field != "" && !seen[f.Field] { + res = append(res, f.Field) + } + return res +} + +func (s *Spec) hasTemporalField() bool { + for _, f := range []*Field{s.X, s.Y, s.Y1, s.Y2, s.Color, s.Measure, s.Stage, s.Dimension, s.Size} { + if f.isTemporal() { + return true + } + } + return false +} + +// Fields returns the names of the metrics view fields the chart references, in a stable order and without duplicates. +func (s *Spec) Fields() []string { + var res []string + seen := make(map[string]bool) + add := func(name string) { + if name == "" || name == MeasuresField || seen[name] { + return + } + seen[name] = true + res = append(res, name) + } + for _, f := range []*Field{s.X, s.Y, s.Y1, s.Y2, s.Measure, s.Stage, s.Dimension, s.Size, s.Color} { + if f == nil { + continue + } + add(f.Field) + for _, m := range f.Fields { + add(m) + } + } + return res +} + +// TimeGrainName returns the TIME_GRAIN_* name of the resolved grain, or an empty string when no temporal field is used. +func (s *Spec) TimeGrainName() string { + if s.TimeGrain == metricsview.TimeGrainUnspecified { + return "" + } + return "TIME_GRAIN_" + strings.ToUpper(string(s.TimeGrain)) +} + +// parseTimeGrain maps a TIME_GRAIN_* name (as used in the chart spec) to a metricsview grain. +func parseTimeGrain(s string) (metricsview.TimeGrain, error) { + if s == "" { + return metricsview.TimeGrainUnspecified, nil + } + g := metricsview.TimeGrain(strings.ToLower(strings.TrimPrefix(s, "TIME_GRAIN_"))) + if !g.Valid() || g == metricsview.TimeGrainUnspecified { + return "", fmt.Errorf("invalid time_grain %q", s) + } + return g, nil +} diff --git a/runtime/ai/chartquery/testdata/area_chart.json b/runtime/ai/chartquery/testdata/area_chart.json new file mode 100644 index 000000000000..6af386167367 --- /dev/null +++ b/runtime/ai/chartquery/testdata/area_chart.json @@ -0,0 +1,118 @@ +{ + "time_grain": "TIME_GRAIN_DAY", + "row_limit": 1500, + "top_n": [ + { + "channel": "color", + "field": "app_or_site", + "limit": 10, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "app_or_site" + } + ], + "measures": [ + { + "name": "requests" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "requests", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "app_or_site" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 11, + "offset": null, + "time_zone": "America/New_York", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "__time", + "compute": { + "time_floor": { + "dimension": "__time", + "grain": "day" + } + } + }, + { + "name": "app_or_site" + } + ], + "measures": [ + { + "name": "requests" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "__time", + "desc": true + }, + { + "name": "app_or_site", + "desc": false + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "in", + "exprs": [ + { + "name": "app_or_site" + }, + { + "val": [ + "app" + ] + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "America/New_York", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/bar_chart.json b/runtime/ai/chartquery/testdata/bar_chart.json new file mode 100644 index 000000000000..b95a8796f004 --- /dev/null +++ b/runtime/ai/chartquery/testdata/bar_chart.json @@ -0,0 +1,160 @@ +{ + "row_limit": 1500, + "top_n": [ + { + "channel": "x", + "field": "advertiser", + "limit": 10, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "eq", + "exprs": [ + { + "name": "country" + }, + { + "val": "US" + } + ] + } + }, + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + } + ] + } + }, + "having": null, + "limit": 11, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "eq", + "exprs": [ + { + "name": "country" + }, + { + "val": "US" + } + ] + } + }, + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + "A", + "B" + ] + } + ] + } + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/bar_chart_delta_sort.json b/runtime/ai/chartquery/testdata/bar_chart_delta_sort.json new file mode 100644 index 000000000000..ad50e51de4f9 --- /dev/null +++ b/runtime/ai/chartquery/testdata/bar_chart_delta_sort.json @@ -0,0 +1,149 @@ +{ + "row_limit": 1500, + "top_n": [ + { + "channel": "x", + "field": "advertiser", + "limit": 5, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + } + ], + "measures": [ + { + "name": "bids" + }, + { + "name": "bids_delta", + "compute": { + "comparison_delta": { + "measure": "bids" + } + } + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids_delta", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": { + "start": "2023-12-01T00:00:00Z", + "end": "2024-01-01T00:00:00Z" + }, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 6, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + } + ], + "measures": [ + { + "name": "bids" + }, + { + "name": "bids_prev", + "compute": { + "comparison_value": { + "measure": "bids" + } + } + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": { + "start": "2023-12-01T00:00:00Z", + "end": "2024-01-01T00:00:00Z" + }, + "where": { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + "A", + "B" + ] + } + ] + } + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/bar_chart_horizontal.json b/runtime/ai/chartquery/testdata/bar_chart_horizontal.json new file mode 100644 index 000000000000..45091c0509bb --- /dev/null +++ b/runtime/ai/chartquery/testdata/bar_chart_horizontal.json @@ -0,0 +1,128 @@ +{ + "horizontal": true, + "row_limit": 1500, + "top_n": [ + { + "channel": "y", + "field": "advertiser", + "limit": 50, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 51, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + "A", + "B" + ] + } + ] + } + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/bar_chart_show_null.json b/runtime/ai/chartquery/testdata/bar_chart_show_null.json new file mode 100644 index 000000000000..54ffa23e5682 --- /dev/null +++ b/runtime/ai/chartquery/testdata/bar_chart_show_null.json @@ -0,0 +1,91 @@ +{ + "row_limit": 1500, + "top_n": [ + { + "channel": "x", + "field": "advertiser", + "limit": 20, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "advertiser", + "desc": false + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": null, + "having": null, + "limit": 21, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "advertiser", + "desc": false + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "in", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + "A", + null + ] + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/combo_chart_nominal.json b/runtime/ai/chartquery/testdata/combo_chart_nominal.json new file mode 100644 index 000000000000..03a324c4ba2e --- /dev/null +++ b/runtime/ai/chartquery/testdata/combo_chart_nominal.json @@ -0,0 +1,130 @@ +{ + "row_limit": 5, + "top_n": [ + { + "channel": "x", + "field": "country", + "limit": 5, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "country" + } + ], + "measures": [ + { + "name": "qps" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "qps", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "country" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 6, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "country" + } + ], + "measures": [ + { + "name": "qps" + }, + { + "name": "requests" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "qps", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "country" + }, + { + "val": [ + null + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "country" + }, + { + "val": [ + "US", + "CA" + ] + } + ] + } + } + ] + } + }, + "having": null, + "limit": 6, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/combo_chart_temporal.json b/runtime/ai/chartquery/testdata/combo_chart_temporal.json new file mode 100644 index 000000000000..4da663d6c5bd --- /dev/null +++ b/runtime/ai/chartquery/testdata/combo_chart_temporal.json @@ -0,0 +1,50 @@ +{ + "time_grain": "TIME_GRAIN_DAY", + "row_limit": 1500, + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "__time", + "compute": { + "time_floor": { + "dimension": "__time", + "grain": "day" + } + } + } + ], + "measures": [ + { + "name": "qps" + }, + { + "name": "requests" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "__time", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": null, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + }, + "measure_domain": [ + "qps", + "requests" + ] +} diff --git a/runtime/ai/chartquery/testdata/donut_chart.json b/runtime/ai/chartquery/testdata/donut_chart.json new file mode 100644 index 000000000000..23583ae7ea30 --- /dev/null +++ b/runtime/ai/chartquery/testdata/donut_chart.json @@ -0,0 +1,145 @@ +{ + "row_limit": 20, + "top_n": [ + { + "channel": "color", + "field": "username", + "limit": 20, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "username" + } + ], + "measures": [ + { + "name": "commits" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "commits", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "username" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 21, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "username" + } + ], + "measures": [ + { + "name": "commits" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "commits", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "in", + "exprs": [ + { + "name": "username" + }, + { + "val": [ + "alice", + "bob" + ] + } + ] + } + }, + "having": null, + "limit": 21, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + }, + "extra": { + "total": { + "metrics_view": "mv", + "dimensions": null, + "measures": [ + { + "name": "commits" + } + ], + "pivot_on": null, + "spine": null, + "sort": null, + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "username" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": null, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } +} diff --git a/runtime/ai/chartquery/testdata/flint_bar_chart.json b/runtime/ai/chartquery/testdata/flint_bar_chart.json new file mode 100644 index 000000000000..72adca3f0fe5 --- /dev/null +++ b/runtime/ai/chartquery/testdata/flint_bar_chart.json @@ -0,0 +1,168 @@ +{ + "chart_type": "Bar Chart", + "fields": [ + "advertiser", + "bids" + ], + "measures": [ + "bids" + ], + "row_limit": 1500, + "top_n": [ + { + "channel": "x", + "field": "advertiser", + "limit": 10, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "eq", + "exprs": [ + { + "name": "country" + }, + { + "val": "US" + } + ] + } + }, + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + } + ] + } + }, + "having": null, + "limit": 11, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "eq", + "exprs": [ + { + "name": "country" + }, + { + "val": "US" + } + ] + } + }, + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + "A", + "B" + ] + } + ] + } + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/flint_bar_chart_facet.json b/runtime/ai/chartquery/testdata/flint_bar_chart_facet.json new file mode 100644 index 000000000000..087113e2ac88 --- /dev/null +++ b/runtime/ai/chartquery/testdata/flint_bar_chart_facet.json @@ -0,0 +1,229 @@ +{ + "chart_type": "Bar Chart", + "fields": [ + "advertiser", + "bids", + "country" + ], + "measures": [ + "bids" + ], + "row_limit": 1500, + "top_n": [ + { + "channel": "x", + "field": "advertiser", + "limit": 20, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 21, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + }, + { + "channel": "column", + "field": "country", + "limit": 6, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "country" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "country" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 7, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + }, + { + "name": "country" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + "A", + "B" + ] + } + ] + } + } + ] + } + }, + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "country" + }, + { + "val": [ + null + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "country" + }, + { + "val": [ + "US", + "CA" + ] + } + ] + } + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/flint_bar_chart_sorted_by_name.json b/runtime/ai/chartquery/testdata/flint_bar_chart_sorted_by_name.json new file mode 100644 index 000000000000..8119583c209c --- /dev/null +++ b/runtime/ai/chartquery/testdata/flint_bar_chart_sorted_by_name.json @@ -0,0 +1,135 @@ +{ + "chart_type": "Bar Chart", + "fields": [ + "advertiser", + "bids" + ], + "measures": [ + "bids" + ], + "row_limit": 1500, + "top_n": [ + { + "channel": "x", + "field": "advertiser", + "limit": 20, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "advertiser", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 21, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + "B", + "A" + ] + } + ] + } + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/flint_bullet_chart.json b/runtime/ai/chartquery/testdata/flint_bullet_chart.json new file mode 100644 index 000000000000..37ba8f7961fd --- /dev/null +++ b/runtime/ai/chartquery/testdata/flint_bullet_chart.json @@ -0,0 +1,142 @@ +{ + "chart_type": "Bullet Chart", + "fields": [ + "advertiser", + "bids", + "target" + ], + "measures": [ + "bids", + "target" + ], + "row_limit": 1500, + "top_n": [ + { + "channel": "y", + "field": "advertiser", + "limit": 20, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + } + ], + "measures": [ + { + "name": "bids" + }, + { + "name": "target" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 21, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + } + ], + "measures": [ + { + "name": "bids" + }, + { + "name": "target" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + "A" + ] + } + ] + } + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/flint_grouped_bar_chart.json b/runtime/ai/chartquery/testdata/flint_grouped_bar_chart.json new file mode 100644 index 000000000000..dba37347ea80 --- /dev/null +++ b/runtime/ai/chartquery/testdata/flint_grouped_bar_chart.json @@ -0,0 +1,229 @@ +{ + "chart_type": "Grouped Bar Chart", + "fields": [ + "advertiser", + "bids", + "device" + ], + "measures": [ + "bids" + ], + "row_limit": 1500, + "top_n": [ + { + "channel": "x", + "field": "advertiser", + "limit": 20, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 21, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + }, + { + "channel": "group", + "field": "device", + "limit": 10, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "device" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 11, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + }, + { + "name": "device" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + "A", + "B" + ] + } + ] + } + } + ] + } + }, + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + null + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + "iOS", + "Android" + ] + } + ] + } + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/flint_heatmap.json b/runtime/ai/chartquery/testdata/flint_heatmap.json new file mode 100644 index 000000000000..61aeb6dd97b2 --- /dev/null +++ b/runtime/ai/chartquery/testdata/flint_heatmap.json @@ -0,0 +1,227 @@ +{ + "chart_type": "Heatmap", + "fields": [ + "device", + "advertiser", + "bids" + ], + "measures": [ + "bids" + ], + "row_limit": 1500, + "top_n": [ + { + "channel": "x", + "field": "device", + "limit": 20, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "device" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 21, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + }, + { + "channel": "y", + "field": "advertiser", + "limit": 50, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 51, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "device" + }, + { + "name": "advertiser" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + null + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + "iOS" + ] + } + ] + } + } + ] + } + }, + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + "A" + ] + } + ] + } + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/flint_line_chart_comparison.json b/runtime/ai/chartquery/testdata/flint_line_chart_comparison.json new file mode 100644 index 000000000000..d0caf95aa872 --- /dev/null +++ b/runtime/ai/chartquery/testdata/flint_line_chart_comparison.json @@ -0,0 +1,158 @@ +{ + "chart_type": "Line Chart", + "time_grain": "TIME_GRAIN_MONTH", + "time_field": "__time", + "fields": [ + "__time", + "bids", + "device" + ], + "measures": [ + "bids" + ], + "row_limit": 1500, + "top_n": [ + { + "channel": "color", + "field": "device", + "limit": 3, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "device" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 4, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "__time", + "compute": { + "time_floor": { + "dimension": "__time", + "grain": "month" + } + } + }, + { + "name": "device" + } + ], + "measures": [ + { + "name": "bids" + }, + { + "name": "bids_prev", + "compute": { + "comparison_value": { + "measure": "bids" + } + } + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "__time", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": { + "start": "2023-12-01T00:00:00Z", + "end": "2024-01-01T00:00:00Z" + }, + "where": { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + null + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + "iOS", + "Android" + ] + } + ] + } + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/flint_multi_measure_fold.json b/runtime/ai/chartquery/testdata/flint_multi_measure_fold.json new file mode 100644 index 000000000000..a0e4bcd300fe --- /dev/null +++ b/runtime/ai/chartquery/testdata/flint_multi_measure_fold.json @@ -0,0 +1,57 @@ +{ + "chart_type": "Line Chart", + "time_grain": "TIME_GRAIN_DAY", + "time_field": "__time", + "fields": [ + "__time", + "bids", + "clicks" + ], + "measures": [ + "bids", + "clicks" + ], + "row_limit": 1500, + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "__time", + "compute": { + "time_floor": { + "dimension": "__time", + "grain": "day" + } + } + } + ], + "measures": [ + { + "name": "bids" + }, + { + "name": "clicks" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "__time", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": null, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "America/New_York", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/flint_pie_chart.json b/runtime/ai/chartquery/testdata/flint_pie_chart.json new file mode 100644 index 000000000000..316e84c04e8b --- /dev/null +++ b/runtime/ai/chartquery/testdata/flint_pie_chart.json @@ -0,0 +1,135 @@ +{ + "chart_type": "Pie Chart", + "fields": [ + "bids", + "device" + ], + "measures": [ + "bids" + ], + "row_limit": 1500, + "top_n": [ + { + "channel": "color", + "field": "device", + "limit": 5, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "device" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 6, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "device" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + null + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + "iOS", + "Android" + ] + } + ] + } + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/flint_scatter_plot_detail.json b/runtime/ai/chartquery/testdata/flint_scatter_plot_detail.json new file mode 100644 index 000000000000..6090234999f9 --- /dev/null +++ b/runtime/ai/chartquery/testdata/flint_scatter_plot_detail.json @@ -0,0 +1,155 @@ +{ + "chart_type": "Scatter Plot", + "fields": [ + "bids", + "clicks", + "device", + "spend", + "campaign_id" + ], + "measures": [ + "bids", + "clicks", + "spend" + ], + "row_limit": 1500, + "top_n": [ + { + "channel": "color", + "field": "device", + "limit": 10, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "device" + } + ], + "measures": [ + { + "name": "bids" + }, + { + "name": "clicks" + }, + { + "name": "spend" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 11, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "device" + }, + { + "name": "campaign_id" + } + ], + "measures": [ + { + "name": "bids" + }, + { + "name": "clicks" + }, + { + "name": "spend" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + null + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + "iOS", + "Android" + ] + } + ] + } + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/flint_slope_chart.json b/runtime/ai/chartquery/testdata/flint_slope_chart.json new file mode 100644 index 000000000000..ba99e99a4ea8 --- /dev/null +++ b/runtime/ai/chartquery/testdata/flint_slope_chart.json @@ -0,0 +1,147 @@ +{ + "chart_type": "Slope Chart", + "time_grain": "TIME_GRAIN_YEAR", + "time_field": "__time", + "fields": [ + "__time", + "bids", + "advertiser" + ], + "measures": [ + "bids" + ], + "row_limit": 1500, + "top_n": [ + { + "channel": "color", + "field": "advertiser", + "limit": 10, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 11, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "__time", + "compute": { + "time_floor": { + "dimension": "__time", + "grain": "year" + } + } + }, + { + "name": "advertiser" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "__time", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + "A", + "B" + ] + } + ] + } + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/flint_stacked_bar_chart.json b/runtime/ai/chartquery/testdata/flint_stacked_bar_chart.json new file mode 100644 index 000000000000..e9fda11b1d93 --- /dev/null +++ b/runtime/ai/chartquery/testdata/flint_stacked_bar_chart.json @@ -0,0 +1,146 @@ +{ + "chart_type": "Stacked Bar Chart", + "time_grain": "TIME_GRAIN_WEEK", + "time_field": "__time", + "fields": [ + "__time", + "bids", + "device" + ], + "measures": [ + "bids" + ], + "row_limit": 1500, + "top_n": [ + { + "channel": "color", + "field": "device", + "limit": 10, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "device" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 11, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "__time", + "compute": { + "time_floor": { + "dimension": "__time", + "grain": "week" + } + } + }, + { + "name": "device" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "__time", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + null + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + "iOS" + ] + } + ] + } + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/flint_waterfall_chart.json b/runtime/ai/chartquery/testdata/flint_waterfall_chart.json new file mode 100644 index 000000000000..95bda6b66f26 --- /dev/null +++ b/runtime/ai/chartquery/testdata/flint_waterfall_chart.json @@ -0,0 +1,52 @@ +{ + "chart_type": "Waterfall Chart", + "time_grain": "TIME_GRAIN_MONTH", + "time_field": "__time", + "fields": [ + "__time", + "spend" + ], + "measures": [ + "spend" + ], + "row_limit": 1500, + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "__time", + "compute": { + "time_floor": { + "dimension": "__time", + "grain": "month" + } + } + } + ], + "measures": [ + { + "name": "spend" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "__time", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": null, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/funnel_chart_dimension.json b/runtime/ai/chartquery/testdata/funnel_chart_dimension.json new file mode 100644 index 000000000000..56eca3a5e5b7 --- /dev/null +++ b/runtime/ai/chartquery/testdata/funnel_chart_dimension.json @@ -0,0 +1,127 @@ +{ + "row_limit": 15, + "top_n": [ + { + "channel": "stage", + "field": "stage", + "limit": 15, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "stage" + } + ], + "measures": [ + { + "name": "users" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "users", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "stage" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 16, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "stage" + } + ], + "measures": [ + { + "name": "users" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "users", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "stage" + }, + { + "val": [ + null + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "stage" + }, + { + "val": [ + "visit", + "signup" + ] + } + ] + } + } + ] + } + }, + "having": null, + "limit": 16, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/funnel_chart_measures.json b/runtime/ai/chartquery/testdata/funnel_chart_measures.json new file mode 100644 index 000000000000..e9745233e51f --- /dev/null +++ b/runtime/ai/chartquery/testdata/funnel_chart_measures.json @@ -0,0 +1,33 @@ +{ + "row_limit": 1, + "main": { + "metrics_view": "mv", + "dimensions": null, + "measures": [ + { + "name": "impressions" + }, + { + "name": "video_starts" + }, + { + "name": "video_completes" + } + ], + "pivot_on": null, + "spine": null, + "sort": null, + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": null, + "having": null, + "limit": 2, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/heatmap.json b/runtime/ai/chartquery/testdata/heatmap.json new file mode 100644 index 000000000000..3d142d2b3ae2 --- /dev/null +++ b/runtime/ai/chartquery/testdata/heatmap.json @@ -0,0 +1,131 @@ +{ + "row_limit": 1500, + "top_n": [ + { + "channel": "y", + "field": "hour", + "limit": 24, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "hour" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "hour" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 25, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "day" + }, + { + "name": "hour" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "day", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "day" + }, + { + "val": [ + "Mon", + "Tue" + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "hour" + }, + { + "val": [ + "0", + "1" + ] + } + ] + } + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/heatmap_temporal.json b/runtime/ai/chartquery/testdata/heatmap_temporal.json new file mode 100644 index 000000000000..827964e516ed --- /dev/null +++ b/runtime/ai/chartquery/testdata/heatmap_temporal.json @@ -0,0 +1,114 @@ +{ + "time_grain": "TIME_GRAIN_DAY", + "row_limit": 1500, + "top_n": [ + { + "channel": "y", + "field": "device", + "limit": 40, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "device" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 41, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "__time", + "compute": { + "time_floor": { + "dimension": "__time", + "grain": "day" + } + } + }, + { + "name": "device" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "__time", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "in", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + "iOS" + ] + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/line_chart.json b/runtime/ai/chartquery/testdata/line_chart.json new file mode 100644 index 000000000000..c3d390cce324 --- /dev/null +++ b/runtime/ai/chartquery/testdata/line_chart.json @@ -0,0 +1,119 @@ +{ + "time_grain": "TIME_GRAIN_MONTH", + "row_limit": 1500, + "top_n": [ + { + "channel": "color", + "field": "device", + "limit": 3, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "device" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "bids", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 4, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "__time", + "compute": { + "time_floor": { + "dimension": "__time", + "grain": "month" + } + } + }, + { + "name": "device" + } + ], + "measures": [ + { + "name": "bids" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "__time", + "desc": true + }, + { + "name": "device", + "desc": false + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "in", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + "iOS", + "Android" + ] + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/line_chart_comparison.json b/runtime/ai/chartquery/testdata/line_chart_comparison.json new file mode 100644 index 000000000000..59ef90140abf --- /dev/null +++ b/runtime/ai/chartquery/testdata/line_chart_comparison.json @@ -0,0 +1,54 @@ +{ + "time_grain": "TIME_GRAIN_DAY", + "row_limit": 1500, + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "__time", + "compute": { + "time_floor": { + "dimension": "__time", + "grain": "day" + } + } + } + ], + "measures": [ + { + "name": "bids" + }, + { + "name": "bids_prev", + "compute": { + "comparison_value": { + "measure": "bids" + } + } + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "__time", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": { + "start": "2023-12-01T00:00:00Z", + "end": "2024-01-01T00:00:00Z" + }, + "where": null, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/pie_chart_custom_order.json b/runtime/ai/chartquery/testdata/pie_chart_custom_order.json new file mode 100644 index 000000000000..080f40b9bdaa --- /dev/null +++ b/runtime/ai/chartquery/testdata/pie_chart_custom_order.json @@ -0,0 +1,92 @@ +{ + "row_limit": 20, + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "weekday" + } + ], + "measures": [ + { + "name": "commits" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "commits", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "in", + "exprs": [ + { + "name": "weekday" + }, + { + "val": [ + "Mon", + "Tue", + "Wed" + ] + } + ] + } + }, + "having": null, + "limit": 21, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + }, + "extra": { + "total": { + "metrics_view": "mv", + "dimensions": null, + "measures": [ + { + "name": "commits" + } + ], + "pivot_on": null, + "spine": null, + "sort": null, + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "weekday" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": null, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } +} diff --git a/runtime/ai/chartquery/testdata/scatter_plot.json b/runtime/ai/chartquery/testdata/scatter_plot.json new file mode 100644 index 000000000000..1f6f0e0deab3 --- /dev/null +++ b/runtime/ai/chartquery/testdata/scatter_plot.json @@ -0,0 +1,142 @@ +{ + "row_limit": 1500, + "top_n": [ + { + "channel": "color", + "field": "device", + "limit": 5, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "device" + } + ], + "measures": [ + { + "name": "bids" + }, + { + "name": "clicks" + }, + { + "name": "impressions" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "clicks", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 6, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "advertiser" + }, + { + "name": "device" + } + ], + "measures": [ + { + "name": "bids" + }, + { + "name": "clicks" + }, + { + "name": "impressions" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "clicks", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "and", + "exprs": [ + { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "advertiser" + }, + { + "val": [ + null + ] + } + ] + } + }, + { + "cond": { + "op": "in", + "exprs": [ + { + "name": "device" + }, + { + "val": [ + "iOS", + "Android" + ] + } + ] + } + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/chartquery/testdata/stacked_bar_multi_measure.json b/runtime/ai/chartquery/testdata/stacked_bar_multi_measure.json new file mode 100644 index 000000000000..59627b244271 --- /dev/null +++ b/runtime/ai/chartquery/testdata/stacked_bar_multi_measure.json @@ -0,0 +1,50 @@ +{ + "time_grain": "TIME_GRAIN_WEEK", + "row_limit": 1500, + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "__time", + "compute": { + "time_floor": { + "dimension": "__time", + "grain": "week" + } + } + } + ], + "measures": [ + { + "name": "clicks" + }, + { + "name": "impressions" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "__time", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": null, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + }, + "measure_domain": [ + "clicks", + "impressions" + ] +} diff --git a/runtime/ai/chartquery/testdata/stacked_bar_normalized.json b/runtime/ai/chartquery/testdata/stacked_bar_normalized.json new file mode 100644 index 000000000000..40b101f8aa78 --- /dev/null +++ b/runtime/ai/chartquery/testdata/stacked_bar_normalized.json @@ -0,0 +1,119 @@ +{ + "time_grain": "TIME_GRAIN_DAY", + "row_limit": 1500, + "top_n": [ + { + "channel": "color", + "field": "username", + "limit": 3, + "query": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "username" + } + ], + "measures": [ + { + "name": "commits" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "commits", + "desc": true + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "nin", + "exprs": [ + { + "name": "username" + }, + { + "val": [ + null + ] + } + ] + } + }, + "having": null, + "limit": 4, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } + } + ], + "main": { + "metrics_view": "mv", + "dimensions": [ + { + "name": "date", + "compute": { + "time_floor": { + "dimension": "date", + "grain": "day" + } + } + }, + { + "name": "username" + } + ], + "measures": [ + { + "name": "commits" + } + ], + "pivot_on": null, + "spine": null, + "sort": [ + { + "name": "date", + "desc": true + }, + { + "name": "username", + "desc": false + } + ], + "time_range": { + "start": "2024-01-01T00:00:00Z", + "end": "2024-02-01T00:00:00Z" + }, + "comparison_time_range": null, + "where": { + "cond": { + "op": "in", + "exprs": [ + { + "name": "username" + }, + { + "val": [ + "alice", + "bob" + ] + } + ] + } + }, + "having": null, + "limit": 1501, + "offset": null, + "time_zone": "", + "use_display_names": false, + "rows": false + } +} diff --git a/runtime/ai/click_ui.go b/runtime/ai/click_ui.go index 7074642253ad..77832af809a9 100644 --- a/runtime/ai/click_ui.go +++ b/runtime/ai/click_ui.go @@ -3,7 +3,6 @@ package ai import ( "context" "errors" - "strings" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/rilldata/rill/runtime" @@ -46,7 +45,7 @@ func (t *ClickUI) CheckAccess(ctx context.Context) (bool, error) { } // Browser actions are only meaningful for the first-party Rill client. - if !strings.HasPrefix(s.CatalogSession().UserAgent, "rill") { + if !isRillUserAgent(s) { return false, nil } ui, ok := UIContextFromContext(ctx) diff --git a/runtime/ai/create_chart.go b/runtime/ai/create_chart.go index dafed1a174c1..20a5532054db 100644 --- a/runtime/ai/create_chart.go +++ b/runtime/ai/create_chart.go @@ -1,42 +1,126 @@ package ai import ( + "cmp" "context" "encoding/json" + "errors" "fmt" + "maps" + "slices" + "strconv" "strings" + "time" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" runtimev1 "github.com/rilldata/rill/proto/gen/rill/runtime/v1" "github.com/rilldata/rill/runtime" + "github.com/rilldata/rill/runtime/ai/chartquery" + "github.com/rilldata/rill/runtime/ai/mcpapp" + "github.com/rilldata/rill/runtime/drivers" "github.com/rilldata/rill/runtime/metricsview" "github.com/rilldata/rill/runtime/pkg/pathutil" + "google.golang.org/protobuf/encoding/protojson" ) const CreateChartName = "create_chart" +// chartWidget identifies the widget mode of the result, shared with the MCP Apps widget. +const chartWidget = "chart" + +// chartQueryTimeout bounds the data queries of a chart (the same as query_metrics_view). +const chartQueryTimeout = 2 * time.Minute + +// chartMarkdownRows is the number of rows included in the markdown fallback of an MCP result. +const chartMarkdownRows = 20 + type CreateChart struct { Runtime *runtime.Runtime } var _ Tool[CreateChartArgs, *CreateChartResult] = (*CreateChart)(nil) +var _ MCPResultDecorator[*CreateChartResult] = (*CreateChart)(nil) + +var _ MCPAppTool = (*CreateChart)(nil) + type CreateChartArgs map[string]any +// CreateChartResult is the result of create_chart. +// The in-app chat renders the chart from the spec and fetches the data itself, so it only receives the first four fields: +// the widget mode, which is always present, plus the chart type, spec and message it received before. +// External MCP clients cannot query Rill, so for them the result also carries the chart-ready data (see ChartDataResult), +// the field metadata needed to format it, the project theme and a link that opens the chart in Rill, +// and the spec is returned with the effective top-N limit of each channel filled in (see specWithLimits). +// Those fields are omitted when empty, so the in-app result keeps its old shape plus widget. type CreateChartResult struct { - ChartType string `json:"chart_type"` - Spec map[string]any `json:"spec"` - Message string `json:"message"` + // Widget selects the mode of the MCP Apps widget bundle. + Widget string `json:"widget"` + ChartType string `json:"chart_type"` + // Spec is the chart spec of the in-app shape; Input is the effective input of the Flint shape (see ChartInput). One of the two is set. + Spec map[string]any `json:"spec,omitempty"` + Input *ChartInput `json:"input,omitempty"` + Message string `json:"message"` + + Title string `json:"title,omitempty"` + MetricsView *ChartMetricsView `json:"metrics_view,omitempty"` + *ChartDataResult + // Theme is the protojson form of the project's default theme spec, when the project has one. + Theme map[string]any `json:"theme,omitempty"` + OpenURL string `json:"open_url,omitempty"` } -func (t *CreateChart) Spec() *mcp.Tool { - var inputSchema *jsonschema.Schema - err := json.Unmarshal([]byte(metricsview.ChartsJSONSchema), &inputSchema) - if err != nil { - panic(fmt.Errorf("failed to unmarshal input schema: %w", err)) - } +// ChartMetricsView summarizes the metrics view a chart is built on. +type ChartMetricsView struct { + Name string `json:"name"` + DisplayName string `json:"display_name,omitempty"` + TimeDimension string `json:"time_dimension,omitempty"` + SmallestTimeGrain string `json:"smallest_time_grain,omitempty"` +} + +// ChartDataResult is the chart-ready data shared by create_chart (for external clients) and chart_data. +// Rows are keyed by field name; comparison values are under _prev and temporal values are RFC3339 UTC strings. +// Data is always present when the data was queried, as [] when there are no rows, so that a renderer can tell an empty chart +// from a result without data. It is tagged omitzero rather than omitempty for that: a nil slice (never produced by chartquery) +// is omitted while an empty one is kept, and the inferred output schema keeps it optional, which the MCP server's output validation +// needs for in-app results, whose embedded ChartDataResult is nil. +type ChartDataResult struct { + TimeGrain string `json:"time_grain,omitempty"` + Data []map[string]any `json:"data,omitzero"` + Fields map[string]*ChartField `json:"fields,omitempty"` + // SemanticTypes and FieldDisplayNames are the per-field inputs of the Flint chart compiler (see chartSemantics), + // including the comparison values of the measures when a comparison is set. + SemanticTypes map[string]string `json:"semantic_types,omitempty"` + FieldDisplayNames map[string]string `json:"field_display_names,omitempty"` + DomainValues map[string][]any `json:"domain_values,omitempty"` + Totals map[string]any `json:"totals,omitempty"` + ResolvedTimeRange *chartquery.TimeRange `json:"resolved_time_range,omitempty"` + ResolvedComparisonTimeRange *chartquery.TimeRange `json:"resolved_comparison_time_range,omitempty"` + // Truncated reports any truncation (top-N or rows); RowsTruncated reports that rows were cut, + // in which case a temporal chart keeps its newest buckets (see chartquery.Output). + Truncated bool `json:"truncated,omitempty"` + RowsTruncated bool `json:"rows_truncated,omitempty"` + TruncationWarning string `json:"truncation_warning,omitempty"` +} + +// ChartField describes a metrics view field used by a chart, with what a renderer needs to label and format it. +type ChartField struct { + Name string `json:"name"` + // Kind is "measure", "dimension" or "time". + Kind string `json:"kind"` + DisplayName string `json:"display_name,omitempty"` + Description string `json:"description,omitempty"` + Type string `json:"type,omitempty"` + FormatPreset string `json:"format_preset,omitempty"` + FormatD3 string `json:"format_d3,omitempty"` + FormatD3Locale map[string]any `json:"format_d3_locale,omitempty"` + LowerIsBetter bool `json:"lower_is_better,omitempty"` +} +// Spec is the in-app definition of the tool: Rill's own chart types and spec vocabulary, which the in-app chat renders itself. +// External MCP clients get the Flint shape instead (see MCPSpec). +func (t *CreateChart) Spec() *mcp.Tool { return &mcp.Tool{ Name: CreateChartName, Title: "Create chart", @@ -51,24 +135,28 @@ func (t *CreateChart) Spec() *mcp.Tool { "openai/toolInvocation/invoking": "Creating chart...", "openai/toolInvocation/invoked": "Created chart", }, - InputSchema: inputSchema, + InputSchema: chartsInputSchema(), } } +// MCPSpec is the definition advertised on the MCP server: the Flint shape of the arguments (a chart spec of chart type and +// channel bindings plus a data block naming the metrics view), described from the embedded chart catalog. See chart_flint.go. +func (t *CreateChart) MCPSpec() *mcp.Tool { + spec := t.Spec() + spec.Description = flintCreateChartDescription() + spec.InputSchema = flintChartsInputSchema() + return spec +} + func (t *CreateChart) CheckAccess(ctx context.Context) (bool, error) { - // Must be able to query metrics + // Must be able to query metrics and have the chat_charts feature flag. + // The tool is offered to every client: Rill's own clients render the chart themselves, and external MCP clients get the chart data, + // which the MCP Apps widget renders when the server serves it (see Session.MCPServer) and other clients get as a markdown table. s := GetSession(ctx) if !s.Claims().Can(runtime.ReadMetrics) { return false, nil } - - // Only allow for rill user agents since it doesn't work with external MCP clients - if !strings.HasPrefix(s.CatalogSession().UserAgent, "rill") { - return false, nil - } - - // Must have the chat_charts feature flag - ff, err := t.Runtime.FeatureFlags(ctx, s.InstanceID(), s.Claims()) + ff, err := s.FeatureFlags(ctx) if err != nil { return false, err } @@ -78,9 +166,133 @@ func (t *CreateChart) CheckAccess(ctx context.Context) (bool, error) { func (t *CreateChart) Handler(ctx context.Context, args CreateChartArgs) (*CreateChartResult, error) { s := GetSession(ctx) + req, err := parseChartRequest(ctx, t.Runtime, s, args) + if err != nil { + return nil, err + } + + res := &CreateChartResult{ + Widget: chartWidget, + ChartType: req.chartType, + Spec: req.spec, + Input: req.input, + Message: fmt.Sprintf("Chart created successfully: %s", req.chartType), + } + + // The in-app chat fetches the data itself from the spec. External clients cannot, so run the chart's queries for them. + if isRillUserAgent(s) { + return res, nil + } + + instance, err := t.Runtime.Instance(ctx, s.InstanceID()) + if err != nil { + return nil, fmt.Errorf("failed to get instance: %w", err) + } + var limits map[string]int + res.ChartDataResult, limits, err = queryChartData(ctx, t.Runtime, s, instance, req) + if err != nil { + return nil, err + } + if req.flint != nil { + res.Input = flintInputWithLimits(req.input, limits, res.TimeGrain) + res.Title = flintTitle(req.flint, req.input.ChartSpec, res.Fields) + } else { + res.Spec = specWithLimits(req.spec, limits) + res.Title = chartTitle(req.parsed, res.Fields) + } + res.MetricsView = &ChartMetricsView{ + Name: req.metricsView(), + DisplayName: req.mv.DisplayName, + TimeDimension: req.mv.TimeDimension, + } + if req.mv.SmallestTimeGrain != runtimev1.TimeGrain_TIME_GRAIN_UNSPECIFIED { + res.MetricsView.SmallestTimeGrain = req.mv.SmallestTimeGrain.String() + } + res.Theme, err = chartTheme(ctx, t.Runtime, instance) + if err != nil { + return nil, err + } + res.OpenURL, err = aiMessageOpenURL(instance.FrontendURL, s.ID(), s.ParentID) + if err != nil { + return nil, fmt.Errorf("failed to generate open URL: %w", err) + } + + return res, nil +} + +// MCPAppUI links create_chart to the MCP Apps widget, which renders the chart from the result. +func (t *CreateChart) MCPAppUI() *mcpapp.ToolUI { + return &mcpapp.ToolUI{} +} + +// DecorateMCPResult renders the chart as markdown for MCP clients that cannot render the widget: +// a title line with the time range, a table of the first rows, the row count and the link to open the chart in Rill. +func (t *CreateChart) DecorateMCPResult(ctx context.Context, out *CreateChartResult, res *mcp.CallToolResult) { + text := out.Message + if out.ChartDataResult != nil { + text = chartMarkdown(out) + } + res.Content = []mcp.Content{&mcp.TextContent{Text: text}} +} + +// chartRequest is a validated create_chart or chart_data call, in the in-app shape (spec and parsed) or the Flint shape (input and flint). +type chartRequest struct { + chartType string + spec map[string]any + parsed *chartquery.Spec + input *ChartInput + flint *chartquery.FlintSpec + // semanticTypes are the caller's overrides of the derived semantic types (Flint shape only). + semanticTypes map[string]any + mv *runtimev1.MetricsViewSpec +} + +func (r *chartRequest) metricsView() string { + if r.flint != nil { + return r.flint.MetricsView + } + return r.parsed.MetricsView +} + +// fields returns the metrics view fields the chart binds and which of them are temporal. +func (r *chartRequest) fields() (names []string, temporal map[string]bool) { + temporal = make(map[string]bool) + if r.flint != nil { + for _, e := range r.flint.Encodings { + if e.Kind == chartquery.FieldKindTime { + temporal[e.Field] = true + } + } + return r.flint.Fields(), temporal + } + for _, f := range []*chartquery.Field{r.parsed.X, r.parsed.Y, r.parsed.Dimension} { + if f != nil && f.Type == chartquery.TypeTemporal { + temporal[f.Field] = true + } + } + return r.parsed.Fields(), temporal +} + +// chartsInputSchema returns the JSON schema of the chart arguments shared by create_chart and chart_data. +func chartsInputSchema() *jsonschema.Schema { + var schema *jsonschema.Schema + err := json.Unmarshal([]byte(metricsview.ChartsJSONSchema), &schema) + if err != nil { + panic(fmt.Errorf("failed to unmarshal input schema: %w", err)) + } + return schema +} + +// parseChartRequest validates the chart arguments against the metrics view the caller has access to. +// The Flint shape (chart_spec and data) is recognized by its chart_spec; anything else is the in-app shape (chart_type and spec). +func parseChartRequest(ctx context.Context, rt *runtime.Runtime, s *Session, args map[string]any) (*chartRequest, error) { + if _, ok := args["chart_spec"]; ok { + return parseFlintChartRequest(ctx, rt, s, args) + } + chartType, ok := args["chart_type"].(string) if !ok || chartType == "" { - return nil, fmt.Errorf("chart_type is required and must be a string") + return nil, fmt.Errorf("chart_spec and data are required: the chart type and encodings, and the metrics view and time range to query (the deprecated chart_type and spec shape is also accepted)") } spec, ok := args["spec"].(map[string]any) @@ -123,14 +335,7 @@ func (t *CreateChart) Handler(ctx context.Context, args CreateChartArgs) (*Creat "TIME_GRAIN_HOUR", "TIME_GRAIN_DAY", "TIME_GRAIN_WEEK", "TIME_GRAIN_MONTH", "TIME_GRAIN_QUARTER", "TIME_GRAIN_YEAR", } - isValid := false - for _, valid := range validTimeGrains { - if timeGrainStr == valid { - isValid = true - break - } - } - if !isValid { + if !slices.Contains(validTimeGrains, timeGrainStr) { return nil, fmt.Errorf("'time_grain' must be one of: %v", validTimeGrains) } } @@ -160,16 +365,40 @@ func (t *CreateChart) Handler(ctx context.Context, args CreateChartArgs) (*Creat } } - // Validate that the metrics view exists - ctrl, err := t.Runtime.Controller(ctx, s.InstanceID()) + mv, err := loadMetricsView(ctx, rt, s, metricsView) + if err != nil { + return nil, err + } + + // Validate that all field references in the chart spec exist in the metrics view + if err := validateChartFields(chartType, spec, mv); err != nil { + return nil, fmt.Errorf("field validation failed: %w", err) + } + + parsed, err := chartquery.ParseSpec(chartType, spec) + if err != nil { + return nil, err + } + + return &chartRequest{ + chartType: chartType, + spec: spec, + parsed: parsed, + mv: mv, + }, nil +} + +// loadMetricsView returns the valid spec of a metrics view the caller has access to, as the caller may see it. +func loadMetricsView(ctx context.Context, rt *runtime.Runtime, s *Session, name string) (*runtimev1.MetricsViewSpec, error) { + ctrl, err := rt.Controller(ctx, s.InstanceID()) if err != nil { return nil, err } - r, err := ctrl.Get(ctx, &runtimev1.ResourceName{Kind: runtime.ResourceKindMetricsView, Name: metricsView}, false) + r, err := ctrl.Get(ctx, &runtimev1.ResourceName{Kind: runtime.ResourceKindMetricsView, Name: name}, false) if err != nil { - return nil, fmt.Errorf("metrics view %q not found: %w", metricsView, err) + return nil, fmt.Errorf("metrics view %q not found: %w", name, err) } - r, access, err := t.Runtime.ApplySecurityPolicy(ctx, s.InstanceID(), s.Claims(), r) + r, access, err := rt.ApplySecurityPolicy(ctx, s.InstanceID(), s.Claims(), r) if err != nil { return nil, err } @@ -178,22 +407,435 @@ func (t *CreateChart) Handler(ctx context.Context, args CreateChartArgs) (*Creat } mvState := r.GetMetricsView().State if mvState.ValidSpec == nil { - return nil, fmt.Errorf("metrics view %q is invalid", metricsView) + return nil, fmt.Errorf("metrics view %q is invalid", name) } + return mvState.ValidSpec, nil +} - // Validate that all field references in the chart spec exist in the metrics view - if err := validateChartFields(chartType, spec, mvState.ValidSpec); err != nil { - return nil, fmt.Errorf("field validation failed: %w", err) +// queryChartData runs the chart's queries with the session's claims and the instance's AI query limits. +// It returns the chart-ready data and the effective top-N limit of each spec channel (see chartquery.Output.Limits). +// The result always carries the field metadata and the resolved time ranges, even when the queries return no rows, +// so that a renderer can show an empty state for the chart's fields and time range. +func queryChartData(ctx context.Context, rt *runtime.Runtime, s *Session, instance *drivers.Instance, req *chartRequest) (*ChartDataResult, map[string]int, error) { + cfg, err := instance.Config() + if err != nil { + return nil, nil, fmt.Errorf("failed to get instance config: %w", err) + } + + var plan *chartquery.Plan + if req.flint != nil { + plan, err = chartquery.BuildFlint(req.flint) + } else { + plan, err = chartquery.Build(req.parsed) } + if err != nil { + return nil, nil, err + } + + // Apply a timeout across the chart's queries to prevent runaway queries + ctx, cancel := context.WithTimeout(ctx, chartQueryTimeout) + defer cancel() + + out, err := chartquery.Execute(ctx, plan, &metricsQueryRunner{ + rt: rt, + instanceID: s.InstanceID(), + claims: s.Claims(), + limits: metricsview.QueryLimits{ + RequireTimeRange: cfg.AIRequireTimeRange, + MaxTimeRangeDays: cfg.AIMaxTimeRangeDays, + }, + }) + if err != nil { + return nil, nil, err + } + + names, temporal := req.fields() + fields := chartFields(names, temporal, req.mv, out.Types) + semanticTypes, displayNames := chartSemantics(fields, out.TimeGrain, out.ResolvedComparisonTimeRange != nil, req.semanticTypes) + return &ChartDataResult{ + TimeGrain: out.TimeGrain, + Data: out.Data, + Fields: fields, + SemanticTypes: semanticTypes, + FieldDisplayNames: displayNames, + DomainValues: out.DomainValues, + Totals: out.Totals, + ResolvedTimeRange: out.ResolvedTimeRange, + ResolvedComparisonTimeRange: out.ResolvedComparisonTimeRange, + Truncated: out.Truncated, + RowsTruncated: out.RowsTruncated, + TruncationWarning: out.TruncationWarning, + }, out.Limits, nil +} + +// specWithLimits returns a copy of the spec with the effective top-N limit of each channel filled in, +// so that a renderer can show the limit that was applied where the spec left it to the default. The input maps are not modified. +func specWithLimits(spec map[string]any, limits map[string]int) map[string]any { + if len(limits) == 0 { + return spec + } + res := maps.Clone(spec) + for channel, limit := range limits { + field, ok := res[channel].(map[string]any) + if !ok { + continue + } + field = maps.Clone(field) + field["limit"] = limit + res[channel] = field + } + return res +} + +// metricsQueryRunner runs chart queries through the runtime's metrics resolver. +type metricsQueryRunner struct { + rt *runtime.Runtime + instanceID string + claims *runtime.SecurityClaims + limits metricsview.QueryLimits +} - // Return the chart specification in a structured format - return &CreateChartResult{ - ChartType: chartType, - Spec: spec, - Message: fmt.Sprintf("Chart created successfully: %s", chartType), +func (r *metricsQueryRunner) Query(ctx context.Context, q *metricsview.Query) (*chartquery.Result, error) { + q.QueryLimits = &r.limits + props, err := q.AsMap() + if err != nil { + return nil, err + } + + res, _, err := r.rt.Resolve(ctx, &runtime.ResolveOptions{ + InstanceID: r.instanceID, + Resolver: "metrics", + ResolverProperties: props, + Claims: r.claims, + }) + if err != nil { + return nil, err + } + defer res.Close() + + schema, rows, err := resolverResultToRows(res) + if err != nil { + return nil, err + } + types := make(map[string]string, len(schema)) + for _, f := range schema { + types[f.Name] = f.Type + } + + tr, ctr, err := resolveTimeRanges(res) + if err != nil { + return nil, err + } + + return &chartquery.Result{ + Rows: rows, + Types: types, + TimeRange: chartTimeRange(tr), + ComparisonTimeRange: chartTimeRange(ctr), }, nil } +func chartTimeRange(tr *metricsview.TimeRange) *chartquery.TimeRange { + if tr == nil { + return nil + } + return &chartquery.TimeRange{Start: tr.Start, End: tr.End} +} + +// chartFields describes the metrics view fields a chart references (temporal marks the ones used as time fields), +// with the types of the result columns. +func chartFields(names []string, temporal map[string]bool, mv *runtimev1.MetricsViewSpec, types map[string]string) map[string]*ChartField { + fields := make(map[string]*ChartField) + for _, name := range names { + field := &ChartField{Name: name, Type: types[name]} + if i := slices.IndexFunc(mv.Measures, func(m *runtimev1.MetricsViewSpec_Measure) bool { return m.Name == name }); i >= 0 { + m := mv.Measures[i] + field.Kind = "measure" + field.DisplayName = m.DisplayName + field.Description = m.Description + field.FormatPreset = m.FormatPreset + field.FormatD3 = m.FormatD3 + field.LowerIsBetter = m.LowerIsBetter + if m.FormatD3Locale != nil { + field.FormatD3Locale = m.FormatD3Locale.AsMap() + } + if field.Type == "" && m.DataType != nil { + field.Type = strings.TrimPrefix(m.DataType.Code.String(), "CODE_") + } + } else if i := slices.IndexFunc(mv.Dimensions, func(d *runtimev1.MetricsViewSpec_Dimension) bool { return d.Name == name }); i >= 0 { + d := mv.Dimensions[i] + field.Kind = "dimension" + field.DisplayName = d.DisplayName + field.Description = d.Description + if field.Type == "" && d.DataType != nil { + field.Type = strings.TrimPrefix(d.DataType.Code.String(), "CODE_") + } + } else { + continue + } + // The time dimension is always among the dimensions (the parser adds it), so it is found above and retyped here. + if temporal[name] || name == mv.TimeDimension { + field.Kind = "time" + if field.Type == "" { + field.Type = "TIMESTAMP" + } + } + if field.DisplayName == "" { + field.DisplayName = name + } + fields[name] = field + } + return fields +} + +// chartTitle builds the chart title the same way the frontend chart providers do. +func chartTitle(parsed *chartquery.Spec, fields map[string]*ChartField) string { + label := func(f *chartquery.Field) string { + if f == nil || f.Field == "" { + return "" + } + if f.Type == chartquery.TypeTemporal { + return "Time" + } + if field, ok := fields[f.Field]; ok && field.DisplayName != "" { + return field.DisplayName + } + return f.Field + } + labels := func(names []string) string { + res := make([]string, len(names)) + for i, n := range names { + res[i] = n + if field, ok := fields[n]; ok && field.DisplayName != "" { + res[i] = field.DisplayName + } + } + return strings.Join(res, ", ") + } + preposition := func(x *chartquery.Field, over, other string) string { + if x != nil && x.Type == chartquery.TypeTemporal { + return over + } + return other + } + + switch parsed.ChartType { + case chartquery.BarChart, chartquery.LineChart, chartquery.AreaChart, chartquery.StackedBar, chartquery.StackedBarNormalized: + if parsed.Y != nil && len(parsed.Y.Fields) > 0 { + return fmt.Sprintf("%s %s %s", labels(parsed.Y.Fields), preposition(parsed.X, "over", "by"), label(parsed.X)) + } + title := fmt.Sprintf("%s %s %s", label(parsed.Y), preposition(parsed.X, "over", "per"), label(parsed.X)) + if parsed.Color != nil && parsed.Color.Field != "" && parsed.Color.Field != chartquery.MeasuresField { + title += " split by " + label(parsed.Color) + } + return title + case chartquery.DonutChart, chartquery.PieChart: + if parsed.Color != nil && parsed.Color.Field != "" { + return fmt.Sprintf("%s split by %s", label(parsed.Measure), label(parsed.Color)) + } + return label(parsed.Measure) + case chartquery.FunnelChart: + if parsed.Measure == nil || (parsed.Measure.Field == "" && len(parsed.Measure.Fields) == 0) { + return "Funnel" + } + if parsed.BreakdownMode == "measures" || len(parsed.Measure.Fields) > 0 { + names := parsed.Measure.Fields + if len(names) == 0 { + names = []string{parsed.Measure.Field} + } + return labels(names) + " funnel" + } + if parsed.Stage != nil && parsed.Stage.Field != "" { + return fmt.Sprintf("%s funnel by %s", label(parsed.Measure), label(parsed.Stage)) + } + return label(parsed.Measure) + case chartquery.Heatmap: + return fmt.Sprintf("%s by %s and %s", label(parsed.Color), label(parsed.X), label(parsed.Y)) + case chartquery.ComboChart: + measures := label(parsed.Y1) + if l := label(parsed.Y2); l != "" { + measures += " & " + l + } + return fmt.Sprintf("%s %s %s", measures, preposition(parsed.X, "over", "per"), label(parsed.X)) + case chartquery.ScatterPlot: + title := fmt.Sprintf("%s vs %s", label(parsed.X), label(parsed.Y)) + if l := label(parsed.Dimension); l != "" { + title += " for " + l + } + if l := label(parsed.Color); l != "" { + title += " split by " + l + } + if l := label(parsed.Size); l != "" { + title += " sized by " + l + } + return title + } + return "" +} + +// chartTheme returns the project's default theme spec as protojson, or nil when the project has none. +func chartTheme(ctx context.Context, rt *runtime.Runtime, instance *drivers.Instance) (map[string]any, error) { + if instance.Theme == "" { + return nil, nil + } + + ctrl, err := rt.Controller(ctx, instance.ID) + if err != nil { + return nil, err + } + r, err := ctrl.Get(ctx, &runtimev1.ResourceName{Kind: runtime.ResourceKindTheme, Name: instance.Theme}, false) + if err != nil { + if errors.Is(err, drivers.ErrResourceNotFound) { + // A dangling theme reference is a project misconfiguration that should not fail the chart. + return nil, nil + } + return nil, fmt.Errorf("failed to get theme %q: %w", instance.Theme, err) + } + spec := r.GetTheme().GetSpec() + if spec == nil { + return nil, nil + } + + data, err := protojson.Marshal(spec) + if err != nil { + return nil, fmt.Errorf("failed to marshal theme: %w", err) + } + var theme map[string]any + if err := json.Unmarshal(data, &theme); err != nil { + return nil, fmt.Errorf("failed to unmarshal theme: %w", err) + } + return theme, nil +} + +// chartMarkdown renders a chart result as markdown: a bold title with the time range, a table of the first rows, +// the row count and the link to open the chart in Rill. +func chartMarkdown(out *CreateChartResult) string { + var b strings.Builder + + fmt.Fprintf(&b, "**%s: %s**", chartTypeLabel(out.ChartType), out.Title) + if tr := out.ResolvedTimeRange; tr != nil { + fmt.Fprintf(&b, " (%s to %s", formatChartTime(tr.Start), formatChartTime(tr.End)) + if ctr := out.ResolvedComparisonTimeRange; ctr != nil { + fmt.Fprintf(&b, ", vs %s to %s", formatChartTime(ctr.Start), formatChartTime(ctr.End)) + } + b.WriteString(")") + } + b.WriteString("\n\n") + + if len(out.Data) > 0 { + columns := chartColumns(out) + for i, c := range columns { + if i > 0 { + b.WriteString(" | ") + } else { + b.WriteString("| ") + } + b.WriteString(escapeMarkdownCell(c.title)) + } + b.WriteString(" |\n|") + for range columns { + b.WriteString(" - |") + } + b.WriteString("\n") + for _, row := range out.Data[:min(len(out.Data), chartMarkdownRows)] { + b.WriteString("|") + for _, c := range columns { + b.WriteString(" ") + b.WriteString(escapeMarkdownCell(formatChartValue(row[c.name]))) + b.WriteString(" |") + } + b.WriteString("\n") + } + fmt.Fprintf(&b, "\n_%d of %d rows shown_", min(len(out.Data), chartMarkdownRows), len(out.Data)) + } else { + b.WriteString("_No data for this chart._") + } + if out.TruncationWarning != "" { + b.WriteString("\n\n") + b.WriteString(out.TruncationWarning) + } + if out.OpenURL != "" { + fmt.Fprintf(&b, "\n\nOpen the interactive chart in Rill: %s", out.OpenURL) + } + + return b.String() +} + +type chartColumn struct { + name string + title string +} + +// chartColumns orders the result columns for the markdown table using the field metadata the result carries: +// time fields, then dimensions, then measures, each measure followed by its comparison value, and each group sorted by name; +// columns without field metadata come last. +func chartColumns(out *CreateChartResult) []chartColumn { + rank := func(name string) int { + switch f := out.Fields[name]; { + case f == nil: + return 3 + case f.Kind == "time": + return 0 + case f.Kind == "dimension": + return 1 + default: + return 2 + } + } + names := slices.Sorted(maps.Keys(out.Data[0])) + slices.SortStableFunc(names, func(a, b string) int { return cmp.Compare(rank(a), rank(b)) }) + + var columns []chartColumn + for _, name := range names { + if base, ok := strings.CutSuffix(name, chartquery.PrevSuffix); ok && out.Fields[base] != nil { + continue // Added right after its measure below + } + title := name + if f := out.Fields[name]; f != nil && f.DisplayName != "" { + title = f.DisplayName + } + columns = append(columns, chartColumn{name: name, title: title}) + if prev := name + chartquery.PrevSuffix; out.Fields[name] != nil && slices.Contains(names, prev) { + columns = append(columns, chartColumn{name: prev, title: title + " (prev)"}) + } + } + return columns +} + +func chartTypeLabel(chartType string) string { + label := strings.ReplaceAll(chartType, "_", " ") + if label == "" { + return label + } + return strings.ToUpper(label[:1]) + label[1:] +} + +func formatChartTime(t time.Time) string { + t = t.UTC() + if t.Hour() == 0 && t.Minute() == 0 && t.Second() == 0 { + return t.Format(time.DateOnly) + } + return t.Format(time.RFC3339) +} + +func formatChartValue(v any) string { + switch v := v.(type) { + case nil: + return "" + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + case float32: + return strconv.FormatFloat(float64(v), 'f', -1, 32) + default: + return fmt.Sprintf("%v", v) + } +} + +func escapeMarkdownCell(s string) string { + s = strings.ReplaceAll(s, "|", "\\|") + return strings.ReplaceAll(s, "\n", " ") +} + // validateChartFields validates that all field references in the chart spec exist in the metrics view func validateChartFields(chartType string, spec map[string]any, mvSpec *runtimev1.MetricsViewSpec) error { // Build a map of available fields (dimensions and measures) @@ -424,6 +1066,8 @@ const createChartDescription = `# Chart Visualization Tool Create visualization charts based on metrics views. This tool generates chart specifications that will be rendered in the chat interface. +Clients that cannot render charts (for example terminals or IDE chats) receive the chart's data as a table together with a link. Whenever the result includes an ` + "`open_url`" + `, surface it to the user so they can open the interactive chart in Rill. + ## Required Parameters All chart specifications must include: diff --git a/runtime/ai/create_chart_test.go b/runtime/ai/create_chart_test.go new file mode 100644 index 000000000000..0e153ac73971 --- /dev/null +++ b/runtime/ai/create_chart_test.go @@ -0,0 +1,586 @@ +package ai_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/google/uuid" + "github.com/rilldata/rill/runtime" + "github.com/rilldata/rill/runtime/ai" + "github.com/rilldata/rill/runtime/pkg/activity" + "github.com/rilldata/rill/runtime/testruntime" + "github.com/stretchr/testify/require" +) + +// createChartTestFiles is a project with a time series metrics view and a default theme. +func createChartTestFiles(rillYAML string) map[string]string { + return map[string]string{ + "rill.yaml": rillYAML, + "bids.sql": ` +SELECT * FROM (VALUES + ('Acme', 'iOS', TIMESTAMP '2024-01-01 10:00:00', 10), + ('Acme', 'Android', TIMESTAMP '2024-01-02 10:00:00', 5), + ('Beta', 'iOS', TIMESTAMP '2024-01-02 11:00:00', 7), + ('Gamma', 'iOS', TIMESTAMP '2024-01-03 09:00:00', 1), + (NULL, 'iOS', TIMESTAMP '2024-01-03 12:00:00', 100) +) AS t(advertiser_name, device_os, event_time, bids) +`, + "bids_metrics.yaml": ` +type: metrics_view +display_name: Bids +model: bids +timeseries: event_time +smallest_time_grain: hour +dimensions: +- column: advertiser_name +- column: device_os +measures: +- name: total_bids + expression: SUM(bids) + format_preset: humanize +- name: bid_count + expression: COUNT(*) +explore: + skip: true +`, + "themes/brand.yaml": ` +type: theme +colors: + primary: "#4f46e5" + secondary: "#f59e0b" +`, + } +} + +func newChartSession(t *testing.T, rt *runtime.Runtime, instanceID, userAgent string) *ai.Session { + claims := &runtime.SecurityClaims{UserID: uuid.NewString(), SkipChecks: true} + r := ai.NewRunner(rt, activity.NewNoopClient()) + s, err := r.Session(t.Context(), &ai.SessionOptions{ + InstanceID: instanceID, + Claims: claims, + UserAgent: userAgent, + }) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, s.Flush(t.Context())) + }) + return s +} + +func barChartArgs() ai.CreateChartArgs { + return ai.CreateChartArgs{ + "chart_type": "bar_chart", + "spec": map[string]any{ + "metrics_view": "bids_metrics", + "time_range": map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-01-08T00:00:00Z"}, + "color": "primary", + "x": map[string]any{"field": "advertiser_name", "type": "nominal", "limit": 2, "sort": "-y"}, + "y": map[string]any{"field": "total_bids", "type": "quantitative"}, + }, + } +} + +// TestCreateChartExternalClient checks the result an external MCP client receives: the chart spec plus the chart-ready data, +// field metadata, metrics view summary, theme, title and the link to open the chart in Rill. +func TestCreateChartExternalClient(t *testing.T) { + rt, instanceID := testruntime.NewInstanceWithOptions(t, testruntime.InstanceOptions{ + Files: createChartTestFiles("theme: brand\n"), + FrontendURL: "https://ui.rilldata.com/demo/bids", + }) + testruntime.RequireReconcileState(t, rt, instanceID, 4, 0, 0) + + s := newChartSession(t, rt, instanceID, "claude-ai/1.0") + + var res *ai.CreateChartResult + toolRes, err := s.CallTool(t.Context(), ai.RoleUser, ai.CreateChartName, &res, barChartArgs()) + require.NoError(t, err) + + // The contract fields that are always present; the spec echoes the limit that was applied (see TestCreateChartSpecLimits) + require.Equal(t, "chart", res.Widget) + require.Equal(t, "bar_chart", res.ChartType) + require.Equal(t, "bids_metrics", res.Spec["metrics_view"]) + require.EqualValues(t, 2, res.Spec["x"].(map[string]any)["limit"]) + require.Equal(t, "Chart created successfully: bar_chart", res.Message) + + // The external-only fields + require.Equal(t, "Total Bids per Advertiser Name", res.Title) + require.Equal(t, &ai.ChartMetricsView{Name: "bids_metrics", DisplayName: "Bids", TimeDimension: "event_time", SmallestTimeGrain: "TIME_GRAIN_HOUR"}, res.MetricsView) + require.NotNil(t, res.ChartDataResult) + require.Empty(t, res.TimeGrain, "no temporal field in the chart") + + // Top 2 advertisers by total bids, nulls excluded, in the top-N order + require.Equal(t, []any{"Acme", "Beta"}, res.DomainValues["advertiser_name"]) + require.Len(t, res.Data, 2) + require.Equal(t, "Acme", res.Data[0]["advertiser_name"]) + require.EqualValues(t, 15, res.Data[0]["total_bids"]) + require.Equal(t, "Beta", res.Data[1]["advertiser_name"]) + require.EqualValues(t, 7, res.Data[1]["total_bids"]) + require.True(t, res.Truncated, "a third advertiser exists beyond the limit") + require.False(t, res.RowsTruncated, "only a top-N was cut") + require.Contains(t, res.TruncationWarning, `only the top 2 values of "advertiser_name" are shown`) + + // Field metadata for the renderer + require.Len(t, res.Fields, 2) + require.Equal(t, &ai.ChartField{Name: "advertiser_name", Kind: "dimension", DisplayName: "Advertiser Name", Type: "STRING"}, res.Fields["advertiser_name"]) + require.Equal(t, "measure", res.Fields["total_bids"].Kind) + require.Equal(t, "Total Bids", res.Fields["total_bids"].DisplayName) + require.Equal(t, "humanize", res.Fields["total_bids"].FormatPreset) + require.NotEmpty(t, res.Fields["total_bids"].Type) + + require.Equal(t, "2024-01-01T00:00:00Z", res.ResolvedTimeRange.Start.UTC().Format("2006-01-02T15:04:05Z07:00")) + require.Nil(t, res.ResolvedComparisonTimeRange) + require.Nil(t, res.Totals) + + // The project theme as protojson + require.Equal(t, "#4f46e5", res.Theme["primaryColorRaw"]) + require.Equal(t, "#f59e0b", res.Theme["secondaryColorRaw"]) + + require.Equal(t, fmt.Sprintf("https://ui.rilldata.com/demo/bids/-/ai/%s/message/%s/-/open", s.ID(), toolRes.Call.ID), res.OpenURL) + requireValidChartResult(t, res) + + // The exact JSON an external client receives + data, err := json.MarshalIndent(res, "", " ") + require.NoError(t, err) + t.Logf("create_chart result for an external client:\n%s", data) + var keys map[string]any + require.NoError(t, json.Unmarshal(data, &keys)) + for _, k := range []string{"widget", "chart_type", "spec", "message", "title", "metrics_view", "data", "fields", "domain_values", "resolved_time_range", "theme", "open_url", "truncated", "truncation_warning"} { + require.Contains(t, keys, k) + } + for _, k := range []string{"version", "time_grain", "totals", "resolved_comparison_time_range", "rows_truncated"} { + require.NotContains(t, keys, k) + } +} + +// TestCreateChartSpecLimits checks that an external result's spec carries the effective limit of every channel that got a top-N +// (the default where the spec set none, and on the input's y channel for a horizontal bar chart), +// while the in-app result returns the spec as given. +func TestCreateChartSpecLimits(t *testing.T) { + rt, instanceID := testruntime.NewInstanceWithOptions(t, testruntime.InstanceOptions{ + Files: createChartTestFiles(""), + }) + testruntime.RequireReconcileState(t, rt, instanceID, 4, 0, 0) + + timeRange := map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-01-08T00:00:00Z"} + external := newChartSession(t, rt, instanceID, "claude-ai/1.0") + + // The defaults of a nominal x axis and a color split + var stacked *ai.CreateChartResult + _, err := external.CallTool(t.Context(), ai.RoleUser, ai.CreateChartName, &stacked, ai.CreateChartArgs{ + "chart_type": "stacked_bar", + "spec": map[string]any{ + "metrics_view": "bids_metrics", + "time_range": timeRange, + "color": map[string]any{"field": "device_os", "type": "nominal"}, + "x": map[string]any{"field": "advertiser_name", "type": "nominal", "sort": "-y"}, + "y": map[string]any{"field": "total_bids", "type": "quantitative"}, + }, + }) + require.NoError(t, err) + require.EqualValues(t, 20, stacked.Spec["x"].(map[string]any)["limit"]) + require.EqualValues(t, 10, stacked.Spec["color"].(map[string]any)["limit"]) + require.Equal(t, "-y", stacked.Spec["x"].(map[string]any)["sort"], "the rest of the channel is kept") + require.NotContains(t, stacked.Spec["y"], "limit") + + // A horizontal bar chart carries the dimension, and so the limit, on y + horizontal := ai.CreateChartArgs{ + "chart_type": "bar_chart", + "spec": map[string]any{ + "metrics_view": "bids_metrics", + "time_range": timeRange, + "color": "primary", + "x": map[string]any{"field": "total_bids", "type": "quantitative"}, + "y": map[string]any{"field": "advertiser_name", "type": "nominal", "sort": "-x"}, + }, + } + var bar *ai.CreateChartResult + _, err = external.CallTool(t.Context(), ai.RoleUser, ai.CreateChartName, &bar, horizontal) + require.NoError(t, err) + require.EqualValues(t, 20, bar.Spec["y"].(map[string]any)["limit"]) + require.NotContains(t, bar.Spec["x"], "limit") + require.Equal(t, []any{"Acme", "Beta", "Gamma"}, bar.DomainValues["advertiser_name"]) + + // The in-app result returns the spec as given + rill := newChartSession(t, rt, instanceID, "rill/1.0") + var inApp *ai.CreateChartResult + _, err = rill.CallTool(t.Context(), ai.RoleUser, ai.CreateChartName, &inApp, horizontal) + require.NoError(t, err) + require.NotContains(t, inApp.Spec["y"], "limit") + require.Nil(t, inApp.ChartDataResult) +} + +// TestCreateChartTemporalComparison checks time flooring, comparison values and the time grain in the result. +func TestCreateChartTemporalComparison(t *testing.T) { + rt, instanceID := testruntime.NewInstanceWithOptions(t, testruntime.InstanceOptions{ + Files: createChartTestFiles(""), + }) + testruntime.RequireReconcileState(t, rt, instanceID, 4, 0, 0) + + s := newChartSession(t, rt, instanceID, "chatgpt/1.0") + + var res *ai.CreateChartResult + _, err := s.CallTool(t.Context(), ai.RoleUser, ai.CreateChartName, &res, ai.CreateChartArgs{ + "chart_type": "line_chart", + "spec": map[string]any{ + "metrics_view": "bids_metrics", + "time_range": map[string]any{"start": "2024-01-02T00:00:00Z", "end": "2024-01-04T00:00:00Z"}, + "comparison_time_range": map[string]any{"start": "2023-12-31T00:00:00Z", "end": "2024-01-02T00:00:00Z"}, + "time_grain": "TIME_GRAIN_DAY", + "color": "primary", + "x": map[string]any{"field": "event_time", "type": "temporal"}, + "y": map[string]any{"field": "total_bids", "type": "quantitative"}, + }, + }) + require.NoError(t, err) + + require.Equal(t, "Total Bids over Time", res.Title) + require.Equal(t, "TIME_GRAIN_DAY", res.TimeGrain) + require.Len(t, res.Data, 2) + require.Equal(t, "2024-01-02T00:00:00Z", res.Data[0]["event_time"], "temporal values are RFC3339 UTC bucket starts") + require.EqualValues(t, 12, res.Data[0]["total_bids"]) + require.Contains(t, res.Data[0], "total_bids_prev") + require.Equal(t, "time", res.Fields["event_time"].Kind) + require.Equal(t, "Event Time", res.Fields["event_time"].DisplayName, "the metrics view's display name is kept for the time dimension") + require.NotNil(t, res.ResolvedComparisonTimeRange) + require.Empty(t, res.OpenURL, "no frontend URL configured") + require.Nil(t, res.Theme, "no theme configured") + require.Empty(t, res.DomainValues) + require.False(t, res.Truncated) + require.False(t, res.RowsTruncated) + require.NotContains(t, res.Spec["x"], "limit", "a temporal axis gets no top-N") +} + +// TestCreateChartDonutTotals checks the total query of circular charts. +func TestCreateChartDonutTotals(t *testing.T) { + rt, instanceID := testruntime.NewInstanceWithOptions(t, testruntime.InstanceOptions{ + Files: createChartTestFiles(""), + }) + testruntime.RequireReconcileState(t, rt, instanceID, 4, 0, 0) + + s := newChartSession(t, rt, instanceID, "cursor/1.0") + + var res *ai.CreateChartResult + _, err := s.CallTool(t.Context(), ai.RoleUser, ai.CreateChartName, &res, ai.CreateChartArgs{ + "chart_type": "donut_chart", + "spec": map[string]any{ + "metrics_view": "bids_metrics", + "time_range": map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-01-08T00:00:00Z"}, + "color": map[string]any{"field": "device_os", "type": "nominal", "limit": 10}, + "measure": map[string]any{"field": "total_bids", "type": "quantitative"}, + }, + }) + require.NoError(t, err) + require.Equal(t, "Total Bids split by Device Os", res.Title) + require.Equal(t, []any{"iOS", "Android"}, res.DomainValues["device_os"]) + require.EqualValues(t, 10, res.Spec["color"].(map[string]any)["limit"]) + require.Len(t, res.Data, 2) + require.EqualValues(t, 123, res.Totals["total_bids"]) + require.False(t, res.Truncated) +} + +// TestCreateChartEmptyResult checks the contract for a time range with no rows: data is present as an empty array, +// and so are the field metadata and the resolved time range, so that the widget can show its empty state. chart_data behaves the same. +func TestCreateChartEmptyResult(t *testing.T) { + rt, instanceID := testruntime.NewInstanceWithOptions(t, testruntime.InstanceOptions{ + Files: createChartTestFiles(""), + }) + testruntime.RequireReconcileState(t, rt, instanceID, 4, 0, 0) + + s := newChartSession(t, rt, instanceID, "claude-ai/1.0") + + // The data is from January 2024; this range is a year earlier. + args := barChartArgs() + args["spec"].(map[string]any)["time_range"] = map[string]any{"start": "2023-01-01T00:00:00Z", "end": "2023-01-08T00:00:00Z"} + + var res *ai.CreateChartResult + _, err := s.CallTool(t.Context(), ai.RoleUser, ai.CreateChartName, &res, args) + require.NoError(t, err) + require.Empty(t, res.Data) + require.Len(t, res.Fields, 2) + require.Equal(t, "dimension", res.Fields["advertiser_name"].Kind) + require.Equal(t, "measure", res.Fields["total_bids"].Kind) + require.NotNil(t, res.ResolvedTimeRange) + require.Equal(t, "2023-01-01T00:00:00Z", res.ResolvedTimeRange.Start.UTC().Format("2006-01-02T15:04:05Z07:00")) + require.False(t, res.Truncated) + requireValidChartResult(t, res) + + data, err := json.Marshal(res) + require.NoError(t, err) + var keys map[string]any + require.NoError(t, json.Unmarshal(data, &keys)) + require.Equal(t, []any{}, keys["data"]) + require.Contains(t, keys, "fields") + require.Contains(t, keys, "resolved_time_range") + + var dataRes *ai.ChartDataResult + _, err = s.CallTool(t.Context(), ai.RoleUser, ai.ChartDataName, &dataRes, ai.ChartDataArgs(args)) + require.NoError(t, err) + require.Empty(t, dataRes.Data) + require.Len(t, dataRes.Fields, 2) + require.NotNil(t, dataRes.ResolvedTimeRange) + data, err = json.Marshal(dataRes) + require.NoError(t, err) + keys = nil + require.NoError(t, json.Unmarshal(data, &keys)) + require.Equal(t, []any{}, keys["data"]) + require.Contains(t, keys, "fields") + require.Contains(t, keys, "resolved_time_range") +} + +// TestCreateChartAccess covers the user agent and feature flag matrix of create_chart and chart_data. +// Whether the MCP server serves the widget plays no part here: servers without the widget leave chart_data out at registration time +// (see TestMCPAppsWithoutWidget in runtime/server). +func TestCreateChartAccess(t *testing.T) { + rt, instanceID := testruntime.NewInstanceWithOptions(t, testruntime.InstanceOptions{ + Files: createChartTestFiles(""), + }) + testruntime.RequireReconcileState(t, rt, instanceID, 4, 0, 0) + + access := func(t *testing.T, s *ai.Session, name string) bool { + tool, ok := s.Tool(name) + require.True(t, ok) + allowed, err := tool.CheckAccess(ai.WithSession(t.Context(), s)) + require.NoError(t, err) + return allowed + } + + // Default flags: every client gets create_chart; only external clients get chart_data, since in-app clients fetch chart data themselves. + rill := newChartSession(t, rt, instanceID, "rill/1.0") + external := newChartSession(t, rt, instanceID, "claude-ai/1.0") + require.True(t, access(t, rill, ai.CreateChartName)) + require.False(t, access(t, rill, ai.ChartDataName)) + require.True(t, access(t, external, ai.CreateChartName)) + require.True(t, access(t, external, ai.ChartDataName)) + + // The in-app result carries exactly the spec fields, not the data, and still passes the output schema (see requireValidChartResult). + var res *ai.CreateChartResult + _, err := rill.CallTool(t.Context(), ai.RoleUser, ai.CreateChartName, &res, barChartArgs()) + require.NoError(t, err) + require.Nil(t, res.ChartDataResult) + requireValidChartResult(t, res) + data, err := json.Marshal(res) + require.NoError(t, err) + var keys map[string]any + require.NoError(t, json.Unmarshal(data, &keys)) + require.ElementsMatch(t, []string{"widget", "chart_type", "spec", "message"}, mapKeys(keys)) + + // chart_data returns the data fields only. + var dataRes *ai.ChartDataResult + _, err = external.CallTool(t.Context(), ai.RoleUser, ai.ChartDataName, &dataRes, ai.ChartDataArgs(barChartArgs())) + require.NoError(t, err) + require.Len(t, dataRes.Data, 2) + require.Equal(t, []any{"Acme", "Beta"}, dataRes.DomainValues["advertiser_name"]) + require.Contains(t, dataRes.Fields, "total_bids") + + // chat_charts is the master switch for both tools. A session caches its feature flags, so the change is checked with new sessions. + inst, err := rt.Instance(t.Context(), instanceID) + require.NoError(t, err) + inst.FeatureFlags = map[string]string{"chat_charts": "false"} + require.NoError(t, rt.EditInstance(t.Context(), inst, false)) + rill = newChartSession(t, rt, instanceID, "rill/1.0") + external = newChartSession(t, rt, instanceID, "claude-ai/1.0") + require.False(t, access(t, rill, ai.CreateChartName)) + require.False(t, access(t, external, ai.CreateChartName)) + require.False(t, access(t, external, ai.ChartDataName)) + + // Without ReadMetrics there is no access at all. + inst.FeatureFlags = nil + require.NoError(t, rt.EditInstance(t.Context(), inst, false)) + limited := newChartSessionWithClaims(t, rt, instanceID, "claude-ai/1.0", &runtime.SecurityClaims{UserID: uuid.NewString(), Permissions: []runtime.Permission{runtime.UseAI}}) + require.False(t, access(t, limited, ai.CreateChartName)) + require.False(t, access(t, limited, ai.ChartDataName)) +} + +func newChartSessionWithClaims(t *testing.T, rt *runtime.Runtime, instanceID, userAgent string, claims *runtime.SecurityClaims) *ai.Session { + r := ai.NewRunner(rt, activity.NewNoopClient()) + s, err := r.Session(t.Context(), &ai.SessionOptions{ + InstanceID: instanceID, + Claims: claims, + UserAgent: userAgent, + }) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, s.Flush(t.Context())) + }) + return s +} + +// requireValidChartResult validates a create_chart result the way the MCP server does: as JSON against the tool's inferred output schema. +// Both the external shape (with data, as [] when empty) and the in-app shape (without the data fields) must pass, +// which is why ChartDataResult.Data is omitzero rather than required. +func requireValidChartResult(t *testing.T, res *ai.CreateChartResult) { + t.Helper() + schema, ok := ai.MCPToolSpecs()[ai.CreateChartName].OutputSchema.(*jsonschema.Schema) + require.True(t, ok) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + data, err := json.Marshal(res) + require.NoError(t, err) + var v any + require.NoError(t, json.Unmarshal(data, &v)) + require.NoError(t, resolved.Validate(v)) +} + +func mapKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +// flintBarChartArgs are create_chart arguments in the Flint shape external clients use (see CreateChart.MCPSpec). +func flintBarChartArgs() ai.CreateChartArgs { + return ai.CreateChartArgs{ + "chart_spec": map[string]any{ + "chartType": "Bar Chart", + "encodings": map[string]any{ + "x": map[string]any{"field": "advertiser_name", "limit": 2, "sortBy": "y", "sortOrder": "descending"}, + "y": "total_bids", + }, + }, + "data": map[string]any{ + "metrics_view": "bids_metrics", + "time_range": map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-01-08T00:00:00Z"}, + }, + } +} + +// TestCreateChartFlint checks the Flint shape of the arguments: the result echoes the effective input (chart spec with the applied +// limits, data block with the resolved grain), carries the chart compiler's semantic types and display names next to the rows, +// titles the chart from the bound fields, and reports bad chart types with the valid names. +func TestCreateChartFlint(t *testing.T) { + rt, instanceID := testruntime.NewInstanceWithOptions(t, testruntime.InstanceOptions{ + Files: createChartTestFiles("theme: brand\n"), + FrontendURL: "https://ui.rilldata.com/demo/bids", + }) + testruntime.RequireReconcileState(t, rt, instanceID, 4, 0, 0) + + s := newChartSession(t, rt, instanceID, "claude-ai/1.0") + + var res *ai.CreateChartResult + toolRes, err := s.CallTool(t.Context(), ai.RoleUser, ai.CreateChartName, &res, flintBarChartArgs()) + require.NoError(t, err) + + require.Equal(t, "chart", res.Widget) + require.Equal(t, "Bar Chart", res.ChartType) + require.Nil(t, res.Spec, "the in-app spec is not part of a Flint result") + require.Equal(t, "Chart created successfully: Bar Chart", res.Message) + require.Equal(t, "Total Bids by Advertiser Name", res.Title) + + // The effective input: the chart spec with the applied limit, the rest of the encodings as given + require.NotNil(t, res.Input) + require.Equal(t, "Bar Chart", res.Input.ChartSpec["chartType"]) + encodings := res.Input.ChartSpec["encodings"].(map[string]any) + require.EqualValues(t, 2, encodings["x"].(map[string]any)["limit"]) + require.Equal(t, "y", encodings["x"].(map[string]any)["sortBy"]) + require.Equal(t, "total_bids", encodings["y"], "a plain field without a top-N is echoed as is") + require.Equal(t, "bids_metrics", res.Input.Data["metrics_view"]) + require.NotContains(t, res.Input.Data, "time_grain", "no time field is bound") + + // Top 2 advertisers by total bids, nulls excluded, in the top-N order + require.Equal(t, []any{"Acme", "Beta"}, res.DomainValues["advertiser_name"]) + require.Len(t, res.Data, 2) + require.Equal(t, "Acme", res.Data[0]["advertiser_name"]) + require.EqualValues(t, 15, res.Data[0]["total_bids"]) + require.True(t, res.Truncated) + require.Equal(t, "dimension", res.Fields["advertiser_name"].Kind) + require.Equal(t, "measure", res.Fields["total_bids"].Kind) + + // The chart compiler's inputs: an integer measure is a Count, a dimension a Category + require.Equal(t, map[string]string{"advertiser_name": "Category", "total_bids": "Count"}, res.SemanticTypes) + require.Equal(t, map[string]string{"advertiser_name": "Advertiser Name", "total_bids": "Total Bids"}, res.FieldDisplayNames) + + require.Equal(t, "#4f46e5", res.Theme["primaryColorRaw"]) + require.Equal(t, fmt.Sprintf("https://ui.rilldata.com/demo/bids/-/ai/%s/message/%s/-/open", s.ID(), toolRes.Call.ID), res.OpenURL) + requireValidChartResult(t, res) + + data, err := json.MarshalIndent(res, "", " ") + require.NoError(t, err) + t.Logf("create_chart result for a Flint call:\n%s", data) + var keys map[string]any + require.NoError(t, json.Unmarshal(data, &keys)) + for _, k := range []string{"widget", "chart_type", "input", "message", "title", "metrics_view", "data", "fields", "semantic_types", "field_display_names", "domain_values", "resolved_time_range", "theme", "open_url", "truncated"} { + require.Contains(t, keys, k) + } + require.NotContains(t, keys, "spec") + + // A time series with a comparison and a headline: the time field is floored at the grain, its semantic type follows the grain, + // the comparison values take the measure's type (here the caller's override) and a "(previous period)" display name. + var line *ai.CreateChartResult + _, err = s.CallTool(t.Context(), ai.RoleUser, ai.CreateChartName, &line, ai.CreateChartArgs{ + "chart_spec": map[string]any{ + "chartType": "Line Chart", + "title": " Bids per day ", + "encodings": map[string]any{"x": "event_time", "y": "total_bids", "color": "device_os"}, + }, + "data": map[string]any{ + "metrics_view": "bids_metrics", + "time_range": map[string]any{"start": "2024-01-02T00:00:00Z", "end": "2024-01-04T00:00:00Z"}, + "comparison_time_range": map[string]any{"start": "2023-12-31T00:00:00Z", "end": "2024-01-02T00:00:00Z"}, + }, + "semantic_types": map[string]any{"total_bids": "Quantity"}, + }) + require.NoError(t, err) + require.Equal(t, "Bids per day", line.Title, "the headline wins") + require.Equal(t, "TIME_GRAIN_DAY", line.TimeGrain, "the grain defaults to day") + require.Equal(t, "TIME_GRAIN_DAY", line.Input.Data["time_grain"]) + require.EqualValues(t, 10, line.Input.ChartSpec["encodings"].(map[string]any)["color"].(map[string]any)["limit"], "a series channel defaults to 10") + require.Equal(t, "Date", line.SemanticTypes["event_time"]) + require.Equal(t, "Quantity", line.SemanticTypes["total_bids"], "the caller's override wins") + require.Equal(t, "Quantity", line.SemanticTypes["total_bids_prev"]) + require.Equal(t, "Category", line.SemanticTypes["device_os"]) + require.Equal(t, "Time", line.FieldDisplayNames["event_time"]) + require.Equal(t, "Total Bids (previous period)", line.FieldDisplayNames["total_bids_prev"]) + require.Equal(t, "time", line.Fields["event_time"].Kind) + require.Contains(t, line.Data[0], "total_bids_prev") + require.Equal(t, "2024-01-02T00:00:00Z", line.Data[0]["event_time"]) + require.NotNil(t, line.ResolvedComparisonTimeRange) + + // Without a headline, a time field on x reads "over Time" and a series dimension "split by" + var area *ai.CreateChartResult + _, err = s.CallTool(t.Context(), ai.RoleUser, ai.CreateChartName, &area, ai.CreateChartArgs{ + "chart_spec": map[string]any{"chartType": "Area Chart", "encodings": map[string]any{"x": "event_time", "y": "total_bids", "color": "device_os"}}, + "data": map[string]any{"metrics_view": "bids_metrics", "time_range": map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-01-08T00:00:00Z"}, "time_grain": "TIME_GRAIN_MONTH"}, + }) + require.NoError(t, err) + require.Equal(t, "Total Bids over Time split by Device Os", area.Title) + require.Equal(t, "YearMonth", area.SemanticTypes["event_time"]) + + // Errors name the valid chart types and channels so the model can repair the call + _, err = s.CallTool(t.Context(), ai.RoleUser, ai.CreateChartName, &res, ai.CreateChartArgs{ + "chart_spec": map[string]any{"chartType": "Sankey", "encodings": map[string]any{"x": "advertiser_name", "y": "total_bids"}}, + "data": flintBarChartArgs()["data"], + }) + require.ErrorContains(t, err, `unknown chart type "Sankey"`) + require.ErrorContains(t, err, "Waterfall Chart") + _, err = s.CallTool(t.Context(), ai.RoleUser, ai.CreateChartName, &res, ai.CreateChartArgs{ + "chart_spec": map[string]any{"chartType": "Bar Chart", "encodings": map[string]any{"x": "advertiser_name", "y": "total_bids", "goal": "bid_count"}}, + "data": flintBarChartArgs()["data"], + }) + require.ErrorContains(t, err, `does not accept channel "goal"`) + _, err = s.CallTool(t.Context(), ai.RoleUser, ai.CreateChartName, &res, ai.CreateChartArgs{ + "chart_spec": map[string]any{"chartType": "Bar Chart", "encodings": map[string]any{"x": "publisher", "y": "total_bids"}}, + "data": flintBarChartArgs()["data"], + }) + require.ErrorContains(t, err, `field "publisher" on channel "x" not found`) + + // chart_data takes the Flint shape too and returns the data section with the compiler inputs + var dataRes *ai.ChartDataResult + _, err = s.CallTool(t.Context(), ai.RoleUser, ai.ChartDataName, &dataRes, ai.ChartDataArgs(flintBarChartArgs())) + require.NoError(t, err) + require.Len(t, dataRes.Data, 2) + require.Equal(t, "Category", dataRes.SemanticTypes["advertiser_name"]) + require.Equal(t, "Total Bids", dataRes.FieldDisplayNames["total_bids"]) + + // The in-app client is not offered the Flint shape, but a call in that shape still echoes the input without data + rill := newChartSession(t, rt, instanceID, "rill/1.0") + var inApp *ai.CreateChartResult + _, err = rill.CallTool(t.Context(), ai.RoleUser, ai.CreateChartName, &inApp, flintBarChartArgs()) + require.NoError(t, err) + require.Nil(t, inApp.ChartDataResult) + require.Nil(t, inApp.Spec) + require.Equal(t, "Bar Chart", inApp.Input.ChartSpec["chartType"]) +} diff --git a/runtime/ai/develop_file.go b/runtime/ai/develop_file.go index 0b97f613e951..50be2d33fbfd 100644 --- a/runtime/ai/develop_file.go +++ b/runtime/ai/develop_file.go @@ -52,7 +52,7 @@ func (t *DevelopFile) Spec() *mcp.Tool { } func (t *DevelopFile) CheckAccess(ctx context.Context) (bool, error) { - return checkDeveloperAccess(ctx, t.Runtime, true) + return checkDeveloperAccess(ctx, true) } func (t *DevelopFile) Handler(ctx context.Context, args *DevelopFileArgs) (*DevelopFileResult, error) { diff --git a/runtime/ai/developer_agent.go b/runtime/ai/developer_agent.go index d3f332335527..b73c2db9c724 100644 --- a/runtime/ai/developer_agent.go +++ b/runtime/ai/developer_agent.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "strings" "github.com/modelcontextprotocol/go-sdk/mcp" aiv1 "github.com/rilldata/rill/proto/gen/rill/ai/v1" @@ -45,7 +44,7 @@ func (t *DeveloperAgent) Spec() *mcp.Tool { } func (t *DeveloperAgent) CheckAccess(ctx context.Context) (bool, error) { - return checkDeveloperAccess(ctx, t.Runtime, true) + return checkDeveloperAccess(ctx, true) } func (t *DeveloperAgent) Handler(ctx context.Context, args *DeveloperAgentArgs) (*DeveloperAgentResult, error) { @@ -214,7 +213,7 @@ Task: {{ .prompt }} // checkDeveloperAccess checks whether developer tools should be available in the current session. // If internal is true, it indicates that the tool should not be exposed to external clients (like MCP). -func checkDeveloperAccess(ctx context.Context, rt *runtime.Runtime, internal bool) (bool, error) { +func checkDeveloperAccess(ctx context.Context, internal bool) (bool, error) { // Must be able to use AI and edit the project s := GetSession(ctx) if !s.Claims().Can(runtime.UseAI) || !s.Claims().Can(runtime.EditRepo) { @@ -223,13 +222,13 @@ func checkDeveloperAccess(ctx context.Context, rt *runtime.Runtime, internal boo // Don't expose agent tools to external clients (like MCP) if internal { - if !strings.HasPrefix(s.CatalogSession().UserAgent, "rill") { + if !isRillUserAgent(s) { return false, nil } } // Must have the developer_agent feature flag - ff, err := rt.FeatureFlags(ctx, s.InstanceID(), s.Claims()) + ff, err := s.FeatureFlags(ctx) if err != nil { return false, err } diff --git a/runtime/ai/external_tools.go b/runtime/ai/external_tools.go index 20e81280492d..b69706a41150 100644 --- a/runtime/ai/external_tools.go +++ b/runtime/ai/external_tools.go @@ -547,7 +547,7 @@ func (r *Runner) compileExternalTool(instanceID, environment string, vars map[st }) }, // External tools are never exposed through Rill's own MCP server. - RegisterWithMCPServer: func(srv *mcp.Server) {}, + RegisterWithMCPServer: func(srv *mcp.Server, meta map[string]any) {}, } } diff --git a/runtime/ai/feedback_agent.go b/runtime/ai/feedback_agent.go index 6238d7438d9a..d9f22e60423f 100644 --- a/runtime/ai/feedback_agent.go +++ b/runtime/ai/feedback_agent.go @@ -59,7 +59,7 @@ func (t *FeedbackAgent) CheckAccess(ctx context.Context) (bool, error) { } // Only allow for rill user agents since it's not useful in MCP contexts. - if !strings.HasPrefix(s.CatalogSession().UserAgent, "rill") { + if !isRillUserAgent(s) { return false, nil } return true, nil diff --git a/runtime/ai/file_list.go b/runtime/ai/file_list.go index b0ffb53053ae..d1d570444a16 100644 --- a/runtime/ai/file_list.go +++ b/runtime/ai/file_list.go @@ -41,7 +41,7 @@ func (t *ListFiles) Spec() *mcp.Tool { } func (t *ListFiles) CheckAccess(ctx context.Context) (bool, error) { - return checkDeveloperAccess(ctx, t.Runtime, false) + return checkDeveloperAccess(ctx, false) } func (t *ListFiles) Handler(ctx context.Context, args *ListFilesArgs) (*ListFilesResult, error) { diff --git a/runtime/ai/file_read.go b/runtime/ai/file_read.go index fca080520373..8bcd62a11e79 100644 --- a/runtime/ai/file_read.go +++ b/runtime/ai/file_read.go @@ -42,7 +42,7 @@ func (t *ReadFile) Spec() *mcp.Tool { } func (t *ReadFile) CheckAccess(ctx context.Context) (bool, error) { - return checkDeveloperAccess(ctx, t.Runtime, false) + return checkDeveloperAccess(ctx, false) } func (t *ReadFile) Handler(ctx context.Context, args *ReadFileArgs) (*ReadFileResult, error) { diff --git a/runtime/ai/file_search.go b/runtime/ai/file_search.go index e65eff2fabcd..b403ebc7faaa 100644 --- a/runtime/ai/file_search.go +++ b/runtime/ai/file_search.go @@ -54,7 +54,7 @@ func (t *SearchFiles) Spec() *mcp.Tool { } func (t *SearchFiles) CheckAccess(ctx context.Context) (bool, error) { - return checkDeveloperAccess(ctx, t.Runtime, false) + return checkDeveloperAccess(ctx, false) } func (t *SearchFiles) Handler(ctx context.Context, args *SearchFilesArgs) (*SearchFilesResult, error) { diff --git a/runtime/ai/file_write.go b/runtime/ai/file_write.go index afdbdc66ed71..c20766c1facd 100644 --- a/runtime/ai/file_write.go +++ b/runtime/ai/file_write.go @@ -52,7 +52,7 @@ func (t *WriteFile) Spec() *mcp.Tool { } func (t *WriteFile) CheckAccess(ctx context.Context) (bool, error) { - return checkDeveloperAccess(ctx, t.Runtime, false) + return checkDeveloperAccess(ctx, false) } func (t *WriteFile) Handler(ctx context.Context, args *WriteFileArgs) (*WriteFileResult, error) { diff --git a/runtime/ai/instructions/data/analysis.md b/runtime/ai/instructions/data/analysis.md index 156014c5e1ee..9bd801a5a33f 100644 --- a/runtime/ai/instructions/data/analysis.md +++ b/runtime/ai/instructions/data/analysis.md @@ -40,7 +40,8 @@ If you have access to the "create_chart" tool, create a chart after running "que - The user explicitly requests a table-only response - The query returns only a single scalar value -Choose the appropriate chart type based on your data: +{{ if .External }}Follow the `rill-charts` skill for choosing a chart type, mapping fields to channels and the pitfalls to avoid. +{{ else }}Choose the appropriate chart type based on your data: - Time series data: line_chart or area_chart (better for cumulative trends) - Category comparisons: bar_chart or stacked_bar - Part-to-whole relationships: donut_chart @@ -48,7 +49,7 @@ Choose the appropriate chart type based on your data: - Two measures from the same metrics view: Use combo_chart - Multiple measures from the same metrics view (more than 2): Use stacked bar chart with multiple measure fields - Distribution across two dimensions: heatmap - +{{ end }} ## Analysis guidelines **Phase 1: discovery**: diff --git a/runtime/ai/instructions/data/charts.md b/runtime/ai/instructions/data/charts.md new file mode 100644 index 000000000000..01792429fec5 --- /dev/null +++ b/runtime/ai/instructions/data/charts.md @@ -0,0 +1,37 @@ +--- +description: How to chart a metrics view with the create_chart tool, which chart type fits which question, and the pitfalls that produce misleading charts +--- + +## Charts with create_chart + +The `create_chart` tool draws a chart from a metrics view. You describe the chart (`chart_spec`: a chart type and which field goes on which channel) and what to query (`data`: the metrics view, time range, grain and filter); Rill runs the query, formats the measures and renders the chart. Never pass rows, colors or axis settings. The tool description lists the chart types, their channels and the argument shape. + +### Before you chart + +- Know the fields: use `get_metrics_view` for the measures, dimensions and the time dimension, and reference them by their exact names. +- Look at the data first with `query_metrics_view` whenever the shape matters: how many distinct values a dimension has, whether a measure is a count, an amount or a rate, and whether a dimension mixes a total with its parts. Chart one level, never a total next to its parts. +- One chart answers one question. Prefer fewer series: cap a dimension with `limit`, and split with `row` or `column` facets instead of piling categories onto `color`. +- Pick the grain that gives roughly 10 to 100 points for the range: days for a few weeks, weeks for a quarter, months for a year or more. + +### Choosing a chart + +- A trend over time: Line Chart, with several measures on `y` or a dimension on `color`; Area Chart to stack parts; Bump Chart for ranks; Slope Chart for two periods. +- Categories compared: Bar Chart for one measure; Stacked Bar Chart with the parts on `color`; Grouped Bar Chart with the clustering dimension on `group`; Bar Table for many categories with value labels; Lollipop Chart for a ranking. +- Share of a whole: Pie Chart with the measure on `size` and the dimension on `color`, `innerRadius` for a donut; only for a handful of values that add up to a whole. +- How a total changed: Waterfall Chart with the step on `x` and the signed change on `y`; leave `color` unbound, it is reserved for a start/delta/end type field. +- Two measures per category: Scatter Plot with the dimension on `detail`, optional `size` and `color`; Heatmap for two dimensions and one measure. +- A distribution: Histogram, Boxplot, Violin Plot or Density Plot need one row per observation, so put the finest dimension on `detail`. + +### Titles + +Set `title` to the finding in a sentence and `subtitle` to what is measured, of whom, when and in what unit, for example "Spend fell 18% after the March price change" over "Daily total spend by device, Jan–Apr 2024, USD". Axes and legends take their labels from the metrics view, so do not restate field names. + +### Pitfalls + +- Parts of a whole go on a Stacked Bar Chart with `color`; values compared side by side go on a Grouped Bar Chart with `group`. A Bar Chart with a second dimension on `color` stacks. +- Counts and amounts on one axis share a scale and the smaller one flattens; use `row` facets or separate charts. +- More than about ten values on `color` is unreadable; use `limit`, a Bar Table or facets. +- A dimension with a single value in the range gives one mark; choose another breakdown. +- A chart is a snapshot of its arguments: when the user asks for another period, grain or breakdown, create a new chart. +- Do not restate the chart's numbers in prose; name the insight. The chart shows its data and an "Open in Rill" link. +- When the result reports that values were cut, coarsen `time_grain` or lower the `limit`s and create the chart again. diff --git a/runtime/ai/list_bucket_objects.go b/runtime/ai/list_bucket_objects.go index 4c8e72e644e8..1980b4c9cfaf 100644 --- a/runtime/ai/list_bucket_objects.go +++ b/runtime/ai/list_bucket_objects.go @@ -58,7 +58,7 @@ func (t *ListBucketObjects) Spec() *mcp.Tool { } func (t *ListBucketObjects) CheckAccess(ctx context.Context) (bool, error) { - return checkDeveloperAccess(ctx, t.Runtime, false) + return checkDeveloperAccess(ctx, false) } func (t *ListBucketObjects) Handler(ctx context.Context, args *ListBucketObjectsArgs) (*ListBucketObjectsResult, error) { diff --git a/runtime/ai/list_buckets.go b/runtime/ai/list_buckets.go index c020f6d22c87..0c703bdf351d 100644 --- a/runtime/ai/list_buckets.go +++ b/runtime/ai/list_buckets.go @@ -43,7 +43,7 @@ func (t *ListBuckets) Spec() *mcp.Tool { } func (t *ListBuckets) CheckAccess(ctx context.Context) (bool, error) { - return checkDeveloperAccess(ctx, t.Runtime, false) + return checkDeveloperAccess(ctx, false) } func (t *ListBuckets) Handler(ctx context.Context, args *ListBucketsArgs) (*ListBucketsResult, error) { diff --git a/runtime/ai/list_tables.go b/runtime/ai/list_tables.go index 35acdaee1073..32d4d3b9bd20 100644 --- a/runtime/ai/list_tables.go +++ b/runtime/ai/list_tables.go @@ -54,7 +54,7 @@ func (t *ListTables) Spec() *mcp.Tool { } func (t *ListTables) CheckAccess(ctx context.Context) (bool, error) { - return checkDeveloperAccess(ctx, t.Runtime, false) + return checkDeveloperAccess(ctx, false) } func (t *ListTables) Handler(ctx context.Context, args *ListTablesArgs) (*ListTablesResult, error) { diff --git a/runtime/ai/mcp.go b/runtime/ai/mcp.go index dd67971e03b7..da0c35963689 100644 --- a/runtime/ai/mcp.go +++ b/runtime/ai/mcp.go @@ -6,12 +6,18 @@ import ( "encoding/json" "errors" "fmt" + "maps" + "slices" "strings" + "sync" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/rilldata/rill/runtime/ai/mcpapp" + "github.com/rilldata/rill/runtime/pkg/activity" "github.com/rilldata/rill/runtime/pkg/jsonschemautil" + "go.opentelemetry.io/otel/attribute" "go.uber.org/zap" ) @@ -46,23 +52,74 @@ If you have edit access, the server also exposes tools for inspecting and editin - **Write a file:** Use "write_file" to create, update or delete a file. If the file declares a Rill resource, it returns the resource's status and any errors encountered after reconciliation. ` -// MCPToolSpecs returns the specs of all registered tools, keyed by name. -// The specs are freshly built and owned by the caller, so they may be mutated. +// toolRegistry holds the tools compiled without a runtime, keyed by name. +// It serves callers that need the tool definitions but cannot run the tools, such as the unified MCP server in the admin service. +// It is safe to build with a nil runtime: Spec and MCPAppUI do not use it (asserted by TestMCPToolSpecs). +var toolRegistry = sync.OnceValue(func() map[string]*CompiledTool { + return NewRunner(nil, nil).Tools +}) + +// MCPToolSpecs returns the specs of all registered tools as advertised on MCP servers, keyed by name (see CompiledTool.mcpSpec). +// The specs are copies owned by the caller, so they may be extended, for example with extra input properties or _meta. // It is used by the unified MCP server in the admin service, which advertises the tools without being able to run them. func MCPToolSpecs() map[string]*mcp.Tool { - // Safe to pass a nil runtime: Spec() does not use it (asserted by TestMCPToolSpecs). - runner := NewRunner(nil, nil) - specs := make(map[string]*mcp.Tool, len(runner.Tools)) - for name, t := range runner.Tools { - specs[name] = t.Spec + tools := toolRegistry() + specs := make(map[string]*mcp.Tool, len(tools)) + for name, t := range tools { + spec := *t.mcpSpec() + spec.Meta = maps.Clone(spec.Meta) + // CloneSchemas copies the nested schemas and their maps but shares the plain slices, so Required is cloned separately. + if s, ok := spec.InputSchema.(*jsonschema.Schema); ok { + s = s.CloneSchemas() + s.Required = slices.Clone(s.Required) + spec.InputSchema = s + } + if s, ok := spec.OutputSchema.(*jsonschema.Schema); ok { + spec.OutputSchema = s.CloneSchemas() + } + specs[name] = &spec } return specs } +// IsMCPAppOnlyTool reports whether only the MCP Apps widget may call a tool (see mcpapp.ToolUI.AppOnly). +// Such tools should only be advertised by servers that serve the widget. +func IsMCPAppOnlyTool(toolName string) bool { + t, ok := toolRegistry()[toolName] + return ok && t.AppUI.AppOnly() +} + +// MCPAppToolMeta returns the _meta.ui to register a tool with: the link to the widget when it is served (a may be nil), +// the visibility of an app-only tool regardless, and nil for tools without a UI. +// It is exported for the unified MCP server in the admin service, which advertises the tools without being able to run them. +func MCPAppToolMeta(toolName string, a *mcpapp.Asset) map[string]any { + t, ok := toolRegistry()[toolName] + if !ok { + return nil + } + return t.AppUI.Meta(a) +} + // MCPServer returns a new MCP server scoped to the current session. // Since it is scoped to the session, a new MCP server should be created for each client connection. // Using a separate MCP server for each client enables tailoring the server's instructions and available tools to the end user's claims. func (s *Session) MCPServer(ctx context.Context) *mcp.Server { + // Serve the MCP Apps widget (interactive charts) alongside the chart tools: when the chat_charts flag is on and the binary + // carries the widget bundle. Without the widget, clients get the text fallback of create_chart. + var widget *mcpapp.Asset + ff, err := s.FeatureFlags(ctx) + if err != nil { + if !errors.Is(err, ctx.Err()) { + s.logger.Error("failed to resolve feature flags", zap.Error(err)) + } + } else if ff["chat_charts"] { + widget, _ = mcpapp.Widget() + } + var capabilities *mcp.ServerCapabilities + if widget != nil { + capabilities = mcpapp.ServerCapabilities() + } + // Create the MCP server. srv := mcp.NewServer( &mcp.Implementation{ @@ -72,22 +129,8 @@ func (s *Session) MCPServer(ctx context.Context) *mcp.Server { }, &mcp.ServerOptions{ Instructions: MCPInstructions, - InitializedHandler: func(ctx context.Context, r *mcp.InitializedRequest) { - // Save user agent in the session - clientInfo := r.Session.InitializeParams().ClientInfo - if clientInfo != nil && clientInfo.Name != "" { - userAgent := clientInfo.Name - if clientInfo.Version != "" { - userAgent += "/" + clientInfo.Version - } - - err := s.UpdateUserAgent(ctx, userAgent) - if err != nil && !errors.Is(err, ctx.Err()) { - s.logger.Warn("failed to update user agent", zap.Error(err)) - } - } - }, - HasTools: true, + Capabilities: capabilities, + HasTools: true, }, ) @@ -104,6 +147,19 @@ func (s *Session) MCPServer(ctx context.Context) *mcp.Server { } }) + // Record what the client tells us about itself in the initialize handshake: the user agent is updated from the client info + // (the session was created with the HTTP user agent, which is a less specific signal) and an activity event captures the client + // and whether it supports the MCP Apps UI extension. This runs as middleware rather than an InitializedHandler because the + // stateless transport does not carry the client info over to the "notifications/initialized" request. + srv.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + if params, ok := req.GetParams().(*mcp.InitializeParams); ok && method == "initialize" { + s.recordMCPInitialize(ctx, params) + } + return next(ctx, method, req) + } + }) + // Tolerantly decode tool arguments where object/array-typed fields arrive as JSON-encoded strings. // This works around a serialization bug in some MCP clients (see https://github.com/anthropics/claude-code/issues/25865). // The coercion runs only as a fallback: if a tool call fails the SDK's input schema validation, @@ -126,9 +182,14 @@ func (s *Session) MCPServer(ctx context.Context) *mcp.Server { } }) - // Add only the tools that the user has access to + // Add only the tools that the user has access to, each with the _meta.ui of its part in the MCP Apps widget: + // create_chart links to the widget when it is served, and app-only tools, which only the widget may call, + // declare their visibility and are left out when it is not served (the unified server does the same, see admin/server/mcp.go). ctx = WithSession(ctx, s) for _, t := range s.runner.Tools { + if widget == nil && t.AppUI.AppOnly() { + continue + } ok, err := t.CheckAccess(ctx) if err != nil { if !errors.Is(err, ctx.Err()) { @@ -139,7 +200,12 @@ func (s *Session) MCPServer(ctx context.Context) *mcp.Server { if !ok { continue } - t.RegisterWithMCPServer(srv) + t.RegisterWithMCPServer(srv, t.AppUI.Meta(widget)) + } + + // Serve the widget resource. + if widget != nil { + mcpapp.Register(srv, widget) } return srv @@ -161,6 +227,45 @@ func isMCPInputValidationFailure(res mcp.Result, err error) bool { return ok && strings.HasPrefix(text.Text, `validating "arguments"`) } +// recordMCPInitialize updates the session's user agent from the client info and records an activity event with the client's +// identity, protocol version and MCP Apps support. +func (s *Session) recordMCPInitialize(ctx context.Context, params *mcp.InitializeParams) { + var clientName, clientVersion string + if params.ClientInfo != nil { + clientName = params.ClientInfo.Name + clientVersion = params.ClientInfo.Version + } + if clientName != "" { + userAgent := clientName + if clientVersion != "" { + userAgent += "/" + clientVersion + } + err := s.UpdateUserAgent(ctx, userAgent) + if err != nil && !errors.Is(err, ctx.Err()) { + s.logger.Warn("failed to update user agent", zap.Error(err)) + } + } + + var uiExtension bool + var uiExtensionSettings string + if params.Capabilities != nil { + if ext, ok := params.Capabilities.Extensions[mcpapp.UIExtension]; ok { + uiExtension = true + if data, err := json.Marshal(ext); err == nil { + uiExtensionSettings = string(data) + } + } + } + + s.activity.Record(ctx, activity.EventTypeLog, "mcp_initialize", + attribute.String("client_name", clientName), + attribute.String("client_version", clientVersion), + attribute.String("protocol_version", params.ProtocolVersion), + attribute.Bool("ui_extension", uiExtension), + attribute.String("ui_extension_settings", uiExtensionSettings), + ) +} + // coerceMCPToolArgs rewrites a tool call's raw arguments, // JSON-decoding any string values in positions where the tool's input schema expects an object or array. // It returns whether the arguments were rewritten. @@ -175,7 +280,7 @@ func (s *Session) coerceMCPToolArgs(req mcp.Request) bool { if !ok || t.Spec == nil { return false } - schema, ok := t.Spec.InputSchema.(*jsonschema.Schema) + schema, ok := t.mcpSpec().InputSchema.(*jsonschema.Schema) if !ok || schema == nil { return false } diff --git a/runtime/ai/mcp_test.go b/runtime/ai/mcp_test.go index cb77d107f236..e80af030ec80 100644 --- a/runtime/ai/mcp_test.go +++ b/runtime/ai/mcp_test.go @@ -3,6 +3,7 @@ package ai_test import ( "testing" + "github.com/google/jsonschema-go/jsonschema" "github.com/google/uuid" "github.com/rilldata/rill/runtime" "github.com/rilldata/rill/runtime/ai" @@ -22,6 +23,43 @@ func TestMCPToolSpecs(t *testing.T) { require.NotNil(t, spec.InputSchema, "tool %q", name) } require.Contains(t, specs, ai.QueryMetricsViewName) + + // The specs are copies: extending one does not leak into the next call. + schema := specs[ai.QueryMetricsViewName].InputSchema.(*jsonschema.Schema) + schema.Properties["project"] = &jsonschema.Schema{Type: "string"} + schema.Required = append(schema.Required, "project") + specs[ai.QueryMetricsViewName].Meta["ui"] = map[string]any{"resourceUri": "ui://x"} + again := ai.MCPToolSpecs()[ai.QueryMetricsViewName] + require.NotContains(t, again.InputSchema.(*jsonschema.Schema).Properties, "project") + require.NotContains(t, again.InputSchema.(*jsonschema.Schema).Required, "project") + require.NotContains(t, again.Meta, "ui") + require.Contains(t, again.Meta, "openai/toolInvocation/invoking", "the tool's own meta is kept") + + // Tools may advertise a different definition on MCP servers than in-app (see ai.MCPSpecProvider): + // create_chart takes the Flint shape externally and keeps Rill's own chart spec for the in-app chat. + external := specs[ai.CreateChartName].InputSchema.(*jsonschema.Schema) + require.Contains(t, external.Properties, "chart_spec") + require.Contains(t, external.Properties, "data") + require.Contains(t, external.Properties, "chart_type", "the previous shape stays valid for clients with a cached tool list") + require.Empty(t, external.Required) + require.Contains(t, external.Defs, "Expression", "the data block reuses the filter expression schema") + require.Contains(t, specs[ai.CreateChartName].Description, "Waterfall Chart") + require.NotNil(t, specs[ai.CreateChartName].OutputSchema, "the MCP spec inherits the inferred output schema") + require.Contains(t, specs[ai.ChartDataName].InputSchema.(*jsonschema.Schema).Properties, "chart_spec") + inApp, err := ai.NewRunner(nil, nil).Tools[ai.CreateChartName].AsProto() + require.NoError(t, err) + require.Contains(t, inApp.InputSchema, `"chart_type"`) + require.NotContains(t, inApp.InputSchema, `"chart_spec"`) + require.NotContains(t, inApp.Description, "Waterfall Chart") + + // The tools that take part in the MCP Apps widget are known without a runtime too. + require.False(t, ai.IsMCPAppOnlyTool(ai.CreateChartName), "create_chart is called by the model") + require.True(t, ai.IsMCPAppOnlyTool(ai.ChartDataName)) + require.False(t, ai.IsMCPAppOnlyTool(ai.QueryMetricsViewName)) + require.False(t, ai.IsMCPAppOnlyTool("unknown")) + require.Nil(t, ai.MCPAppToolMeta(ai.QueryMetricsViewName, nil)) + require.Nil(t, ai.MCPAppToolMeta(ai.CreateChartName, nil), "create_chart links to the widget, which requires the asset") + require.Equal(t, []any{"app"}, ai.MCPAppToolMeta(ai.ChartDataName, nil)["ui"].(map[string]any)["visibility"], "app-only visibility does not need the asset") } // TestSessionCreateIfNotExists asserts that a caller can address a session by an ID of its own choosing, diff --git a/runtime/ai/mcpapp/README.md b/runtime/ai/mcpapp/README.md new file mode 100644 index 000000000000..b9666c6a11e4 --- /dev/null +++ b/runtime/ai/mcpapp/README.md @@ -0,0 +1,126 @@ +# mcpapp: interactive charts for MCP hosts + +This package makes Rill's MCP server render charts inside MCP hosts such as Claude (web, desktop, mobile), +ChatGPT, Cursor and VS Code. It implements the server side of the **MCP Apps** extension and owns the widget +bundle, the resource and tool metadata that describe it, and the capability that advertises it. + +## Standards + +| Layer | Standard | Where Rill implements it | +| - | - | - | +| Transport and protocol | [Model Context Protocol](https://modelcontextprotocol.io/specification), streamable HTTP transport, JSON-RPC 2.0; the server negotiates protocol versions `2025-11-25`, `2025-06-18`, `2025-03-26` and `2024-11-05` | `runtime/server/mcp.go` (runtime endpoint), `admin/server/mcp.go` (unified Rill Cloud endpoint), both on `github.com/modelcontextprotocol/go-sdk` v1.4.1 | +| Tools with structured results | MCP `tools/call` with `content` and `structuredContent` validated against the tool's `outputSchema` | `runtime/ai/ai.go` (`RegisterTool`), `runtime/ai/create_chart.go`, `runtime/ai/chart_data.go` | +| Interactive UI | [MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps), the official MCP extension [SEP-1865](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/1865) (specification revision `2026-01-26`, extension identifier `io.modelcontextprotocol/ui`, source in [modelcontextprotocol/ext-apps](https://github.com/modelcontextprotocol/ext-apps)) | this package, `runtime/ai/mcp.go`, `admin/server/mcp.go`; the widget in `web-mcp-app/` | +| ChatGPT compatibility | OpenAI's plugin UI metadata layered on MCP Apps (`openai/*` `_meta` keys); ChatGPT reads the standard `_meta.ui` fields first | `WidgetResource` in this package; `openai/toolInvocation/*` strings on the tools | + +Nothing here is Rill-specific protocol: any host that implements MCP Apps renders the chart, and any host +that does not falls back to the tool's text result. + +## How it works + +1. **Capability.** When the widget is served, the server's `initialize` result advertises the extension: + `capabilities.extensions["io.modelcontextprotocol/ui"] = {"mimeTypes": ["text/html;profile=mcp-app"]}` + (`ServerCapabilities`). Hosts that support MCP Apps advertise the same extension in their request; + the server does not gate on it because the stateless transport does not carry the client's + capabilities on later requests, and the extension is harmless to hosts that ignore it. +2. **UI resource.** The widget is one self-contained HTML document, served as an MCP resource with URI + `ui://rill/widget/.html` and MIME type `text/html;profile=mcp-app` (`WidgetResource`, + `Register`). `` is the first 12 hex characters of the SHA-256 of the bundle: hosts cache + resources by URI, so a new build gets a new URI and is refetched. A resource template also serves the + current bundle under any `ui://rill/widget/*.html` URI, because hosts keep the tool list of the build + they last saw and request that URI after a release. +3. **Tool linkage.** `create_chart` carries `_meta.ui.resourceUri` pointing at the resource + (`WidgetToolMeta`). When the model calls it, the host reads the resource, renders it in a sandboxed + iframe (a native WebView on mobile) and delivers the call's arguments and result to the iframe through + the extension's `ui/notifications/tool-input` and `ui/notifications/tool-result` messages. + On MCP servers the tool takes the [Flint](https://github.com/microsoft/flint-chart) shape of a chart + (`ai.MCPSpecProvider`, `runtime/ai/chart_flint.go`): `chart_spec` with a `chartType` from the embedded + catalog (`runtime/ai/chartquery/flint_catalog.json`, generated from the installed `flint-chart` + package) and `encodings` binding channels to fields, plus a `data` block naming the metrics view, time + range, comparison, filter and grain. The in-app chat keeps Rill's own chart spec (`Spec`). +4. **Data passing.** The tool result has two parts. `content` is a markdown rendering (title, a table of + the first rows, the "Open in Rill" link) for the model and for hosts without MCP Apps. + `structuredContent` is the full chart payload the widget renders from: the effective input (the chart + spec with the applied top-N limits and the data block with the resolved grain), rows keyed by field, + field metadata with formats, the Flint semantic types and display names derived from the metrics view + (`runtime/ai/chart_semantics.go`), domain values, resolved time ranges, the project theme and the deep + link. The contract is the JSON form of `CreateChartResult` in `runtime/ai/create_chart.go`. The + widget compiles the spec with Flint and renders the Vega-Lite it produces; it needs no network access + and never talks to the runtime directly. +5. **App-only tools.** `chart_data` carries `_meta.ui.visibility: ["app"]` (`AppOnlyToolMeta`). Hosts + hide it from the model and only let the widget call it, through the host's `tools/call` bridge, when + the user changes the time grain, the top-N limit or the comparison inside the widget. Those calls are + not persisted as conversation messages and are not counted as billable tool calls. +6. **Host context.** The host tells the widget its theme (light or dark), CSS variables, display mode + (`inline` or `fullscreen`), container size, locale and time zone. The widget sends `ui/open-link` for + "Open in Rill" and `ui/update-model-context` so the model knows what the chart currently shows. + +Request flow for one chart: + +``` +model -> tools/call create_chart ------------> runtime: validate the chart spec against the catalog and the metrics view, + plan the queries from the bound fields (chartquery.BuildFlint), run them + through the metrics resolver, return content (markdown) + structuredContent +host -> resources/read ui://rill/widget/.. -> runtime (or admin): widget HTML (cached by the host afterwards) +host -> iframe: tool-input, tool-result ----> widget compiles the spec with Flint and renders the Vega-Lite with Rill's theme +widget -> host -> tools/call chart_data -----> runtime: fresh rows for the changed spec (app-only, ephemeral) +``` + +## Security + +- The host enforces a Content Security Policy built from the resource's `_meta.ui.csp`. Rill declares no + `connectDomains` and no `frameDomains`; the only external origin is `https://assets.claude.ai` in + `resourceDomains`, which lets the widget load the host font Claude provides. Everything else is inline. +- The widget runs with the same security claims as the conversation: every query behind `create_chart` + and `chart_data` goes through the metrics resolver with the caller's claims, so metrics view security + policies, row filters and field access apply unchanged. +- No tokens are ever placed in the result or the widget; data flows only through the host's MCP bridge. + +## Gating and rollout + +- The `chat_charts` feature flag (default on) is the single gate: it enables the chart tools and, on the + runtime's own endpoint, the widget. Clients without MCP Apps get the markdown table and the link. +- The widget is served whenever the binary carries the bundle. The unified `/v1/mcp` endpoint of the admin + service cannot evaluate per-project flags, so it serves the widget unconditionally and leaves the + per-project check to the runtime, which rejects `create_chart` calls for projects with charts disabled. +- `chart_data` may only be called by the widget, so both endpoints advertise it exactly when they serve the + widget (`Session.MCPServer` in runtime/ai/mcp.go and `mcpForwardedToolSpecs` in admin/server/mcp.go); + `create_chart` is advertised regardless. +- `chart_data` calls count in the `tool_call` metric like every other data tool, since they run real + queries; they are not persisted in the session, since they are widget plumbing rather than conversation. +- Both servers register the resource and the `_meta.ui` entries with the same helpers from this package + (`Register`, `WidgetToolMeta`, `AppOnlyToolMeta`, `ToolWithMeta`, `ServerCapabilities`), so the two + endpoints advertise identical metadata. + +## Building and serving the bundle + +- `embed/widget.html` is the build output of the `web-mcp-app` workspace and is not committed. + `make runtime.mcpapp.embed` builds it and copies it into `embed/`; `make cli` runs that through + `cli.prepare`, and the Rill Cloud image build runs it before `go build` embeds the file. +- `Widget()` returns the embedded bundle, or the file named by `RILL_MCP_APP_DIR` during development + (re-read whenever it changes, so `npm run dev -w web-mcp-app` gives a hot-reload loop). When no bundle is + available the server simply does not advertise the widget. + +## Testing + +- `go test ./runtime/ai/mcpapp/` covers the metadata (structpb-safe types), URI hashing, the resource + template for stale URIs and the `_meta` merge helper. +- `go test ./runtime/server/ -run MCP` and `go test ./admin/server/ -run 'MCP|CallRuntimeTool'` cover + capability negotiation, `resources/list` and `resources/read`, tool metadata with the flag on and off, + the markdown fallback and the pass-through of results on the unified endpoint. +- Protocol check against a running server: + `npx @modelcontextprotocol/inspector --cli http://localhost:9009/mcp --transport http --method resources/list`. +- End to end in Claude: add the server as a custom connector (through a tunnel for a local server, with the + Host header rewritten to `localhost:`), or through `mcp-remote` in Claude Desktop; in ChatGPT, + Plugins > Add custom MCP server. + +## References + +- MCP Apps specification: https://modelcontextprotocol.io/docs/extensions/apps and + https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx +- MCP Apps SDK (used by the widget): https://modelcontextprotocol.github.io/ext-apps/api/ +- Claude: https://claude.com/docs/connectors/building/mcp-apps/getting-started and the design guidelines + under the same path +- ChatGPT: https://developers.openai.com/plugins/build/chatgpt-ui and + https://developers.openai.com/plugins/reference +- MCP specification: https://modelcontextprotocol.io/specification diff --git a/runtime/ai/mcpapp/embed/README.md b/runtime/ai/mcpapp/embed/README.md new file mode 100644 index 000000000000..aedd0664508e --- /dev/null +++ b/runtime/ai/mcpapp/embed/README.md @@ -0,0 +1,16 @@ +# MCP Apps widget bundle + +This directory holds the single-file HTML bundle of the MCP Apps widget that renders Rill charts inside +MCP hosts such as Claude and ChatGPT. It is served as the `ui://rill/widget/.html` resource of +Rill's MCP server (see `runtime/ai/mcpapp`). + +`widget.html` is a build artifact and is not committed: + +- `make runtime.mcpapp.embed` builds the `web-mcp-app` workspace and copies `web-mcp-app/dist/widget.html` + here. `make cli` runs it through the `cli.prepare` target, and the Rill Cloud image build + (`.github/workflows/rill-cloud.yml`) runs it before the Go build embeds the file. +- When the file is absent (for example in a plain `go build` or `go test`), `mcpapp.Widget()` reports that + no widget is available and the MCP server does not advertise the resource. +- For local development, set `RILL_MCP_APP_DIR` to a directory containing `widget.html` + (for example `web-mcp-app/dist` while `npm run dev -w web-mcp-app` is running); the runtime re-reads the + file whenever it changes. diff --git a/runtime/ai/mcpapp/mcpapp.go b/runtime/ai/mcpapp/mcpapp.go new file mode 100644 index 000000000000..d873220faa32 --- /dev/null +++ b/runtime/ai/mcpapp/mcpapp.go @@ -0,0 +1,262 @@ +// Package mcpapp serves the MCP Apps widget that renders Rill charts inside MCP hosts such as Claude and ChatGPT. +// +// MCP Apps (the io.modelcontextprotocol/ui extension) lets a server register a resource with a ui:// URI whose body is a +// self-contained HTML document. A tool links to it through _meta.ui.resourceUri, and the host renders the HTML in a sandboxed +// iframe, pushing the tool call's result into it. This package owns the embedded widget bundle, the resource and tool +// metadata that describe it, and the server capability that advertises support for the extension. +// +// The package has no dependency on the runtime so that tool specs can be built without one. +package mcpapp + +import ( + "context" + "crypto/sha256" + "embed" + "encoding/hex" + "fmt" + "maps" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const ( + // MIMEType is the MIME type of MCP Apps HTML resources. + MIMEType = "text/html;profile=mcp-app" + // ResourceName is the name of the widget resource. + ResourceName = "rill-widget" + // UIExtension is the identifier of the MCP Apps extension in capability negotiation. + UIExtension = "io.modelcontextprotocol/ui" + // VisibilityApp marks a tool as callable only by the widget (through the host), not by the model. + VisibilityApp = "app" + + // DirEnv is an environment variable naming a directory to load the widget from instead of the embedded copy. + // The file is re-read whenever it changes, which gives a hot reload loop during widget development. + DirEnv = "RILL_MCP_APP_DIR" + + widgetFile = "widget.html" + uriPrefix = "ui://rill/widget/" + // uriTemplate matches every widget URI, including ones from earlier builds (see Register). + uriTemplate = uriPrefix + "{build}.html" +) + +//go:embed all:embed +var embedFS embed.FS + +// Asset is a loaded widget bundle. +type Asset struct { + // Name is the file name of the bundle. + Name string + // Hash is the first 12 hex characters of the SHA-256 of HTML. + // The URI embeds it so that hosts, which cache resources by URI, refetch the widget exactly when the bundle changes. + Hash string + // URI is the ui:// URI the widget resource is served at. + URI string + // HTML is the self-contained widget document. + HTML string +} + +// ToolUI describes how a tool takes part in the MCP Apps widget. +type ToolUI struct { + // Visibility lists who may call the tool. Empty means the model: the tool renders the widget with its result. + // VisibilityApp means only the widget may call it (through the host), for example to refetch data. + Visibility []string +} + +// AppOnly reports whether only the widget may call the tool: its visibility is exactly VisibilityApp. +// Calls to such tools are plumbing (the widget refetching data) rather than conversation turns. +func (u *ToolUI) AppOnly() bool { + return u != nil && slices.Equal(u.Visibility, []string{VisibilityApp}) +} + +// Meta returns the _meta.ui entry for a tool with this UI, or nil when the tool has nothing to advertise. +// Widget-rendering tools link to the widget resource, which requires the asset; app-only tools only declare their visibility. +func (u *ToolUI) Meta(a *Asset) map[string]any { + if u == nil { + return nil + } + if len(u.Visibility) > 0 { + return AppOnlyToolMeta(u.Visibility) + } + if a == nil { + return nil + } + return WidgetToolMeta(a) +} + +// widgetCache memoizes the loaded widget. The embedded copy never changes; a DirEnv override is re-read when its mtime changes. +var widgetCache struct { + sync.Mutex + embedded *Asset + embeddedDone bool + overridePath string + overrideTime time.Time + override *Asset +} + +// Widget returns the widget bundle, or false when none is available. +// The bundle comes from DirEnv when set, otherwise from the copy embedded at build time (see embed/README.md). +func Widget() (*Asset, bool) { + widgetCache.Lock() + defer widgetCache.Unlock() + + if dir := os.Getenv(DirEnv); dir != "" { + path := filepath.Join(dir, widgetFile) + info, err := os.Stat(path) + if err != nil { + return nil, false + } + if widgetCache.override == nil || widgetCache.overridePath != path || !widgetCache.overrideTime.Equal(info.ModTime()) { + html, err := os.ReadFile(path) + if err != nil { + return nil, false + } + widgetCache.override = newAsset(html) + widgetCache.overridePath = path + widgetCache.overrideTime = info.ModTime() + } + return widgetCache.override, true + } + + if !widgetCache.embeddedDone { + widgetCache.embeddedDone = true + html, err := embedFS.ReadFile("embed/" + widgetFile) + if err == nil { + widgetCache.embedded = newAsset(html) + } + } + return widgetCache.embedded, widgetCache.embedded != nil +} + +func newAsset(html []byte) *Asset { + sum := sha256.Sum256(html) + hash := hex.EncodeToString(sum[:])[:12] + return &Asset{ + Name: widgetFile, + Hash: hash, + URI: uriPrefix + hash + ".html", + HTML: string(html), + } +} + +// WidgetResource returns the MCP resource that serves the widget. +// Its _meta follows the MCP Apps spec plus the ChatGPT extras: a CSP that only allows Claude's font assets, +// a host-drawn border, inline and fullscreen display modes, and a description that keeps the model from restating the chart. +// Nested values are only map[string]any and []any so that they can also be converted with structpb. +func WidgetResource(a *Asset) *mcp.Resource { + return &mcp.Resource{ + URI: a.URI, + Name: ResourceName, + Title: resourceTitle, + Description: resourceDescription, + MIMEType: MIMEType, + Size: int64(len(a.HTML)), + Meta: resourceMeta(), + } +} + +const ( + resourceTitle = "Rill chart" + resourceDescription = "Interactive Rill chart widget rendered from the result of create_chart." +) + +// resourceMeta returns the _meta of the widget resource; a fresh map on every call since the SDK may retain it. +func resourceMeta() map[string]any { + return map[string]any{ + "ui": map[string]any{ + "csp": map[string]any{ + "connectDomains": []any{}, + "resourceDomains": []any{"https://assets.claude.ai"}, + "frameDomains": []any{}, + "baseUriDomains": []any{}, + }, + "prefersBorder": true, + }, + "openai/ui": map[string]any{ + "availableDisplayModes": []any{"inline", "fullscreen"}, + }, + "openai/widgetDescription": "Interactive Rill chart. Summarize the insight in one or two sentences; do not restate the numbers shown in the chart.", + } +} + +// WidgetToolMeta returns the _meta entry that links a tool to the widget resource. +func WidgetToolMeta(a *Asset) map[string]any { + return map[string]any{ + "ui": map[string]any{ + "resourceUri": a.URI, + }, + } +} + +// AppOnlyToolMeta returns the _meta entry that restricts a tool to the given callers (see VisibilityApp). +func AppOnlyToolMeta(visibility []string) map[string]any { + vis := make([]any, len(visibility)) + for i, v := range visibility { + vis[i] = v + } + return map[string]any{ + "ui": map[string]any{ + "visibility": vis, + }, + } +} + +// ToolWithMeta returns a shallow copy of a tool spec with meta merged into its _meta, or the spec itself when meta is empty. +// The copy gets its own _meta map, so the spec's _meta (which also flows into the in-app tool definitions) is left untouched. +func ToolWithMeta(spec *mcp.Tool, meta map[string]any) *mcp.Tool { + if len(meta) == 0 { + return spec + } + res := *spec + res.Meta = make(map[string]any, len(spec.Meta)+len(meta)) + maps.Copy(res.Meta, spec.Meta) + maps.Copy(res.Meta, meta) + return &res +} + +// Register adds the widget resource to the server. +// The current URI is listed as a resource. A template additionally serves the current bundle under any +// widget URI, because hosts cache the tool list and keep requesting the URI of the build they last saw; +// after a release that would otherwise fail until they refresh. The response echoes the requested URI. +func Register(srv *mcp.Server, a *Asset) { + read := func(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + if !strings.HasPrefix(req.Params.URI, uriPrefix) || !strings.HasSuffix(req.Params.URI, ".html") { + return nil, fmt.Errorf("unknown resource %q", req.Params.URI) + } + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{{ + URI: req.Params.URI, + MIMEType: MIMEType, + Text: a.HTML, + Meta: resourceMeta(), + }}, + }, nil + } + srv.AddResource(WidgetResource(a), read) + srv.AddResourceTemplate(&mcp.ResourceTemplate{ + URITemplate: uriTemplate, + Name: ResourceName, + Title: resourceTitle, + Description: resourceDescription, + MIMEType: MIMEType, + Meta: resourceMeta(), + }, read) +} + +// ServerCapabilities returns the server capabilities to use when the widget is served: +// the SDK's default logging capability plus the MCP Apps extension with the MIME types the server can render. +// Tools and resources capabilities are still inferred by the SDK from what is registered. +func ServerCapabilities() *mcp.ServerCapabilities { + caps := &mcp.ServerCapabilities{ + Logging: &mcp.LoggingCapabilities{}, + } + caps.AddExtension(UIExtension, map[string]any{ + "mimeTypes": []any{MIMEType}, + }) + return caps +} diff --git a/runtime/ai/mcpapp/mcpapp_test.go b/runtime/ai/mcpapp/mcpapp_test.go new file mode 100644 index 000000000000..8b5c86483c68 --- /dev/null +++ b/runtime/ai/mcpapp/mcpapp_test.go @@ -0,0 +1,186 @@ +package mcpapp + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +// TestMetaIsStructpbSafe asserts that the metadata attached to the resource and the tools only uses value types +// that structpb accepts, since tool _meta also flows into in-app LLM tool definitions through structpb.NewStruct. +func TestMetaIsStructpbSafe(t *testing.T) { + a := newAsset([]byte("")) + + _, err := structpb.NewStruct(WidgetResource(a).Meta) + require.NoError(t, err) + + _, err = structpb.NewStruct(WidgetToolMeta(a)) + require.NoError(t, err) + + _, err = structpb.NewStruct(AppOnlyToolMeta([]string{VisibilityApp})) + require.NoError(t, err) + + _, err = structpb.NewStruct((&ToolUI{Visibility: []string{VisibilityApp}}).Meta(nil)) + require.NoError(t, err) +} + +func TestAssetURIContainsHash(t *testing.T) { + a := newAsset([]byte("v1")) + require.Len(t, a.Hash, 12) + require.Equal(t, "ui://rill/widget/"+a.Hash+".html", a.URI) + require.Contains(t, WidgetToolMeta(a)["ui"].(map[string]any)["resourceUri"], a.Hash) + + // A different bundle gets a different URI, so hosts that cache by URI pick up the new widget. + b := newAsset([]byte("v2")) + require.NotEqual(t, a.URI, b.URI) +} + +func TestToolUIMeta(t *testing.T) { + a := newAsset([]byte("")) + + // A widget-rendering tool links to the resource, but only when the asset is available. + widget := &ToolUI{} + require.Equal(t, WidgetToolMeta(a), widget.Meta(a)) + require.Nil(t, widget.Meta(nil)) + + // An app-only tool declares its visibility regardless of the asset. + appOnly := &ToolUI{Visibility: []string{VisibilityApp}} + require.Equal(t, map[string]any{"ui": map[string]any{"visibility": []any{"app"}}}, appOnly.Meta(nil)) + require.True(t, appOnly.AppOnly()) + require.False(t, widget.AppOnly()) + require.False(t, (&ToolUI{Visibility: []string{"model", VisibilityApp}}).AppOnly()) + + var none *ToolUI + require.Nil(t, none.Meta(a)) + require.False(t, none.AppOnly()) +} + +func TestToolWithMeta(t *testing.T) { + spec := &mcp.Tool{Name: "create_chart", Meta: map[string]any{"openai/toolInvocation/invoking": "Creating chart..."}} + + // Empty meta returns the spec itself. + require.Same(t, spec, ToolWithMeta(spec, nil)) + require.Same(t, spec, ToolWithMeta(spec, map[string]any{})) + + // Otherwise a shallow copy with the merged _meta, leaving the spec's own _meta untouched. + a := newAsset([]byte("")) + got := ToolWithMeta(spec, WidgetToolMeta(a)) + require.NotSame(t, spec, got) + require.Equal(t, "create_chart", got.Name) + require.Equal(t, "Creating chart...", got.Meta["openai/toolInvocation/invoking"]) + require.Equal(t, a.URI, got.Meta["ui"].(map[string]any)["resourceUri"]) + require.NotContains(t, spec.Meta, "ui") + + // A spec without _meta gets one. + got = ToolWithMeta(&mcp.Tool{Name: "chart_data"}, AppOnlyToolMeta([]string{VisibilityApp})) + require.Equal(t, []any{"app"}, got.Meta["ui"].(map[string]any)["visibility"]) +} + +func TestWidgetDirOverride(t *testing.T) { + dir := t.TempDir() + t.Setenv(DirEnv, dir) + + // No file yet: no widget. + _, ok := Widget() + require.False(t, ok) + + // The file is picked up without a restart. + path := filepath.Join(dir, "widget.html") + require.NoError(t, os.WriteFile(path, []byte("v1"), 0o644)) + a, ok := Widget() + require.True(t, ok) + require.Equal(t, "v1", a.HTML) + + // A change to the file (with a new mtime) is re-read and gets a new URI. + require.NoError(t, os.WriteFile(path, []byte("v2"), 0o644)) + require.NoError(t, os.Chtimes(path, time.Now(), time.Now().Add(time.Second))) + b, ok := Widget() + require.True(t, ok) + require.Equal(t, "v2", b.HTML) + require.NotEqual(t, a.URI, b.URI) +} + +func TestRegisterAndCapabilities(t *testing.T) { + a := newAsset([]byte("")) + srv := mcp.NewServer(&mcp.Implementation{Name: "test"}, &mcp.ServerOptions{Capabilities: ServerCapabilities()}) + Register(srv, a) + + client := mcp.NewClient(&mcp.Implementation{Name: "client"}, nil) + ct, st := mcp.NewInMemoryTransports() + _, err := srv.Connect(t.Context(), st, nil) + require.NoError(t, err) + cs, err := client.Connect(t.Context(), ct, nil) + require.NoError(t, err) + defer cs.Close() + + caps := cs.InitializeResult().Capabilities + require.NotNil(t, caps.Logging) + require.Contains(t, caps.Extensions, UIExtension) + require.NotNil(t, caps.Resources) + + list, err := cs.ListResources(t.Context(), &mcp.ListResourcesParams{}) + require.NoError(t, err) + require.Len(t, list.Resources, 1) + require.Equal(t, a.URI, list.Resources[0].URI) + require.Equal(t, MIMEType, list.Resources[0].MIMEType) + require.Equal(t, true, list.Resources[0].Meta["ui"].(map[string]any)["prefersBorder"]) + + res, err := cs.ReadResource(t.Context(), &mcp.ReadResourceParams{URI: a.URI}) + require.NoError(t, err) + require.Len(t, res.Contents, 1) + require.Equal(t, MIMEType, res.Contents[0].MIMEType) + require.Equal(t, a.HTML, res.Contents[0].Text) +} + +// TestRegisterServesStaleURIs asserts that the widget is served under its current URI and under the URI of any +// earlier build, since hosts cache the tool list and keep requesting the URI they last saw after a release. +func TestRegisterServesStaleURIs(t *testing.T) { + ctx := t.Context() + a := newAsset([]byte("current")) + + srv := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0"}, nil) + Register(srv, a) + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := srv.Connect(ctx, serverTransport, nil) + require.NoError(t, err) + defer serverSession.Close() + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "0"}, nil) + clientSession, err := client.Connect(ctx, clientTransport, nil) + require.NoError(t, err) + defer clientSession.Close() + + // The current URI is listed and readable. + list, err := clientSession.ListResources(ctx, nil) + require.NoError(t, err) + require.Len(t, list.Resources, 1) + require.Equal(t, a.URI, list.Resources[0].URI) + require.Equal(t, MIMEType, list.Resources[0].MIMEType) + + res, err := clientSession.ReadResource(ctx, &mcp.ReadResourceParams{URI: a.URI}) + require.NoError(t, err) + require.Len(t, res.Contents, 1) + require.Equal(t, a.URI, res.Contents[0].URI) + require.Equal(t, MIMEType, res.Contents[0].MIMEType) + require.Equal(t, a.HTML, res.Contents[0].Text) + + // A stale URI from an earlier build gets the current bundle, echoing the requested URI. + stale := "ui://rill/widget/000000000000.html" + res, err = clientSession.ReadResource(ctx, &mcp.ReadResourceParams{URI: stale}) + require.NoError(t, err) + require.Len(t, res.Contents, 1) + require.Equal(t, stale, res.Contents[0].URI) + require.Equal(t, MIMEType, res.Contents[0].MIMEType) + require.Equal(t, a.HTML, res.Contents[0].Text) + require.NotNil(t, res.Contents[0].Meta["ui"]) + + // Anything else is not found. + _, err = clientSession.ReadResource(ctx, &mcp.ReadResourceParams{URI: "ui://rill/other.html"}) + require.Error(t, err) +} diff --git a/runtime/ai/metrics_view_list.go b/runtime/ai/metrics_view_list.go index 03e1a0a4c458..ec6f7e2fa60c 100644 --- a/runtime/ai/metrics_view_list.go +++ b/runtime/ai/metrics_view_list.go @@ -99,7 +99,7 @@ func (t *ListMetricsViews) Handler(ctx context.Context, args *ListMetricsViewsAr // Rill's own agents receive the project instructions and always-apply skills directly in their prompts, // so this enrichment is only for external MCP clients (identified by a non-rill user agent). var aiInstructions strings.Builder - if !strings.HasPrefix(session.CatalogSession().UserAgent, "rill") { + if !isRillUserAgent(session) { instance, err := t.Runtime.Instance(ctx, session.InstanceID()) if err != nil { return nil, fmt.Errorf("failed to get instance %q: %w", session.InstanceID(), err) diff --git a/runtime/ai/metrics_view_query.go b/runtime/ai/metrics_view_query.go index f57fd2b69183..a6ed8afa96c6 100644 --- a/runtime/ai/metrics_view_query.go +++ b/runtime/ai/metrics_view_query.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "net/url" "strconv" "time" @@ -285,7 +284,7 @@ func (t *QueryMetricsView) Handler(ctx context.Context, args QueryMetricsViewArg } // Generate an open URL for the query - openURL, err := t.generateOpenURL(ctx, session.InstanceID(), session.ID(), session.ParentID) + openURL, err := aiMessageOpenURL(instance.FrontendURL, session.ID(), session.ParentID) if err != nil { return nil, fmt.Errorf("failed to generate open URL: %w", err) } @@ -308,33 +307,6 @@ func (t *QueryMetricsView) Handler(ctx context.Context, args QueryMetricsViewArg return result, nil } -// generateOpenURL generates an open URL for the given query parameters -func (t *QueryMetricsView) generateOpenURL(ctx context.Context, instanceID, sessionID, callID string) (string, error) { - // Get instance to access the configured frontend URL - instance, err := t.Runtime.Instance(ctx, instanceID) - if err != nil { - return "", fmt.Errorf("failed to get instance: %w", err) - } - - // If there's no frontend URL (e.g. perhaps in test cases or during rollout), return an empty string - if instance.FrontendURL == "" { - return "", nil - } - - // Build the complete URL for the query - openURL, err := url.Parse(instance.FrontendURL) - if err != nil { - return "", fmt.Errorf("failed to parse frontend URL %q: %w", instance.FrontendURL, err) - } - - openURL.Path, err = url.JoinPath(openURL.Path, "-", "ai", sessionID, "message", callID, "-", "open") - if err != nil { - return "", fmt.Errorf("failed to join path: %w", err) - } - - return openURL.String(), nil -} - func resolveTimeRanges(res runtime.ResolverResult) (*metricsview.TimeRange, *metricsview.TimeRange, error) { meta := res.Meta() if meta == nil { diff --git a/runtime/ai/navigate.go b/runtime/ai/navigate.go index dd948c3eba6d..59f94453c95f 100644 --- a/runtime/ai/navigate.go +++ b/runtime/ai/navigate.go @@ -3,7 +3,6 @@ package ai import ( "context" "errors" - "strings" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/rilldata/rill/runtime" @@ -48,7 +47,7 @@ func (t *Navigate) CheckAccess(ctx context.Context) (bool, error) { } // Only allow for rill user agents since it's not functional in MCP contexts. - if !strings.HasPrefix(s.CatalogSession().UserAgent, "rill") { + if !isRillUserAgent(s) { return false, nil } return true, nil diff --git a/runtime/ai/project_status.go b/runtime/ai/project_status.go index 7ffa12ba3f54..ea6c3ef794dc 100644 --- a/runtime/ai/project_status.go +++ b/runtime/ai/project_status.go @@ -57,7 +57,7 @@ func (t *ProjectStatus) Spec() *mcp.Tool { } func (t *ProjectStatus) CheckAccess(ctx context.Context) (bool, error) { - return checkDeveloperAccess(ctx, t.Runtime, false) + return checkDeveloperAccess(ctx, false) } func (t *ProjectStatus) Handler(ctx context.Context, args *ProjectStatusArgs) (*ProjectStatusResult, error) { diff --git a/runtime/ai/query_sql.go b/runtime/ai/query_sql.go index d4940fb9076d..f62ca2f18ffc 100644 --- a/runtime/ai/query_sql.go +++ b/runtime/ai/query_sql.go @@ -48,7 +48,7 @@ func (t *QuerySQL) Spec() *mcp.Tool { } func (t *QuerySQL) CheckAccess(ctx context.Context) (bool, error) { - return checkDeveloperAccess(ctx, t.Runtime, false) + return checkDeveloperAccess(ctx, false) } func (t *QuerySQL) Handler(ctx context.Context, args *QuerySQLArgs) (*QuerySQLResult, error) { diff --git a/runtime/ai/router_agent.go b/runtime/ai/router_agent.go index 3f30dfc412ff..c213942462fc 100644 --- a/runtime/ai/router_agent.go +++ b/runtime/ai/router_agent.go @@ -69,7 +69,7 @@ func (t *RouterAgent) CheckAccess(ctx context.Context) (bool, error) { } // Only allow for rill user agents since it's not useful in MCP contexts. - if !strings.HasPrefix(s.CatalogSession().UserAgent, "rill") { + if !isRillUserAgent(s) { return false, nil } return true, nil diff --git a/runtime/ai/show_table.go b/runtime/ai/show_table.go index 334eb8b808c9..6ca41a529bde 100644 --- a/runtime/ai/show_table.go +++ b/runtime/ai/show_table.go @@ -56,7 +56,7 @@ func (t *ShowTable) Spec() *mcp.Tool { } func (t *ShowTable) CheckAccess(ctx context.Context) (bool, error) { - return checkDeveloperAccess(ctx, t.Runtime, false) + return checkDeveloperAccess(ctx, false) } func (t *ShowTable) Handler(ctx context.Context, args *ShowTableArgs) (*ShowTableResult, error) { diff --git a/runtime/ai/skills.go b/runtime/ai/skills.go index a57c8f70f1c5..6fc4dc9b9e1a 100644 --- a/runtime/ai/skills.go +++ b/runtime/ai/skills.go @@ -7,6 +7,8 @@ import ( "strings" "github.com/rilldata/rill/runtime" + "github.com/rilldata/rill/runtime/ai/instructions" + "github.com/rilldata/rill/runtime/parser" "go.uber.org/zap" ) @@ -65,6 +67,16 @@ func (s *BaseSession) Skills(ctx context.Context) ([]*Skill, error) { Body: spec.Body, }) } + builtin, err := s.builtinSkills(ctx) + if err != nil { + return nil, err + } + for _, sk := range builtin { + // A project skill of the same name takes precedence. + if !slices.ContainsFunc(skills, func(other *Skill) bool { return other.Name == sk.Name }) { + skills = append(skills, sk) + } + } slices.SortFunc(skills, func(a, b *Skill) int { return strings.Compare(a.Name, b.Name) }) s.skills = skills @@ -72,6 +84,37 @@ func (s *BaseSession) Skills(ctx context.Context) ([]*Skill, error) { return s.skills, nil } +// chartSkillName is the built-in skill that teaches external MCP clients how to use create_chart. +const chartSkillName = "rill-charts" + +// builtinSkills returns the skills Rill ships for external MCP clients. +// Rill's own agents carry the same guidance in their prompts, so they get none. +// The chart skill is an always-apply skill, so external clients receive it in the ai_instructions of list_metrics_views +// and do not depend on the host loading skills; it is only served where create_chart is (see CreateChart.CheckAccess). +func (s *BaseSession) builtinSkills(ctx context.Context) ([]*Skill, error) { + if strings.HasPrefix(s.dto.UserAgent, "rill") { + return nil, nil + } + ff, err := s.FeatureFlags(ctx) + if err != nil { + return nil, err + } + if !ff["chat_charts"] { + return nil, nil + } + instr, err := instructions.Load("charts.md", instructions.Options{External: true}) + if err != nil { + return nil, err + } + return []*Skill{{ + Name: chartSkillName, + Description: instr.Description, + Agents: []string{parser.SkillAgentAnalyst}, + AlwaysApply: true, + Body: instr.Body, + }}, nil +} + // checkSkillAccess checks whether the skill tools should be available in the current session. // They are served even when the project defines no skills, so that external clients see the same tools for every project: // list_skills then returns an empty list and load_skill a not-found error. diff --git a/runtime/ai/skills_test.go b/runtime/ai/skills_test.go index 500489875e21..5f6204ed16b8 100644 --- a/runtime/ai/skills_test.go +++ b/runtime/ai/skills_test.go @@ -310,3 +310,53 @@ ARPU excludes trial users.`, assertAccess(t, s, ai.ListSkillsName, false) assertAccess(t, s, ai.LoadSkillName, false) } + +// TestBuiltinChartSkill verifies that external MCP clients get the built-in rill-charts skill (always-apply, so it rides in +// ai_instructions) while Rill's own agents, which carry the chart guidance in their prompts, do not. +func TestBuiltinChartSkill(t *testing.T) { + rt, instanceID := testruntime.NewInstanceWithOptions(t, testruntime.InstanceOptions{ + Files: map[string]string{ + "models/orders.sql": `SELECT 1 AS revenue`, + "metrics/orders.yaml": ` +type: metrics_view +model: orders +measures: +- name: revenue + expression: SUM(revenue) +`, + }, + }) + testruntime.RequireReconcileState(t, rt, instanceID, 4, 0, 0) + + r := ai.NewRunner(rt, activity.NewNoopClient()) + external, err := r.Session(t.Context(), &ai.SessionOptions{ + InstanceID: instanceID, + Claims: &runtime.SecurityClaims{UserID: uuid.NewString(), SkipChecks: true}, + UserAgent: "mcp-client", + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, external.Flush(t.Context())) }) + + var listRes *ai.ListSkillsResult + _, err = external.CallTool(t.Context(), ai.RoleUser, ai.ListSkillsName, &listRes, &ai.ListSkillsArgs{}) + require.NoError(t, err) + require.Len(t, listRes.Skills, 1) + require.Equal(t, "rill-charts", listRes.Skills[0].Name) + require.True(t, listRes.Skills[0].AlwaysApply) + require.Equal(t, []string{parser.SkillAgentAnalyst}, listRes.Skills[0].Agents) + + var loadRes *ai.LoadSkillResult + _, err = external.CallTool(t.Context(), ai.RoleUser, ai.LoadSkillName, &loadRes, &ai.LoadSkillArgs{Name: "rill-charts"}) + require.NoError(t, err) + require.Contains(t, loadRes.Body, "Grouped Bar Chart") + + var mvRes *ai.ListMetricsViewsResult + _, err = external.CallTool(t.Context(), ai.RoleUser, ai.ListMetricsViewsName, &mvRes, &ai.ListMetricsViewsArgs{}) + require.NoError(t, err) + require.Contains(t, mvRes.AIInstructions, "## Skill: rill-charts") + + internal := newSession(t, rt, instanceID) + _, err = internal.CallTool(t.Context(), ai.RoleUser, ai.ListSkillsName, &listRes, &ai.ListSkillsArgs{}) + require.NoError(t, err) + require.Empty(t, listRes.Skills) +} diff --git a/runtime/ai/util.go b/runtime/ai/util.go index c5cb4b7e51e0..dc841b2981e6 100644 --- a/runtime/ai/util.go +++ b/runtime/ai/util.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "net/url" "strings" "text/template" @@ -13,6 +14,30 @@ import ( "github.com/rilldata/rill/runtime/pkg/jsonval" ) +// isRillUserAgent reports whether the session belongs to a first-party Rill client (the in-app chat sends "rill/"). +// External MCP clients have other user agents; several tools are only useful, or only work, in one of the two. +func isRillUserAgent(s *Session) bool { + return strings.HasPrefix(s.CatalogSession().UserAgent, "rill") +} + +// aiMessageOpenURL returns the URL that opens the given tool call in the Rill UI (e.g. a query or chart replayed into an Explore). +// It takes the instance's frontend URL and returns an empty string when there is none (e.g. in tests or during rollout). +func aiMessageOpenURL(frontendURL, sessionID, callID string) (string, error) { + if frontendURL == "" { + return "", nil + } + + openURL, err := url.Parse(frontendURL) + if err != nil { + return "", fmt.Errorf("failed to parse frontend URL %q: %w", frontendURL, err) + } + openURL.Path, err = url.JoinPath(openURL.Path, "-", "ai", sessionID, "message", callID, "-", "open") + if err != nil { + return "", fmt.Errorf("failed to join path: %w", err) + } + return openURL.String(), nil +} + // SchemaField represents a field a result's schema. // Used to reduce result size through resolverResultToTabular. type SchemaField struct { @@ -20,9 +45,25 @@ type SchemaField struct { Type string `json:"type"` } -// resolverResultToTabular converts a resolver result into a tabular schema and row set. +// resolverResultToTabular converts a resolver result into a tabular schema and row set (rows as value slices in schema order). func resolverResultToTabular(res runtime.ResolverResult) ([]SchemaField, [][]any, error) { - // Build schema fields + fields, rows, err := resolverResultToRows(res) + if err != nil { + return nil, nil, err + } + data := make([][]any, len(rows)) + for i, row := range rows { + vals := make([]any, len(fields)) + for j, f := range fields { + vals[j] = row[f.Name] + } + data[i] = vals + } + return fields, data, nil +} + +// resolverResultToRows converts a resolver result into its schema and rows keyed by field name, with JSON-friendly values. +func resolverResultToRows(res runtime.ResolverResult) ([]SchemaField, []map[string]any, error) { schema := res.Schema() fields := make([]SchemaField, len(schema.Fields)) for i, f := range schema.Fields { @@ -32,9 +73,8 @@ func resolverResultToTabular(res runtime.ResolverResult) ([]SchemaField, [][]any } } - // Collect rows as value slices in schema field order schemaType := &runtimev1.Type{Code: runtimev1.Type_CODE_STRUCT, StructType: schema} - var data [][]any + var rows []map[string]any for { row, err := res.Next() if err != nil { @@ -52,15 +92,10 @@ func resolverResultToTabular(res runtime.ResolverResult) ([]SchemaField, [][]any if !ok { return nil, nil, fmt.Errorf("expected row to be map[string]any, got %T", v) } - - vals := make([]any, len(fields)) - for i, f := range fields { - vals[i] = rowMap[f.Name] - } - data = append(data, vals) + rows = append(rows, rowMap) } - return fields, data, nil + return fields, rows, nil } // normalizeFilePath converts a model-provided file path to an absolute path within the project (e.g. "models/foo.sql" to "/models/foo.sql"). diff --git a/runtime/pkg/middleware/mcp_session.go b/runtime/pkg/middleware/mcp_session.go index 0b0eeb3a448a..b6c560fe7dc5 100644 --- a/runtime/pkg/middleware/mcp_session.go +++ b/runtime/pkg/middleware/mcp_session.go @@ -1,6 +1,7 @@ package middleware import ( + "context" "net/http" "strings" @@ -23,12 +24,14 @@ const maxMCPSessionIDLength = 256 // and every response carries the session ID the request was served with. // A request with a malformed session ID is rejected: the spec restricts session IDs to visible ASCII, // and the length bound protects the catalog, which stores the ID as the AI session's ID. +// The wrapped handler can tell an issued ID from one the client sent with MCPSessionIDIssued. func MCPSessionIDMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { sessionID := r.Header.Get(MCPSessionIDHeader) if sessionID == "" { sessionID = uuid.NewString() r.Header.Set(MCPSessionIDHeader, sessionID) + r = r.WithContext(context.WithValue(r.Context(), mcpSessionIDIssuedKey{}, true)) } else if len(sessionID) > maxMCPSessionIDLength || strings.ContainsFunc(sessionID, func(c rune) bool { return c < 0x21 || c > 0x7E }) { http.Error(w, "Bad Request: invalid Mcp-Session-Id header", http.StatusBadRequest) return @@ -37,3 +40,13 @@ func MCPSessionIDMiddleware(next http.Handler) http.Handler { next.ServeHTTP(w, r) }) } + +// mcpSessionIDIssuedKey marks the context of a request whose session ID was issued by MCPSessionIDMiddleware. +type mcpSessionIDIssuedKey struct{} + +// MCPSessionIDIssued reports whether the request's session ID was issued by MCPSessionIDMiddleware rather than sent by the client. +// A client that sends no session ID is either starting a connection or not part of one at all (a sessionless probe such as resources/list). +func MCPSessionIDIssued(ctx context.Context) bool { + issued, _ := ctx.Value(mcpSessionIDIssuedKey{}).(bool) + return issued +} diff --git a/runtime/server/mcp.go b/runtime/server/mcp.go index 3bc93d811227..00708fb83fb9 100644 --- a/runtime/server/mcp.go +++ b/runtime/server/mcp.go @@ -1,7 +1,10 @@ package server import ( + "bytes" "context" + "encoding/json" + "io" "net/http" "time" @@ -14,6 +17,9 @@ import ( "go.uber.org/zap" ) +// mcpChartDataTimeout bounds chart_data calls, which the chart widget makes interactively. +const mcpChartDataTimeout = 45 * time.Second + // mcpHandler creates an MCP server handler. // It uses a new implementation that replaces the logic in mcp_server.go. func (s *Server) mcpHandler() http.Handler { @@ -38,6 +44,11 @@ func (s *Server) mcpHandler() http.Handler { // The session ID is issued by MCPSessionIDMiddleware if the client did not send one. sessionID := r.Header.Get(middleware.MCPSessionIDHeader) + // Requests without a session ID of their own normally start a conversation (initialize, or a tool call from a proxy that keeps its own sessions). + // Any other sessionless request (resources/list, tools/list, ping, ...) is not part of a conversation, + // so it gets a transient session that is never persisted instead of leaving an orphan session behind. + transient := middleware.MCPSessionIDIssued(r.Context()) && !mcpRequestStartsSession(r) + // It's just preliminary: for direct connections, the MCP server updates it with the actual user agent after the initialization handshake. // Requests proxied by the admin service's unified MCP server don't carry that handshake, so the forwarded user agent is the only signal of the real client. userAgent := r.UserAgent() @@ -50,6 +61,7 @@ func (s *Server) mcpHandler() http.Handler { InstanceID: instanceID, SessionID: sessionID, CreateIfNotExists: true, + Transient: transient, Claims: auth.GetClaims(r.Context(), instanceID), UserAgent: userAgent, }) @@ -64,6 +76,9 @@ func (s *Server) mcpHandler() http.Handler { srv.AddReceivingMiddleware(mcpRequestSourceMiddleware()) srv.AddReceivingMiddleware(middleware.TimeoutMCPMiddleware(func(method, tool string) time.Duration { // Sets an upper limit, but note that some tools enforce shorter timeouts in their implementation. + if tool == ai.ChartDataName { + return mcpChartDataTimeout + } return 5 * time.Minute })) @@ -77,6 +92,36 @@ func (s *Server) mcpHandler() http.Handler { })) } +// mcpRequestPeekBytes bounds how much of a request body mcpRequestStartsSession reads to find the JSON-RPC method. +// Protocol requests are tiny; only tool calls (such as write_file with a large file) can exceed it. +const mcpRequestPeekBytes = 4 << 20 + +// mcpRequestStartsSession reports whether a request without a session ID should create a persistent AI session: +// an "initialize" handshake or a "tools/call" (which proxies such as the admin service's unified MCP server send without a handshake). +// It peeks at the JSON-RPC method in a bounded prefix of the body and stitches the body back together for the MCP handler. +// A GET request never starts a conversation. A body it cannot classify (larger than the prefix, malformed, or without a method) +// is treated as starting a session: a persistent session is at worst an orphan, whereas a transient one would lose the messages of a real tool call. +func mcpRequestStartsSession(r *http.Request) bool { + if r.Method != http.MethodPost || r.Body == nil { + return false + } + prefix, err := io.ReadAll(io.LimitReader(r.Body, mcpRequestPeekBytes)) + r.Body = struct { + io.Reader + io.Closer + }{io.MultiReader(bytes.NewReader(prefix), r.Body), r.Body} + if err != nil { + return true + } + var msg struct { + Method string `json:"method"` + } + if err := json.Unmarshal(prefix, &msg); err != nil || msg.Method == "" { + return true + } + return msg.Method == "initialize" || msg.Method == "tools/call" +} + // mcpRequestSourceMiddleware tags MCP requests with the "mcp" source so billable queries they trigger // are attributed to MCP (programmatic access). func mcpRequestSourceMiddleware() mcp.Middleware { diff --git a/runtime/server/mcp_test.go b/runtime/server/mcp_test.go index f1059ae800b8..e1d54969daa8 100644 --- a/runtime/server/mcp_test.go +++ b/runtime/server/mcp_test.go @@ -2,13 +2,18 @@ package server import ( "context" + "io" "net/http" "net/http/httptest" + "os" + "path/filepath" + "strings" "testing" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/rilldata/rill/runtime" "github.com/rilldata/rill/runtime/ai" + "github.com/rilldata/rill/runtime/ai/mcpapp" "github.com/rilldata/rill/runtime/drivers" "github.com/rilldata/rill/runtime/pkg/activity" "github.com/rilldata/rill/runtime/pkg/ratelimit" @@ -80,6 +85,8 @@ explore: ai.GetMetricsViewName, ai.QueryMetricsViewName, ai.QueryMetricsViewSummaryName, + ai.CreateChartName, + ai.ChartDataName, ai.ProjectStatusName, ai.QuerySQLName, ai.ListTablesName, @@ -307,3 +314,261 @@ func TestMCPSessionContinuity(t *testing.T) { }) } } + +// TestMCPRequestStartsSession covers the classification of sessionless MCP requests, and that the body reaches the MCP handler intact. +func TestMCPRequestStartsSession(t *testing.T) { + check := func(t *testing.T, method, body string, want bool) { + t.Helper() + var r io.Reader + if body != "" { + r = strings.NewReader(body) + } + req := httptest.NewRequest(method, "/mcp", r) + require.Equal(t, want, mcpRequestStartsSession(req)) + got, err := io.ReadAll(req.Body) + require.NoError(t, err) + require.Equal(t, body, string(got), "the body must be left intact for the MCP handler") + } + + check(t, http.MethodPost, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}`, true) + check(t, http.MethodPost, `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_metrics_views","arguments":{}}}`, true) + check(t, http.MethodPost, `{"jsonrpc":"2.0","id":1,"method":"resources/list","params":{}}`, false) + check(t, http.MethodPost, `{"jsonrpc":"2.0","method":"notifications/initialized"}`, false) + check(t, http.MethodGet, "", false) + + // A body larger than the peeked prefix cannot be classified and starts a session, so a large write_file call is never misclassified. + large := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"write_file","arguments":{"path":"/models/m.sql","contents":"` + strings.Repeat("x", mcpRequestPeekBytes) + `"}}}` + check(t, http.MethodPost, large, true) + // Neither can a body that is not JSON. + check(t, http.MethodPost, "not json", true) +} + +// TestMCPApps checks the MCP Apps surface when the widget bundle is available: +// the widget resource and capability are advertised, create_chart carries the _meta.ui link, chart_data is app-only, +// create_chart returns markdown content plus structured chart data, and sessionless protocol requests do not persist sessions. +func TestMCPApps(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "widget.html"), []byte("rill widget"), 0o644)) + t.Setenv(mcpapp.DirEnv, dir) + widget, ok := mcpapp.Widget() + require.True(t, ok) + + rt, instanceID := testruntime.NewInstanceWithOptions(t, testruntime.InstanceOptions{ + Files: mcpChartTestFiles(""), + }) + testruntime.RequireReconcileState(t, rt, instanceID, 3, 0, 0) + + srv, err := NewServer(context.Background(), &Options{}, rt, zap.NewNop(), ratelimit.NewNoop(), activity.NewNoopClient()) + require.NoError(t, err) + httpSrv := httptest.NewServer(auth.HTTPMiddleware(srv.aud, srv.mcpHandler())) + defer httpSrv.Close() + + countSessions := func(t *testing.T) int { + catalog, release, err := rt.Catalog(t.Context(), instanceID) + require.NoError(t, err) + defer release() + sessions, err := catalog.FindAISessions(t.Context(), "", "") + require.NoError(t, err) + return len(sessions) + } + + // A client that supports the MCP Apps UI extension + clientOpts := &mcp.ClientOptions{Capabilities: &mcp.ClientCapabilities{}} + clientOpts.Capabilities.AddExtension(mcpapp.UIExtension, map[string]any{"mimeTypes": []any{mcpapp.MIMEType}}) + mcpClient := mcp.NewClient(&mcp.Implementation{Name: "claude-ai", Version: "1.0.0"}, clientOpts) + // Claude and ChatGPT speak protocol 2025-06-18, whose handshake is an "initialize" request that starts a session. + // The SDK client defaults to a newer protocol that discovers the server without one. + conn, err := mcpClient.Connect(t.Context(), &mcp.StreamableClientTransport{Endpoint: httpSrv.URL}, &mcp.ClientSessionOptions{ProtocolVersion: "2025-06-18"}) + require.NoError(t, err) + defer conn.Close() + require.Contains(t, conn.InitializeResult().Capabilities.Extensions, mcpapp.UIExtension) + require.NotNil(t, conn.InitializeResult().Capabilities.Resources) + sessionsAfterInit := countSessions(t) + require.GreaterOrEqual(t, sessionsAfterInit, 1) + + // create_chart links to the widget and chart_data is only visible to the widget + tools, err := conn.ListTools(t.Context(), &mcp.ListToolsParams{}) + require.NoError(t, err) + toolsByName := make(map[string]*mcp.Tool) + for _, tool := range tools.Tools { + toolsByName[tool.Name] = tool + } + require.Contains(t, toolsByName, ai.CreateChartName) + require.Contains(t, toolsByName, ai.ChartDataName) + require.Equal(t, widget.URI, toolsByName[ai.CreateChartName].Meta["ui"].(map[string]any)["resourceUri"]) + require.Equal(t, "Creating chart...", toolsByName[ai.CreateChartName].Meta["openai/toolInvocation/invoking"], "existing meta is kept") + require.Equal(t, []any{"app"}, toolsByName[ai.ChartDataName].Meta["ui"].(map[string]any)["visibility"]) + require.Nil(t, toolsByName[ai.QueryMetricsViewName].Meta["ui"]) + + // The widget resource is listed and readable + resources, err := conn.ListResources(t.Context(), &mcp.ListResourcesParams{}) + require.NoError(t, err) + require.Len(t, resources.Resources, 1) + require.Equal(t, widget.URI, resources.Resources[0].URI) + require.Equal(t, "text/html;profile=mcp-app", resources.Resources[0].MIMEType) + require.Equal(t, true, resources.Resources[0].Meta["ui"].(map[string]any)["prefersBorder"]) + read, err := conn.ReadResource(t.Context(), &mcp.ReadResourceParams{URI: widget.URI}) + require.NoError(t, err) + require.Len(t, read.Contents, 1) + require.Equal(t, "text/html;profile=mcp-app", read.Contents[0].MIMEType) + require.Equal(t, widget.HTML, read.Contents[0].Text) + + // create_chart takes the Flint shape externally and returns a markdown fallback as content and the chart data as structured content. + // The previous shape is still accepted, since hosts cache a connector's tool list: it renders through the widget's legacy path. + legacy, err := conn.CallTool(t.Context(), &mcp.CallToolParams{Name: ai.CreateChartName, Arguments: mcpLegacyChartArgs()}) + require.NoError(t, err) + require.False(t, legacy.IsError, "the previous shape is still accepted") + legacyOut := legacy.StructuredContent.(map[string]any) + require.Contains(t, legacyOut, "spec") + require.NotContains(t, legacyOut, "input") + res, err := conn.CallTool(t.Context(), &mcp.CallToolParams{Name: ai.CreateChartName, Arguments: mcpChartArgs()}) + require.NoError(t, err) + require.False(t, res.IsError) + require.Len(t, res.Content, 1) + text := res.Content[0].(*mcp.TextContent).Text + require.True(t, strings.HasPrefix(text, "**Bar Chart: Total Bids by Country** (2024-01-01 to 2024-02-01)"), text) + require.Contains(t, text, "| Country | Total Bids |") + require.Contains(t, text, "| US | 10 |") + require.Contains(t, text, "_2 of 2 rows shown_") + structured, ok := res.StructuredContent.(map[string]any) + require.True(t, ok, "structuredContent should be an object, got %T", res.StructuredContent) + require.Equal(t, "chart", structured["widget"]) + require.Equal(t, "Bar Chart", structured["chart_type"]) + require.NotContains(t, structured, "version") + require.NotContains(t, structured, "spec") + require.Len(t, structured["data"], 2) + require.Equal(t, []any{"US", "CA"}, structured["domain_values"].(map[string]any)["country"]) + require.Contains(t, structured["fields"], "total_bids") + require.Equal(t, "Category", structured["semantic_types"].(map[string]any)["country"]) + require.Equal(t, "Total Bids", structured["field_display_names"].(map[string]any)["total_bids"]) + input := structured["input"].(map[string]any) + require.Equal(t, "Bar Chart", input["chart_spec"].(map[string]any)["chartType"]) + require.EqualValues(t, 10, input["chart_spec"].(map[string]any)["encodings"].(map[string]any)["x"].(map[string]any)["limit"], "the input carries the applied limit") + require.Equal(t, "mv1", input["data"].(map[string]any)["metrics_view"]) + + // chart_data is callable by the widget and returns the data fields only + res, err = conn.CallTool(t.Context(), &mcp.CallToolParams{Name: ai.ChartDataName, Arguments: mcpChartArgs()}) + require.NoError(t, err) + require.False(t, res.IsError) + structured, ok = res.StructuredContent.(map[string]any) + require.True(t, ok) + require.Len(t, structured["data"], 2) + require.NotContains(t, structured, "spec") + require.NotContains(t, structured, "input") + + // A protocol request without a session ID gets a transient session: the catalog is unchanged. + sessionsBefore := countSessions(t) + body := `{"jsonrpc":"2.0","id":1,"method":"resources/list","params":{}}` + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, httpSrv.URL, strings.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode, string(respBody)) + require.Contains(t, string(respBody), widget.URI) + require.Equal(t, sessionsBefore, countSessions(t), "sessionless resources/list must not persist a session") +} + +// TestMCPAppsWithoutWidget asserts that without the widget bundle create_chart is still served and external clients get the chart +// as markdown and data, while the widget surface is absent: no capability, no resource, no resourceUri on create_chart, +// and no chart_data, which only the widget may call. +func TestMCPAppsWithoutWidget(t *testing.T) { + // An override directory without a bundle hides the copy embedded in the binary. + t.Setenv(mcpapp.DirEnv, t.TempDir()) + + rt, instanceID := testruntime.NewInstanceWithOptions(t, testruntime.InstanceOptions{ + Files: mcpChartTestFiles(""), + }) + testruntime.RequireReconcileState(t, rt, instanceID, 3, 0, 0) + + srv, err := NewServer(context.Background(), &Options{}, rt, zap.NewNop(), ratelimit.NewNoop(), activity.NewNoopClient()) + require.NoError(t, err) + httpSrv := httptest.NewServer(auth.HTTPMiddleware(srv.aud, srv.mcpHandler())) + defer httpSrv.Close() + + mcpClient := mcp.NewClient(&mcp.Implementation{Name: "mcp/test", Version: "1.0.0"}, nil) + conn, err := mcpClient.Connect(t.Context(), &mcp.StreamableClientTransport{Endpoint: httpSrv.URL}, nil) + require.NoError(t, err) + defer conn.Close() + + require.NotContains(t, conn.InitializeResult().Capabilities.Extensions, mcpapp.UIExtension) + require.Nil(t, conn.InitializeResult().Capabilities.Resources) + + // create_chart is listed without a link to the widget; chart_data is not listed at all + tools, err := conn.ListTools(t.Context(), &mcp.ListToolsParams{}) + require.NoError(t, err) + toolsByName := make(map[string]*mcp.Tool) + for _, tool := range tools.Tools { + toolsByName[tool.Name] = tool + require.Nil(t, tool.Meta["ui"], "tool %q", tool.Name) + } + require.Contains(t, toolsByName, ai.CreateChartName) + require.NotContains(t, toolsByName, ai.ChartDataName) + require.Equal(t, "Creating chart...", toolsByName[ai.CreateChartName].Meta["openai/toolInvocation/invoking"]) + + // An external client gets the chart as markdown and data + res, err := conn.CallTool(t.Context(), &mcp.CallToolParams{Name: ai.CreateChartName, Arguments: mcpChartArgs()}) + require.NoError(t, err) + require.False(t, res.IsError) + require.Contains(t, res.Content[0].(*mcp.TextContent).Text, "| Country | Total Bids |") + structured, ok := res.StructuredContent.(map[string]any) + require.True(t, ok) + require.Len(t, structured["data"], 2) +} + +// mcpChartTestFiles is a project with a small time series metrics view for the chart tools. +func mcpChartTestFiles(rillYAML string) map[string]string { + return map[string]string{ + "rill.yaml": rillYAML, + "m.sql": ` +SELECT * FROM (VALUES ('US', TIMESTAMP '2024-01-15 00:00:00', 10), ('CA', TIMESTAMP '2024-01-16 00:00:00', 5)) AS t(country, event_time, bids) +`, + "mv1.yaml": ` +type: metrics_view +model: m +timeseries: event_time +dimensions: +- column: country +measures: +- name: total_bids + expression: SUM(bids) +explore: + skip: true +`, + } +} + +// mcpChartArgs are create_chart (and chart_data) arguments for the metrics view of mcpChartTestFiles, in the Flint shape external clients use. +func mcpChartArgs() map[string]any { + return map[string]any{ + "chart_spec": map[string]any{ + "chartType": "Bar Chart", + "encodings": map[string]any{ + "x": map[string]any{"field": "country", "limit": 10, "sortBy": "y", "sortOrder": "descending"}, + "y": "total_bids", + }, + }, + "data": map[string]any{ + "metrics_view": "mv1", + "time_range": map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-02-01T00:00:00Z"}, + }, + } +} + +// mcpLegacyChartArgs are the same chart in the in-app shape, which the MCP server does not advertise. +func mcpLegacyChartArgs() map[string]any { + return map[string]any{ + "chart_type": "bar_chart", + "spec": map[string]any{ + "metrics_view": "mv1", + "time_range": map[string]any{"start": "2024-01-01T00:00:00Z", "end": "2024-02-01T00:00:00Z"}, + "color": "primary", + "x": map[string]any{"field": "country", "type": "nominal", "limit": 10, "sort": "-y"}, + "y": map[string]any{"field": "total_bids", "type": "quantitative"}, + }, + } +} diff --git a/scripts/web-test-code-quality.sh b/scripts/web-test-code-quality.sh index 774fc3934c9b..94d225fc1fad 100755 --- a/scripts/web-test-code-quality.sh +++ b/scripts/web-test-code-quality.sh @@ -14,15 +14,16 @@ else fi # This script mirrors the original GitHub Action, but can also be run locally with parity. -# In CI, ADMIN/LOCAL/COMMON are passed from dorny/paths-filter. +# In CI, ADMIN/LOCAL/COMMON/MCPAPP are passed from dorny/paths-filter. # Locally, if they are not set, we compute them from `git diff`. filter_defaults_if_unset() { - # If any of ADMIN/LOCAL/COMMON are set, assume caller is controlling behavior. - if [[ -n "${ADMIN:-}" || -n "${LOCAL:-}" || -n "${COMMON:-}" ]]; then + # If any of ADMIN/LOCAL/COMMON/MCPAPP are set, assume caller is controlling behavior. + if [[ -n "${ADMIN:-}" || -n "${LOCAL:-}" || -n "${COMMON:-}" || -n "${MCPAPP:-}" ]]; then ADMIN="${ADMIN:-false}" LOCAL="${LOCAL:-false}" COMMON="${COMMON:-false}" + MCPAPP="${MCPAPP:-false}" return fi @@ -38,6 +39,7 @@ filter_defaults_if_unset() { match_admin="false" match_local="false" match_common="false" + match_mcpapp="false" while IFS= read -r f; do [[ -z "$f" ]] && continue @@ -46,26 +48,30 @@ filter_defaults_if_unset() { # admin: .github/workflows/web-test.yml OR web-admin/** # local: .github/workflows/web-test.yml OR web-local/** # common: .github/workflows/web-test.yml OR web-common/** + # mcpapp: .github/workflows/web-test.yml OR web-common/** OR web-mcp-app/** if [[ "$f" == ".github/workflows/web-test.yml" ]]; then match_admin="true" match_local="true" match_common="true" + match_mcpapp="true" continue fi [[ "$f" == web-admin/* ]] && match_admin="true" [[ "$f" == web-local/* ]] && match_local="true" - [[ "$f" == web-common/* ]] && match_common="true" + [[ "$f" == web-common/* ]] && { match_common="true"; match_mcpapp="true"; } + [[ "$f" == web-mcp-app/* ]] && match_mcpapp="true" done <<< "$changed" ADMIN="$match_admin" LOCAL="$match_local" COMMON="$match_common" + MCPAPP="$match_mcpapp" } filter_defaults_if_unset echo "Web code quality checks" -echo "filters: admin=$ADMIN local=$LOCAL common=$COMMON" +echo "filters: admin=$ADMIN local=$LOCAL common=$COMMON mcpapp=$MCPAPP" echo "" echo "== NPM Install ==" @@ -115,6 +121,13 @@ if [[ "$ADMIN" == "true" ]]; then npx svelte-check --workspace web-admin --no-tsconfig || exit_code=$? fi +if [[ "$MCPAPP" == "true" ]]; then + echo "" + echo "== lint and type checks for the MCP App widget ==" + npx eslint web-mcp-app --quiet || exit_code=$? + npm run check -w web-mcp-app || exit_code=$? +fi + echo "" echo "== type check non-svelte files (with temporary whitelist) ==" bash ./scripts/tsc-with-whitelist.sh || exit_code=$? diff --git a/web-admin/src/routes/[organization]/[project]/-/ai/[conversationId]/message/[messageId]/-/open/+page.ts b/web-admin/src/routes/[organization]/[project]/-/ai/[conversationId]/message/[messageId]/-/open/+page.ts index bb62eafec200..e42aa7b7b18b 100644 --- a/web-admin/src/routes/[organization]/[project]/-/ai/[conversationId]/message/[messageId]/-/open/+page.ts +++ b/web-admin/src/routes/[organization]/[project]/-/ai/[conversationId]/message/[messageId]/-/open/+page.ts @@ -9,7 +9,7 @@ export async function load({ parent, params: { organization, project } }) { const { runtime, message, result } = await parent(); const client = getCloudRuntimeClient(runtime); - const query = maybeGetMetricsResolverQueryFromMessage(message); + const query = maybeGetMetricsResolverQueryFromMessage(message, result); const resolvedTimeRanges = getResolvedTimeRangesFromMessage(result); await openQuery({ diff --git a/web-common/src/app.css b/web-common/src/app.css index a8f5fde37b9b..b2e7e2e07bb1 100644 --- a/web-common/src/app.css +++ b/web-common/src/app.css @@ -1,42 +1,10 @@ @import "./colors.css"; +@import "./styles/chart-base.css"; @tailwind base; @tailwind components; @tailwind utilities; @layer utilities { - .ui-copy-number { - font-feature-settings: - "case" 0, - "cpsp" 0, - "dlig" 0, - "frac" 0, - "dnom" 0, - "numr" 0, - "salt" 0, - "subs" 0, - "sups" 0, - "tnum", - "zero" 0, - "ss01", - "ss02" 0, - "ss03" 0, - "ss04" 0, - "cv01" 0, - "cv02" 0, - "cv03" 0, - "cv04" 0, - "cv05" 0, - "cv06" 0, - "cv07" 0, - "cv08" 0, - "cv09" 0, - "cv10" 0, - "cv11" 0, - "calt", - "ccmp", - "kern"; - } - .ui-measure-bar-included { @apply bg-theme-100; } @@ -141,147 +109,6 @@ --report: oklch(0.5876 0.1389 241.97); --connector: oklch(45.147% 0.0181 260.735); - --color-theme-50: var( - --color-theme-light-50, - var(--color-primary-light-50) - ); - --color-theme-100: var( - --color-theme-light-100, - var(--color-primary-light-100) - ); - --color-theme-200: var( - --color-theme-light-200, - var(--color-primary-light-200) - ); - --color-theme-300: var( - --color-theme-light-300, - var(--color-primary-light-300) - ); - --color-theme-400: var( - --color-theme-light-400, - var(--color-primary-light-400) - ); - --color-theme-500: var( - --color-theme-light-500, - var(--color-primary-light-500) - ); - --color-theme-600: var( - --color-theme-light-600, - var(--color-primary-light-600) - ); - --color-theme-700: var( - --color-theme-light-700, - var(--color-primary-light-700) - ); - --color-theme-800: var( - --color-theme-light-800, - var(--color-primary-light-800) - ); - --color-theme-900: var( - --color-theme-light-900, - var(--color-primary-light-900) - ); - --color-theme-950: var( - --color-theme-light-950, - var(--color-primary-light-950) - ); - - --color-theme-secondary-50: var( - --color-theme-secondary-light-50, - var(--color-secondary-light-50) - ); - --color-theme-secondary-100: var( - --color-theme-secondary-light-100, - var(--color-secondary-light-100) - ); - --color-theme-secondary-200: var( - --color-theme-secondary-light-200, - var(--color-secondary-light-200) - ); - --color-theme-secondary-300: var( - --color-theme-secondary-light-300, - var(--color-secondary-light-300) - ); - --color-theme-secondary-400: var( - --color-theme-secondary-light-400, - var(--color-secondary-light-400) - ); - --color-theme-secondary-500: var( - --color-theme-secondary-light-500, - var(--color-secondary-light-500) - ); - --color-theme-secondary-600: var( - --color-theme-secondary-light-600, - var(--color-secondary-light-600) - ); - --color-theme-secondary-700: var( - --color-theme-secondary-light-700, - var(--color-secondary-light-700) - ); - --color-theme-secondary-800: var( - --color-theme-secondary-light-800, - var(--color-secondary-light-800) - ); - --color-theme-secondary-900: var( - --color-theme-secondary-light-900, - var(--color-secondary-light-900) - ); - --color-theme-secondary-950: var( - --color-theme-secondary-light-950, - var(--color-secondary-light-950) - ); - - /* SEQUENTIAL PALETTE - For ordered data that progresses from low to high */ - --color-sequential-1: hsl(211deg 79% 94%); - --color-sequential-2: hsl(211deg 63% 86%); - --color-sequential-3: hsl(211deg 75% 77%); - --color-sequential-4: hsl(210deg 73% 64%); - --color-sequential-5: hsl(208deg 76% 51%); - --color-sequential-6: hsl(210deg 100% 43%); - --color-sequential-7: hsl(212deg 100% 36%); - --color-sequential-8: hsl(214deg 100% 29%); - --color-sequential-9: hsl(217deg 100% 22%); - - /* DIVERGING PALETTE - For data that diverges from a critical midpoint */ - --color-diverging-1: hsl(353deg 87% 48%); - --color-diverging-2: hsl(12deg 100% 62%); - --color-diverging-3: hsl(27deg 100% 70%); - --color-diverging-4: hsl(40deg 96% 82%); - --color-diverging-5: hsl(59deg 48% 94%); - --color-diverging-6: hsl(194deg 100% 86%); - --color-diverging-7: hsl(199deg 91% 73%); - --color-diverging-8: hsl(202deg 83% 57%); - --color-diverging-9: hsl(207deg 100% 44%); - --color-diverging-10: hsl(217deg 100% 39%); - --color-diverging-11: hsl(237deg 69% 34%); - - /* QUALITATIVE PALETTE - For categorical data without inherent ordering */ - /* Based on ColorBrewer Paired + Set3, extended to 24 colors */ - --color-qualitative-1: hsl(156deg 56% 52%); - --color-qualitative-2: hsl(27deg 100% 65%); - --color-qualitative-3: hsl(195deg 100% 46%); - --color-qualitative-4: hsl(289deg 61% 76%); - --color-qualitative-5: hsl(109deg 56% 64%); - --color-qualitative-6: hsl(41deg 83% 69%); - --color-qualitative-7: hsl(349deg 76% 71%); - --color-qualitative-8: hsl(217deg 49% 61%); - --color-qualitative-9: hsl(165deg 100% 36%); - --color-qualitative-10: hsl(16deg 95% 70%); - --color-qualitative-11: hsl(236deg 65% 74%); - --color-qualitative-12: hsl(75deg 43% 66%); - --color-qualitative-13: hsl(330deg 70% 60%); - --color-qualitative-14: hsl(55deg 85% 58%); - --color-qualitative-15: hsl(180deg 85% 42%); - --color-qualitative-16: hsl(270deg 55% 65%); - --color-qualitative-17: hsl(140deg 60% 50%); - --color-qualitative-18: hsl(30deg 90% 62%); - --color-qualitative-19: hsl(355deg 80% 65%); - --color-qualitative-20: hsl(210deg 60% 55%); - --color-qualitative-21: hsl(170deg 70% 45%); - --color-qualitative-22: hsl(45deg 75% 60%); - --color-qualitative-23: hsl(250deg 60% 68%); - --color-qualitative-24: hsl(95deg 50% 58%); - --editor-selection: #b0daffb3; --line-highlight: #ededed5b; } @@ -372,144 +199,6 @@ --popover-foreground: var(--color-neutral-light-50); --popover-footer: var(--color-rill-gray-dark-400); - /* Theme declaration */ - --color-theme-50: var(--color-theme-dark-50, var(--color-primary-dark-50)); - --color-theme-100: var( - --color-theme-dark-100, - var(--color-primary-dark-100) - ); - --color-theme-200: var( - --color-theme-dark-200, - var(--color-primary-dark-200) - ); - --color-theme-300: var( - --color-theme-dark-300, - var(--color-primary-dark-300) - ); - --color-theme-400: var( - --color-theme-dark-400, - var(--color-primary-dark-400) - ); - --color-theme-500: var( - --color-theme-dark-500, - var(--color-primary-dark-500) - ); - --color-theme-600: var( - --color-theme-dark-600, - var(--color-primary-dark-600) - ); - --color-theme-700: var( - --color-theme-dark-700, - var(--color-primary-dark-700) - ); - --color-theme-800: var( - --color-theme-dark-800, - var(--color-primary-dark-800) - ); - --color-theme-900: var( - --color-theme-dark-900, - var(--color-primary-dark-900) - ); - --color-theme-950: var( - --color-theme-dark-950, - var(--color-primary-dark-950) - ); - - --color-theme-secondary-50: var( - --color-theme-secondary-dark-50, - var(--color-secondary-dark-50) - ); - --color-theme-secondary-100: var( - --color-theme-secondary-dark-100, - var(--color-secondary-dark-100) - ); - --color-theme-secondary-200: var( - --color-theme-secondary-dark-200, - var(--color-secondary-dark-200) - ); - --color-theme-secondary-300: var( - --color-theme-secondary-dark-300, - var(--color-secondary-dark-300) - ); - --color-theme-secondary-400: var( - --color-theme-secondary-dark-400, - var(--color-secondary-dark-400) - ); - --color-theme-secondary-500: var( - --color-theme-secondary-dark-500, - var(--color-secondary-dark-500) - ); - --color-theme-secondary-600: var( - --color-theme-secondary-dark-600, - var(--color-secondary-dark-600) - ); - --color-theme-secondary-700: var( - --color-theme-secondary-dark-700, - var(--color-secondary-dark-700) - ); - --color-theme-secondary-800: var( - --color-theme-secondary-dark-800, - var(--color-secondary-dark-800) - ); - --color-theme-secondary-900: var( - --color-theme-secondary-dark-900, - var(--color-secondary-dark-900) - ); - --color-theme-secondary-950: var( - --color-theme-secondary-dark-950, - var(--color-secondary-dark-950) - ); - - /* SEQUENTIAL PALETTE - Dark mode (brighter for visibility) */ - --color-sequential-1: hsl(210deg 20% 25%); - --color-sequential-2: hsl(210deg 25% 30%); - --color-sequential-3: hsl(210deg 30% 35%); - --color-sequential-4: hsl(210deg 35% 40%); - --color-sequential-5: hsl(210deg 40% 45%); - --color-sequential-6: hsl(210deg 45% 50%); - --color-sequential-7: hsl(210deg 50% 55%); - --color-sequential-8: hsl(210deg 55% 60%); - --color-sequential-9: hsl(210deg 60% 65%); - - /* DIVERGING PALETTE - Dark mode (adjusted for dark backgrounds) */ - --color-diverging-1: hsl(270deg 40% 30%); - --color-diverging-2: hsl(260deg 45% 35%); - --color-diverging-3: hsl(250deg 50% 40%); - --color-diverging-4: hsl(240deg 45% 45%); - --color-diverging-5: hsl(230deg 40% 50%); - --color-diverging-6: hsl(220deg 35% 55%); - --color-diverging-7: hsl(85deg 40% 50%); - --color-diverging-8: hsl(55deg 45% 45%); - --color-diverging-9: hsl(35deg 50% 40%); - --color-diverging-10: hsl(25deg 45% 35%); - --color-diverging-11: hsl(15deg 40% 30%); - - /* QUALITATIVE PALETTE - Dark mode (saturated for distinction) */ - --color-qualitative-1: hsl(163deg 50% 45%); - --color-qualitative-2: hsl(50deg 60% 50%); - --color-qualitative-3: hsl(230deg 55% 45%); - --color-qualitative-4: hsl(320deg 50% 50%); - --color-qualitative-5: hsl(140deg 55% 50%); - --color-qualitative-6: hsl(85deg 50% 55%); - --color-qualitative-7: hsl(10deg 55% 45%); - --color-qualitative-8: hsl(260deg 50% 45%); - --color-qualitative-9: hsl(170deg 55% 45%); - --color-qualitative-10: hsl(40deg 55% 50%); - --color-qualitative-11: hsl(280deg 50% 50%); - --color-qualitative-12: hsl(120deg 45% 55%); - --color-qualitative-13: hsl(340deg 60% 55%); - --color-qualitative-14: hsl(65deg 70% 55%); - --color-qualitative-15: hsl(190deg 70% 50%); - --color-qualitative-16: hsl(285deg 55% 60%); - --color-qualitative-17: hsl(150deg 60% 55%); - --color-qualitative-18: hsl(35deg 75% 58%); - --color-qualitative-19: hsl(5deg 70% 60%); - --color-qualitative-20: hsl(220deg 60% 60%); - --color-qualitative-21: hsl(175deg 65% 50%); - --color-qualitative-22: hsl(55deg 65% 55%); - --color-qualitative-23: hsl(265deg 55% 65%); - --color-qualitative-24: hsl(110deg 55% 60%); - --editor-selection: #304e75; --line-highlight: #afafaf33; } diff --git a/web-common/src/components/vega/VegaLiteRenderer.svelte b/web-common/src/components/vega/VegaLiteRenderer.svelte index 67d994cc3e3a..da9ffdcc1f4c 100644 --- a/web-common/src/components/vega/VegaLiteRenderer.svelte +++ b/web-common/src/components/vega/VegaLiteRenderer.svelte @@ -1,7 +1,7 @@ + + diff --git a/web-mcp-app/package.json b/web-mcp-app/package.json new file mode 100644 index 000000000000..5e3088822498 --- /dev/null +++ b/web-mcp-app/package.json @@ -0,0 +1,34 @@ +{ + "name": "web-mcp-app", + "private": true, + "version": "0.0.0", + "type": "module", + "description": "Single-file MCP Apps widget that renders Rill charts inside Claude, ChatGPT and other MCP Apps hosts.", + "scripts": { + "build": "vite build && node scripts/check-size.mjs", + "dev": "vite build --watch --mode development", + "harness": "vite --open /harness/index.html", + "generate:catalog": "node scripts/generate-flint-catalog.mjs", + "test": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test", + "check": "node scripts/check.mjs", + "lint": "eslint .", + "format": "prettier --write ." + }, + "devDependencies": { + "@modelcontextprotocol/ext-apps": "^2.0.3", + "@playwright/test": "^1.50.1", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "autoprefixer": "^10.4.20", + "jsdom": "^26.0.0", + "postcss": "^8.4.47", + "svelte": "^5.0.0", + "svelte-check": "^4.0.4", + "tailwindcss": "^3.4.13", + "vite": "^8.0.0", + "vite-plugin-singlefile": "^2.3.3", + "vitest": "^4.1.1", + "web-common": "*" + } +} diff --git a/web-mcp-app/playwright.config.ts b/web-mcp-app/playwright.config.ts new file mode 100644 index 000000000000..7fda755fced2 --- /dev/null +++ b/web-mcp-app/playwright.config.ts @@ -0,0 +1,15 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests", + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [["github"], ["list"]] : [["list"]], + timeout: 60_000, + use: { + trace: "retain-on-failure", + viewport: { width: 800, height: 700 }, + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], +}); diff --git a/web-mcp-app/postcss.config.cjs b/web-mcp-app/postcss.config.cjs new file mode 100644 index 000000000000..065f0252d0be --- /dev/null +++ b/web-mcp-app/postcss.config.cjs @@ -0,0 +1 @@ +module.exports = require("../web-common/postcss.config.cjs"); diff --git a/web-mcp-app/scripts/check-size.mjs b/web-mcp-app/scripts/check-size.mjs new file mode 100644 index 000000000000..32eafe3ab351 --- /dev/null +++ b/web-mcp-app/scripts/check-size.mjs @@ -0,0 +1,24 @@ +// Fails the build when the single-file widget exceeds the gzip budget. +// Hosts fetch the resource once per connection and cache it, so this is a +// first-render cost, but it still has to stay comfortably below Claude's +// tool-result limits and mobile WebView comfort. +import { readFileSync } from "node:fs"; +import { gzipSync } from "node:zlib"; + +const BUDGET_BYTES = 650 * 1024; +const file = new URL("../dist/widget.html", import.meta.url); + +const raw = readFileSync(file); +const gzipped = gzipSync(raw, { level: 9 }).length; +const kb = (n) => `${(n / 1024).toFixed(1)} KB`; + +console.log( + `dist/widget.html: ${kb(raw.length)} raw, ${kb(gzipped)} gzipped (budget ${kb(BUDGET_BYTES)})`, +); + +if (gzipped > BUDGET_BYTES) { + console.error( + `widget.html exceeds the ${kb(BUDGET_BYTES)} gzip budget by ${kb(gzipped - BUDGET_BYTES)}`, + ); + process.exit(1); +} diff --git a/web-mcp-app/scripts/check.mjs b/web-mcp-app/scripts/check.mjs new file mode 100644 index 000000000000..de00184c7dc4 --- /dev/null +++ b/web-mcp-app/scripts/check.mjs @@ -0,0 +1,36 @@ +// Runs svelte-check over the widget and fails only on diagnostics in this workspace. +// The TypeScript program also pulls in the web-common modules the widget imports, and +// web-common carries a few known type errors that the repo tracks separately +// (scripts/tsc-with-whitelist.sh); those are listed as context, not as failures. +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const result = spawnSync( + "npx", + ["svelte-check", "--tsconfig", "./tsconfig.json", "--output", "machine"], + { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }, +); +const output = `${result.stdout ?? ""}${result.stderr ?? ""}`; + +const own = []; +const upstream = []; +for (const line of output.split("\n")) { + const match = line.match(/^\d+ (ERROR|WARNING) "([^"]+)" (\d+:\d+) (".*")$/); + if (!match) continue; + const [, level, file, position, message] = match; + const entry = `${level} ${file}:${position} ${JSON.parse(message)}`; + (file.startsWith("../") ? upstream : own).push(entry); +} + +if (upstream.length) { + console.log(`${upstream.length} diagnostic(s) in imported web-common files (not failing this check):`); + for (const entry of upstream) console.log(` ${entry}`); +} +if (own.length) { + console.error(`${own.length} diagnostic(s) in web-mcp-app:`); + for (const entry of own) console.error(` ${entry}`); + process.exit(1); +} +const completed = output.match(/COMPLETED (\d+) FILES/); +console.log(`svelte-check: ${completed ? completed[1] : "?"} files, no diagnostics in web-mcp-app`); diff --git a/web-mcp-app/scripts/generate-fixtures.mjs b/web-mcp-app/scripts/generate-fixtures.mjs new file mode 100644 index 000000000000..a99b3cceb554 --- /dev/null +++ b/web-mcp-app/scripts/generate-fixtures.mjs @@ -0,0 +1,1095 @@ +// Generates the fixture files under harness/fixtures/. Each fixture is one tool call: +// the `create_chart` arguments and the result the host forwards to the widget, shaped +// like `CreateChartResult` in runtime/ai/create_chart.go. `bar_chart.json` is the +// canonical example the backend output is checked against. +// Run with `node scripts/generate-fixtures.mjs && npx prettier --write harness/fixtures`; +// the output is committed. +import { mkdirSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const outDir = fileURLToPath(new URL("../harness/fixtures/", import.meta.url)); +mkdirSync(outDir, { recursive: true }); + +const RANGE = { start: "2023-09-09T00:00:00Z", end: "2023-09-16T00:00:00Z" }; +const COMPARISON_RANGE = { + start: "2023-09-02T00:00:00Z", + end: "2023-09-09T00:00:00Z", +}; +const OPEN_URL = + "https://ui.rilldata.com/demo/bids/-/ai/session-0b1c/message/call-4f2a/-/open"; +const THEME = { + primaryColorRaw: "#4f46e5", + light: { primary: "#4f46e5", secondary: "#f97316" }, + dark: { primary: "#818cf8", secondary: "#fb923c" }, +}; +const METRICS_VIEW = { + name: "bids_metrics", + display_name: "Bids", + time_dimension: "__time", + smallest_time_grain: "TIME_GRAIN_HOUR", +}; + +const FIELDS = { + __time: { + name: "__time", + kind: "time", + display_name: "Time", + type: "TIMESTAMP", + }, + advertiser_name: { + name: "advertiser_name", + kind: "dimension", + display_name: "Advertiser Name", + description: "Name of the advertiser bidding on the impression.", + type: "STRING", + }, + publisher: { + name: "publisher", + kind: "dimension", + display_name: "Publisher", + type: "STRING", + }, + device: { + name: "device", + kind: "dimension", + display_name: "Device", + type: "STRING", + }, + campaign_stage: { + name: "campaign_stage", + kind: "dimension", + display_name: "Campaign Stage", + type: "STRING", + }, + total_bids: { + name: "total_bids", + kind: "measure", + display_name: "Total Bids", + description: "Count of bid requests.", + type: "INT64", + format_preset: "humanize", + }, + impressions: { + name: "impressions", + kind: "measure", + display_name: "Impressions", + type: "INT64", + format_preset: "humanize", + }, + total_spend: { + name: "total_spend", + kind: "measure", + display_name: "Total Spend", + type: "FLOAT64", + format_preset: "currency_usd", + }, + win_rate: { + name: "win_rate", + kind: "measure", + display_name: "Win Rate", + type: "FLOAT64", + format_preset: "percentage", + lower_is_better: false, + }, + avg_bid_price: { + name: "avg_bid_price", + kind: "measure", + display_name: "Avg Bid Price", + type: "FLOAT64", + format_d3: "$.2f", + }, + spend_change: { + name: "spend_change", + kind: "measure", + display_name: "Spend Change", + description: "Week-over-week change in spend.", + type: "FLOAT64", + format_preset: "currency_usd", + }, +}; + +const DAYS = Array.from( + { length: 7 }, + (_, i) => `2023-09-${String(9 + i).padStart(2, "0")}T00:00:00Z`, +); +const ADVERTISERS = [ + "Acme Media", + "Beta Brands", + "Cobalt Retail", + "Delta Travel", + "Echo Games", + "Foxtrot Foods", + "Gamma Motors", + "Helix Health", +]; +const PUBLISHERS = [ + "news.example", + "sports.example", + "video.example", + "music.example", + "shop.example", +]; +const DEVICES = ["Mobile", "Desktop", "Tablet"]; +const STAGES = ["Impression", "Click", "Add to cart", "Purchase"]; + +// Deterministic numbers so the fixtures are stable across regenerations. +let seed = 42; +function rand() { + seed = (seed * 1664525 + 1013904223) % 4294967296; + return seed / 4294967296; +} +const int = (min, max) => Math.floor(min + rand() * (max - min)); +const round = (value, digits = 2) => + Math.round(value * 10 ** digits) / 10 ** digits; + +const pickFields = (...names) => + Object.fromEntries(names.map((name) => [name, FIELDS[name]])); + +const CHART_LABELS = { + bar_chart: "Bar", + line_chart: "Line", + area_chart: "Stacked area", + stacked_bar: "Stacked bar", + stacked_bar_normalized: "Stacked bar (normalized)", + donut_chart: "Donut", + pie_chart: "Pie", + funnel_chart: "Funnel", + heatmap: "Heatmap", + combo_chart: "Combo", + scatter_plot: "Scatter", +}; + +function textFallback(chartType, structured) { + const { + title, + data = [], + fields = {}, + resolved_time_range: range, + } = structured; + const header = `**${CHART_LABELS[chartType] ?? chartType}: ${title}**${ + range ? ` (${range.start.slice(0, 10)} to ${range.end.slice(0, 10)})` : "" + }`; + if (!data.length) return `${header}\n\n_No rows._`; + const columns = Object.keys(data[0]); + const label = (column) => + column.endsWith("_prev") + ? `${fields[column.slice(0, -5)]?.display_name ?? column.slice(0, -5)} (prev)` + : (fields[column]?.display_name ?? column); + const lines = [ + `| ${columns.map(label).join(" | ")} |`, + `| ${columns.map(() => "-").join(" | ")} |`, + ...data + .slice(0, 20) + .map( + (row) => + `| ${columns.map((column) => String(row[column] ?? "")).join(" | ")} |`, + ), + ]; + const parts = [ + header, + "", + ...lines, + "", + `_${Math.min(20, data.length)} of ${data.length} rows shown_`, + ]; + if (structured.open_url) + parts.push( + "", + `Open the interactive chart in Rill: ${structured.open_url}`, + ); + return parts.join("\n"); +} + +function fixture({ name, description, expect, chartType, spec, structured }) { + const structuredContent = { + widget: "chart", + chart_type: chartType, + spec, + message: `Chart created successfully: ${chartType}`, + ...structured, + }; + return { + name, + description, + expect, + arguments: { chart_type: chartType, spec }, + result: { + content: [ + { type: "text", text: textFallback(chartType, structuredContent) }, + ], + structuredContent, + }, + }; +} + +function common(extra = {}) { + return { + metrics_view: METRICS_VIEW, + resolved_time_range: RANGE, + theme: THEME, + open_url: OPEN_URL, + truncated: false, + ...extra, + }; +} + +function write(name, content) { + writeFileSync( + `${outDir}${name}.json`, + JSON.stringify(content, null, 2) + "\n", + ); + console.log(`wrote harness/fixtures/${name}.json`); +} + +const timeRange = { start: RANGE.start, end: RANGE.end }; + +// Canonical bar chart: nominal x, one measure, static primary color. +const barBids = [128430, 99210, 87650, 64320, 51200, 40880, 28100, 19750]; +const barSpec = { + metrics_view: "bids_metrics", + time_range: timeRange, + x: { field: "advertiser_name", type: "nominal", limit: 10, sort: "-y" }, + y: { field: "total_bids", type: "quantitative" }, + color: "primary", +}; +const barStructured = common({ + title: "Total Bids per Advertiser Name", + data: ADVERTISERS.map((advertiser_name, i) => ({ + advertiser_name, + total_bids: barBids[i], + })), + fields: pickFields("advertiser_name", "total_bids"), + domain_values: { advertiser_name: ADVERTISERS }, +}); +write( + "bar_chart", + fixture({ + name: "Bar chart", + description: + "Canonical create_chart result: top advertisers by total bids.", + expect: "canvas", + chartType: "bar_chart", + spec: barSpec, + structured: barStructured, + }), +); + +write( + "horizontal_bar", + fixture({ + name: "Horizontal bar chart", + description: "Measure on x and the dimension on y draws horizontal bars.", + expect: "canvas", + chartType: "bar_chart", + spec: { + metrics_view: "bids_metrics", + time_range: timeRange, + x: { field: "total_bids", type: "quantitative" }, + y: { field: "advertiser_name", type: "nominal", limit: 10, sort: "-x" }, + color: "primary", + }, + structured: barStructured, + }), +); + +write( + "multi_measure_bar", + fixture({ + name: "Multi-measure bar chart", + description: + "Two measures side by side; the color channel is the synthetic rill_measures field.", + expect: "canvas", + chartType: "bar_chart", + spec: { + metrics_view: "bids_metrics", + time_range: timeRange, + x: { field: "advertiser_name", type: "nominal", limit: 5, sort: "-y" }, + y: { + field: "total_bids", + type: "quantitative", + fields: ["total_bids", "impressions"], + }, + color: { field: "rill_measures", type: "nominal" }, + }, + structured: common({ + title: "Total Bids, Impressions by Advertiser Name", + data: ADVERTISERS.slice(0, 5).map((advertiser_name, i) => ({ + advertiser_name, + total_bids: barBids[i], + impressions: Math.round(barBids[i] * (0.55 + rand() * 0.2)), + })), + fields: pickFields("advertiser_name", "total_bids", "impressions"), + domain_values: { + advertiser_name: ADVERTISERS.slice(0, 5), + rill_measures: ["total_bids", "impressions"], + }, + }), + }), +); + +// Daily series with one missing bucket (Sep 12) that the widget zero-fills. +const dailyBids = [41200, 45800, 39900, 0, 47300, 52100, 36400]; +const lineSpec = { + metrics_view: "bids_metrics", + time_range: timeRange, + time_grain: "TIME_GRAIN_DAY", + x: { field: "__time", type: "temporal" }, + y: { field: "total_bids", type: "quantitative", zeroBasedOrigin: true }, + color: "primary", +}; +write( + "line_chart", + fixture({ + name: "Line chart", + description: + "Daily total bids; Sep 12 is missing from the rows and gets zero-filled.", + expect: "canvas", + chartType: "line_chart", + spec: lineSpec, + structured: common({ + title: "Total Bids over Time", + time_grain: "TIME_GRAIN_DAY", + data: DAYS.filter((_, i) => i !== 3).map((__time, i) => ({ + __time, + total_bids: dailyBids[i < 3 ? i : i + 1], + })), + fields: pickFields("__time", "total_bids"), + }), + }), +); + +write( + "comparison", + fixture({ + name: "Line chart with comparison", + description: + "Daily total bids compared with the previous week (`_prev` columns).", + expect: "canvas", + chartType: "line_chart", + spec: { + metrics_view: "bids_metrics", + time_range: timeRange, + comparison_time_range: { + start: COMPARISON_RANGE.start, + end: COMPARISON_RANGE.end, + }, + time_grain: "TIME_GRAIN_DAY", + x: { field: "__time", type: "temporal" }, + y: { field: "total_bids", type: "quantitative" }, + color: "primary", + }, + structured: common({ + title: "Total Bids over Time", + time_grain: "TIME_GRAIN_DAY", + data: DAYS.map((__time, i) => ({ + __time, + total_bids: dailyBids[i] || 43800, + total_bids_prev: Math.round( + (dailyBids[i] || 43800) * (0.8 + rand() * 0.3), + ), + })), + fields: pickFields("__time", "total_bids"), + resolved_comparison_time_range: COMPARISON_RANGE, + }), + }), +); + +const areaRows = []; +DAYS.forEach((__time, dayIndex) => { + DEVICES.forEach((device, deviceIndex) => { + // Leave two holes to exercise per-series zero-filling. + if ( + (dayIndex === 3 && device === "Mobile") || + (dayIndex === 5 && device === "Tablet") + ) + return; + areaRows.push({ + __time, + device, + total_spend: round(1800 + deviceIndex * 600 + rand() * 900), + }); + }); +}); +write( + "area_chart", + fixture({ + name: "Stacked area chart", + description: "Daily spend split by device, with two missing buckets.", + expect: "canvas", + chartType: "area_chart", + spec: { + metrics_view: "bids_metrics", + time_range: timeRange, + time_grain: "TIME_GRAIN_DAY", + x: { field: "__time", type: "temporal" }, + y: { field: "total_spend", type: "quantitative" }, + color: { field: "device", type: "nominal", limit: 3 }, + }, + structured: common({ + title: "Total Spend over Time split by Device", + time_grain: "TIME_GRAIN_DAY", + data: areaRows, + fields: pickFields("__time", "device", "total_spend"), + domain_values: { device: DEVICES }, + }), + }), +); + +const stackedRows = []; +PUBLISHERS.forEach((publisher, p) => { + DEVICES.forEach((device, d) => { + stackedRows.push({ + publisher, + device, + impressions: int(20000, 90000) - p * 2500 - d * 1500, + }); + }); +}); +for (const [name, chartType, label] of [ + ["stacked_bar", "stacked_bar", "Stacked bar chart"], + [ + "stacked_bar_normalized", + "stacked_bar_normalized", + "Normalized stacked bar chart", + ], +]) { + write( + name, + fixture({ + name: label, + description: "Impressions per publisher split by device.", + expect: "canvas", + chartType, + spec: { + metrics_view: "bids_metrics", + time_range: timeRange, + x: { field: "publisher", type: "nominal", limit: 10, sort: "-y" }, + y: { field: "impressions", type: "quantitative" }, + color: { field: "device", type: "nominal", limit: 10 }, + }, + structured: common({ + title: "Impressions per Publisher split by Device", + data: stackedRows, + fields: pickFields("publisher", "device", "impressions"), + domain_values: { publisher: PUBLISHERS, device: DEVICES }, + }), + }), + ); +} + +const spendByPublisher = [48210.5, 36990.25, 25480.75, 18760.0, 9340.4]; +write( + "donut_chart", + fixture({ + name: "Donut chart", + description: "Spend share per publisher with a total in the center.", + expect: "canvas", + chartType: "donut_chart", + spec: { + metrics_view: "bids_metrics", + time_range: timeRange, + measure: { field: "total_spend", type: "quantitative", showTotal: true }, + color: { field: "publisher", type: "nominal", limit: 5 }, + innerRadius: 50, + }, + structured: common({ + title: "Total Spend split by Publisher", + data: PUBLISHERS.map((publisher, i) => ({ + publisher, + total_spend: spendByPublisher[i], + })), + fields: pickFields("publisher", "total_spend"), + domain_values: { publisher: PUBLISHERS }, + totals: { total_spend: 151220.9 }, + }), + }), +); + +write( + "pie_chart", + fixture({ + name: "Pie chart", + description: "Bid share per device.", + expect: "canvas", + chartType: "pie_chart", + spec: { + metrics_view: "bids_metrics", + time_range: timeRange, + measure: { field: "total_bids", type: "quantitative" }, + color: { field: "device", type: "nominal", limit: 10 }, + }, + structured: common({ + title: "Total Bids split by Device", + data: DEVICES.map((device, i) => ({ + device, + total_bids: [312400, 201300, 48900][i], + })), + fields: pickFields("device", "total_bids"), + domain_values: { device: DEVICES }, + totals: { total_bids: 562600 }, + }), + }), +); + +write( + "funnel_chart", + fixture({ + name: "Funnel chart", + description: "Bids by campaign stage.", + expect: "canvas", + chartType: "funnel_chart", + spec: { + metrics_view: "bids_metrics", + time_range: timeRange, + measure: { field: "total_bids", type: "quantitative" }, + stage: { field: "campaign_stage", type: "nominal", limit: 15 }, + mode: "width", + percentMode: "top", + }, + structured: common({ + title: "Total Bids funnel by Campaign Stage", + data: STAGES.map((campaign_stage, i) => ({ + campaign_stage, + total_bids: [562600, 148200, 61300, 22400][i], + })), + fields: pickFields("campaign_stage", "total_bids"), + domain_values: { campaign_stage: STAGES }, + }), + }), +); + +const heatmapRows = []; +PUBLISHERS.forEach((publisher) => { + DEVICES.forEach((device) => { + heatmapRows.push({ + publisher, + device, + win_rate: round(0.08 + rand() * 0.3, 3), + }); + }); +}); +write( + "heatmap", + fixture({ + name: "Heatmap", + description: + "Win rate per publisher and device. No domain_values: the widget falls back to first appearance.", + expect: "canvas", + chartType: "heatmap", + spec: { + metrics_view: "bids_metrics", + time_range: timeRange, + x: { field: "publisher", type: "nominal", limit: 40 }, + y: { field: "device", type: "nominal", limit: 40 }, + color: { field: "win_rate", type: "quantitative" }, + }, + structured: common({ + title: "Win Rate by Publisher and Device", + data: heatmapRows, + fields: pickFields("publisher", "device", "win_rate"), + }), + }), +); + +write( + "combo_chart", + fixture({ + name: "Combo chart", + description: "Daily bids as bars with the win rate as a line.", + expect: "canvas", + chartType: "combo_chart", + spec: { + metrics_view: "bids_metrics", + time_range: timeRange, + time_grain: "TIME_GRAIN_DAY", + x: { field: "__time", type: "temporal" }, + y1: { field: "total_bids", type: "quantitative", mark: "bar" }, + y2: { field: "win_rate", type: "quantitative", mark: "line" }, + color: { field: "rill_measures", type: "nominal" }, + }, + structured: common({ + title: "Total Bids & Win Rate over Time", + time_grain: "TIME_GRAIN_DAY", + data: DAYS.map((__time, i) => ({ + __time, + total_bids: dailyBids[i] || 43800, + win_rate: round(0.12 + rand() * 0.1, 3), + })), + fields: pickFields("__time", "total_bids", "win_rate"), + domain_values: { rill_measures: ["total_bids", "win_rate"] }, + }), + }), +); + +const scatterRows = []; +ADVERTISERS.forEach((advertiser_name, i) => { + DEVICES.forEach((device) => { + scatterRows.push({ + advertiser_name, + device, + total_bids: Math.round(barBids[i] * (0.2 + rand() * 0.3)), + win_rate: round(0.05 + rand() * 0.35, 3), + impressions: int(5000, 60000), + }); + }); +}); +write( + "scatter_plot", + fixture({ + name: "Scatter plot", + description: + "Bids against win rate per advertiser and device, sized by impressions.", + expect: "canvas", + chartType: "scatter_plot", + spec: { + metrics_view: "bids_metrics", + time_range: timeRange, + x: { field: "total_bids", type: "quantitative" }, + y: { field: "win_rate", type: "quantitative" }, + dimension: { field: "advertiser_name", type: "nominal", limit: 50 }, + color: { field: "device", type: "nominal", limit: 3 }, + size: { field: "impressions", type: "quantitative" }, + }, + structured: common({ + title: + "Total Bids vs Win Rate for Advertiser Name split by Device sized by Impressions", + data: scatterRows, + fields: pickFields( + "advertiser_name", + "device", + "total_bids", + "win_rate", + "impressions", + ), + domain_values: { device: DEVICES }, + }), + }), +); + +// `truncated` alone means a top-N cap applied; the x limit is the one the backend applied. +write( + "truncated", + fixture({ + name: "Truncated bar chart", + description: + "The server capped the x values and tells the model how to narrow the chart.", + expect: "canvas", + chartType: "bar_chart", + spec: barSpec, + structured: { + ...barStructured, + truncated: true, + truncation_warning: + "Only the first 50 of 120 advertisers are shown. Lower the x limit or add a filter to see the rest.", + }, + }), +); + +// Row truncation keeps the newest buckets: the first three days were cut, not empty. +write( + "truncated_rows", + fixture({ + name: "Row-truncated line chart", + description: + "The row limit cut the oldest days. The widget does not zero-fill before Sep 12 and says the most recent periods are shown.", + expect: "canvas", + chartType: "line_chart", + spec: lineSpec, + structured: common({ + title: "Total Bids over Time", + time_grain: "TIME_GRAIN_DAY", + data: DAYS.slice(3).map((__time, i) => ({ + __time, + total_bids: dailyBids[i + 3] || 43800, + })), + fields: pickFields("__time", "total_bids"), + truncated: true, + rows_truncated: true, + truncation_warning: + "The chart data is incomplete: the data was limited to 4 rows. To see the complete picture, use a coarser time_grain, lower the field limits, or add filters.", + }), + }), +); + +// A zero-row result still carries `data`, as an empty array; `domain_values` is omitempty and absent. +write( + "empty", + fixture({ + name: "Empty result", + description: + "The query returned no rows: `data` is empty and the server omits `domain_values`.", + expect: "empty", + chartType: "bar_chart", + spec: barSpec, + structured: common({ + title: "Total Bids per Advertiser Name", + data: [], + fields: pickFields("advertiser_name", "total_bids"), + }), + }), +); + +write("no_data", { + name: "Result without data", + description: + "The host did not forward the data section (e.g. Claude wrote a large result to a file). The widget shows an error with Open in Rill.", + expect: "error", + arguments: { chart_type: "bar_chart", spec: barSpec }, + result: { + content: [ + { type: "text", text: "**Bar: Total Bids per Advertiser Name**" }, + ], + structuredContent: { + widget: "chart", + chart_type: "bar_chart", + spec: barSpec, + message: "Chart created successfully: bar_chart", + title: "Total Bids per Advertiser Name", + open_url: OPEN_URL, + }, + }, +}); + +write("unsupported_widget", { + name: "Unsupported widget mode", + description: + "A newer server sent a widget mode this bundle does not implement.", + expect: "error", + arguments: { type: "canvas", name: "overview" }, + result: { + content: [{ type: "text", text: "Dashboard: Overview" }], + structuredContent: { + widget: "dashboard", + type: "canvas", + name: "overview", + title: "Overview", + open_url: "https://ui.rilldata.com/demo/bids/canvas/overview", + }, + }, +}); + +write("tool_error", { + name: "Tool error", + description: "The tool call itself failed.", + expect: "error", + arguments: { + chart_type: "bar_chart", + spec: { ...barSpec, metrics_view: "missing_metrics" }, + }, + result: { + isError: true, + content: [ + { type: "text", text: 'metrics view "missing_metrics" not found' }, + ], + }, +}); + +// --------------------------------------------------------------------------------------------- +// Flint results: `create_chart` called with Flint's `chart_spec` and Rill's data binding in the +// `data` slot (see .claude/plans/flint-implementation-contract.md). The server echoes the +// effective input (limits filled in) and derives the semantic types and display names. + +/** Mirrors the server's derivation: format preset first, then the data type. */ +function semanticTypeOf(field, grain) { + if (field.kind === "time") { + return ( + { + TIME_GRAIN_YEAR: "Year", + TIME_GRAIN_QUARTER: "YearQuarter", + TIME_GRAIN_MONTH: "YearMonth", + TIME_GRAIN_WEEK: "YearWeek", + TIME_GRAIN_DAY: "Date", + }[grain] ?? "DateTime" + ); + } + if (field.kind === "dimension") return "Category"; + if ( + field.format_preset === "currency_usd" || + field.format_preset === "currency_eur" + ) + return "Amount"; + if (field.format_preset === "percentage") return "Percentage"; + if (field.format_d3?.includes("$")) return "Amount"; + if (field.format_d3?.includes("%")) return "Percentage"; + if (["INT8", "INT16", "INT32", "INT64", "INT128"].includes(field.type)) + return "Count"; + return "Number"; +} + +function semanticsFor(fields, grain, rows) { + const semantic_types = {}; + const field_display_names = {}; + for (const [name, field] of Object.entries(fields)) { + semantic_types[name] = semanticTypeOf(field, grain); + field_display_names[name] = + field.kind === "time" ? "Time" : field.display_name; + const previous = `${name}_prev`; + if (field.kind === "measure" && rows.some((row) => previous in row)) { + semantic_types[previous] = semantic_types[name]; + field_display_names[previous] = `${field.display_name} (previous period)`; + } + } + return { semantic_types, field_display_names }; +} + +const BINDING = { metrics_view: "bids_metrics", time_range: timeRange }; + +function flintFixture({ + name, + description, + expect, + chartSpec, + data = BINDING, + structured, +}) { + const rows = structured.data ?? []; + const structuredContent = { + widget: "chart", + chart_type: chartSpec.chartType, + input: { chart_spec: chartSpec, data }, + message: `Chart created successfully: ${chartSpec.chartType}`, + ...structured, + ...semanticsFor(structured.fields ?? {}, data.time_grain, rows), + }; + return { + name, + description, + expect, + arguments: { chart_spec: chartSpec, data }, + result: { + content: [ + { + type: "text", + text: textFallback(chartSpec.chartType, structuredContent), + }, + ], + structuredContent, + }, + }; +} + +const flintBarSpec = { + chartType: "Bar Chart", + encodings: { + x: { + field: "advertiser_name", + sortBy: "y", + sortOrder: "descending", + limit: 10, + }, + y: "total_bids", + }, +}; +const flintBarStructured = common({ + title: "Total Bids by Advertiser Name", + data: ADVERTISERS.map((advertiser_name, i) => ({ + advertiser_name, + total_bids: barBids[i], + })), + fields: pickFields("advertiser_name", "total_bids"), + domain_values: { advertiser_name: ADVERTISERS }, +}); +write( + "flint_bar_chart", + flintFixture({ + name: "Flint bar chart", + description: + "Canonical Flint result: top advertisers by total bids, with the server's effective limit on x.", + expect: "canvas", + chartSpec: flintBarSpec, + structured: flintBarStructured, + }), +); + +write( + "flint_line_comparison", + flintFixture({ + name: "Flint line chart with comparison", + description: + "Daily total bids with the previous week as `_prev` columns; the widget folds them into a second series.", + expect: "canvas", + chartSpec: { + chartType: "Line Chart", + encodings: { x: "__time", y: "total_bids" }, + }, + data: { + ...BINDING, + comparison_time_range: { + start: COMPARISON_RANGE.start, + end: COMPARISON_RANGE.end, + }, + time_grain: "TIME_GRAIN_DAY", + }, + structured: common({ + title: "Total Bids over Time", + time_grain: "TIME_GRAIN_DAY", + data: DAYS.map((__time, i) => ({ + __time, + total_bids: dailyBids[i] || 43800, + total_bids_prev: Math.round( + (dailyBids[i] || 43800) * (0.8 + rand() * 0.3), + ), + })), + fields: pickFields("__time", "total_bids"), + resolved_comparison_time_range: COMPARISON_RANGE, + }), + }), +); + +write( + "flint_waterfall", + flintFixture({ + name: "Flint waterfall chart", + description: + "Spend change per publisher; Flint colors the bars by sign and adds the totals.", + expect: "canvas", + chartSpec: { + chartType: "Waterfall Chart", + subtitle: "Week over week, USD", + encodings: { x: { field: "publisher", limit: 10 }, y: "spend_change" }, + chartProperties: { totals: "both" }, + }, + structured: common({ + title: "Spend Change by Publisher", + data: PUBLISHERS.map((publisher, i) => ({ + publisher, + spend_change: [1240.5, -410.25, 655.0, -980.75, 310.4][i], + })), + fields: pickFields("publisher", "spend_change"), + domain_values: { publisher: PUBLISHERS }, + }), + }), +); + +write( + "flint_pie", + flintFixture({ + name: "Flint donut chart", + description: "Bid share per device as a Pie Chart with an inner radius.", + expect: "canvas", + chartSpec: { + chartType: "Pie Chart", + encodings: { size: "total_bids", color: { field: "device", limit: 10 } }, + chartProperties: { innerRadius: 55 }, + }, + structured: common({ + title: "Total Bids by Device", + data: DEVICES.map((device, i) => ({ + device, + total_bids: [312400, 201300, 48900][i], + })), + fields: pickFields("device", "total_bids"), + domain_values: { device: DEVICES }, + }), + }), +); + +write( + "flint_heatmap", + flintFixture({ + name: "Flint heatmap", + description: "Win rate per publisher and device.", + expect: "canvas", + chartSpec: { + chartType: "Heatmap", + encodings: { + x: { field: "publisher", limit: 40 }, + y: { field: "device", limit: 40 }, + color: "win_rate", + }, + }, + structured: common({ + title: "Win Rate by Publisher and Device", + data: heatmapRows, + fields: pickFields("publisher", "device", "win_rate"), + domain_values: { publisher: PUBLISHERS, device: DEVICES }, + }), + }), +); + +const MONTHS = ["2023-07-01T00:00:00Z", "2023-08-01T00:00:00Z"]; +const slopeRows = []; +ADVERTISERS.slice(0, 4).forEach((advertiser_name, i) => { + MONTHS.forEach((__time, month) => { + slopeRows.push({ + __time, + advertiser_name, + total_bids: barBids[i] + month * int(-30000, 30000), + }); + }); +}); +write( + "flint_slope", + flintFixture({ + name: "Flint slope chart", + description: + "Two months of bids per advertiser at month grain (YearMonth semantics).", + expect: "canvas", + chartSpec: { + chartType: "Slope Chart", + encodings: { + x: "__time", + y: "total_bids", + color: { field: "advertiser_name", limit: 10 }, + }, + }, + data: { + metrics_view: "bids_metrics", + time_range: { + start: "2023-07-01T00:00:00Z", + end: "2023-09-01T00:00:00Z", + }, + time_grain: "TIME_GRAIN_MONTH", + }, + structured: common({ + title: "Total Bids over Time by Advertiser Name", + time_grain: "TIME_GRAIN_MONTH", + resolved_time_range: { + start: "2023-07-01T00:00:00Z", + end: "2023-09-01T00:00:00Z", + }, + data: slopeRows, + fields: pickFields("__time", "advertiser_name", "total_bids"), + domain_values: { advertiser_name: ADVERTISERS.slice(0, 4) }, + }), + }), +); + +write( + "flint_grouped_bar", + flintFixture({ + name: "Flint grouped bar chart", + description: + "Impressions per publisher with devices side by side (the `group` channel).", + expect: "canvas", + chartSpec: { + chartType: "Grouped Bar Chart", + encodings: { + x: { field: "publisher", limit: 10 }, + y: "impressions", + group: { field: "device", limit: 10 }, + }, + }, + structured: common({ + title: "Impressions by Publisher and Device", + data: stackedRows, + fields: pickFields("publisher", "device", "impressions"), + domain_values: { publisher: PUBLISHERS, device: DEVICES }, + }), + }), +); + +write( + "flint_empty", + flintFixture({ + name: "Flint empty result", + description: "A Flint result whose query returned no rows.", + expect: "empty", + chartSpec: flintBarSpec, + structured: common({ + title: "Total Bids by Advertiser Name", + data: [], + fields: pickFields("advertiser_name", "total_bids"), + }), + }), +); diff --git a/web-mcp-app/scripts/generate-flint-catalog.mjs b/web-mcp-app/scripts/generate-flint-catalog.mjs new file mode 100644 index 000000000000..6aafbf3f8847 --- /dev/null +++ b/web-mcp-app/scripts/generate-flint-catalog.mjs @@ -0,0 +1,60 @@ +// Generates runtime/ai/chartquery/flint_catalog.json from the installed flint-chart package: +// the Vega-Lite chart types the MCP `create_chart` tool accepts, with the channels each one takes +// and its tunable properties. The Go side embeds the file to validate chart specs and to describe +// the chart types to the model, so the catalog cannot drift from the compiler the widget renders with. +// Run from the repo root after bumping the flint-chart dependency: +// +// npm run generate:catalog -w web-mcp-app +// +// The output is committed. +import { createRequire } from "node:module"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { vlAllTemplateDefs } from "flint-chart/vegalite"; + +// The package does not export its package.json, so it is read next to the resolved entry point. +const require = createRequire(import.meta.url); +const packageRoot = resolve(dirname(require.resolve("flint-chart/vegalite")), "../.."); +const { version } = JSON.parse( + readFileSync(resolve(packageRoot, "package.json"), "utf8"), +); + +const outPath = fileURLToPath( + new URL("../../runtime/ai/chartquery/flint_catalog.json", import.meta.url), +); + +// Chart types that need data a metrics view query cannot provide: geographic coordinates +// or region ids, and a single metric tile the canvas already has a native widget for. +const EXCLUDED = new Set(["Map", "Choropleth", "KPI Card"]); + +function property(def) { + const out = { key: def.key, label: def.label ?? def.key, type: def.type }; + if (def.type === "continuous") { + if (typeof def.min === "number") out.min = def.min; + if (typeof def.max === "number") out.max = def.max; + if (typeof def.step === "number") out.step = def.step; + } + if (def.type === "discrete" && Array.isArray(def.options)) { + out.options = def.options.map((option) => ({ + value: option.value, + label: option.label ?? String(option.value), + })); + } + if (def.defaultValue !== undefined) out.default = def.defaultValue; + return out; +} + +const chartTypes = vlAllTemplateDefs + .filter((def) => !EXCLUDED.has(def.chart)) + .map((def) => ({ + name: def.chart, + channels: [...def.channels], + properties: (def.properties ?? []).map(property), + })); + +const catalog = { flint_version: version, chart_types: chartTypes }; +writeFileSync(outPath, `${JSON.stringify(catalog, null, 2)}\n`); +console.log( + `wrote ${chartTypes.length} chart types (flint-chart ${version}) to ${outPath}`, +); diff --git a/web-mcp-app/src/App.svelte b/web-mcp-app/src/App.svelte new file mode 100644 index 000000000000..b2bbd9763d9e --- /dev/null +++ b/web-mcp-app/src/App.svelte @@ -0,0 +1,410 @@ + + +{#if view.kind === "ready" && session.isFullscreen} + + openLink(view.kind === "ready" ? view.chart.openUrl : undefined)} + onExit={() => void session.requestDisplayMode("inline")} + onControlsChange={applyControls} + onPresentationChange={applyPresentation} + /> +{:else} +
+ {#if view.kind === "connecting"} + + {:else if view.kind === "loading"} + + {:else if view.kind === "error"} + + openLink(view.kind === "error" ? view.openUrl : undefined)} + /> + {:else if view.kind === "empty"} + + openLink(view.kind === "empty" ? view.openUrl : undefined)} + /> + {:else} + + openLink(view.kind === "ready" ? view.chart.openUrl : undefined)} + onFullscreen={() => void session.requestDisplayMode("fullscreen")} + /> + {/if} +
+{/if} + + diff --git a/web-mcp-app/src/components/CardFooter.svelte b/web-mcp-app/src/components/CardFooter.svelte new file mode 100644 index 000000000000..e610ec18a583 --- /dev/null +++ b/web-mcp-app/src/components/CardFooter.svelte @@ -0,0 +1,56 @@ + + +{#if openUrl || secondary} +
+ {#if secondary === "fullscreen"} + + {:else if secondary === "exit-fullscreen"} + + {/if} + {#if openUrl} + + {/if} +
+{/if} + + diff --git a/web-mcp-app/src/components/CardHeader.svelte b/web-mcp-app/src/components/CardHeader.svelte new file mode 100644 index 000000000000..c06c49f77eef --- /dev/null +++ b/web-mcp-app/src/components/CardHeader.svelte @@ -0,0 +1,22 @@ + + +
+

{title}

+ {#if caption} +

{caption}

+ {/if} +
+ + diff --git a/web-mcp-app/src/components/ChartCard.svelte b/web-mcp-app/src/components/ChartCard.svelte new file mode 100644 index 000000000000..95a1c00330fc --- /dev/null +++ b/web-mcp-app/src/components/ChartCard.svelte @@ -0,0 +1,72 @@ + + +
+ + (notices = next)} + /> + {#if banner} + + {/if} + {#if refreshError} +

{refreshError}

+ {/if} + +
+ + diff --git a/web-mcp-app/src/components/ChartSurface.svelte b/web-mcp-app/src/components/ChartSurface.svelte new file mode 100644 index 000000000000..84d6c1816140 --- /dev/null +++ b/web-mcp-app/src/components/ChartSurface.svelte @@ -0,0 +1,110 @@ + + +{#if chart.engine === "flint"} + +{:else} +
+ {#if fontsReady} +
+ +
+ {/if} + {#if dimmed} +
+ +
+ {/if} +
+{/if} + + diff --git a/web-mcp-app/src/components/DataTable.svelte b/web-mcp-app/src/components/DataTable.svelte new file mode 100644 index 000000000000..5c26ebb1af56 --- /dev/null +++ b/web-mcp-app/src/components/DataTable.svelte @@ -0,0 +1,75 @@ + + + + +
+ + + + {#each table.columns as column (column.key)} + + {/each} + + + + {#each table.rows as row, index (index)} + + {#each table.columns as column (column.key)} + + {/each} + + {/each} + +
{column.label}
{column.format(row[column.key])}
+ {#if table.totalRows > table.rows.length} +

+ {m.mcp_chart_rows_shown({ + shown: table.rows.length, + total: table.totalRows, + })} +

+ {/if} +
+ + diff --git a/web-mcp-app/src/components/EmptyState.svelte b/web-mcp-app/src/components/EmptyState.svelte new file mode 100644 index 000000000000..8998df83aff4 --- /dev/null +++ b/web-mcp-app/src/components/EmptyState.svelte @@ -0,0 +1,38 @@ + + +
+
+
+ +
+ + diff --git a/web-mcp-app/src/components/ErrorState.svelte b/web-mcp-app/src/components/ErrorState.svelte new file mode 100644 index 000000000000..1ff2b68171c8 --- /dev/null +++ b/web-mcp-app/src/components/ErrorState.svelte @@ -0,0 +1,43 @@ + + + + + diff --git a/web-mcp-app/src/components/FlintSurface.svelte b/web-mcp-app/src/components/FlintSurface.svelte new file mode 100644 index 000000000000..1d83506618c0 --- /dev/null +++ b/web-mcp-app/src/components/FlintSurface.svelte @@ -0,0 +1,186 @@ + + +
+ {#if compiled?.error} + + {:else if fontsReady && compiled?.spec} +
+ +
+ {/if} + {#if dimmed} +
+ +
+ {/if} +
+ + diff --git a/web-mcp-app/src/components/FullscreenView.svelte b/web-mcp-app/src/components/FullscreenView.svelte new file mode 100644 index 000000000000..309bfdc94822 --- /dev/null +++ b/web-mcp-app/src/components/FullscreenView.svelte @@ -0,0 +1,226 @@ + + +
+
+ +
+ +
+ (tab = next as Tab)} + /> + {#if controls} + {#if grainOptions.length > 1} + + onControlsChange({ ...controlState, grain: grain as V1TimeGrain })} + /> + {/if} + {#if controls.topN} + + onControlsChange({ ...controlState, topN: Number(topN) })} + /> + {/if} + {#if controls.comparison} + + onControlsChange({ + ...controlState, + comparison: comparison === "on", + })} + /> + {/if} + {/if} +
+ + {#if panel && tab === "chart"} + +
+ {#if panel.chartType} + + onPresentationChange?.(panel.apply.chartType(type))} + /> + {/if} + {#if panel.layout} + onPresentationChange?.(panel.apply.layout(id))} + /> + {/if} + {#each panel.properties as property (property.key)} + + onPresentationChange?.(panel.apply.property(property.key, choice))} + /> + {/each} +
+ {/if} + + {#if refreshError} +

{refreshError}

+ {/if} + +
+ {#if tab === "chart"} + (notices = next)} + /> + {:else} +
+ +
+ {/if} +
+ + {#if banner} + + {/if} + + +
+ + diff --git a/web-mcp-app/src/components/SegmentedControl.svelte b/web-mcp-app/src/components/SegmentedControl.svelte new file mode 100644 index 000000000000..8e904d70bc09 --- /dev/null +++ b/web-mcp-app/src/components/SegmentedControl.svelte @@ -0,0 +1,58 @@ + + +
+ +
+ {#each options as option (option.value)} + + {/each} +
+
+ + diff --git a/web-mcp-app/src/components/Skeleton.svelte b/web-mcp-app/src/components/Skeleton.svelte new file mode 100644 index 000000000000..09175a2a0d16 --- /dev/null +++ b/web-mcp-app/src/components/Skeleton.svelte @@ -0,0 +1,66 @@ + + +
+ {#if !compact} +
+
+ {/if} +
+
+
+
+
+
+
+
+
+ + diff --git a/web-mcp-app/src/components/TruncatedBanner.svelte b/web-mcp-app/src/components/TruncatedBanner.svelte new file mode 100644 index 000000000000..1646608a0bcb --- /dev/null +++ b/web-mcp-app/src/components/TruncatedBanner.svelte @@ -0,0 +1,23 @@ + + + + + diff --git a/web-mcp-app/src/lib/adapt.spec.ts b/web-mcp-app/src/lib/adapt.spec.ts new file mode 100644 index 000000000000..b216b3b5eb17 --- /dev/null +++ b/web-mcp-app/src/lib/adapt.spec.ts @@ -0,0 +1,315 @@ +import { TOTAL_DOMAIN_KEY } from "@rilldata/web-common/features/components/charts/circular/constants"; +import { get } from "svelte/store"; +import { describe, expect, it } from "vitest"; +import area from "../../harness/fixtures/area_chart.json"; +import bar from "../../harness/fixtures/bar_chart.json"; +import combo from "../../harness/fixtures/combo_chart.json"; +import comparison from "../../harness/fixtures/comparison.json"; +import donut from "../../harness/fixtures/donut_chart.json"; +import empty from "../../harness/fixtures/empty.json"; +import funnel from "../../harness/fixtures/funnel_chart.json"; +import heatmap from "../../harness/fixtures/heatmap.json"; +import line from "../../harness/fixtures/line_chart.json"; +import multi from "../../harness/fixtures/multi_measure_bar.json"; +import truncated from "../../harness/fixtures/truncated.json"; +import truncatedRows from "../../harness/fixtures/truncated_rows.json"; +import { adaptChartResult, MAX_TABLE_ROWS } from "./adapt"; +import type { RillChartResult } from "./contract"; + +const result = (fixture: { result: { structuredContent?: unknown } }) => + fixture.result.structuredContent as RillChartResult; + +const light = { isDark: false, locale: "en" }; + +describe("adaptChartResult", () => { + it("builds measures, fields and domain values for the canonical bar chart", () => { + const chart = adaptChartResult(result(bar), light); + expect(chart.chartType).toBe("bar_chart"); + expect(chart.rowCount).toBe(8); + expect(chart.measures).toEqual([ + expect.objectContaining({ + name: "total_bids", + displayName: "Total Bids", + formatPreset: "humanize", + }), + ]); + expect(chart.snapshot.fields.advertiser_name).toEqual( + expect.objectContaining({ displayName: "Advertiser Name" }), + ); + expect(chart.snapshot.domainValues?.advertiser_name).toEqual( + result(bar).domain_values?.advertiser_name, + ); + expect(chart.snapshot.hasComparison).toBe(false); + expect(chart.title).toBe("Total Bids per Advertiser Name"); + expect(chart.caption).toBe("Sep 9 – Sep 15, 2023 · Bids"); + expect(chart.openUrl).toBe(result(bar).open_url); + expect(get(chart.chartData)).toBe(chart.snapshot); + }); + + it("resolves the project theme per mode", () => { + const lightChart = adaptChartResult(result(bar), light); + const darkChart = adaptChartResult(result(bar), { isDark: true }); + expect(lightChart.theme?.primary).toBe("#4f46e5"); + expect(darkChart.theme?.primary).toBe("#818cf8"); + expect(lightChart.snapshot.theme.primary.hex()).toBe("#4f46e5"); + expect(darkChart.snapshot.isDarkMode).toBe(true); + }); + + it("falls back to the humanized chart type when the result has no title", () => { + const chart = adaptChartResult({ ...result(bar), title: undefined }, light); + expect(chart.title).toBe("Bar chart"); + expect(chart.timeRangeLabel).toBe("Sep 9 – Sep 15, 2023"); + }); + + it("converts temporal values to dates and zero-fills missing buckets", () => { + const chart = adaptChartResult(result(line), light); + expect(chart.timeGrain).toBe("TIME_GRAIN_DAY"); + expect(chart.temporalField).toBe("__time"); + // Six rows arrive, the seventh (Sep 12) is filled with zero. + expect(chart.rowCount).toBe(7); + const rows = chart.snapshot.data as Array<{ + __time: Date; + total_bids: number; + }>; + rows.forEach((row) => expect(row.__time).toBeInstanceOf(Date)); + expect(rows.map((row) => row.__time.getDate())).toEqual([ + 9, 10, 11, 12, 13, 14, 15, + ]); + // Same wall-clock time as the UTC bucket start, in the system zone. + expect(rows[0].__time.getHours()).toBe(0); + expect(rows[3].total_bids).toBe(0); + expect(chart.snapshot.fields.__time).toEqual( + expect.objectContaining({ timeUnit: "yearmonthdate", format: "%b %d" }), + ); + expect(chart.captionParts).toEqual([ + "Sep 9 – Sep 15, 2023", + "by day", + "Bids", + ]); + }); + + it("fills the whole range when only a top-N cap applied", () => { + // `truncated` without `rows_truncated` means a nominal channel was capped; the time axis + // is complete, so the leading and trailing empty buckets are zeros like any other. + const data = (result(line).data ?? []).filter((row) => + ["11", "13"].includes(String(row.__time).slice(8, 10)), + ); + const chart = adaptChartResult( + { ...result(line), data, truncated: true }, + light, + ); + const dates = (chart.snapshot.data as Array<{ __time: Date }>).map((row) => + row.__time.getDate(), + ); + expect(dates).toEqual([9, 10, 11, 12, 13, 14, 15]); + expect(chart.rowsTruncated).toBe(false); + }); + + it("does not zero-fill before the first bucket of a row-truncated series", () => { + // The server keeps the newest rows, so Sep 9 and 10 were cut rather than empty. Sep 12 is + // a hole inside the data and Sep 14 and 15 come after the newest row; those are zeros. + const data = (result(line).data ?? []).filter((row) => + ["11", "13"].includes(String(row.__time).slice(8, 10)), + ); + const chart = adaptChartResult( + { ...result(line), data, truncated: true, rows_truncated: true }, + light, + ); + const rows = chart.snapshot.data as Array<{ + __time: Date; + total_bids: number; + }>; + expect(rows.map((row) => row.__time.getDate())).toEqual([ + 11, 12, 13, 14, 15, + ]); + expect(rows.map((row) => row.total_bids === 0)).toEqual([ + false, + true, + false, + true, + true, + ]); + expect(chart.rowsTruncated).toBe(true); + }); + + it("zero-fills per series when a color dimension is present", () => { + const chart = adaptChartResult(result(area), light); + expect(chart.rowCount).toBe(21); + const rows = chart.snapshot.data as Array<{ + __time: Date; + device: string; + total_spend: number; + }>; + const mobileSep12 = rows.find( + (row) => row.device === "Mobile" && row.__time.getDate() === 12, + ); + expect(mobileSep12?.total_spend).toBe(0); + expect(chart.snapshot.domainValues?.device).toEqual([ + "Mobile", + "Desktop", + "Tablet", + ]); + }); + + it("detects comparison columns", () => { + const chart = adaptChartResult(result(comparison), light); + expect(chart.hasComparison).toBe(true); + expect(chart.captionParts).toContain("vs. previous period"); + expect(chart.table.columns.map((column) => column.key)).toEqual([ + "__time", + "total_bids", + "total_bids_prev", + ]); + expect(chart.table.columns[2].label).toBe("Total Bids (prev)"); + }); + + it("labels the multi-measure color domain with display names", () => { + const chart = adaptChartResult(result(multi), light); + expect(chart.snapshot.domainValues?.rill_measures).toEqual([ + "Total Bids", + "Impressions", + ]); + expect(chart.measures.map((measure) => measure.name)).toEqual([ + "total_bids", + "impressions", + ]); + }); + + it("labels the combo color domain with both measures", () => { + const chart = adaptChartResult(result(combo), light); + expect(chart.snapshot.domainValues?.rill_measures).toEqual([ + "Total Bids", + "Win Rate", + ]); + }); + + it("carries the donut total and synthesizes an Other slice when asked", () => { + const chart = adaptChartResult(result(donut), light); + expect(chart.snapshot.domainValues?.[TOTAL_DOMAIN_KEY]).toEqual([151220.9]); + expect(chart.snapshot.domainValues?.__other_value).toBeUndefined(); + + const withOther = adaptChartResult( + { + ...result(donut), + spec: { ...result(donut).spec, show_other: true }, + }, + light, + ); + // 151220.9 total minus the 138781.9 the five visible publishers add up to. + expect(withOther.snapshot.domainValues?.__other_value?.[0]).toBeCloseTo( + 12439, + 3, + ); + expect(withOther.snapshot.domainValues?.publisher).toContain("Other"); + }); + + it("falls back to first appearance for heatmap axes without domain values", () => { + const chart = adaptChartResult(result(heatmap), light); + expect(chart.snapshot.domainValues?.publisher).toEqual([ + "news.example", + "sports.example", + "video.example", + "music.example", + "shop.example", + ]); + expect(chart.snapshot.domainValues?.device).toEqual([ + "Mobile", + "Desktop", + "Tablet", + ]); + }); + + it("passes funnel stages through untouched", () => { + const chart = adaptChartResult(result(funnel), light); + expect(chart.snapshot.domainValues?.campaign_stage).toEqual([ + "Impression", + "Click", + "Add to cart", + "Purchase", + ]); + }); + + it("coerces numeric strings in measure columns", () => { + const chart = adaptChartResult( + { + ...result(bar), + data: [ + { advertiser_name: "Acme", total_bids: "12345" }, + { advertiser_name: "Beta", total_bids: 9876 }, + ], + }, + light, + ); + expect(chart.snapshot.data).toEqual([ + { advertiser_name: "Acme", total_bids: 12345 }, + { advertiser_name: "Beta", total_bids: 9876 }, + ]); + }); + + it("reports empty results", () => { + // A zero-row result carries an empty `data` array. + expect(result(empty).data).toEqual([]); + expect(adaptChartResult(result(empty), light).rowCount).toBe(0); + }); + + it("tells apart a top-N cap from cut rows", () => { + expect(adaptChartResult(result(bar), light).truncationNote).toBeUndefined(); + + const capped = adaptChartResult(result(truncated), light); + expect(capped.truncated).toBe(true); + expect(capped.rowsTruncated).toBe(false); + // The x limit the backend applied is on the spec. + expect(capped.truncationNote).toBe("Showing the top 10 values."); + expect(capped.truncationWarning).toContain("Lower the x limit"); + + const cut = adaptChartResult(result(truncatedRows), light); + expect(cut.rowsTruncated).toBe(true); + expect(cut.truncationNote).toBe("Showing the most recent periods."); + // The first three days were cut, not empty, so nothing is filled before Sep 12. + expect(cut.rowCount).toBe(4); + + // Cut rows of a chart without a time axis are neither periods nor top values. + const points = adaptChartResult( + { ...result(bar), truncated: true, rows_truncated: true }, + light, + ); + expect(points.truncationNote).toBe("Showing a subset of the data."); + }); + + it("formats table cells with Rill's measure formatters and caps the rows", () => { + const chart = adaptChartResult(result(bar), light); + const measure = chart.table.columns.find( + (column) => column.key === "total_bids", + )!; + expect(measure.format(128430)).toBe("128.4k"); + expect(measure.format(null)).toBe(""); + + const manyRows = Array.from({ length: MAX_TABLE_ROWS + 50 }, (_, i) => ({ + advertiser_name: `adv ${i}`, + total_bids: i, + })); + const big = adaptChartResult({ ...result(bar), data: manyRows }, light); + expect(big.table.rows).toHaveLength(MAX_TABLE_ROWS); + expect(big.table.totalRows).toBe(MAX_TABLE_ROWS + 50); + }); + + it("uses the time zone from the result before the option", () => { + const zoned = adaptChartResult( + { + ...result(line), + resolved_time_range: { + start: "2023-09-09T00:00:00Z", + end: "2023-09-16T00:00:00Z", + time_zone: "America/New_York", + }, + }, + { ...light, timeZone: "Asia/Tokyo" }, + ); + expect(zoned.timeZone).toBe("America/New_York"); + expect( + adaptChartResult(result(line), { ...light, timeZone: "Asia/Tokyo" }) + .timeZone, + ).toBe("Asia/Tokyo"); + expect(adaptChartResult(result(line), light).timeZone).toBe("UTC"); + }); +}); diff --git a/web-mcp-app/src/lib/adapt.ts b/web-mcp-app/src/lib/adapt.ts new file mode 100644 index 000000000000..abb1b87c9514 --- /dev/null +++ b/web-mcp-app/src/lib/adapt.ts @@ -0,0 +1,890 @@ +import { timeGrainToVegaTimeUnitMap } from "@rilldata/web-common/components/vega/util"; +import type { CartesianChartSpec } from "@rilldata/web-common/features/components/charts/cartesian/CartesianChartProvider"; +import { toVerticalSpec } from "@rilldata/web-common/features/components/charts/cartesian/orientation"; +import type { CircularChartSpec } from "@rilldata/web-common/features/components/charts/circular/CircularChartProvider"; +import { + OTHER_VALUE, + OTHER_VALUE_DOMAIN_KEY, + TOTAL_DOMAIN_KEY, +} from "@rilldata/web-common/features/components/charts/circular/constants"; +import type { ComboChartSpec } from "@rilldata/web-common/features/components/charts/combo/ComboChartProvider"; +import type { HeatmapChartSpec } from "@rilldata/web-common/features/components/charts/heatmap/HeatmapChartProvider"; +import type { ScatterPlotChartSpec } from "@rilldata/web-common/features/components/charts/scatter/ScatterPlotChartProvider"; +import type { + ChartDataResult, + ChartDomainValues, + ChartFieldsMap, + ChartSpec, + ChartType, + FieldConfig, + TimeDimensionDefinition, +} from "@rilldata/web-common/features/components/charts/types"; +import { + getFieldsByType, + isFieldConfig, + isMultiFieldConfig, +} from "@rilldata/web-common/features/components/charts/util"; +import { ComparisonDeltaPreviousSuffix } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry"; +import { themeManager } from "@rilldata/web-common/features/themes/theme-manager"; +import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; +import { createMeasureValueFormatter } from "@rilldata/web-common/lib/number-formatting/format-measure-value"; +import { TIME_GRAIN } from "@rilldata/web-common/lib/time/config"; +import { convertISOStringToJSDateWithSameTimeAsSelectedTimeZone } from "@rilldata/web-common/lib/time/timezone"; +import { + V1TimeGrain, + type MetricsViewSpecDimension, + type MetricsViewSpecMeasure, +} from "@rilldata/web-common/runtime-client/gen/index.schemas"; +import { timeFormat } from "d3-time-format"; +import { DateTime, type DurationLikeObject } from "luxon"; +import { readable, type Readable } from "svelte/store"; +import { + buildCaptionParts, + CAPTION_SEPARATOR, + formatTimeRange, +} from "./caption"; +import { + isCartesianChartType, + isFlintResult, + normalizeRows, + type ChartResult, + type FlintChartResult, + type RillChartResult, + type WireField, + type WireFlintChartSpec, + type WireRow, + type WireTimeRange, +} from "./contract"; +import { topNTarget } from "./controls"; +import { flintTemporalField, flintTopNTarget } from "./flint-controls"; +import { buildFlintCompileInput, type FlintCompileInput } from "./flint-input"; + +/** + * Turns a `create_chart` result into what the chart surface renders from, plus the title, + * caption and data table the card shows around it: web-common's `Chart.svelte` inputs for a + * Rill result, Flint's compile input for a Flint result. + * Pure: no DOM, no network, no host session. Everything the in-app chart gets from + * TanStack queries and dashboard stores is derived here from the wire payload. + */ + +export interface AdaptOptions { + isDark: boolean; + /** + * Zone used when the result does not name one. Rill buckets temporal values in UTC + * unless the query asked for a zone, so the fallback is UTC rather than the host's zone. + */ + timeZone?: string; + locale?: string; + /** + * Flint results only: the chart spec to compile instead of the one the result carries, + * i.e. the presentation controls' edits (chart type, properties, views) applied client-side. + */ + chartSpec?: WireFlintChartSpec; +} + +export type TableColumnKind = "time" | "dimension" | "measure"; + +export interface TableColumn { + key: string; + label: string; + kind: TableColumnKind; + format: (value: unknown) => string; +} + +export interface TableModel { + columns: TableColumn[]; + rows: WireRow[]; + totalRows: number; +} + +/** What the card and the fullscreen view show around either kind of chart. */ +export interface AdaptedChartBase { + /** The measures the chart draws, in the shape Rill's value formatters take. */ + measures: MetricsViewSpecMeasure[]; + /** Project theme variables for the current mode, as `Chart.svelte` expects them. */ + theme: Record | undefined; + title: string; + /** The formatted time range on its own; the first caption part when the result has one. */ + timeRangeLabel: string | undefined; + captionParts: string[]; + caption: string; + table: TableModel; + rowCount: number; + timeZone: string; + timeGrain: V1TimeGrain | undefined; + temporalField: string | undefined; + hasComparison: boolean; + /** Any budget cut the data: a top-N cap or the row limit. */ + truncated: boolean; + /** The row limit cut the rows; a time series keeps its newest buckets. */ + rowsTruncated: boolean; + /** The localized banner line for a truncated result. */ + truncationNote: string | undefined; + /** The server's own explanation of the cut, written for the model. */ + truncationWarning: string | undefined; + openUrl: string | undefined; +} + +/** A Rill result, rendered by web-common's `Chart.svelte` from Rill's chart config. */ +export interface AdaptedRillChart extends AdaptedChartBase { + engine: "rill"; + chartType: ChartType; + spec: ChartSpec; + chartData: Readable; + /** The same value the store holds; convenient for spec generation and tests. */ + snapshot: ChartDataResult; +} + +/** A Flint result, compiled by `compileFlintSpec` and rendered with the Vega-Lite renderer. */ +export interface AdaptedFlintChart extends AdaptedChartBase { + engine: "flint"; + chartType: string; + /** The chart spec in effect: the result's, or the presentation controls' edit of it. */ + chartSpec: WireFlintChartSpec; + flint: FlintCompileInput; + /** + * The rows Flint compiles, with temporal values converted to the resolved time zone and the + * comparison folded into long form; the table shows the result's own rows. + */ + rows: WireRow[]; +} + +export type AdaptedChart = AdaptedRillChart | AdaptedFlintChart; + +export const MAX_TABLE_ROWS = 500; +/** Zero-filling stops when a range would need more buckets than this. */ +export const MAX_FILL_BUCKETS = 1500; +export const DEFAULT_TIME_ZONE = "UTC"; +const DEFAULT_TIME_GRAIN = V1TimeGrain.TIME_GRAIN_DAY; +/** Label the chart providers use for the time axis; titles key on it ("over Time"). */ +const TIME_AXIS_LABEL = "Time"; + +/** Adapts either kind of result; see `adaptChartResult` and `adaptFlintResult`. */ +export function adaptResult( + result: ChartResult, + options: AdaptOptions, +): AdaptedChart { + return isFlintResult(result) + ? adaptFlintResult(result, options) + : adaptChartResult(result, options); +} + +export function adaptChartResult( + result: RillChartResult, + options: AdaptOptions, +): AdaptedRillChart { + const chartType = result.chart_type; + const spec = result.spec as unknown as ChartSpec; + const timeZone = + result.resolved_time_range?.time_zone || + result.spec.time_range?.time_zone || + options.timeZone || + DEFAULT_TIME_ZONE; + const locale = options.locale ?? "en"; + + const { + measures: measureNames, + dimensions: dimensionNames, + timeDimensions, + } = getFieldsByType(spec); + const temporalField = timeDimensions[0]; + const timeGrain = resolveTimeGrain( + result.time_grain ?? result.spec.time_grain, + temporalField !== undefined, + ); + const fields = buildFields( + result.fields ?? {}, + measureNames, + dimensionNames, + timeDimensions, + timeGrain, + ); + const measures = measureNames.map( + (name) => fields[name] as MetricsViewSpecMeasure, + ); + + let rows = normalizeRows(result.data); + const measureKeys = measureKeysInRows(measureNames, rows); + rows = rows.map((row) => coerceMeasureValues(row, measureKeys)); + const hasComparison = measureNames.some((name) => + measureKeys.includes(name + ComparisonDeltaPreviousSuffix), + ); + + const range = result.resolved_time_range ?? result.spec.time_range; + if ( + temporalField && + timeGrain && + range && + rows.length > 0 && + supportsZeroFill(chartType) + ) { + const series = seriesDimension(chartType, spec, temporalField); + rows = zeroFillTimeBuckets(rows, { + timeField: temporalField, + grain: timeGrain, + range, + timeZone, + fillKeys: measureKeys, + seriesField: series, + seriesValues: series + ? stringDomain(result.domain_values?.[series]) + : undefined, + rowsTruncated: result.rows_truncated === true, + }); + } + if (timeDimensions.length > 0) { + rows = convertTimeFields(rows, timeDimensions, timeZone); + } + + const domainValues = buildDomainValues(chartType, spec, rows, result, fields); + + const snapshot: ChartDataResult = { + data: rows as ChartDataResult["data"], + isFetching: false, + error: null, + fields, + domainValues, + isDarkMode: options.isDark, + hasComparison, + theme: themeManager.resolveThemeColors(result.theme, options.isDark), + }; + + // `create_chart` always sends a title; the humanized type id ("Bar chart") is a last resort. + const fallbackTitle = chartType.replace(/_/g, " "); + const title = + result.title?.trim() || + fallbackTitle[0].toUpperCase() + fallbackTitle.slice(1); + const timeRangeLabel = range + ? formatTimeRange(range, timeZone, locale, timeGrain) + : undefined; + const captionParts = buildCaptionParts({ + range, + grain: timeGrain, + showGrain: temporalField !== undefined, + hasComparison, + metricsViewLabel: + result.metrics_view?.display_name || result.metrics_view?.name, + timeZone, + locale, + }); + + return { + engine: "rill", + chartType, + spec, + chartData: readable(snapshot), + snapshot, + measures, + theme: themeManager.resolveThemeObject(result.theme, options.isDark), + title, + timeRangeLabel, + captionParts, + caption: captionParts.join(CAPTION_SEPARATOR), + table: buildTable( + rows, + fields, + timeDimensions, + dimensionNames, + measureNames, + hasComparison, + timeGrain, + ), + rowCount: rows.length, + timeZone, + timeGrain, + temporalField, + hasComparison, + truncated: result.truncated === true, + rowsTruncated: result.rows_truncated === true, + truncationNote: truncationNote( + result, + temporalField, + topNTarget(chartType, result.spec)?.limit, + ), + truncationWarning: result.truncation_warning || undefined, + openUrl: result.open_url || undefined, + }; +} + +/** + * Adapts a Flint result: the rows are converted to the resolved time zone like Rill's charts do + * (Flint takes `Date` values), the measures get Rill's formatters, and `flint` holds the compile + * input for the surface. The title comes from the result, else from the spec's headline; the + * spec's subtitle leads the caption. No zero-filling: Flint lays out temporal axes itself. + */ +export function adaptFlintResult( + result: FlintChartResult, + options: AdaptOptions, +): AdaptedFlintChart { + const chartSpec = options.chartSpec ?? result.input.chart_spec; + const timeZone = + result.resolved_time_range?.time_zone || + result.input.data.time_range?.time_zone || + options.timeZone || + DEFAULT_TIME_ZONE; + const locale = options.locale ?? "en"; + const wireFields = result.fields ?? {}; + const timeDimension = result.metrics_view?.time_dimension; + + const measureNames: string[] = []; + const dimensionNames: string[] = []; + const timeNames: string[] = []; + for (const [name, field] of Object.entries(wireFields)) { + if (field.kind === "measure") measureNames.push(name); + else if (field.kind === "time" || name === timeDimension) + timeNames.push(name); + else dimensionNames.push(name); + } + const temporalField = flintTemporalField( + chartSpec, + wireFields, + timeDimension, + ); + const timeGrain = resolveTimeGrain( + result.time_grain ?? result.input.data.time_grain, + temporalField !== undefined, + ); + const fields = buildFields( + wireFields, + measureNames, + dimensionNames, + timeNames, + timeGrain, + ); + + let rows = normalizeRows(result.data); + const measureKeys = measureKeysInRows(measureNames, rows); + rows = rows.map((row) => coerceMeasureValues(row, measureKeys)); + if (timeNames.length > 0) { + rows = convertTimeFields(rows, timeNames, timeZone); + } + + const flint = buildFlintCompileInput(chartSpec, result, rows, timeGrain); + const hasComparison = + flint.comparison !== undefined || + measureNames.some((name) => + measureKeys.includes(name + ComparisonDeltaPreviousSuffix), + ); + + // Each comparison column is formatted like its measure. + const measures: MetricsViewSpecMeasure[] = []; + for (const name of measureNames) { + const measure = fields[name] as MetricsViewSpecMeasure; + measures.push(measure); + const previous = name + ComparisonDeltaPreviousSuffix; + if (measureKeys.includes(previous)) { + measures.push({ + ...measure, + name: previous, + displayName: flint.fields.field_display_names[previous], + }); + } + } + + const range = result.resolved_time_range ?? result.input.data.time_range; + const title = + result.title?.trim() || chartSpec.title?.trim() || chartSpec.chartType; + const timeRangeLabel = range + ? formatTimeRange(range, timeZone, locale, timeGrain) + : undefined; + const subtitle = chartSpec.subtitle?.trim(); + const captionParts = [ + ...(subtitle ? [subtitle] : []), + ...buildCaptionParts({ + range, + grain: timeGrain, + showGrain: temporalField !== undefined, + hasComparison, + metricsViewLabel: + result.metrics_view?.display_name || result.metrics_view?.name, + timeZone, + locale, + }), + ]; + + return { + engine: "flint", + chartType: chartSpec.chartType, + chartSpec, + flint, + rows: flint.rows, + measures, + theme: themeManager.resolveThemeObject(result.theme, options.isDark), + title, + timeRangeLabel, + captionParts, + caption: captionParts.join(CAPTION_SEPARATOR), + table: buildTable( + rows, + fields, + timeNames, + dimensionNames, + measureNames, + hasComparison, + timeGrain, + ), + rowCount: rows.length, + timeZone, + timeGrain, + temporalField, + hasComparison, + truncated: result.truncated === true, + rowsTruncated: result.rows_truncated === true, + truncationNote: truncationNote( + result, + temporalField, + flintTopNTarget(chartSpec, wireFields, timeDimension)?.limit, + ), + truncationWarning: result.truncation_warning || undefined, + openUrl: result.open_url || undefined, + }; +} + +function resolveTimeGrain( + grain: V1TimeGrain | undefined, + hasTemporalField: boolean, +): V1TimeGrain | undefined { + if (grain && grain !== V1TimeGrain.TIME_GRAIN_UNSPECIFIED) return grain; + return hasTemporalField ? DEFAULT_TIME_GRAIN : undefined; +} + +function buildFields( + wireFields: Record, + measureNames: string[], + dimensionNames: string[], + timeNames: string[], + grain: V1TimeGrain | undefined, +): ChartFieldsMap { + const fields: ChartFieldsMap = {}; + for (const name of measureNames) { + const field = wireFields[name]; + const measure: MetricsViewSpecMeasure = { + name, + displayName: field?.display_name || name, + description: field?.description, + formatPreset: field?.format_preset, + formatD3: field?.format_d3, + formatD3Locale: + field?.format_d3_locale as MetricsViewSpecMeasure["formatD3Locale"], + lowerIsBetter: field?.lower_is_better, + }; + fields[name] = measure; + } + for (const name of dimensionNames) { + const field = wireFields[name]; + const dimension: MetricsViewSpecDimension = { + name, + displayName: field?.display_name || name, + description: field?.description, + }; + fields[name] = dimension; + } + for (const name of timeNames) { + const definition: TimeDimensionDefinition = grain + ? { + field: name, + displayName: TIME_AXIS_LABEL, + timeUnit: timeGrainToVegaTimeUnitMap[grain], + format: TIME_GRAIN[grain]?.d3format, + } + : { field: name, displayName: TIME_AXIS_LABEL }; + fields[name] = definition; + } + return fields; +} + +/** Measure columns present in the rows: the measures plus any `_prev` companions. */ +function measureKeysInRows(measureNames: string[], rows: WireRow[]): string[] { + const keys = new Set(measureNames); + const sample = rows.slice(0, 50); + for (const name of measureNames) { + const previous = name + ComparisonDeltaPreviousSuffix; + if (sample.some((row) => previous in row)) keys.add(previous); + } + return Array.from(keys); +} + +/** Measures arrive as JSON numbers, but large integers may be serialized as strings. */ +function coerceMeasureValues(row: WireRow, measureKeys: string[]): WireRow { + let copy: WireRow | undefined; + for (const key of measureKeys) { + const value = row[key]; + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + copy ??= { ...row }; + copy[key] = parsed; + } + } + } + return copy ?? row; +} + +function supportsZeroFill(chartType: ChartType): boolean { + return isCartesianChartType(chartType) || chartType === "combo_chart"; +} + +/** The nominal dimension that splits a time series into several series, if any. */ +function seriesDimension( + chartType: ChartType, + spec: ChartSpec, + temporalField: string, +): string | undefined { + const color = (spec as { color?: FieldConfig | string }).color; + if (isFieldConfig(color) && color.type === "nominal") { + return color.field; + } + if (isCartesianChartType(chartType)) { + const vertical = toVerticalSpec(spec as CartesianChartSpec); + const x = vertical.x; + if (x && x.type === "nominal" && x.field !== temporalField) return x.field; + } + return undefined; +} + +interface ZeroFillOptions { + timeField: string; + grain: V1TimeGrain; + range: WireTimeRange; + timeZone: string; + fillKeys: string[]; + seriesField?: string; + seriesValues?: string[]; + /** + * Set when the server cut the rows. It keeps the newest buckets, so the buckets before the + * first data point were cut rather than empty and must not read as zero. + */ + rowsTruncated?: boolean; +} + +function parseInstant(value: unknown, zone: string): DateTime | undefined { + let parsed: DateTime | undefined; + if (typeof value === "string") parsed = DateTime.fromISO(value, { zone }); + else if (typeof value === "number") + parsed = DateTime.fromMillis(value, { zone }); + else if (value instanceof Date) parsed = DateTime.fromJSDate(value, { zone }); + return parsed?.isValid ? parsed : undefined; +} + +/** + * Adds a zero row for every grain bucket of the time range that has no data, per series. + * Replaces the aggregation API's `fillMissing`, which the MCP data path does not use. + * Buckets are walked from the earliest data point so week starts and DST follow the data + * instead of a locale rule. The fill runs from the range start to the range end; when the + * server cut the rows it kept the newest buckets, so the fill starts at the first data + * bucket instead. A top-N cap alone leaves the time axis complete and changes nothing here. + */ +export function zeroFillTimeBuckets( + rows: WireRow[], + options: ZeroFillOptions, +): WireRow[] { + const { timeField, grain, range, timeZone, fillKeys, seriesField } = options; + const unit = TIME_GRAIN[grain]?.unit; + if (!unit) return rows; + const step: DurationLikeObject = { [`${unit}s`]: 1 }; + + const rangeStart = DateTime.fromISO(range.start, { zone: timeZone }); + const rangeEnd = DateTime.fromISO(range.end, { zone: timeZone }); + if (!rangeStart.isValid || !rangeEnd.isValid) return rows; + + const present = new Map>(); + const seriesSample = new Map(); + let earliest: DateTime | undefined; + for (const row of rows) { + const instant = parseInstant(row[timeField], timeZone); + if (!instant) continue; + if (!earliest || instant < earliest) earliest = instant; + const seriesKey = seriesField ? String(row[seriesField]) : ""; + if (!present.has(seriesKey)) present.set(seriesKey, new Set()); + present.get(seriesKey)!.add(instant.toMillis()); + if (seriesField && !seriesSample.has(seriesKey)) { + seriesSample.set(seriesKey, row[seriesField]); + } + } + if (!earliest) return rows; + + let cursor = earliest; + if (!options.rowsTruncated) { + let steps = 0; + while (cursor > rangeStart && steps++ < MAX_FILL_BUCKETS) { + cursor = cursor.minus(step); + } + } + const buckets: DateTime[] = []; + while (cursor < rangeEnd) { + buckets.push(cursor); + if (buckets.length > MAX_FILL_BUCKETS) return rows; + cursor = cursor.plus(step); + } + + const series = seriesField + ? options.seriesValues?.length + ? options.seriesValues + : Array.from(present.keys()) + : [""]; + const filled = rows.map((row, index) => ({ row, index })); + let synthetic = rows.length; + for (const bucket of buckets) { + const millis = bucket.toMillis(); + for (const seriesKey of series) { + if (present.get(seriesKey)?.has(millis)) continue; + const row: WireRow = { [timeField]: bucket.toUTC().toISO() }; + if (seriesField) { + row[seriesField] = seriesSample.has(seriesKey) + ? seriesSample.get(seriesKey) + : seriesKey; + } + for (const key of fillKeys) row[key] = 0; + filled.push({ row, index: synthetic++ }); + } + } + + return filled + .map(({ row, index }) => ({ + row, + index, + millis: parseInstant(row[timeField], timeZone)?.toMillis() ?? 0, + })) + .sort((a, b) => a.millis - b.millis || a.index - b.index) + .map(({ row }) => row); +} + +/** Mirrors `adjustDataForTimeZone` in web-common's chart data provider. */ +function convertTimeFields( + rows: WireRow[], + timeFields: string[], + timeZone: string, +): WireRow[] { + return rows.map((row) => { + const copy = { ...row }; + for (const field of timeFields) { + const value = copy[field]; + const iso = + typeof value === "string" + ? value + : typeof value === "number" + ? new Date(value).toISOString() + : value instanceof Date + ? value.toISOString() + : undefined; + if (iso !== undefined) { + copy[field] = convertISOStringToJSDateWithSameTimeAsSelectedTimeZone( + iso, + timeZone, + ); + } + } + return copy; + }); +} + +function stringDomain(values: unknown[] | undefined): string[] | undefined { + if (!Array.isArray(values)) return undefined; + return values + .filter((value) => value !== null && value !== undefined) + .map((value) => String(value)); +} + +function firstAppearance(rows: WireRow[], field: string): string[] { + const seen = new Set(); + for (const row of rows) { + const value = row[field]; + if (value === null || value === undefined) continue; + seen.add(String(value)); + } + return Array.from(seen); +} + +function measureLabels(names: string[] | undefined, fields: ChartFieldsMap) { + return (names ?? []).map((name) => fields[name]?.displayName || name); +} + +function sumMeasure(rows: WireRow[], field: string): number { + return rows.reduce((total, row) => { + const value = row[field]; + return typeof value === "number" && Number.isFinite(value) + ? total + value + : total; + }, 0); +} + +/** + * Ordered domains for the nominal channels: the backend's top-N order when it sent one, + * else first appearance in the rows. The circular total and the multi-measure legend + * labels are derived here because the spec generators read them from the same map. + */ +function buildDomainValues( + chartType: ChartType, + spec: ChartSpec, + rows: WireRow[], + result: ChartResult, + fields: ChartFieldsMap, +): ChartDomainValues { + const domain: ChartDomainValues = {}; + for (const [key, values] of Object.entries(result.domain_values ?? {})) { + if (!Array.isArray(values)) continue; + const defined = values.filter( + (value) => value !== null && value !== undefined, + ); + domain[key] = defined.every((value) => typeof value === "number") + ? (defined as number[]) + : defined.map((value) => String(value)); + } + + const fallback = (field: FieldConfig | string | undefined) => { + if (!isFieldConfig(field) || field.type !== "nominal") return; + if (!(field.field in domain)) + domain[field.field] = firstAppearance(rows, field.field); + }; + + switch (chartType) { + case "bar_chart": + case "line_chart": + case "area_chart": + case "stacked_bar": + case "stacked_bar_normalized": { + const vertical = toVerticalSpec(spec as CartesianChartSpec); + fallback(vertical.x); + if (isFieldConfig(vertical.color)) { + if (isMultiFieldConfig(vertical.y)) { + domain[vertical.color.field] = measureLabels( + vertical.y.fields, + fields, + ); + } else { + fallback(vertical.color); + } + } + break; + } + case "combo_chart": { + const combo = spec as ComboChartSpec; + fallback(combo.x); + if (combo.color?.field && combo.y1?.field && combo.y2?.field) { + domain[combo.color.field] = measureLabels( + [combo.y1.field, combo.y2.field], + fields, + ); + } + break; + } + case "donut_chart": + case "pie_chart": { + const circular = spec as CircularChartSpec; + fallback(circular.color); + const measureField = circular.measure?.field; + if (measureField) { + const sum = sumMeasure(rows, measureField); + const total = result.totals?.[measureField] ?? sum; + domain[TOTAL_DOMAIN_KEY] = [total]; + const other = total - sum; + if (circular.show_other === true && circular.color && other > 0) { + domain[OTHER_VALUE_DOMAIN_KEY] = [other]; + const colors = domain[circular.color.field]; + if (Array.isArray(colors) && !colors.includes(OTHER_VALUE as never)) { + domain[circular.color.field] = [ + ...(colors as string[]), + OTHER_VALUE, + ]; + } + } + } + break; + } + case "heatmap": { + const heatmap = spec as HeatmapChartSpec; + fallback(heatmap.x); + fallback(heatmap.y); + break; + } + case "scatter_plot": { + fallback((spec as ScatterPlotChartSpec).color); + break; + } + case "funnel_chart": + break; + } + return domain; +} + +/** + * The banner line for a truncated result. Row truncation keeps the newest buckets of a time + * series; otherwise the cut is the top-N cap of the chart's nominal channel. + */ +function truncationNote( + result: ChartResult, + temporalField: string | undefined, + topN: number | undefined, +): string | undefined { + if (result.truncated !== true) return undefined; + if (result.rows_truncated === true) { + return temporalField + ? m.mcp_chart_truncated_recent_periods() + : m.mcp_chart_truncated(); + } + return topN === undefined + ? m.mcp_chart_truncated() + : m.mcp_chart_truncated_top_values({ n: topN }); +} + +function buildTable( + rows: WireRow[], + fields: ChartFieldsMap, + timeNames: string[], + dimensionNames: string[], + measureNames: string[], + hasComparison: boolean, + grain: V1TimeGrain | undefined, +): TableModel { + const columns: TableColumn[] = []; + const timeFormatter = timeFormat( + (grain && TIME_GRAIN[grain]?.d3format) || "%Y-%m-%d %H:%M", + ); + for (const name of timeNames) { + columns.push({ + key: name, + label: fields[name]?.displayName || name, + kind: "time", + format: (value) => + value instanceof Date + ? timeFormatter(value) + : value === null || value === undefined + ? "" + : String(value), + }); + } + for (const name of dimensionNames) { + columns.push({ + key: name, + label: fields[name]?.displayName || name, + kind: "dimension", + format: (value) => + value === null || value === undefined ? "" : String(value), + }); + } + for (const name of measureNames) { + const measure = fields[name] as MetricsViewSpecMeasure | undefined; + const formatter = createMeasureValueFormatter( + measure ?? { name }, + ); + const format = (value: unknown) => { + if (typeof value === "number") return formatter(value) ?? ""; + return value === null || value === undefined ? "" : String(value); + }; + const label = measure?.displayName || name; + columns.push({ key: name, label, kind: "measure", format }); + if (hasComparison) { + columns.push({ + key: name + ComparisonDeltaPreviousSuffix, + label: m.mcp_chart_previous_column({ name: label }), + kind: "measure", + format, + }); + } + } + return { + columns, + rows: rows.slice(0, MAX_TABLE_ROWS), + totalRows: rows.length, + }; +} diff --git a/web-mcp-app/src/lib/caption.spec.ts b/web-mcp-app/src/lib/caption.spec.ts new file mode 100644 index 000000000000..3e23652c65c0 --- /dev/null +++ b/web-mcp-app/src/lib/caption.spec.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import { buildCaptionParts, formatTimeRange, grainLabel } from "./caption"; + +const week = { start: "2023-09-09T00:00:00Z", end: "2023-09-16T00:00:00Z" }; + +describe("formatTimeRange", () => { + it("shows a day-aligned range inclusively", () => { + expect(formatTimeRange(week, "UTC", "en")).toBe("Sep 9 – Sep 15, 2023"); + }); + + it("shows both years when they differ", () => { + expect( + formatTimeRange( + { start: "2023-12-25T00:00:00Z", end: "2024-01-08T00:00:00Z" }, + "UTC", + "en", + ), + ).toBe("Dec 25, 2023 – Jan 7, 2024"); + }); + + it("collapses a single day", () => { + expect( + formatTimeRange( + { start: "2023-09-09T00:00:00Z", end: "2023-09-10T00:00:00Z" }, + "UTC", + "en", + ), + ).toBe("Sep 9, 2023"); + }); + + it("keeps times for sub-day grains and unaligned ranges", () => { + const label = formatTimeRange( + { start: "2023-09-09T06:00:00Z", end: "2023-09-09T18:00:00Z" }, + "UTC", + "en", + "TIME_GRAIN_HOUR", + ); + expect(label).toMatch(/Sep 9.*6:00.*–.*Sep 9, 2023.*6:00/); + }); + + it("respects the time zone", () => { + // Midnight UTC is 8 PM the day before in New York, so the range is no longer + // day-aligned there and keeps its times. + expect(formatTimeRange(week, "America/New_York", "en")).toMatch( + /^Sep 8.*8:00.*– Sep 15, 2023.*8:00/, + ); + expect( + formatTimeRange( + { start: "2023-09-09T04:00:00Z", end: "2023-09-16T04:00:00Z" }, + "America/New_York", + "en", + ), + ).toBe("Sep 9 – Sep 15, 2023"); + }); + + it("localizes month names", () => { + expect(formatTimeRange(week, "UTC", "es")).toMatch(/sept/i); + }); +}); + +describe("buildCaptionParts", () => { + it("orders range, grain, comparison and metrics view", () => { + expect( + buildCaptionParts({ + range: week, + grain: "TIME_GRAIN_DAY", + showGrain: true, + hasComparison: true, + metricsViewLabel: "Bids", + timeZone: "UTC", + locale: "en", + }), + ).toEqual([ + "Sep 9 – Sep 15, 2023", + "by day", + "vs. previous period", + "Bids", + ]); + }); + + it("omits the grain for charts without a time axis", () => { + expect( + buildCaptionParts({ + range: week, + grain: "TIME_GRAIN_DAY", + showGrain: false, + hasComparison: false, + timeZone: "UTC", + locale: "en", + }), + ).toEqual(["Sep 9 – Sep 15, 2023"]); + }); + + it("labels grains", () => { + expect(grainLabel("TIME_GRAIN_MONTH")).toBe("by month"); + }); +}); diff --git a/web-mcp-app/src/lib/caption.ts b/web-mcp-app/src/lib/caption.ts new file mode 100644 index 000000000000..0776a8622a13 --- /dev/null +++ b/web-mcp-app/src/lib/caption.ts @@ -0,0 +1,102 @@ +import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; +import { TIME_GRAIN } from "@rilldata/web-common/lib/time/config"; +import { V1TimeGrain } from "@rilldata/web-common/runtime-client/gen/index.schemas"; +import { DateTime } from "luxon"; +import type { WireTimeRange } from "./contract"; + +export interface CaptionInput { + range?: WireTimeRange; + /** The grain in use; shown only when the chart has a temporal axis. */ + grain?: V1TimeGrain; + showGrain: boolean; + hasComparison: boolean; + metricsViewLabel?: string; + timeZone: string; + locale: string; +} + +const GRAINS_FINER_THAN_DAY: ReadonlySet = new Set([ + V1TimeGrain.TIME_GRAIN_MILLISECOND, + V1TimeGrain.TIME_GRAIN_SECOND, + V1TimeGrain.TIME_GRAIN_MINUTE, + V1TimeGrain.TIME_GRAIN_HOUR, +]); + +function isStartOfDay(dt: DateTime): boolean { + return dt.hour === 0 && dt.minute === 0 && dt.second === 0; +} + +/** + * Human-readable time range such as "Sep 9 – Sep 15, 2023". + * Ranges are half-open on the wire; a day-aligned end is shown as the last included day, + * which is how Rill's own time controls label ranges. + */ +export function formatTimeRange( + range: WireTimeRange, + timeZone: string, + locale: string, + grain?: V1TimeGrain, +): string { + const start = DateTime.fromISO(range.start, { zone: timeZone }).setLocale( + locale, + ); + let end = DateTime.fromISO(range.end, { zone: timeZone }).setLocale(locale); + if (!start.isValid || !end.isValid) return `${range.start} – ${range.end}`; + + const subDay = grain !== undefined && GRAINS_FINER_THAN_DAY.has(grain); + const dayAligned = isStartOfDay(start) && isStartOfDay(end); + if (!subDay && dayAligned) { + end = end.minus({ days: 1 }); + } + + if (subDay || !dayAligned) { + const withTime: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + year: start.year === end.year ? undefined : "numeric", + hour: "numeric", + minute: "2-digit", + }; + const startLabel = start.toLocaleString(withTime); + const endLabel = end.toLocaleString({ ...withTime, year: "numeric" }); + return `${startLabel} – ${endLabel}`; + } + + const sameDay = start.hasSame(end, "day"); + const full: Intl.DateTimeFormatOptions = { + month: "short", + day: "numeric", + year: "numeric", + }; + if (sameDay) return start.toLocaleString(full); + const startLabel = start.toLocaleString( + start.year === end.year ? { month: "short", day: "numeric" } : full, + ); + return `${startLabel} – ${end.toLocaleString(full)}`; +} + +export function grainLabel(grain: V1TimeGrain): string { + return `${m.time_grain_by()} ${TIME_GRAIN[grain]?.label ?? grain}`; +} + +/** Caption parts in display order; the UI joins them with a separator. */ +export function buildCaptionParts(input: CaptionInput): string[] { + const parts: string[] = []; + if (input.range) { + parts.push( + formatTimeRange(input.range, input.timeZone, input.locale, input.grain), + ); + } + if (input.showGrain && input.grain) { + parts.push(grainLabel(input.grain)); + } + if (input.hasComparison) { + parts.push(m.mcp_chart_vs_previous_period()); + } + if (input.metricsViewLabel) { + parts.push(input.metricsViewLabel); + } + return parts; +} + +export const CAPTION_SEPARATOR = " · "; diff --git a/web-mcp-app/src/lib/contract.spec.ts b/web-mcp-app/src/lib/contract.spec.ts new file mode 100644 index 000000000000..c66b9a835570 --- /dev/null +++ b/web-mcp-app/src/lib/contract.spec.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; +import bar from "../../harness/fixtures/bar_chart.json"; +import empty from "../../harness/fixtures/empty.json"; +import noData from "../../harness/fixtures/no_data.json"; +import unsupported from "../../harness/fixtures/unsupported_widget.json"; +import { + hasChartData, + normalizeRows, + parseChartPayload, + textContent, + withChartData, + type ChartDataPayload, + type ChartResult, +} from "./contract"; + +const result = (fixture: { result: { structuredContent?: unknown } }) => + fixture.result.structuredContent as ChartResult; + +describe("parseChartPayload", () => { + it("accepts the canonical bar chart result", () => { + const parsed = parseChartPayload(bar.result.structuredContent); + expect(parsed.kind).toBe("chart"); + if (parsed.kind !== "chart") return; + expect(parsed.result.chart_type).toBe("bar_chart"); + expect(parsed.result.title).toBe("Total Bids per Advertiser Name"); + expect(hasChartData(parsed.result)).toBe(true); + }); + + it("rejects a payload without a widget mode", () => { + const withoutWidget = Object.fromEntries( + Object.entries(bar.result.structuredContent).filter( + ([key]) => key !== "widget", + ), + ); + expect(parseChartPayload(withoutWidget).kind).toBe("invalid"); + }); + + it("reports unsupported widget modes together with the deep link", () => { + const parsed = parseChartPayload(unsupported.result.structuredContent); + expect(parsed).toEqual({ + kind: "unsupported_widget", + widget: "dashboard", + openUrl: "https://ui.rilldata.com/demo/bids/canvas/overview", + }); + }); + + it("rejects malformed payloads", () => { + const chart = { widget: "chart", chart_type: "bar_chart", spec: {} }; + expect(parseChartPayload(undefined).kind).toBe("invalid"); + expect(parseChartPayload("text").kind).toBe("invalid"); + expect(parseChartPayload({ ...chart, chart_type: "pivot" }).kind).toBe( + "invalid", + ); + expect(parseChartPayload({ ...chart, spec: "x" }).kind).toBe("invalid"); + expect(parseChartPayload({ ...chart, data: "rows" }).kind).toBe("invalid"); + expect( + parseChartPayload({ ...chart, data: { columns: [], rows: [] } }).kind, + ).toBe("invalid"); + expect(parseChartPayload({ ...chart, fields: [] }).kind).toBe("invalid"); + expect(parseChartPayload(chart).kind).toBe("chart"); + }); + + it("keeps the deep link on invalid payloads", () => { + const parsed = parseChartPayload({ + widget: "chart", + chart_type: "nope", + open_url: "https://example.com/open", + }); + expect(parsed.kind).toBe("invalid"); + if (parsed.kind === "invalid") { + expect(parsed.openUrl).toBe("https://example.com/open"); + } + }); +}); + +describe("hasChartData", () => { + it("is true for any data array, including an empty one", () => { + expect(result(empty).data).toEqual([]); + expect(hasChartData(result(empty))).toBe(true); + expect(hasChartData({ data: [] })).toBe(true); + }); + + it("is false when the data section was not forwarded", () => { + expect(hasChartData(result(noData))).toBe(false); + expect(hasChartData({ fields: result(bar).fields })).toBe(false); + }); +}); + +describe("normalizeRows", () => { + it("passes keyed rows through and drops non-objects", () => { + expect(normalizeRows([{ a: 1 }, null as never, 3 as never])).toEqual([ + { a: 1 }, + ]); + expect(normalizeRows(undefined)).toEqual([]); + }); +}); + +describe("withChartData", () => { + it("replaces the data section and keeps the rest of the result", () => { + const base = result(bar); + const spec = { ...base.spec, time_grain: "TIME_GRAIN_WEEK" as const }; + const payload: ChartDataPayload = { + data: [], + fields: base.fields, + resolved_time_range: base.resolved_time_range, + }; + const replaced = withChartData(base, spec, payload); + expect(replaced.spec).toBe(spec); + expect(replaced.title).toBe(base.title); + expect(replaced.open_url).toBe(base.open_url); + expect(replaced.theme).toBe(base.theme); + // A zero-row chart_data result has no `domain_values`; the previous ones must not show through. + expect(replaced.data).toEqual([]); + expect(replaced.domain_values).toBeUndefined(); + expect(hasChartData(replaced)).toBe(true); + }); +}); + +describe("textContent", () => { + it("joins text blocks", () => { + expect( + textContent({ + content: [ + { type: "text", text: "a" }, + { type: "image", data: "", mimeType: "image/png" }, + { type: "text", text: "b" }, + ], + }), + ).toBe("a\nb"); + }); +}); diff --git a/web-mcp-app/src/lib/contract.ts b/web-mcp-app/src/lib/contract.ts new file mode 100644 index 000000000000..f2f1d8e3be33 --- /dev/null +++ b/web-mcp-app/src/lib/contract.ts @@ -0,0 +1,319 @@ +import type { ChartType } from "@rilldata/web-common/features/components/charts/types"; +import type { + V1ThemeSpec, + V1TimeGrain, +} from "@rilldata/web-common/runtime-client/gen/index.schemas"; +import type { AppToolResult } from "@modelcontextprotocol/ext-apps"; + +/** + * Wire contract between Rill's `create_chart` / `chart_data` MCP tools and this widget + * (`CreateChartResult` in `runtime/ai/create_chart.go`); the backend emits it as `structuredContent`. + * The payload is untrusted input: `parseChartPayload` checks the shape before anything renders. + * + * Two result shapes exist. A Flint result carries `input`, the effective Flint chart spec plus the + * metrics view binding the rows were queried with, and the widget compiles it with Flint. A Rill + * result carries `spec`, Rill's own chart config for one of the 11 built-in chart types, and renders + * through web-common's chart components; it is what older runtimes answer with. + */ + +/** A tool result as the host forwards it: the MCP SDK's `CallToolResult`. */ +export type ToolResult = AppToolResult; + +/** The `widget` value of a result this bundle renders; other values come from newer servers. */ +export const CHART_WIDGET_MODE = "chart"; + +/** + * The Rill chart types `create_chart` can return. Listed by hand because web-common's + * `CHART_CONFIG` cannot be imported here: it pulls the icons and the query providers into + * the bundle. + */ +export const CHART_TYPES: ChartType[] = [ + "bar_chart", + "line_chart", + "area_chart", + "stacked_bar", + "stacked_bar_normalized", + "donut_chart", + "pie_chart", + "funnel_chart", + "heatmap", + "combo_chart", + "scatter_plot", +]; + +const CARTESIAN_CHART_TYPES: ReadonlySet = new Set([ + "bar_chart", + "line_chart", + "area_chart", + "stacked_bar", + "stacked_bar_normalized", +]); + +export interface WireTimeRange { + start: string; + end: string; + time_zone?: string; +} + +export type WireFieldKind = "measure" | "dimension" | "time"; + +export interface WireField { + name: string; + kind: WireFieldKind; + display_name?: string; + description?: string; + type?: string; + format_preset?: string; + format_d3?: string; + format_d3_locale?: Record; + lower_is_better?: boolean; +} + +export interface WireMetricsView { + name: string; + display_name?: string; + time_dimension?: string; + smallest_time_grain?: V1TimeGrain; +} + +export type WireRow = Record; + +/** The validated `create_chart` input of a Rill result; the field configs are Rill's chart spec vocabulary. */ +export interface WireChartSpec { + metrics_view?: string; + time_range?: WireTimeRange; + comparison_time_range?: WireTimeRange; + time_grain?: V1TimeGrain; + where?: unknown; + [key: string]: unknown; +} + +/** A Flint encoding object: Flint's six keys plus Rill's `limit`, the top-N of a dimension on the channel. */ +export interface WireFlintEncoding { + field?: string; + type?: "quantitative" | "nominal" | "ordinal" | "temporal"; + aggregate?: "count" | "sum" | "average" | "mean"; + sortOrder?: "ascending" | "descending"; + sortBy?: string; + scheme?: string; + limit?: number; +} + +/** A channel binds a field name, an encoding object, or an array of either (Flint's series fold). */ +export type WireFlintEncodingValue = + | string + | WireFlintEncoding + | (string | WireFlintEncoding)[]; + +/** Flint's `chart_spec` as the server echoes it, with the effective limits filled in. */ +export interface WireFlintChartSpec { + chartType: string; + title?: string; + subtitle?: string; + encodings: Record; + chartProperties?: Record; +} + +/** Rill's data binding: what the rows were queried with, in Flint's `data` slot. */ +export interface WireDataBinding { + metrics_view: string; + time_range: WireTimeRange; + comparison_time_range?: WireTimeRange; + where?: unknown; + time_grain?: V1TimeGrain; +} + +/** The effective input of a Flint result; `chart_data` takes the same shape. */ +export interface WireFlintInput { + chart_spec: WireFlintChartSpec; + data: WireDataBinding; +} + +/** Data fields shared by `create_chart` and the app-only `chart_data` tool. */ +export interface ChartDataPayload { + time_grain?: V1TimeGrain; + /** + * Rows keyed by field name; `[]` when the query returned none. The key is missing only + * when the host did not forward the data section (see `hasChartData`). + */ + data?: WireRow[]; + fields?: Record; + /** Flint semantic type per field, derived by the server from the metrics view and the grain. */ + semantic_types?: Record; + /** Display name per field for Flint's axis and legend titles. */ + field_display_names?: Record; + domain_values?: Record; + totals?: Record; + resolved_time_range?: WireTimeRange; + resolved_comparison_time_range?: WireTimeRange; + /** Set when any budget cut the data: a top-N cap on a nominal channel or the row limit. */ + truncated?: boolean; + /** Set when the row limit cut the rows; a time series keeps its newest buckets. */ + rows_truncated?: boolean; + /** The server's explanation of what was cut, written for the model. */ + truncation_warning?: string; +} + +interface ChartResultBase extends ChartDataPayload { + widget: typeof CHART_WIDGET_MODE; + message?: string; + title?: string; + metrics_view?: WireMetricsView; + theme?: V1ThemeSpec; + open_url?: string; +} + +/** A result rendered with web-common's chart components from Rill's chart config. */ +export interface RillChartResult extends ChartResultBase { + chart_type: ChartType; + spec: WireChartSpec; + input?: undefined; +} + +/** A result compiled with Flint from its effective input. */ +export interface FlintChartResult extends ChartResultBase { + chart_type: string; + input: WireFlintInput; + spec?: undefined; +} + +export type ChartResult = RillChartResult | FlintChartResult; + +export type ParsedPayload = + | { kind: "chart"; result: ChartResult } + | { kind: "unsupported_widget"; widget: string; openUrl?: string } + | { kind: "invalid"; reason: string; openUrl?: string }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +export function isChartType(value: unknown): value is ChartType { + return typeof value === "string" && CHART_TYPES.includes(value as ChartType); +} + +export function isCartesianChartType(chartType: ChartType): boolean { + return CARTESIAN_CHART_TYPES.has(chartType); +} + +export function isFlintResult(result: ChartResult): result is FlintChartResult { + return result.input !== undefined; +} + +/** The rows of a data section, dropping anything that is not an object; no section is no rows. */ +export function normalizeRows(data: WireRow[] | undefined): WireRow[] { + return (data ?? []).filter((row): row is WireRow => isRecord(row)); +} + +/** + * Shape check for an untrusted payload. + * A widget mode other than "chart" is reported so the UI can fall back to "Open in Rill". + */ +export function parseChartPayload(payload: unknown): ParsedPayload { + if (!isRecord(payload)) { + return { kind: "invalid", reason: "structuredContent is not an object" }; + } + const openUrl = optionalString(payload.open_url); + if (typeof payload.widget !== "string") { + return { kind: "invalid", reason: "widget is missing", openUrl }; + } + if (payload.widget !== CHART_WIDGET_MODE) { + return { kind: "unsupported_widget", widget: payload.widget, openUrl }; + } + if (payload.input !== undefined) { + const reason = invalidFlintInput(payload.input); + if (reason) return { kind: "invalid", reason, openUrl }; + if (typeof payload.chart_type !== "string") { + return { kind: "invalid", reason: "chart_type is missing", openUrl }; + } + } else { + if (!isChartType(payload.chart_type)) { + return { + kind: "invalid", + reason: `unknown chart_type ${JSON.stringify(payload.chart_type)}`, + openUrl, + }; + } + if (!isRecord(payload.spec)) { + return { kind: "invalid", reason: "spec is not an object", openUrl }; + } + } + if (payload.data !== undefined && !Array.isArray(payload.data)) { + return { kind: "invalid", reason: "data is not an array", openUrl }; + } + if (payload.fields !== undefined && !isRecord(payload.fields)) { + return { kind: "invalid", reason: "fields is not an object", openUrl }; + } + return { kind: "chart", result: payload as unknown as ChartResult }; +} + +/** The reason a Flint `input` is unusable, or undefined when it has what the compiler needs. */ +function invalidFlintInput(input: unknown): string | undefined { + if (!isRecord(input)) return "input is not an object"; + const chartSpec = input.chart_spec; + if (!isRecord(chartSpec)) return "input.chart_spec is not an object"; + if (!optionalString(chartSpec.chartType)) { + return "input.chart_spec.chartType is missing"; + } + if (!isRecord(chartSpec.encodings)) { + return "input.chart_spec.encodings is not an object"; + } + if (!isRecord(input.data)) return "input.data is not an object"; + return undefined; +} + +/** + * True when the result carries its data section. The backend always sends `data`, as `[]` + * for a query without rows; the key is missing only when the host did not forward the + * section (e.g. a large result written to a file). + */ +export function hasChartData(result: ChartDataPayload): boolean { + return Array.isArray(result.data); +} + +/** + * The result with its data section replaced by what `chart_data` returned for `patched`, + * the spec (Rill result) or the input (Flint result) the data was requested with. + * The section is replaced rather than merged because the backend omits the optional keys + * it has nothing for (`domain_values`, `totals`, `truncation_warning`, ...), and a value + * from the previous result must not show through. + */ +export function withChartData( + result: ChartResult, + patched: WireChartSpec | WireFlintInput, + data: ChartDataPayload, +): ChartResult { + const { widget, chart_type, message, title, metrics_view, theme, open_url } = + result; + const base = { + widget, + message, + title, + metrics_view, + theme, + open_url, + ...data, + }; + if (isFlintResult(result)) { + return { ...base, chart_type, input: patched as WireFlintInput }; + } + return { + ...base, + chart_type: result.chart_type, + spec: patched as WireChartSpec, + }; +} + +/** Concatenated text blocks of a tool result; used for error messages. */ +export function textContent(result: ToolResult): string { + return (result.content ?? []) + .map((block) => + block.type === "text" && typeof block.text === "string" ? block.text : "", + ) + .filter(Boolean) + .join("\n"); +} diff --git a/web-mcp-app/src/lib/controls.spec.ts b/web-mcp-app/src/lib/controls.spec.ts new file mode 100644 index 000000000000..0a88b25255ef --- /dev/null +++ b/web-mcp-app/src/lib/controls.spec.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import bar from "../../harness/fixtures/bar_chart.json"; +import funnel from "../../harness/fixtures/funnel_chart.json"; +import horizontal from "../../harness/fixtures/horizontal_bar.json"; +import line from "../../harness/fixtures/line_chart.json"; +import { + availableGrains, + controlOptions, + describeView, + patchSpec, + previousPeriod, + topNChoices, + topNTarget, +} from "./controls"; +import type { RillChartResult } from "./contract"; + +const result = (fixture: { result: { structuredContent?: unknown } }) => + fixture.result.structuredContent as RillChartResult; + +const week = { start: "2023-09-09T00:00:00Z", end: "2023-09-16T00:00:00Z" }; + +describe("availableGrains", () => { + it("offers grains that split the range into a readable number of buckets", () => { + expect( + availableGrains("line_chart", result(line).spec, week, "TIME_GRAIN_HOUR"), + ).toEqual(["TIME_GRAIN_HOUR", "TIME_GRAIN_DAY"]); + expect( + availableGrains("line_chart", result(line).spec, week, "TIME_GRAIN_DAY"), + ).toEqual(["TIME_GRAIN_DAY"]); + }); + + it("returns nothing without a temporal x axis", () => { + expect(availableGrains("bar_chart", result(bar).spec, week)).toEqual([]); + }); +}); + +describe("topNTarget", () => { + it("targets the category channel, also for horizontal bars", () => { + expect(topNTarget("bar_chart", result(bar).spec)).toEqual({ + channel: "x", + limit: 10, + }); + expect(topNTarget("bar_chart", result(horizontal).spec)).toEqual({ + channel: "y", + limit: 10, + }); + expect(topNTarget("funnel_chart", result(funnel).spec)).toEqual({ + channel: "stage", + limit: 15, + }); + expect(topNTarget("line_chart", result(line).spec)).toBeUndefined(); + }); +}); + +describe("topNChoices", () => { + it("offers the presets plus the current limit when it is not one", () => { + expect(topNChoices(undefined)).toEqual([5, 10, 20]); + expect(topNChoices(10)).toEqual([5, 10, 20]); + expect(topNChoices(15)).toEqual([5, 10, 15, 20]); + expect(topNChoices(40)).toEqual([5, 10, 20, 40]); + }); +}); + +describe("patchSpec", () => { + it("applies grain, top-N and comparison without mutating the input", () => { + const spec = result(line).spec; + const patched = patchSpec( + spec, + "line_chart", + { grain: "TIME_GRAIN_HOUR", comparison: true }, + week, + ); + expect(patched.time_grain).toBe("TIME_GRAIN_HOUR"); + expect(patched.comparison_time_range).toEqual({ + start: "2023-09-02T00:00:00.000Z", + end: "2023-09-09T00:00:00Z", + }); + expect(spec.time_grain).toBe("TIME_GRAIN_DAY"); + expect(spec.comparison_time_range).toBeUndefined(); + + const limited = patchSpec( + result(horizontal).spec, + "bar_chart", + { topN: 5 }, + week, + ); + expect((limited.y as { limit: number }).limit).toBe(5); + + const off = patchSpec(patched, "line_chart", { comparison: false }, week); + expect(off.comparison_time_range).toBeUndefined(); + }); +}); + +describe("controlOptions", () => { + it("summarizes what a chart can control", () => { + expect( + controlOptions("bar_chart", result(bar).spec, week, "TIME_GRAIN_HOUR"), + ).toEqual({ + grains: [], + topN: true, + comparison: true, + }); + }); +}); + +describe("previousPeriod", () => { + it("shifts the range back by its own length", () => { + expect(previousPeriod(week)).toEqual({ + start: "2023-09-02T00:00:00.000Z", + end: "2023-09-09T00:00:00Z", + }); + }); +}); + +describe("describeView", () => { + it("states every control value for the model", () => { + expect( + describeView( + "Total Bids over Time", + { grain: "TIME_GRAIN_DAY", topN: 5, comparison: true }, + "Sep 9 – Sep 15, 2023", + ), + ).toBe( + "Chart now shows Total Bids over Time: time grain day, top 5 values, comparison on, Sep 9 – Sep 15, 2023.", + ); + }); + + it("states the limit in effect even when it is not a preset", () => { + expect( + describeView("Total Bids funnel by Campaign Stage", { topN: 15 }), + ).toBe( + "Chart now shows Total Bids funnel by Campaign Stage: time grain n/a, top 15 values, comparison off.", + ); + }); + + it("marks the controls a chart does not offer", () => { + expect(describeView("Total Bids split by Device", {})).toBe( + "Chart now shows Total Bids split by Device: time grain n/a, top values n/a, comparison off.", + ); + }); +}); diff --git a/web-mcp-app/src/lib/controls.ts b/web-mcp-app/src/lib/controls.ts new file mode 100644 index 000000000000..7d38b35229b0 --- /dev/null +++ b/web-mcp-app/src/lib/controls.ts @@ -0,0 +1,251 @@ +import type { CartesianChartSpec } from "@rilldata/web-common/features/components/charts/cartesian/CartesianChartProvider"; +import { + isHorizontal, + toVerticalSpec, +} from "@rilldata/web-common/features/components/charts/cartesian/orientation"; +import type { ChartType } from "@rilldata/web-common/features/components/charts/types"; +import { isFieldConfig } from "@rilldata/web-common/features/components/charts/util"; +import { TIME_GRAIN } from "@rilldata/web-common/lib/time/config"; +import { V1TimeGrain } from "@rilldata/web-common/runtime-client/gen/index.schemas"; +import { DateTime } from "luxon"; +import { + isCartesianChartType, + type WireChartSpec, + type WireTimeRange, +} from "./contract"; + +/** + * Interactive controls (fullscreen only): time grain, top-N and comparison. + * Each change re-queries through the host with the app-only `chart_data` tool, + * so these helpers only patch the chart spec and describe what is shown. + */ + +const TOP_N_PRESETS = [5, 10, 20]; + +export interface ControlState { + grain?: V1TimeGrain; + /** The limit of the chart's top-N channel; see `topNTarget`. */ + topN?: number; + comparison?: boolean; +} + +export interface ControlOptions { + /** Grains the user can pick; empty when the chart has no temporal x axis. */ + grains: V1TimeGrain[]; + /** Whether a nominal x or color channel can take a top-N limit. */ + topN: boolean; + /** Whether period-over-period comparison applies (cartesian charts only). */ + comparison: boolean; +} + +const GRAIN_ORDER: V1TimeGrain[] = [ + V1TimeGrain.TIME_GRAIN_MILLISECOND, + V1TimeGrain.TIME_GRAIN_SECOND, + V1TimeGrain.TIME_GRAIN_MINUTE, + V1TimeGrain.TIME_GRAIN_HOUR, + V1TimeGrain.TIME_GRAIN_DAY, + V1TimeGrain.TIME_GRAIN_WEEK, + V1TimeGrain.TIME_GRAIN_MONTH, + V1TimeGrain.TIME_GRAIN_QUARTER, + V1TimeGrain.TIME_GRAIN_YEAR, +]; + +const SELECTABLE_GRAINS: V1TimeGrain[] = [ + V1TimeGrain.TIME_GRAIN_HOUR, + V1TimeGrain.TIME_GRAIN_DAY, + V1TimeGrain.TIME_GRAIN_WEEK, + V1TimeGrain.TIME_GRAIN_MONTH, + V1TimeGrain.TIME_GRAIN_QUARTER, + V1TimeGrain.TIME_GRAIN_YEAR, +]; + +/** A grain is offered only when the range splits into a readable number of buckets. */ +export const MIN_GRAIN_BUCKETS = 2; +export const MAX_GRAIN_BUCKETS = 1000; + +/** The field on the x axis when it is temporal (cartesian charts and combo charts). */ +export function temporalXField( + chartType: ChartType, + spec: WireChartSpec, +): string | undefined { + const x = isCartesianChartType(chartType) + ? toVerticalSpec(spec as unknown as CartesianChartSpec).x + : chartType === "combo_chart" + ? (spec as { x?: unknown }).x + : undefined; + return isFieldConfig(x) && x.type === "temporal" ? x.field : undefined; +} + +export function bucketCount(range: WireTimeRange, grain: V1TimeGrain): number { + const unit = TIME_GRAIN[grain]?.unit; + const start = DateTime.fromISO(range.start, { zone: "UTC" }); + const end = DateTime.fromISO(range.end, { zone: "UTC" }); + if (!unit || !start.isValid || !end.isValid) return 0; + return Math.ceil(end.diff(start, unit).as(unit)); +} + +/** The selectable grains for a range, no finer than the metrics view's smallest grain. */ +export function grainsForRange( + range: WireTimeRange, + smallestGrain?: V1TimeGrain, +): V1TimeGrain[] { + const minimumOrder = smallestGrain ? GRAIN_ORDER.indexOf(smallestGrain) : -1; + return SELECTABLE_GRAINS.filter((grain) => { + if (GRAIN_ORDER.indexOf(grain) < minimumOrder) return false; + const count = bucketCount(range, grain); + return count >= MIN_GRAIN_BUCKETS && count <= MAX_GRAIN_BUCKETS; + }); +} + +export function availableGrains( + chartType: ChartType, + spec: WireChartSpec, + range: WireTimeRange | undefined, + smallestGrain?: V1TimeGrain, +): V1TimeGrain[] { + if (!range || !temporalXField(chartType, spec)) return []; + return grainsForRange(range, smallestGrain); +} + +export interface TopNTarget { + /** Key of the spec channel that holds the nominal field (the raw channel, not normalized). */ + channel: string; + /** + * The limit in effect. A result's spec carries the limit the backend applied, after its + * defaults and caps, so this is only undefined for a spec that never went through it. + */ + limit: number | undefined; +} + +/** The nominal channel a top-N limit applies to: the category axis first, else the color split. */ +export function topNTarget( + chartType: ChartType, + spec: WireChartSpec, +): TopNTarget | undefined { + const nominal = (channel: string): TopNTarget | undefined => { + const field = spec[channel]; + return isFieldConfig(field) && field.type === "nominal" + ? { channel, limit: field.limit } + : undefined; + }; + switch (chartType) { + case "bar_chart": + case "line_chart": + case "area_chart": + case "stacked_bar": + case "stacked_bar_normalized": { + const categoryChannel = isHorizontal( + spec as unknown as CartesianChartSpec, + ) + ? "y" + : "x"; + return nominal(categoryChannel) ?? nominal("color"); + } + case "combo_chart": + case "heatmap": + return nominal("x"); + case "donut_chart": + case "pie_chart": + case "scatter_plot": + return nominal("color"); + case "funnel_chart": + return nominal("stage"); + } +} + +/** The values the top-N control offers: the presets plus the current limit, ascending. */ +export function topNChoices(current: number | undefined): number[] { + const choices = new Set(TOP_N_PRESETS); + if (current !== undefined) choices.add(current); + return Array.from(choices).sort((a, b) => a - b); +} + +export function supportsComparison(chartType: ChartType): boolean { + return isCartesianChartType(chartType); +} + +export function controlOptions( + chartType: ChartType, + spec: WireChartSpec, + range: WireTimeRange | undefined, + smallestGrain?: V1TimeGrain, +): ControlOptions | undefined { + const options: ControlOptions = { + grains: availableGrains(chartType, spec, range, smallestGrain), + topN: topNTarget(chartType, spec) !== undefined, + comparison: supportsComparison(chartType), + }; + if (options.grains.length < 2 && !options.topN && !options.comparison) { + return undefined; + } + return options; +} + +/** The period of the same length immediately before `range`. */ +export function previousPeriod(range: WireTimeRange): WireTimeRange { + const start = DateTime.fromISO(range.start, { zone: "UTC" }); + const end = DateTime.fromISO(range.end, { zone: "UTC" }); + const length = end.diff(start); + return { + start: start.minus(length).toUTC().toISO() ?? range.start, + end: range.start, + ...(range.time_zone && { time_zone: range.time_zone }), + }; +} + +/** Returns a new spec with the control state applied; the input is left untouched. */ +export function patchSpec( + spec: WireChartSpec, + chartType: ChartType, + state: ControlState, + range: WireTimeRange | undefined, +): WireChartSpec { + const patched: WireChartSpec = JSON.parse(JSON.stringify(spec)); + if (state.grain) { + patched.time_grain = state.grain; + } + if (state.topN !== undefined) { + const target = topNTarget(chartType, spec); + if (target) { + const field = patched[target.channel]; + if (isFieldConfig(field)) { + patched[target.channel] = { ...field, limit: state.topN }; + } + } + } + if (state.comparison === true && supportsComparison(chartType)) { + const base = range ?? spec.time_range; + if (!patched.comparison_time_range && base) { + patched.comparison_time_range = previousPeriod(base); + } + } else if (state.comparison === false) { + delete patched.comparison_time_range; + } + return patched; +} + +/** + * One line for the model about what the chart currently shows (model-facing, English). + * Every control is stated, including the ones the chart does not offer, so the model + * never has to infer a setting from the caption. The top-N is the limit in effect, so + * "top values n/a" only appears for a chart without a top-N channel. + */ +export function describeView( + title: string, + controls: ControlState, + timeRange?: string, +): string { + const grain = controls.grain + ? (TIME_GRAIN[controls.grain]?.unit ?? controls.grain) + : "n/a"; + const parts = [ + `time grain ${grain}`, + controls.topN === undefined + ? "top values n/a" + : `top ${controls.topN} values`, + `comparison ${controls.comparison ? "on" : "off"}`, + ]; + if (timeRange) parts.push(timeRange); + // i18n-ignore: text for the model, not shown to the user + return `Chart now shows ${title}: ${parts.join(", ")}.`; +} diff --git a/web-mcp-app/src/lib/flint-compile.spec.ts b/web-mcp-app/src/lib/flint-compile.spec.ts new file mode 100644 index 000000000000..e7f988bfbde7 --- /dev/null +++ b/web-mcp-app/src/lib/flint-compile.spec.ts @@ -0,0 +1,150 @@ +import { sanitizeFieldName } from "@rilldata/web-common/components/vega/util"; +import { getRillTheme } from "@rilldata/web-common/components/vega/vega-config"; +import { compileFlintSpec } from "@rilldata/web-common/features/custom-viz/flint/compile"; +import { + createMeasureValueFormatter, + humanizeDataType, +} from "@rilldata/web-common/lib/number-formatting/format-measure-value"; +import { FormatPreset } from "@rilldata/web-common/lib/number-formatting/humanizer-types"; +import { expressionFunction, parse, View } from "vega"; +import { expressionInterpreter } from "vega-interpreter"; +import { compile, type TopLevelSpec } from "vega-lite"; +import { describe, expect, it } from "vitest"; +import comparison from "../../harness/fixtures/flint_line_comparison.json"; +import { adaptFlintResult, type AdaptedFlintChart } from "./adapt"; +import type { FlintChartResult } from "./contract"; + +/** + * End-to-end check of the Flint render path per fixture: result -> adapter -> Flint compile -> + * Vega-Lite compile -> headless Vega run with the CSP-safe interpreter, no renderer. This is the + * wiring FlintSurface performs, minus the Svelte shell. + */ + +interface Fixture { + name: string; + expect: string; + result: { structuredContent?: unknown }; +} + +const flintFixtures = Object.entries( + import.meta.glob("../../harness/fixtures/flint_*.json", { + eager: true, + import: "default", + }), +).map(([path, fixture]) => ({ + file: path.split("/").pop()!, + result: fixture.result.structuredContent as FlintChartResult, +})); + +const catalog = Object.values( + import.meta.glob<{ chart_types: { name: string; channels: string[] }[] }>( + "../../../runtime/ai/chartquery/flint_catalog.json", + { eager: true, import: "default" }, + ), +)[0]; + +function registerFormatters(chart: AdaptedFlintChart) { + expressionFunction("humanize", (value: number) => + humanizeDataType(value, FormatPreset.HUMANIZE, "table"), + ); + for (const measure of chart.measures) { + const formatter = createMeasureValueFormatter(measure); + expressionFunction( + sanitizeFieldName(measure.name ?? "measure"), + (value: number) => formatter(value) ?? "", + ); + } +} + +async function runHeadless(chart: AdaptedFlintChart, isDark = false) { + registerFormatters(chart); + const compiled = compileFlintSpec({ + spec: chart.flint.spec, + rows: chart.rows, + fields: chart.flint.fields, + size: { width: 600, height: 320 }, + measureFields: chart.flint.measureFields, + }); + expect(compiled.error).toBeNull(); + expect(compiled.spec).not.toBeNull(); + const { spec: vgSpec } = compile(compiled.spec as TopLevelSpec, { + config: getRillTheme(isDark, chart.theme), + }); + const runtime = parse(vgSpec, undefined, { ast: true }); + const view = new View(runtime, { + renderer: "none", + expr: expressionInterpreter, + }); + await view.runAsync(); + return { view, compiled }; +} + +describe("Flint render path", () => { + it("covers every Flint fixture", () => { + expect(flintFixtures.length).toBeGreaterThanOrEqual(8); + }); + + it("uses chart types and channels the server's catalog knows", () => { + expect(catalog?.chart_types.length).toBeGreaterThan(20); + for (const { file, result } of flintFixtures) { + const entry = catalog.chart_types.find( + (type) => type.name === result.input.chart_spec.chartType, + ); + expect(entry, file).toBeDefined(); + for (const channel of Object.keys(result.input.chart_spec.encodings)) { + expect(entry!.channels, `${file}: ${channel}`).toContain(channel); + } + } + }); + + for (const { file, result } of flintFixtures) { + it(`compiles and runs the ${file} fixture`, async () => { + const chart = adaptFlintResult(result, { isDark: false, locale: "en" }); + if (chart.rowCount === 0) return; + const { view, compiled } = await runHeadless(chart); + expect(compiled.warnings.filter((w) => w.severity === "error")).toEqual( + [], + ); + expect(view.width()).toBeGreaterThan(0); + expect(view.height()).toBeGreaterThan(0); + }); + } + + it("draws the comparison as a second series with the previous-period label", async () => { + const chart = adaptFlintResult( + comparison.result.structuredContent as FlintChartResult, + { isDark: false, locale: "en" }, + ); + expect(chart.hasComparison).toBe(true); + expect(chart.flint.comparison).toEqual({ + channel: "color", + measure: "total_bids", + }); + const { compiled } = await runHeadless(chart); + const spec = compiled.spec as unknown as { + encoding?: { color?: { field?: string; title?: string } }; + data?: { values?: Record[] }; + }; + expect(spec.encoding?.color?.field).toBe("rill_period"); + expect(spec.encoding?.color?.title).toBe("Period"); + const periods = new Set( + (spec.data?.values ?? []).map((row) => row.rill_period), + ); + expect(periods).toEqual( + new Set(["Total Bids", "Total Bids (previous period)"]), + ); + // The table keeps the result's own rows while the chart draws the folded ones. + expect(chart.table.totalRows).toBe(chart.rows.length / 2); + }); + + it("routes measure axes through Rill's formatters", async () => { + const chart = adaptFlintResult( + comparison.result.structuredContent as FlintChartResult, + { isDark: true, locale: "en" }, + ); + const { compiled } = await runHeadless(chart, true); + expect(JSON.stringify(compiled.spec)).toContain( + `"formatType":"${sanitizeFieldName("total_bids")}"`, + ); + }); +}); diff --git a/web-mcp-app/src/lib/flint-controls.spec.ts b/web-mcp-app/src/lib/flint-controls.spec.ts new file mode 100644 index 000000000000..01501b0f3508 --- /dev/null +++ b/web-mcp-app/src/lib/flint-controls.spec.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vitest"; +import bar from "../../harness/fixtures/flint_bar_chart.json"; +import comparison from "../../harness/fixtures/flint_line_comparison.json"; +import grouped from "../../harness/fixtures/flint_grouped_bar.json"; +import pie from "../../harness/fixtures/flint_pie.json"; +import type { FlintChartResult } from "./contract"; +import { + describeFlintView, + flintControlOptions, + flintTemporalField, + flintTopNTarget, + patchFlintInput, +} from "./flint-controls"; + +const result = (fixture: { result: { structuredContent?: unknown } }) => + fixture.result.structuredContent as FlintChartResult; + +const week = { start: "2023-09-09T00:00:00Z", end: "2023-09-16T00:00:00Z" }; + +describe("flintTemporalField", () => { + it("finds the time field on x, by kind or by the metrics view's time dimension", () => { + const res = result(comparison); + expect(flintTemporalField(res.input.chart_spec, res.fields)).toBe("__time"); + expect(flintTemporalField(res.input.chart_spec, undefined, "__time")).toBe( + "__time", + ); + expect( + flintTemporalField(result(bar).input.chart_spec, result(bar).fields), + ).toBeUndefined(); + }); +}); + +describe("flintTopNTarget", () => { + it("targets the first dimension channel with the limit in effect", () => { + const res = result(bar); + expect(flintTopNTarget(res.input.chart_spec, res.fields)).toEqual({ + channel: "x", + limit: 10, + }); + const slices = result(pie); + expect(flintTopNTarget(slices.input.chart_spec, slices.fields)).toEqual({ + channel: "color", + limit: 10, + }); + }); + + it("skips measures and time fields", () => { + const res = result(comparison); + expect(flintTopNTarget(res.input.chart_spec, res.fields)).toBeUndefined(); + }); +}); + +describe("flintControlOptions", () => { + it("offers grains for a time axis and comparison for line charts", () => { + const res = result(comparison); + expect( + flintControlOptions( + res.input.chart_spec, + res.fields, + "__time", + week, + "TIME_GRAIN_HOUR", + ), + ).toEqual({ + grains: ["TIME_GRAIN_HOUR", "TIME_GRAIN_DAY"], + topN: false, + comparison: true, + }); + }); + + it("offers top-N without grains for a categorical bar chart", () => { + const res = result(bar); + expect( + flintControlOptions(res.input.chart_spec, res.fields, "__time", week), + ).toEqual({ + grains: [], + topN: true, + comparison: true, + }); + }); + + it("returns nothing when no control applies", () => { + const res = result(pie); + const spec = { ...res.input.chart_spec, encodings: { size: "total_bids" } }; + expect( + flintControlOptions(spec, res.fields, undefined, week), + ).toBeUndefined(); + }); +}); + +describe("patchFlintInput", () => { + it("sets the grain, the top-N limit and the comparison range without touching the input", () => { + const res = result(grouped); + const before = JSON.stringify(res.input); + const patched = patchFlintInput( + res.input, + res.input.chart_spec, + { grain: "TIME_GRAIN_WEEK", topN: 5, comparison: true }, + week, + res.fields, + ); + expect(patched.data.time_grain).toBe("TIME_GRAIN_WEEK"); + expect(patched.chart_spec.encodings.x).toEqual({ + field: "publisher", + limit: 5, + }); + expect(patched.data.comparison_time_range?.end).toBe(week.start); + expect(patched.data.comparison_time_range?.start).toMatch( + /^2023-09-02T00:00:00/, + ); + expect(JSON.stringify(res.input)).toBe(before); + }); + + it("turns a string encoding into an object to hold the limit", () => { + const res = result(bar); + const spec = { + ...res.input.chart_spec, + encodings: { x: "advertiser_name", y: "total_bids" }, + }; + const patched = patchFlintInput( + res.input, + spec, + { topN: 20 }, + week, + res.fields, + ); + expect(patched.chart_spec.encodings.x).toEqual({ + field: "advertiser_name", + limit: 20, + }); + }); + + it("removes the comparison range when comparison is switched off", () => { + const res = result(comparison); + expect(res.input.data.comparison_time_range).toBeDefined(); + const patched = patchFlintInput( + res.input, + res.input.chart_spec, + { comparison: false }, + week, + res.fields, + ); + expect(patched.data.comparison_time_range).toBeUndefined(); + }); +}); + +describe("describeFlintView", () => { + it("states the chart type, the bindings and every control", () => { + const res = result(bar); + expect( + describeFlintView( + "Total Bids by Advertiser Name", + res.input.chart_spec, + { topN: 10, comparison: false }, + "Sep 9 – Sep 15, 2023", + ), + ).toBe( + "Chart now shows Total Bids by Advertiser Name: Bar Chart with x = advertiser_name, y = total_bids; time grain n/a, top 10 values, comparison off, Sep 9 – Sep 15, 2023.", + ); + }); + + it("lists folded series and the grain unit", () => { + expect( + describeFlintView( + "Bids", + { + chartType: "Line Chart", + encodings: { x: "__time", y: ["total_bids", "total_bids_prev"] }, + }, + { grain: "TIME_GRAIN_DAY", comparison: true }, + ), + ).toBe( + "Chart now shows Bids: Line Chart with x = __time, y = total_bids + total_bids_prev; time grain day, top values n/a, comparison on.", + ); + }); +}); diff --git a/web-mcp-app/src/lib/flint-controls.ts b/web-mcp-app/src/lib/flint-controls.ts new file mode 100644 index 000000000000..5ebdb46cc090 --- /dev/null +++ b/web-mcp-app/src/lib/flint-controls.ts @@ -0,0 +1,173 @@ +import { TIME_GRAIN } from "@rilldata/web-common/lib/time/config"; +import type { V1TimeGrain } from "@rilldata/web-common/runtime-client/gen/index.schemas"; +import type { + WireField, + WireFieldKind, + WireFlintChartSpec, + WireFlintEncoding, + WireFlintInput, + WireTimeRange, +} from "./contract"; +import { + grainsForRange, + previousPeriod, + type ControlOptions, + type ControlState, + type TopNTarget, +} from "./controls"; +import { + COMPARISON_FOLD_CHART_TYPES, + encodingFields, + singleEncodingField, +} from "./flint-input"; + +/** + * The data controls (time grain, top-N, comparison) on the Flint path. They patch the effective + * input the server echoed, which the widget sends back to the app-only `chart_data` tool, and + * describe the view for the model. + */ + +/** Channels whose dimension gets a top-N query on the server, in the order the control picks them. */ +const TOP_N_CHANNELS = ["x", "y", "color", "group", "column", "row"]; + +export function flintFieldKind( + name: string | undefined, + fields: Record | undefined, + timeDimension?: string, +): WireFieldKind | undefined { + if (!name) return undefined; + const kind = fields?.[name]?.kind; + if (kind) return kind; + return name === timeDimension ? "time" : undefined; +} + +/** The time field bound to the x axis, else to the y axis. */ +export function flintTemporalField( + chartSpec: WireFlintChartSpec, + fields: Record | undefined, + timeDimension?: string, +): string | undefined { + for (const channel of ["x", "y"]) { + const field = singleEncodingField(chartSpec.encodings?.[channel]); + if (flintFieldKind(field, fields, timeDimension) === "time") return field; + } + return undefined; +} + +/** The first dimension channel a top-N limit applies to, with the limit the server put in effect. */ +export function flintTopNTarget( + chartSpec: WireFlintChartSpec, + fields: Record | undefined, + timeDimension?: string, +): TopNTarget | undefined { + for (const channel of TOP_N_CHANNELS) { + const value = chartSpec.encodings?.[channel]; + const field = singleEncodingField(value); + if (flintFieldKind(field, fields, timeDimension) !== "dimension") continue; + const limit = + typeof value === "object" && !Array.isArray(value) + ? value.limit + : undefined; + return { channel, limit }; + } + return undefined; +} + +export function supportsFlintComparison(chartType: string): boolean { + return COMPARISON_FOLD_CHART_TYPES.has(chartType); +} + +export function flintControlOptions( + chartSpec: WireFlintChartSpec, + fields: Record | undefined, + timeDimension: string | undefined, + range: WireTimeRange | undefined, + smallestGrain?: V1TimeGrain, +): ControlOptions | undefined { + const temporal = flintTemporalField(chartSpec, fields, timeDimension); + const options: ControlOptions = { + grains: range && temporal ? grainsForRange(range, smallestGrain) : [], + topN: flintTopNTarget(chartSpec, fields, timeDimension) !== undefined, + comparison: supportsFlintComparison(chartSpec.chartType), + }; + if (options.grains.length < 2 && !options.topN && !options.comparison) { + return undefined; + } + return options; +} + +/** + * The `chart_data` arguments for a control state: the current chart spec with the top-N limit on + * its channel, and the data binding with the grain and the comparison range. Inputs are left untouched. + */ +export function patchFlintInput( + input: WireFlintInput, + chartSpec: WireFlintChartSpec, + state: ControlState, + range: WireTimeRange | undefined, + fields: Record | undefined, + timeDimension?: string, +): WireFlintInput { + const patched: WireFlintInput = JSON.parse( + JSON.stringify({ chart_spec: chartSpec, data: input.data }), + ); + if (state.grain) { + patched.data.time_grain = state.grain; + } + if (state.topN !== undefined) { + const target = flintTopNTarget(chartSpec, fields, timeDimension); + if (target) { + const value = patched.chart_spec.encodings[target.channel]; + const encoding: WireFlintEncoding = + typeof value === "string" + ? { field: value } + : (value as WireFlintEncoding); + patched.chart_spec.encodings[target.channel] = { + ...encoding, + limit: state.topN, + }; + } + } + if ( + state.comparison === true && + supportsFlintComparison(chartSpec.chartType) + ) { + const base = range ?? input.data.time_range; + if (!patched.data.comparison_time_range && base) { + patched.data.comparison_time_range = previousPeriod(base); + } + } else if (state.comparison === false) { + delete patched.data.comparison_time_range; + } + return patched; +} + +/** + * One line for the model about what the chart currently shows (model-facing, English): the chart + * type and its bindings, then every data control, including the ones the chart does not offer. + */ +export function describeFlintView( + title: string, + chartSpec: WireFlintChartSpec, + controls: ControlState, + timeRange?: string, +): string { + const bindings = Object.entries(chartSpec.encodings ?? {}) + .map(([channel, value]) => [channel, encodingFields(value)] as const) + .filter(([, fields]) => fields.length > 0) + .map(([channel, fields]) => `${channel} = ${fields.join(" + ")}`) + .join(", "); + const grain = controls.grain + ? (TIME_GRAIN[controls.grain]?.unit ?? controls.grain) + : "n/a"; + const parts = [ + `time grain ${grain}`, + controls.topN === undefined + ? "top values n/a" + : `top ${controls.topN} values`, + `comparison ${controls.comparison ? "on" : "off"}`, + ]; + if (timeRange) parts.push(timeRange); + // i18n-ignore: text for the model, not shown to the user + return `Chart now shows ${title}: ${chartSpec.chartType} with ${bindings || "no bindings"}; ${parts.join(", ")}.`; +} diff --git a/web-mcp-app/src/lib/flint-input.spec.ts b/web-mcp-app/src/lib/flint-input.spec.ts new file mode 100644 index 000000000000..420dcec5f871 --- /dev/null +++ b/web-mcp-app/src/lib/flint-input.spec.ts @@ -0,0 +1,322 @@ +import { describe, expect, it } from "vitest"; +import bar from "../../harness/fixtures/flint_bar_chart.json"; +import comparison from "../../harness/fixtures/flint_line_comparison.json"; +import pie from "../../harness/fixtures/flint_pie.json"; +import waterfall from "../../harness/fixtures/flint_waterfall.json"; +import { normalizeRows, type FlintChartResult } from "./contract"; +import { + buildFlintCompileInput, + COMPARISON_SERIES_FIELD, + comparisonChannel, + deriveFlintFields, + encodingFields, + measureFieldsInRows, + singleEncodingField, + stripFlintChartSpec, +} from "./flint-input"; + +const result = (fixture: { result: { structuredContent?: unknown } }) => + fixture.result.structuredContent as FlintChartResult; + +describe("stripFlintChartSpec", () => { + it("drops the title, the subtitle and Rill's limit keys", () => { + const stripped = stripFlintChartSpec(result(waterfall).input.chart_spec); + expect(stripped).toEqual({ + chartType: "Waterfall Chart", + encodings: { x: { field: "publisher" }, y: "spend_change" }, + chartProperties: { totals: "both" }, + }); + expect(stripped).not.toHaveProperty("subtitle"); + }); + + it("keeps Flint's encoding keys and strips inside arrays", () => { + const stripped = stripFlintChartSpec({ + chartType: "Line Chart", + encodings: { + x: { field: "__time", limit: 5 }, + y: [ + "total_bids", + { field: "impressions", sortOrder: "ascending", limit: 3 }, + ], + }, + }); + expect(stripped.encodings).toEqual({ + x: { field: "__time" }, + y: ["total_bids", { field: "impressions", sortOrder: "ascending" }], + }); + expect(stripped).not.toHaveProperty("chartProperties"); + }); +}); + +describe("stripFlintChartSpec channels", () => { + it("drops the server's detail extension where the chart type has no detail channel", () => { + const stripped = stripFlintChartSpec({ + chartType: "Scatter Plot", + encodings: { x: "total_bids", y: "win_rate", detail: "advertiser_name" }, + }); + expect(stripped.encodings).toEqual({ x: "total_bids", y: "win_rate" }); + const kept = stripFlintChartSpec({ + chartType: "Line Chart", + encodings: { x: "__time", y: "total_bids", detail: "advertiser_name" }, + }); + expect(kept.encodings).toEqual({ + x: "__time", + y: "total_bids", + detail: "advertiser_name", + }); + }); + + it("keeps every channel of a chart type Flint does not know", () => { + const stripped = stripFlintChartSpec({ + chartType: "Unknown Chart", + encodings: { x: "a", detail: "b" }, + }); + expect(stripped.encodings).toEqual({ x: "a", detail: "b" }); + }); +}); + +describe("encoding helpers", () => { + it("reads single fields and all fields", () => { + expect(singleEncodingField("total_bids")).toBe("total_bids"); + expect(singleEncodingField({ field: "device" })).toBe("device"); + expect(singleEncodingField(["a", "b"])).toBeUndefined(); + expect(singleEncodingField(undefined)).toBeUndefined(); + expect(encodingFields(["a", { field: "b" }, { sortBy: "y" }])).toEqual([ + "a", + "b", + ]); + }); +}); + +describe("deriveFlintFields", () => { + it("mirrors the server's derivation from presets, formats and types", () => { + const fields = deriveFlintFields( + { + __time: { name: "__time", kind: "time", display_name: "__time" }, + device: { name: "device", kind: "dimension", display_name: "Device" }, + total_bids: { + name: "total_bids", + kind: "measure", + display_name: "Total Bids", + type: "INT64", + }, + total_spend: { + name: "total_spend", + kind: "measure", + type: "FLOAT64", + format_preset: "currency_usd", + }, + win_rate: { + name: "win_rate", + kind: "measure", + type: "FLOAT64", + format_preset: "percentage", + }, + avg_bid_price: { + name: "avg_bid_price", + kind: "measure", + type: "FLOAT64", + format_d3: "$.2f", + }, + ratio: { name: "ratio", kind: "measure", type: "FLOAT64" }, + }, + "TIME_GRAIN_MONTH", + ); + expect(fields.semantic_types).toEqual({ + __time: "YearMonth", + device: "Category", + total_bids: "Count", + total_spend: "Amount", + win_rate: "Percentage", + avg_bid_price: "Amount", + ratio: "Number", + }); + expect(fields.field_display_names).toEqual({ + __time: "Time", + device: "Device", + total_bids: "Total Bids", + total_spend: "total_spend", + win_rate: "win_rate", + avg_bid_price: "avg_bid_price", + ratio: "ratio", + }); + }); + + it("uses DateTime for grains finer than a day and when the grain is unknown", () => { + const time = { name: "t", kind: "time" as const }; + expect( + deriveFlintFields({ t: time }, "TIME_GRAIN_HOUR").semantic_types.t, + ).toBe("DateTime"); + expect(deriveFlintFields({ t: time }, undefined).semantic_types.t).toBe( + "DateTime", + ); + }); +}); + +describe("buildFlintCompileInput", () => { + it("prefers the server's semantic maps and lists the measure columns", () => { + const res = result(bar); + const rows = normalizeRows(res.data); + const compile = buildFlintCompileInput( + res.input.chart_spec, + res, + rows, + undefined, + ); + expect(compile.spec).toEqual({ + chartType: "Bar Chart", + encodings: { + x: { field: "advertiser_name", sortBy: "y", sortOrder: "descending" }, + y: "total_bids", + }, + }); + expect(compile.fields).toEqual({ + semantic_types: { advertiser_name: "Category", total_bids: "Count" }, + field_display_names: { + advertiser_name: "Advertiser Name", + total_bids: "Total Bids", + }, + }); + expect(compile.measureFields).toEqual(["total_bids"]); + expect(compile.comparison).toBeUndefined(); + }); + + it("falls back to deriving the semantics when the server omitted them", () => { + const res = { ...result(pie) }; + delete res.semantic_types; + delete res.field_display_names; + const compile = buildFlintCompileInput( + res.input.chart_spec, + res, + normalizeRows(res.data), + undefined, + ); + expect(compile.fields.semantic_types).toEqual({ + device: "Category", + total_bids: "Count", + }); + expect(compile.fields.field_display_names.device).toBe("Device"); + }); + + it("folds the previous period in as a colored series in long form", () => { + const res = result(comparison); + const rows = normalizeRows(res.data); + expect(comparisonChannel(res.input.chart_spec, res.fields ?? {})).toEqual({ + channel: "y", + measure: "total_bids", + }); + expect(measureFieldsInRows(res.fields ?? {}, rows)).toEqual([ + "total_bids", + "total_bids_prev", + ]); + const compile = buildFlintCompileInput( + res.input.chart_spec, + res, + rows, + "TIME_GRAIN_DAY", + ); + expect(compile.spec.chartType).toBe("Line Chart"); + expect(compile.spec.encodings).toEqual({ + x: "__time", + y: "total_bids", + color: COMPARISON_SERIES_FIELD, + }); + expect(compile.comparison).toEqual({ + channel: "color", + measure: "total_bids", + }); + expect(compile.rows).toHaveLength(rows.length * 2); + expect(compile.rows[0]).toEqual({ + __time: rows[0].__time, + total_bids: rows[0].total_bids, + rill_period: "Total Bids", + }); + expect(compile.rows[1]).toEqual({ + __time: rows[0].__time, + total_bids: rows[0].total_bids_prev, + rill_period: "Total Bids (previous period)", + }); + expect(compile.measureFields).toEqual(["total_bids"]); + expect(compile.fields.semantic_types.rill_period).toBe("Category"); + expect(compile.fields.field_display_names.rill_period).toBe("Period"); + }); + + it("names the previous period itself when the server did not", () => { + const res = { ...result(comparison) }; + delete res.semantic_types; + delete res.field_display_names; + const compile = buildFlintCompileInput( + res.input.chart_spec, + res, + normalizeRows(res.data), + "TIME_GRAIN_DAY", + ); + expect( + compile.rows.some( + (row) => row.rill_period === "Total Bids (previous period)", + ), + ).toBe(true); + }); + + it("turns a bar chart with a comparison into grouped bars", () => { + const res = result(comparison); + const compile = buildFlintCompileInput( + { chartType: "Bar Chart", encodings: { x: "__time", y: "total_bids" } }, + res, + normalizeRows(res.data), + "TIME_GRAIN_DAY", + ); + expect(compile.spec.chartType).toBe("Grouped Bar Chart"); + expect(compile.spec.encodings?.group).toBe(COMPARISON_SERIES_FIELD); + expect(compile.comparison).toEqual({ + channel: "group", + measure: "total_bids", + }); + }); + + it("layers the areas of a compared area chart", () => { + const res = result(comparison); + const compile = buildFlintCompileInput( + { chartType: "Area Chart", encodings: { x: "__time", y: "total_bids" } }, + res, + normalizeRows(res.data), + "TIME_GRAIN_DAY", + ); + expect(compile.spec.chartProperties).toEqual({ stackMode: "layered" }); + }); + + it("does not fold outside the fold set, for multi-field channels or a taken series channel", () => { + const res = result(comparison); + const rows = normalizeRows(res.data); + const heatmap = buildFlintCompileInput( + { chartType: "Heatmap", encodings: { x: "__time", y: "total_bids" } }, + res, + rows, + "TIME_GRAIN_DAY", + ); + expect(heatmap.comparison).toBeUndefined(); + expect(heatmap.spec.encodings?.y).toBe("total_bids"); + expect(heatmap.rows).toBe(rows); + const multi = buildFlintCompileInput( + { + chartType: "Line Chart", + encodings: { x: "__time", y: ["total_bids", "total_bids"] }, + }, + res, + rows, + "TIME_GRAIN_DAY", + ); + expect(multi.comparison).toBeUndefined(); + const colored = buildFlintCompileInput( + { + chartType: "Bar Chart", + encodings: { x: "__time", y: "total_bids", color: "__time" }, + }, + res, + rows, + "TIME_GRAIN_DAY", + ); + expect(colored.comparison).toBeUndefined(); + expect(colored.spec.chartType).toBe("Bar Chart"); + }); +}); diff --git a/web-mcp-app/src/lib/flint-input.ts b/web-mcp-app/src/lib/flint-input.ts new file mode 100644 index 000000000000..ffeae0a6aa0d --- /dev/null +++ b/web-mcp-app/src/lib/flint-input.ts @@ -0,0 +1,346 @@ +import type { + FlintChartSpec, + FlintEncoding, + FlintEncodingValue, +} from "@rilldata/web-common/features/custom-viz/flint/compile"; +import type { + FlintFields, + FlintSemanticType, +} from "@rilldata/web-common/features/custom-viz/flint/semantic-types"; +import { ComparisonDeltaPreviousSuffix } from "@rilldata/web-common/features/dashboards/filters/measure-filters/measure-filter-entry"; +import { INTEGERS } from "@rilldata/web-common/lib/duckdb-data-types"; +import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; +import { FormatPreset } from "@rilldata/web-common/lib/number-formatting/humanizer-types"; +import { includesCurrencySymbol } from "@rilldata/web-common/lib/number-formatting/utils/d3-format-utils"; +import { V1TimeGrain } from "@rilldata/web-common/runtime-client/gen/index.schemas"; +import { vlGetTemplateChannels } from "flint-chart/vegalite"; +import type { + ChartDataPayload, + WireField, + WireFlintChartSpec, + WireFlintEncoding, + WireFlintEncodingValue, + WireRow, +} from "./contract"; + +/** + * Builds what web-common's `compileFlintSpec` needs from a Flint result: the chart spec stripped + * of what Flint must not see (the card shows the title and subtitle, and `limit` is Rill's own + * top-N key), the per-field semantics, the measure columns to route through Rill's formatters, + * and the comparison fold. Pure: no DOM, no host session. + */ + +/** + * Chart types that can show the previous period as a second series, and the channel that + * separates the series. A bar chart becomes a grouped bar chart so the periods sit side by side + * rather than stacked; the others color the series. + */ +export const COMPARISON_FOLDS: ReadonlyMap< + string, + { chartType: string; channel: "color" | "group" } +> = new Map([ + ["Line Chart", { chartType: "Line Chart", channel: "color" }], + ["Area Chart", { chartType: "Area Chart", channel: "color" }], + ["Bar Chart", { chartType: "Grouped Bar Chart", channel: "group" }], + ["Grouped Bar Chart", { chartType: "Grouped Bar Chart", channel: "group" }], + ["Lollipop Chart", { chartType: "Lollipop Chart", channel: "color" }], + ["Slope Chart", { chartType: "Slope Chart", channel: "color" }], +]); + +export const COMPARISON_FOLD_CHART_TYPES: ReadonlySet = new Set( + COMPARISON_FOLDS.keys(), +); + +/** The column that names the period of each row once the previous period is folded in. */ +export const COMPARISON_SERIES_FIELD = "rill_period"; + +/** The keys of a Flint encoding object; anything else (Rill's `limit`) is dropped before compiling. */ +const FLINT_ENCODING_KEYS = [ + "field", + "type", + "aggregate", + "sortOrder", + "sortBy", + "scheme", +] as const satisfies readonly (keyof FlintEncoding)[]; + +/** What the metrics view's timestamp column is labelled; mirrors web-common's semantic derivation. */ +const TIME_DISPLAY_NAME = "Time"; + +/** + * Keep in sync with `semantic-types.ts` in web-common and with the server's derivation + * (`runtime/ai/chartquery`); grains finer than a day have no dedicated Flint type. + */ +const TIME_GRAIN_TO_SEMANTIC_TYPE: Partial< + Record +> = { + [V1TimeGrain.TIME_GRAIN_YEAR]: "Year", + [V1TimeGrain.TIME_GRAIN_QUARTER]: "YearQuarter", + [V1TimeGrain.TIME_GRAIN_MONTH]: "YearMonth", + [V1TimeGrain.TIME_GRAIN_WEEK]: "YearWeek", + [V1TimeGrain.TIME_GRAIN_DAY]: "Date", +}; + +export interface FlintCompileInput { + /** The spec handed to Flint: no title or subtitle, no `limit` keys, comparison folded. */ + spec: FlintChartSpec; + fields: FlintFields; + /** The rows Flint compiles: the result's, or their long form when a comparison is folded in. */ + rows: WireRow[]; + /** Measure columns present in the rows; each gets a Rill formatter. */ + measureFields: string[]; + /** The measure whose previous period was folded in, and the channel that separates the periods. */ + comparison?: { channel: "color" | "group"; measure: string }; +} + +/** The field a single-field encoding binds; undefined for an array (series fold) or nothing. */ +export function singleEncodingField( + value: WireFlintEncodingValue | undefined, +): string | undefined { + if (value === undefined) return undefined; + if (typeof value === "string") return value || undefined; + if (Array.isArray(value)) return undefined; + return value.field || undefined; +} + +/** Every field an encoding binds, arrays included. */ +export function encodingFields( + value: WireFlintEncodingValue | undefined, +): string[] { + if (value === undefined) return []; + const values = Array.isArray(value) ? value : [value]; + return values + .map((entry) => (typeof entry === "string" ? entry : entry.field)) + .filter((field): field is string => !!field); +} + +function stripEncoding( + value: string | WireFlintEncoding, +): string | FlintEncoding { + if (typeof value === "string") return value; + const stripped: FlintEncoding = {}; + for (const key of FLINT_ENCODING_KEYS) { + if (value[key] !== undefined) { + (stripped as Record)[key] = value[key]; + } + } + return stripped; +} + +/** + * The chart spec as Flint should see it: without the title, the subtitle and Rill's `limit` keys, + * and without the channels the chart type does not accept. The server takes `detail` on every + * chart type as a Rill extension (a row-grain dimension that gets no top-N query); for a type + * whose template has no `detail` channel the rows are already at that grain and the binding is dropped. + */ +export function stripFlintChartSpec( + chartSpec: WireFlintChartSpec, +): FlintChartSpec { + const accepted = templateChannels(chartSpec.chartType); + const encodings: Record = {}; + for (const [channel, value] of Object.entries(chartSpec.encodings ?? {})) { + if (value === undefined || value === null) continue; + if (accepted && !accepted.has(channel)) continue; + encodings[channel] = Array.isArray(value) + ? value.map(stripEncoding) + : stripEncoding(value); + } + return { + chartType: chartSpec.chartType, + encodings, + ...(chartSpec.chartProperties && + Object.keys(chartSpec.chartProperties).length > 0 + ? { chartProperties: { ...chartSpec.chartProperties } } + : {}), + }; +} + +/** The channels a Flint chart type accepts, or undefined for a type Flint does not know. */ +function templateChannels(chartType: string): Set | undefined { + try { + const channels = vlGetTemplateChannels(chartType); + return channels.length > 0 ? new Set(channels) : undefined; + } catch { + return undefined; + } +} + +/** + * Picks a Flint type from how a measure is formatted rather than from its data type, the way + * web-common's `semantic-types.ts` does for custom viz: the format preset encodes the intent. + */ +function measureSemanticType(field: WireField): FlintSemanticType { + switch (field.format_preset as FormatPreset | undefined) { + case FormatPreset.CURRENCY_USD: + case FormatPreset.CURRENCY_EUR: + return "Amount"; + case FormatPreset.PERCENTAGE: + return "Percentage"; + case FormatPreset.INTERVAL: + return "Duration"; + } + if (field.format_d3) { + if (includesCurrencySymbol(field.format_d3)) return "Amount"; + if (field.format_d3.includes("%")) return "Percentage"; + } + // The wire type has no CODE_ prefix; web-common's set is keyed by the runtime's codes. + if (field.type && INTEGERS.has(`CODE_${field.type}`)) return "Count"; + return "Number"; +} + +function timeSemanticType(grain: V1TimeGrain | undefined): FlintSemanticType { + if (!grain) return "DateTime"; + return TIME_GRAIN_TO_SEMANTIC_TYPE[grain] ?? "DateTime"; +} + +/** + * Semantic types and display names derived from the result's field metadata. The server sends + * both already; this is the fallback for a result without them, and it fills the fields the + * server's maps do not mention. + */ +export function deriveFlintFields( + fields: Record, + grain: V1TimeGrain | undefined, +): FlintFields { + const semantic_types: Record = {}; + const field_display_names: Record = {}; + for (const [name, field] of Object.entries(fields)) { + switch (field.kind) { + case "measure": + semantic_types[name] = measureSemanticType(field); + break; + case "time": + semantic_types[name] = timeSemanticType(grain); + break; + default: + semantic_types[name] = "Category"; + } + field_display_names[name] = + field.kind === "time" + ? TIME_DISPLAY_NAME + : field.display_name || field.name || name; + } + return { semantic_types, field_display_names }; +} + +/** Measure columns present in the rows: the measures plus any `_prev` companions. */ +export function measureFieldsInRows( + fields: Record, + rows: WireRow[], +): string[] { + const sample = rows.slice(0, 50); + const present = (name: string) => sample.some((row) => name in row); + const result: string[] = []; + for (const [name, field] of Object.entries(fields)) { + if (field.kind !== "measure") continue; + result.push(name); + const previous = name + ComparisonDeltaPreviousSuffix; + if (present(previous)) result.push(previous); + } + return result; +} + +/** + * The channel whose single measure can be folded with its previous period: `y`, or `x` when the + * measure sits on x (a horizontal layout). Undefined when the chart type does not fold, the + * channel binds several fields, or the series channel is already taken. + */ +export function comparisonChannel( + chartSpec: WireFlintChartSpec, + fields: Record, +): { channel: string; measure: string } | undefined { + const fold = COMPARISON_FOLDS.get(chartSpec.chartType); + if (!fold) return undefined; + const taken = (channel: string) => + encodingFields(chartSpec.encodings?.[channel]).length > 0; + // A grouped bar chart has no color channel, so a colored bar chart cannot become one. + if (taken(fold.channel) || (fold.channel === "group" && taken("color"))) { + return undefined; + } + for (const channel of ["y", "x"]) { + const field = singleEncodingField(chartSpec.encodings?.[channel]); + if (field && fields[field]?.kind === "measure") { + return { channel, measure: field }; + } + } + return undefined; +} + +/** + * The rows in long form: one row per period, the measure holding the period's value and + * `rill_period` naming it. The previous-period column is dropped. + */ +function foldRows( + rows: WireRow[], + measure: string, + labels: { current: string; previous: string }, +): WireRow[] { + const previous = measure + ComparisonDeltaPreviousSuffix; + const folded: WireRow[] = []; + for (const row of rows) { + const { [previous]: previousValue, ...rest } = row; + folded.push({ ...rest, [COMPARISON_SERIES_FIELD]: labels.current }); + folded.push({ + ...rest, + [measure]: previousValue ?? null, + [COMPARISON_SERIES_FIELD]: labels.previous, + }); + } + return folded; +} + +export function buildFlintCompileInput( + chartSpec: WireFlintChartSpec, + result: Pick< + ChartDataPayload, + "fields" | "semantic_types" | "field_display_names" + >, + rows: WireRow[], + timeGrain: V1TimeGrain | undefined, +): FlintCompileInput { + const wireFields = result.fields ?? {}; + const derived = deriveFlintFields(wireFields, timeGrain); + const fields: FlintFields = { + semantic_types: { + ...derived.semantic_types, + ...(result.semantic_types as Record), + }, + field_display_names: { + ...derived.field_display_names, + ...result.field_display_names, + }, + }; + const spec = stripFlintChartSpec(chartSpec); + let measureFields = measureFieldsInRows(wireFields, rows); + + // Fold the previous period in as a second series: the rows go long, the series channel + // names the period, and a bar chart becomes a grouped bar chart so the periods sit side by side. + let comparison: FlintCompileInput["comparison"]; + const target = comparisonChannel(chartSpec, wireFields); + const previous = target && target.measure + ComparisonDeltaPreviousSuffix; + if (target && previous && measureFields.includes(previous)) { + const fold = COMPARISON_FOLDS.get(chartSpec.chartType)!; + const current = + fields.field_display_names[target.measure] ?? target.measure; + const labels = { + current, + previous: + fields.field_display_names[previous] ?? + m.mcp_chart_previous_period_name({ name: current }), + }; + rows = foldRows(rows, target.measure, labels); + spec.chartType = fold.chartType; + (spec.encodings ??= {})[fold.channel] = COMPARISON_SERIES_FIELD; + if (fold.chartType === "Area Chart") { + // Layered rather than stacked areas: the periods are alternatives, not parts of a whole. + spec.chartProperties = { stackMode: "layered", ...spec.chartProperties }; + } + fields.semantic_types[COMPARISON_SERIES_FIELD] = "Category"; + fields.field_display_names[COMPARISON_SERIES_FIELD] = + m.mcp_chart_series_period(); + measureFields = measureFields.filter((name) => name !== previous); + comparison = { channel: fold.channel, measure: target.measure }; + } + + return { spec, fields, rows, measureFields, comparison }; +} diff --git a/web-mcp-app/src/lib/flint-options.spec.ts b/web-mcp-app/src/lib/flint-options.spec.ts new file mode 100644 index 000000000000..2f6ac3a1b038 --- /dev/null +++ b/web-mcp-app/src/lib/flint-options.spec.ts @@ -0,0 +1,150 @@ +import { compileFlintSpec } from "@rilldata/web-common/features/custom-viz/flint/compile"; +import { describe, expect, it } from "vitest"; +import bar from "../../harness/fixtures/flint_bar_chart.json"; +import pie from "../../harness/fixtures/flint_pie.json"; +import { normalizeRows, type FlintChartResult } from "./contract"; +import { buildFlintCompileInput } from "./flint-input"; +import { + buildFlintPanel, + EXCLUDED_CHART_TYPES, + MAX_CHART_TYPE_CANDIDATES, + MAX_PANEL_PROPERTIES, + valueKey, + withChartType, + withProperty, +} from "./flint-options"; + +const result = (fixture: { result: { structuredContent?: unknown } }) => + fixture.result.structuredContent as FlintChartResult; + +function panelFor(fixture: { result: { structuredContent?: unknown } }) { + const res = result(fixture); + const rows = normalizeRows(res.data); + const compile = buildFlintCompileInput( + res.input.chart_spec, + res, + rows, + undefined, + ); + return { + res, + rows, + compile, + panel: buildFlintPanel(res.input.chart_spec, compile, rows), + }; +} + +describe("buildFlintPanel", () => { + it("offers chart types the bindings can move to, the current one first", () => { + const { res, rows, panel } = panelFor(bar); + expect(panel.chartType).toBeDefined(); + const choices = panel.chartType!.choices.map((choice) => choice.value); + expect(choices[0]).toBe("Bar Chart"); + expect(choices.length).toBeGreaterThan(1); + expect(choices.length).toBeLessThanOrEqual(MAX_CHART_TYPE_CANDIDATES + 1); + expect(new Set(choices).size).toBe(choices.length); + for (const type of choices) { + expect(EXCLUDED_CHART_TYPES.has(type)).toBe(false); + const moved = panel.apply.chartType(type); + const movedCompile = buildFlintCompileInput(moved, res, rows, undefined); + expect( + compileFlintSpec({ + spec: movedCompile.spec, + rows, + fields: movedCompile.fields, + size: { width: 600, height: 320 }, + measureFields: movedCompile.measureFields, + }).error, + type, + ).toBeNull(); + } + + const moved = panel.apply.chartType(choices[1]); + expect(moved.chartType).toBe(choices[1]); + expect(Object.keys(moved.encodings).length).toBeGreaterThan(0); + // The result's spec is untouched, and choosing it again restores it. + expect(res.input.chart_spec.chartType).toBe("Bar Chart"); + expect(panel.apply.chartType("Bar Chart")).toBe(res.input.chart_spec); + }); + + it("keeps the choices of the result's spec while another type is shown", () => { + const { res, rows, panel } = panelFor(bar); + const alternative = panel.chartType!.choices[1].value; + const moved = panel.apply.chartType(alternative); + const movedCompile = buildFlintCompileInput(moved, res, rows, undefined); + const next = buildFlintPanel( + moved, + movedCompile, + rows, + res.input.chart_spec, + ); + expect(next.chartType!.current).toBe(alternative); + expect(next.chartType!.choices.map((choice) => choice.value)).toEqual( + panel.chartType!.choices.map((choice) => choice.value), + ); + }); + + it("keeps the applicable binary and discrete properties only", () => { + const { panel } = panelFor(pie); + expect(panel.properties.length).toBeLessThanOrEqual(MAX_PANEL_PROPERTIES); + for (const property of panel.properties) { + expect(property.choices.length).toBeGreaterThan(1); + expect(property.key).not.toBe("innerRadius"); // continuous + } + const sortSlices = panel.properties.find((p) => p.key === "sortSlices"); + expect(sortSlices).toBeDefined(); + const next = panel.apply.property( + "sortSlices", + sortSlices!.choices[1].value, + ); + expect(next.chartProperties).toEqual({ + innerRadius: 55, + sortSlices: JSON.parse(sortSlices!.choices[1].value), + }); + }); + + it("offers the pivot views and writes the chosen state into the properties", () => { + const { panel } = panelFor(bar); + expect(panel.layout).toBeDefined(); + expect(panel.layout!.choices[0].value).toBe(panel.layout!.value); + const flipped = panel.apply.layout(panel.layout!.choices[1].value); + expect(flipped.chartProperties).toEqual({ + pivot: panel.layout!.choices[1].value, + }); + // Choosing the default view again clears the override. + expect( + panel.apply.layout(panel.layout!.choices[0].value).chartProperties, + ).toEqual({}); + }); +}); + +describe("withChartType", () => { + it("re-binds the fields and keeps the limit of a moved field", () => { + const { res, rows, compile } = panelFor(bar); + const moved = withChartType( + res.input.chart_spec, + "Lollipop Chart", + rows, + compile.fields.semantic_types, + ); + expect(moved.chartType).toBe("Lollipop Chart"); + // The top-N limit follows the field; the sort keys of the old type do not. + expect(moved.encodings.x).toEqual({ field: "advertiser_name", limit: 10 }); + expect(moved.encodings.y).toBe("total_bids"); + expect(moved).not.toHaveProperty("chartProperties"); + }); +}); + +describe("withProperty and valueKey", () => { + it("sets, replaces and clears a property", () => { + const spec = { chartType: "Bar Chart", encodings: { x: "a", y: "b" } }; + const set = withProperty(spec, "cornerRadius", 4); + expect(set.chartProperties).toEqual({ cornerRadius: 4 }); + expect( + withProperty(set, "cornerRadius", undefined).chartProperties, + ).toEqual({}); + expect(spec).not.toHaveProperty("chartProperties"); + expect(valueKey(undefined)).toBe("null"); + expect(valueKey("stacked")).toBe('"stacked"'); + }); +}); diff --git a/web-mcp-app/src/lib/flint-options.ts b/web-mcp-app/src/lib/flint-options.ts new file mode 100644 index 000000000000..45628922b834 --- /dev/null +++ b/web-mcp-app/src/lib/flint-options.ts @@ -0,0 +1,311 @@ +import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; +import { + assembleVegaLite, + getChartOptions, + getChartPivot, + vlAdaptChart, + vlGetTemplateDef, + vlRecommendChartTypes, +} from "flint-chart/vegalite"; +import type { + WireFlintChartSpec, + WireFlintEncodingValue, + WireRow, +} from "./contract"; +import { stripFlintChartSpec, type FlintCompileInput } from "./flint-input"; + +/** + * Presentation controls derived from Flint's own option model, so the panel can never offer a + * knob the compiler ignores: applicable binary and discrete chart properties (`getChartOptions`), + * the alternative views of the pivot surface (`getChartPivot`), and chart types the current + * bindings can move to (`vlRecommendChartTypes` + `vlAdaptChart`). Every edit is a new chart + * spec applied client-side; nothing here talks to the server. + * + * Adapted from `packages/flint-mcp/ui/src/options.ts` in microsoft/flint-chart (MIT License, + * Copyright (c) Microsoft Corporation), restricted to the controls this widget renders. + */ + +type AssemblyInput = Parameters[0]; + +/** The shape of a Flint chart option this module reads; declared locally, like `compile.ts` does. */ +interface RawOption { + key: string; + label: string; + type: "continuous" | "discrete" | "binary"; + applicable: boolean; + value?: unknown; + options?: { value: unknown; label: string }[]; +} + +export interface PanelChoice { + value: string; + label: string; +} + +export interface PanelProperty { + key: string; + label: string; + choices: PanelChoice[]; + /** The current choice, as `valueKey` of the property's value. */ + value: string | undefined; +} + +export interface FlintPanel { + /** Chart types the data can move to; undefined when Flint recommends none that compile. */ + chartType: { current: string; choices: PanelChoice[] } | undefined; + /** Alternative views of the same bindings (swap axes, split panels, ...). */ + layout: { label: string; choices: PanelChoice[]; value: string } | undefined; + properties: PanelProperty[]; + apply: { + chartType(type: string): WireFlintChartSpec; + layout(id: string): WireFlintChartSpec; + property(key: string, choice: string): WireFlintChartSpec; + }; +} + +/** The toolbar stays readable: a few properties, a few views, a few chart types. */ +export const MAX_PANEL_PROPERTIES = 3; +export const MAX_LAYOUT_CHOICES = 4; +export const MAX_CHART_TYPE_CANDIDATES = 4; +/** Flint types the server does not plan queries for in this phase. */ +export const EXCLUDED_CHART_TYPES: ReadonlySet = new Set([ + "Map", + "Choropleth", + "KPI Card", +]); + +/** Stable string key for an arbitrary option value (handles undefined and objects). */ +export function valueKey(value: unknown): string { + return JSON.stringify(value ?? null); +} + +function assemblyInput( + compile: FlintCompileInput, + rows: WireRow[], +): AssemblyInput { + return { + data: { values: rows }, + semantic_types: compile.fields.semantic_types, + field_display_names: compile.fields.field_display_names, + chart_spec: compile.spec, + options: { addTooltips: true }, + } as AssemblyInput; +} + +/** The single-field bindings of a spec, as `vlAdaptChart` takes them; series folds are skipped. */ +function singleBindings(chartSpec: WireFlintChartSpec): Record { + const bindings: Record = {}; + for (const [channel, value] of Object.entries(chartSpec.encodings ?? {})) { + const field = + typeof value === "string" + ? value + : Array.isArray(value) + ? undefined + : value?.field; + if (field) bindings[channel] = field; + } + return bindings; +} + +/** + * The binding a moved field keeps: its Rill `limit`, so the top-N survives the move. Its sort + * keys are dropped, since `sortBy` names a channel of the old chart type and the new type's + * own ordering applies. + */ +function encodingFor( + chartSpec: WireFlintChartSpec, + field: string, +): WireFlintEncodingValue { + for (const value of Object.values(chartSpec.encodings ?? {})) { + if ( + typeof value === "object" && + !Array.isArray(value) && + value.field === field + ) { + return value.limit === undefined ? field : { field, limit: value.limit }; + } + } + return field; +} + +/** The spec moved to another chart type, with Flint's re-binding of the fields. */ +export function withChartType( + chartSpec: WireFlintChartSpec, + chartType: string, + rows: WireRow[], + semanticTypes: Record, +): WireFlintChartSpec { + const adapted = vlAdaptChart( + chartSpec.chartType, + chartType, + singleBindings(chartSpec), + rows, + semanticTypes, + ); + const encodings: Record = {}; + for (const [channel, field] of Object.entries(adapted)) { + if (field) encodings[channel] = encodingFor(chartSpec, field); + } + // Properties are template-specific; the new type starts from its own defaults. + const moved: WireFlintChartSpec = { ...chartSpec, chartType, encodings }; + delete moved.chartProperties; + return moved; +} + +/** The spec with a chart property set, or reset to the compiler's default when the choice is undefined. */ +export function withProperty( + chartSpec: WireFlintChartSpec, + key: string, + value: unknown, +): WireFlintChartSpec { + const chartProperties = { ...(chartSpec.chartProperties ?? {}) }; + if (value === undefined) delete chartProperties[key]; + else chartProperties[key] = value; + return { ...chartSpec, chartProperties }; +} + +function propertyChoices(option: RawOption): PanelChoice[] { + if (option.type === "binary") { + return [ + { value: valueKey(true), label: m.mcp_chart_option_on() }, + { value: valueKey(false), label: m.mcp_chart_option_off() }, + ]; + } + return (option.options ?? []).map((choice) => ({ + value: valueKey(choice.value), + label: choice.label, + })); +} + +function panelProperties(input: AssemblyInput): PanelProperty[] { + let options: RawOption[]; + try { + options = getChartOptions(input) as unknown as RawOption[]; + } catch { + return []; + } + return options + .filter( + (option) => + option.applicable && + (option.type === "binary" || option.type === "discrete"), + ) + .slice(0, MAX_PANEL_PROPERTIES) + .map((option) => ({ + key: option.key, + label: option.label, + choices: propertyChoices(option), + value: option.value === undefined ? undefined : valueKey(option.value), + })); +} + +function panelLayout(input: AssemblyInput): FlintPanel["layout"] | undefined { + let pivot: ReturnType; + try { + pivot = getChartPivot(input); + } catch { + return undefined; + } + if (!pivot || pivot.length < 2) return undefined; + const current = pivot.ids[pivot.index] ?? pivot.ids[0]; + const choices = pivot.ids + .map((id, i) => ({ value: id, label: pivot.labels[i] ?? id })) + .filter((choice, i) => i < MAX_LAYOUT_CHOICES || choice.value === current); + return { label: pivot.label, choices, value: current }; +} + +/** + * Chart types the current bindings can move to: Flint's ranking for the data, kept when the + * adapted spec compiles, since a recommendation is made for the dataset rather than for these + * bindings. + */ +function chartTypeCandidates( + chartSpec: WireFlintChartSpec, + compile: FlintCompileInput, + rows: WireRow[], +): string[] { + let ranked: string[]; + try { + ranked = vlRecommendChartTypes(rows, compile.fields.semantic_types, { + max: 12, + }); + } catch { + return []; + } + const candidates: string[] = []; + for (const type of ranked) { + if (candidates.length >= MAX_CHART_TYPE_CANDIDATES) break; + if (type === chartSpec.chartType || EXCLUDED_CHART_TYPES.has(type)) + continue; + if (!vlGetTemplateDef(type)) continue; + try { + const moved = withChartType( + chartSpec, + type, + rows, + compile.fields.semantic_types, + ); + if (Object.keys(moved.encodings).length === 0) continue; + assembleVegaLite( + assemblyInput({ ...compile, spec: stripFlintChartSpec(moved) }, rows), + ); + candidates.push(type); + } catch { + // Flint cannot draw the data as this type; skip it. + } + } + return candidates; +} + +/** + * Builds the panel for the chart spec in effect. The chart-type choices are computed from + * `baseSpec`, the result's own spec, and a switch always moves from it, so the list stays put + * while the user tries types and choosing the original type restores the original bindings. + */ +export function buildFlintPanel( + chartSpec: WireFlintChartSpec, + compile: FlintCompileInput, + rows: WireRow[], + baseSpec: WireFlintChartSpec = chartSpec, +): FlintPanel { + const input = assemblyInput(compile, rows); + const properties = panelProperties(input); + const layout = panelLayout(input); + const candidates = chartTypeCandidates(baseSpec, compile, rows); + const types = [baseSpec.chartType, ...candidates]; + if (!types.includes(chartSpec.chartType)) types.push(chartSpec.chartType); + const layoutKey = (() => { + try { + return getChartPivot(input)?.key ?? "pivot"; + } catch { + return "pivot"; + } + })(); + const defaultLayout = layout?.choices[0]?.value; + + return { + chartType: + candidates.length > 0 + ? { + current: chartSpec.chartType, + choices: types.map((type) => ({ value: type, label: type })), + } + : undefined, + layout, + properties, + apply: { + chartType: (type) => + type === baseSpec.chartType + ? baseSpec + : withChartType(baseSpec, type, rows, compile.fields.semantic_types), + layout: (id) => + withProperty( + chartSpec, + layoutKey, + id === defaultLayout ? undefined : id, + ), + property: (key, choice) => + withProperty(chartSpec, key, JSON.parse(choice) as unknown), + }, + }; +} diff --git a/web-mcp-app/src/lib/host.svelte.ts b/web-mcp-app/src/lib/host.svelte.ts new file mode 100644 index 000000000000..cff77a91eef8 --- /dev/null +++ b/web-mcp-app/src/lib/host.svelte.ts @@ -0,0 +1,158 @@ +import { + App, + type McpUiDisplayMode, + type McpUiHostCapabilities, + type McpUiHostContext, +} from "@modelcontextprotocol/ext-apps"; +import type { ToolResult } from "./contract"; + +export type SessionStatus = + | "connecting" + | "awaiting-input" + | "awaiting-result" + | "received" + | "cancelled" + | "failed"; + +export const MODEL_CONTEXT_DEBOUNCE_MS = 500; +const APP_INFO = { name: "rill-chart", version: "1.0.0" }; + +/** + * Reactive wrapper around the ext-apps `App` session. + * Handlers are registered in the constructor, before `connect()`, because hosts push + * `tool-input` and `tool-result` right after the initialize handshake. + * Every capability is feature-detected from the handshake; nothing branches on the host name. + */ +export class HostSession { + status = $state("connecting"); + // Host payloads are replaced wholesale and must stay plain objects: a deep `$state` proxy + // would leak into the Vega-Lite spec (e.g. a multi-measure `fields` array), and Vega + // structured-clones its specs, which rejects proxies. + hostContext = $state.raw(undefined); + hostCapabilities = $state.raw(undefined); + toolInput = $state.raw | undefined>(undefined); + toolResult = $state.raw(undefined); + error = $state(undefined); + + readonly app: App; + private modelContextTimer: ReturnType | undefined; + + constructor( + app: App = new App( + APP_INFO, + { availableDisplayModes: ["inline", "fullscreen"] }, + { autoResize: true }, + ), + ) { + this.app = app; + app.ontoolinput = (params) => { + this.toolInput = params.arguments; + if (this.status === "awaiting-input") this.status = "awaiting-result"; + }; + app.ontoolresult = (result) => { + this.toolResult = result; + this.status = "received"; + }; + app.ontoolcancelled = (params) => { + this.error = params.reason; + this.status = "cancelled"; + }; + app.onhostcontextchanged = (params) => { + // The host sends only the fields that changed. + this.hostContext = { ...(this.hostContext ?? {}), ...params }; + }; + app.onerror = (error) => { + console.error("rill chart widget: host session error", error); + this.error = error.message; + if (this.status === "connecting") this.status = "failed"; + }; + } + + async connect(): Promise { + try { + await this.app.connect(); + this.hostContext = this.app.getHostContext(); + this.hostCapabilities = this.app.getHostCapabilities(); + if (this.status === "connecting") this.status = "awaiting-input"; + } catch (error) { + console.error("rill chart widget: could not connect to the host", error); + this.error = error instanceof Error ? error.message : String(error); + this.status = "failed"; + } + } + + get displayMode(): McpUiDisplayMode { + return this.hostContext?.displayMode ?? "inline"; + } + + get isFullscreen(): boolean { + return this.displayMode === "fullscreen"; + } + + get canFullscreen(): boolean { + return (this.hostContext?.availableDisplayModes ?? []).includes( + "fullscreen", + ); + } + + get canOpenLinks(): boolean { + return this.hostCapabilities?.openLinks !== undefined; + } + + get canCallServerTools(): boolean { + return this.hostCapabilities?.serverTools !== undefined; + } + + async openLink(url: string): Promise { + try { + await this.app.openLink({ url }); + } catch (error) { + console.warn("rill chart widget: open link rejected", error); + } + } + + async requestDisplayMode( + mode: McpUiDisplayMode, + ): Promise { + try { + const result = await this.app.requestDisplayMode({ mode }); + // Hosts normally also send host-context-changed; applying the granted mode here keeps + // the UI consistent with hosts that only answer the request. + this.hostContext = { + ...(this.hostContext ?? {}), + displayMode: result.mode, + }; + return result.mode; + } catch (error) { + console.warn("rill chart widget: display mode request rejected", error); + return undefined; + } + } + + callServerTool( + name: string, + args: Record, + ): Promise { + return this.app.callServerTool({ name, arguments: args }); + } + + /** Debounced so a burst of control changes produces one model update. */ + updateModelContext(text: string): void { + if (this.modelContextTimer) clearTimeout(this.modelContextTimer); + this.modelContextTimer = setTimeout(() => { + this.modelContextTimer = undefined; + this.app + .updateModelContext({ content: [{ type: "text", text }] }) + .catch((error: unknown) => { + console.warn( + "rill chart widget: model context update rejected", + error, + ); + }); + }, MODEL_CONTEXT_DEBOUNCE_MS); + } + + destroy(): void { + if (this.modelContextTimer) clearTimeout(this.modelContextTimer); + } +} diff --git a/web-mcp-app/src/lib/i18n.ts b/web-mcp-app/src/lib/i18n.ts new file mode 100644 index 000000000000..87a4f06434cf --- /dev/null +++ b/web-mcp-app/src/lib/i18n.ts @@ -0,0 +1,37 @@ +import { + baseLocale, + isLocale, + locales, + overwriteGetLocale, + type Locale, +} from "@rilldata/web-common/lib/i18n/gen/runtime"; + +let currentLocale: Locale = baseLocale; + +// The host tells the widget which locale to use; paraglide's own strategies (cookie, +// global variable) have nothing to read inside a sandboxed iframe. +overwriteGetLocale(() => currentLocale); + +/** Maps a BCP 47 host locale such as "es-MX" onto a supported locale, else the base locale. */ +export function resolveLocale(hostLocale: string | undefined): Locale { + if (!hostLocale) return baseLocale; + const normalized = hostLocale.toLowerCase(); + if (isLocale(normalized)) return normalized; + const language = normalized.split(/[-_]/)[0]; + if (isLocale(language)) return language; + return baseLocale; +} + +export function setLocaleFromHost(hostLocale: string | undefined): Locale { + currentLocale = resolveLocale(hostLocale); + if (typeof document !== "undefined") { + document.documentElement.lang = currentLocale; + } + return currentLocale; +} + +export function getCurrentLocale(): Locale { + return currentLocale; +} + +export const supportedLocales: readonly string[] = locales; diff --git a/web-mcp-app/src/lib/notices.ts b/web-mcp-app/src/lib/notices.ts new file mode 100644 index 000000000000..2ba1544063e7 --- /dev/null +++ b/web-mcp-app/src/lib/notices.ts @@ -0,0 +1,29 @@ +import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; +import type { AdaptedChartBase } from "./adapt"; + +/** What compiling a Flint chart reported: a hard error, or the layout warnings Flint attached. */ +export interface CompileNotices { + error?: string; + /** Flint's non-info warnings, e.g. values dropped because a discrete axis overflowed its budget. */ + warnings: string[]; +} + +export const NO_NOTICES: CompileNotices = { warnings: [] }; + +/** + * The banner under the chart: the server's truncation note, else a generic line when only Flint + * dropped something; the server's explanation and Flint's messages are the hover detail. + */ +export function truncationBanner( + chart: Pick, + notices: CompileNotices, +): { note: string; detail: string | undefined } | undefined { + if (!chart.truncationNote && notices.warnings.length === 0) return undefined; + const detail = [chart.truncationWarning, ...notices.warnings] + .filter((text): text is string => !!text) + .join(" "); + return { + note: chart.truncationNote ?? m.mcp_chart_layout_truncated(), + detail: detail || undefined, + }; +} diff --git a/web-mcp-app/src/lib/sizing.spec.ts b/web-mcp-app/src/lib/sizing.spec.ts new file mode 100644 index 000000000000..4c1686f5aa1d --- /dev/null +++ b/web-mcp-app/src/lib/sizing.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { + INLINE_CHART_DEFAULT_HEIGHT, + INLINE_CHART_MAX_HEIGHT, + INLINE_CHART_MIN_HEIGHT, + inlineChartHeight, +} from "./sizing"; + +describe("inlineChartHeight", () => { + it("defaults when the host sends no dimensions", () => { + expect(inlineChartHeight(undefined)).toBe(INLINE_CHART_DEFAULT_HEIGHT); + expect(inlineChartHeight({ width: 600 })).toBe(INLINE_CHART_DEFAULT_HEIGHT); + }); + + it("clamps to the inline range", () => { + expect(inlineChartHeight({ width: 600, height: 200 })).toBe( + INLINE_CHART_MIN_HEIGHT, + ); + expect(inlineChartHeight({ width: 600, maxHeight: 2000 })).toBe( + INLINE_CHART_MAX_HEIGHT, + ); + expect(inlineChartHeight({ width: 600, height: 400 }, 100)).toBe(300); + }); +}); diff --git a/web-mcp-app/src/lib/sizing.ts b/web-mcp-app/src/lib/sizing.ts new file mode 100644 index 000000000000..755f2dc22c52 --- /dev/null +++ b/web-mcp-app/src/lib/sizing.ts @@ -0,0 +1,54 @@ +import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; + +/** Inline cards keep the chart between these heights; the host auto-fits the card around it. */ +export const INLINE_CHART_MIN_HEIGHT = 220; +export const INLINE_CHART_MAX_HEIGHT = 360; +export const INLINE_CHART_DEFAULT_HEIGHT = 300; +/** Header, footer and paddings around the inline chart, in pixels. */ +export const INLINE_CARD_CHROME_HEIGHT = 112; +/** Design guidelines ask for at least 320px; the card never lays out narrower than this. */ +export const MIN_WIDGET_WIDTH = 320; + +export type ContainerDimensions = McpUiHostContext["containerDimensions"]; + +export function availableHeight( + dimensions: ContainerDimensions, +): number | undefined { + if (!dimensions) return undefined; + if ("height" in dimensions && typeof dimensions.height === "number") { + return dimensions.height; + } + if ("maxHeight" in dimensions && typeof dimensions.maxHeight === "number") { + return dimensions.maxHeight; + } + return undefined; +} + +export function availableWidth( + dimensions: ContainerDimensions, +): number | undefined { + if (!dimensions) return undefined; + if ("width" in dimensions && typeof dimensions.width === "number") { + return dimensions.width; + } + if ("maxWidth" in dimensions && typeof dimensions.maxWidth === "number") { + return dimensions.maxWidth; + } + return undefined; +} + +/** + * Chart height for the inline card: the host's available height minus the card chrome, + * clamped to the inline range. Without a known height the default applies. + */ +export function inlineChartHeight( + dimensions: ContainerDimensions, + chromeHeight = INLINE_CARD_CHROME_HEIGHT, +): number { + const available = availableHeight(dimensions); + if (available === undefined) return INLINE_CHART_DEFAULT_HEIGHT; + return Math.min( + INLINE_CHART_MAX_HEIGHT, + Math.max(INLINE_CHART_MIN_HEIGHT, Math.floor(available - chromeHeight)), + ); +} diff --git a/web-mcp-app/src/lib/spec-generation.spec.ts b/web-mcp-app/src/lib/spec-generation.spec.ts new file mode 100644 index 000000000000..6e82925a0a2b --- /dev/null +++ b/web-mcp-app/src/lib/spec-generation.spec.ts @@ -0,0 +1,115 @@ +import { sanitizeFieldName } from "@rilldata/web-common/components/vega/util"; +import { getRillTheme } from "@rilldata/web-common/components/vega/vega-config"; +import { generateSpec } from "@rilldata/web-common/features/components/charts/spec-generators"; +import { + createMeasureValueFormatter, + humanizeDataType, +} from "@rilldata/web-common/lib/number-formatting/format-measure-value"; +import { FormatPreset } from "@rilldata/web-common/lib/number-formatting/humanizer-types"; +import { expressionFunction, parse, View } from "vega"; +import { expressionInterpreter } from "vega-interpreter"; +import { compile, type TopLevelSpec } from "vega-lite"; +import { describe, expect, it } from "vitest"; +import { adaptChartResult, type AdaptedRillChart } from "./adapt"; +import type { RillChartResult } from "./contract"; + +// The widget registers one Vega expression function per measure (axis and tooltip +// formatting) plus `humanize`; mirror that so the compiled specs resolve them. +function registerFormatters(chart: AdaptedRillChart) { + expressionFunction("humanize", (value: number) => + humanizeDataType(value, FormatPreset.HUMANIZE, "table"), + ); + for (const measure of chart.measures) { + const formatter = createMeasureValueFormatter(measure); + expressionFunction( + sanitizeFieldName(measure.name ?? "measure"), + (value: number) => formatter(value) ?? "", + ); + } +} + +/** Compiles the Vega-Lite spec and evaluates it with the CSP-safe interpreter, no renderer. */ +async function runHeadless(chart: AdaptedRillChart, isDark: boolean) { + registerFormatters(chart); + const vlSpec = generateSpec( + chart.chartType, + chart.spec, + chart.snapshot, + ) as TopLevelSpec; + const { spec: vgSpec } = compile( + { ...vlSpec, width: 600, height: 320 } as TopLevelSpec, + { config: getRillTheme(isDark, chart.theme) }, + ); + const runtime = parse(vgSpec, undefined, { ast: true }); + const view = new View(runtime, { + renderer: "none", + expr: expressionInterpreter, + }); + view.data("metrics-view", chart.snapshot.data); + await view.runAsync(); + return view; +} + +interface Fixture { + name: string; + expect: string; + result: { structuredContent?: RillChartResult }; +} + +const fixtures = Object.entries( + import.meta.glob("../../harness/fixtures/*.json", { + eager: true, + import: "default", + }), +) + .map(([path, fixture]) => ({ file: path.split("/").pop()!, fixture })) + // Flint results (`input`) take the Flint render path; see flint-compile.spec.ts. + .filter( + ({ fixture }) => + fixture.expect === "canvas" && !fixture.result.structuredContent?.input, + ); + +describe("Vega-Lite spec generation from fixtures", () => { + it("covers every renderable fixture", () => { + expect(fixtures.length).toBeGreaterThanOrEqual(13); + }); + + for (const mode of [false, true]) { + for (const { file, fixture } of fixtures) { + it(`${file} produces a serializable spec (${mode ? "dark" : "light"})`, () => { + const chart = adaptChartResult(fixture.result.structuredContent!, { + isDark: mode, + locale: "en", + }); + const spec = generateSpec(chart.chartType, chart.spec, chart.snapshot); + expect(spec).toBeTypeOf("object"); + const serialized = JSON.stringify(spec); + expect(serialized.length).toBeGreaterThan(50); + // Specs reference the data by name, never inline it. + expect(serialized).toContain('"metrics-view"'); + }); + } + } + + for (const { file, fixture } of fixtures) { + it(`${file} compiles and runs through the Vega interpreter`, async () => { + const chart = adaptChartResult(fixture.result.structuredContent!, { + isDark: false, + locale: "en", + }); + const view = await runHeadless(chart, false); + expect(view.data("metrics-view")).toHaveLength(chart.rowCount); + }); + } + + it("keeps the backend's category order in the bar spec", () => { + const { fixture } = fixtures.find(({ file }) => file === "bar_chart.json")!; + const chart = adaptChartResult(fixture.result.structuredContent!, { + isDark: false, + }); + const serialized = JSON.stringify( + generateSpec(chart.chartType, chart.spec, chart.snapshot), + ); + expect(serialized).toContain('"sort":["Acme Media","Beta Brands"'); + }); +}); diff --git a/web-mcp-app/src/lib/theme.spec.ts b/web-mcp-app/src/lib/theme.spec.ts new file mode 100644 index 000000000000..0b29ab424769 --- /dev/null +++ b/web-mcp-app/src/lib/theme.spec.ts @@ -0,0 +1,96 @@ +import { updateAllPaletteColors } from "@rilldata/web-common/features/themes/palette-store"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + applyHostTheme, + resolveFontFamily, + resolveIsDark, + SYSTEM_FONT_STACK, +} from "./theme"; + +vi.mock( + "@rilldata/web-common/features/themes/palette-store", + async (importOriginal) => ({ + ...(await importOriginal()), + updateAllPaletteColors: vi.fn(), + }), +); + +describe("applyHostTheme", () => { + beforeEach(() => { + document.documentElement.className = ""; + document.documentElement.removeAttribute("data-theme"); + document.documentElement.removeAttribute("style"); + }); + + it("toggles Rill's dark class and the ext-apps theme attribute", () => { + const dark = applyHostTheme({ theme: "dark" }); + expect(dark.isDark).toBe(true); + expect(document.documentElement.classList.contains("dark")).toBe(true); + expect(document.documentElement.getAttribute("data-theme")).toBe("dark"); + + const light = applyHostTheme({ theme: "light" }); + expect(light.isDark).toBe(false); + expect(document.documentElement.classList.contains("dark")).toBe(false); + expect(document.documentElement.getAttribute("data-theme")).toBe("light"); + }); + + it("applies host style variables and fonts", () => { + applyHostTheme({ + theme: "light", + styles: { + variables: { + "--font-sans": "Host Sans, sans-serif", + "--color-text-primary": "#123456", + } as never, + css: { fonts: "@font-face { font-family: 'Host Sans'; src: url(x); }" }, + }, + }); + const root = document.documentElement; + expect(root.style.getPropertyValue("--font-sans")).toBe( + "Host Sans, sans-serif", + ); + expect(root.style.getPropertyValue("--color-text-primary")).toBe("#123456"); + expect(document.getElementById("__mcp-host-fonts")?.textContent).toContain( + "Host Sans", + ); + }); + + it("re-reads Rill's cached colors only when the mode or the host variables change", () => { + const refreshes = vi.mocked(updateAllPaletteColors); + const styles = { variables: { "--color-text-primary": "#111" } as never }; + applyHostTheme({ theme: "light", styles }); + const before = refreshes.mock.calls.length; + + // Resizes and other unrelated context changes do not re-read the colors. + applyHostTheme({ + theme: "light", + styles, + containerDimensions: { width: 320, maxHeight: 400 }, + }); + applyHostTheme({ + theme: "light", + styles, + containerDimensions: { width: 640, maxHeight: 400 }, + }); + expect(refreshes.mock.calls.length).toBe(before); + + applyHostTheme({ theme: "dark", styles }); + expect(refreshes.mock.calls.length).toBe(before + 1); + + applyHostTheme({ + theme: "dark", + styles: { variables: { "--color-text-primary": "#eee" } as never }, + }); + expect(refreshes.mock.calls.length).toBe(before + 2); + }); + + it("falls back to the system font stack", () => { + expect(resolveFontFamily()).toBe(SYSTEM_FONT_STACK); + expect(applyHostTheme(undefined).fontFamily).toBe(SYSTEM_FONT_STACK); + }); + + it("falls back to the media query when the host sends no theme", () => { + expect(resolveIsDark(undefined)).toBe(false); + expect(resolveIsDark({ theme: "dark" })).toBe(true); + }); +}); diff --git a/web-mcp-app/src/lib/theme.ts b/web-mcp-app/src/lib/theme.ts new file mode 100644 index 000000000000..5b6a6c1f496f --- /dev/null +++ b/web-mcp-app/src/lib/theme.ts @@ -0,0 +1,72 @@ +import { + applyDocumentTheme, + applyHostFonts, + applyHostStyleVariables, + type McpUiHostContext, +} from "@modelcontextprotocol/ext-apps"; +import { updateAllPaletteColors } from "@rilldata/web-common/features/themes/palette-store"; +import { themeManager } from "@rilldata/web-common/features/themes/theme-manager"; + +/** Fallback when the host sends no `--font-sans`; matches ChatGPT's "system fonts only" rule. */ +export const SYSTEM_FONT_STACK = + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif'; + +export interface AppliedTheme { + isDark: boolean; + /** Resolved font stack for Vega, which draws on a canvas and cannot read CSS variables. */ + fontFamily: string; +} + +/** What Rill's cached colors were last derived from; they are re-read only when this changes. */ +let colorSource: { isDark: boolean; variables: unknown } | undefined; + +export function resolveIsDark( + hostContext: McpUiHostContext | undefined, +): boolean { + if (hostContext?.theme === "dark") return true; + if (hostContext?.theme === "light") return false; + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-color-scheme: dark)").matches + ); +} + +export function resolveFontFamily( + root: HTMLElement = document.documentElement, +): string { + const value = getComputedStyle(root).getPropertyValue("--font-sans").trim(); + return value || SYSTEM_FONT_STACK; +} + +/** + * Applies the host's theme to the document: `data-theme` + `color-scheme` (ext-apps), + * Rill's `.dark` class (what app.css and the Vega theme key on), the host's CSS variables + * and fonts. It runs on every host-context change, including resizes, so the DOM writes are + * idempotent and the colors Rill caches from the computed CSS variables are re-read only + * when the mode or the host variables changed. + */ +export function applyHostTheme( + hostContext: McpUiHostContext | undefined, +): AppliedTheme { + const isDark = resolveIsDark(hostContext); + const root = document.documentElement; + + applyDocumentTheme(isDark ? "dark" : "light"); + root.classList.toggle("dark", isDark); + + const variables = hostContext?.styles?.variables; + if (variables) applyHostStyleVariables(variables, root); + + // ext-apps injects the font faces once and ignores later calls. + const fonts = hostContext?.styles?.css?.fonts; + if (fonts) applyHostFonts(fonts); + + if (isDark !== colorSource?.isDark || variables !== colorSource?.variables) { + colorSource = { isDark, variables }; + themeManager.clearCSSVariableCache(); + updateAllPaletteColors(); + } + + return { isDark, fontFamily: resolveFontFamily(root) }; +} diff --git a/web-mcp-app/src/lib/view.spec.ts b/web-mcp-app/src/lib/view.spec.ts new file mode 100644 index 000000000000..ac9bacab12ea --- /dev/null +++ b/web-mcp-app/src/lib/view.spec.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import bar from "../../harness/fixtures/bar_chart.json"; +import empty from "../../harness/fixtures/empty.json"; +import noData from "../../harness/fixtures/no_data.json"; +import toolError from "../../harness/fixtures/tool_error.json"; +import unsupported from "../../harness/fixtures/unsupported_widget.json"; +import { interpretToolResult } from "./view"; + +describe("interpretToolResult", () => { + it("returns the chart for a complete result", () => { + const interpreted = interpretToolResult(bar.result as never); + expect(interpreted.kind).toBe("chart"); + }); + + it("returns the chart for a zero-row result, whose data is an empty array", () => { + expect(empty.result.structuredContent.data).toEqual([]); + const interpreted = interpretToolResult(empty.result as never); + expect(interpreted.kind).toBe("chart"); + }); + + it("explains a result without data and keeps the deep link", () => { + const interpreted = interpretToolResult(noData.result as never); + expect(interpreted).toEqual({ + kind: "error", + message: expect.stringContaining("did not include chart data"), + openUrl: noData.result.structuredContent.open_url, + }); + }); + + it("explains unsupported widget modes", () => { + const interpreted = interpretToolResult(unsupported.result as never); + expect(interpreted.kind).toBe("error"); + if (interpreted.kind === "error") { + expect(interpreted.message).toContain("dashboard"); + expect(interpreted.openUrl).toContain("canvas/overview"); + } + }); + + it("surfaces tool errors with their text", () => { + const interpreted = interpretToolResult(toolError.result as never); + expect(interpreted).toEqual({ + kind: "error", + message: 'metrics view "missing_metrics" not found', + }); + }); +}); diff --git a/web-mcp-app/src/lib/view.ts b/web-mcp-app/src/lib/view.ts new file mode 100644 index 000000000000..988449351589 --- /dev/null +++ b/web-mcp-app/src/lib/view.ts @@ -0,0 +1,54 @@ +import { m } from "@rilldata/web-common/lib/i18n/gen/messages"; +import { + hasChartData, + parseChartPayload, + textContent, + type ChartResult, + type ToolResult, +} from "./contract"; + +export type InterpretedResult = + | { kind: "chart"; result: ChartResult } + | { kind: "error"; message: string; openUrl?: string }; + +/** + * Decides what a tool result means for the UI before any data processing happens: + * a renderable chart, or an error with the best available deep link. + * A result without `data` is the shape Claude produces when a large result is written to a + * file instead of being forwarded, so it gets an explicit message; a query without rows + * carries `data: []` and renders as the empty state. + */ +export function interpretToolResult(result: ToolResult): InterpretedResult { + if (result.isError) { + return { + kind: "error", + message: textContent(result) || m.mcp_chart_error_tool_failed(), + }; + } + const parsed = parseChartPayload(result.structuredContent); + switch (parsed.kind) { + case "unsupported_widget": + return { + kind: "error", + message: m.mcp_chart_error_unsupported_widget({ + widget: parsed.widget, + }), + openUrl: parsed.openUrl, + }; + case "invalid": + return { + kind: "error", + message: m.mcp_chart_error_invalid_result(), + openUrl: parsed.openUrl, + }; + case "chart": + if (!hasChartData(parsed.result)) { + return { + kind: "error", + message: m.mcp_chart_error_no_data(), + openUrl: parsed.result.open_url, + }; + } + return parsed; + } +} diff --git a/web-mcp-app/src/main.ts b/web-mcp-app/src/main.ts new file mode 100644 index 000000000000..c3344860baa9 --- /dev/null +++ b/web-mcp-app/src/main.ts @@ -0,0 +1,7 @@ +import { mount } from "svelte"; +import App from "./App.svelte"; + +const target = document.getElementById("app"); +if (!target) throw new Error("rill chart widget: #app container missing"); + +mount(App, { target }); diff --git a/web-mcp-app/src/stubs/app-environment.ts b/web-mcp-app/src/stubs/app-environment.ts new file mode 100644 index 000000000000..9a4694c4b726 --- /dev/null +++ b/web-mcp-app/src/stubs/app-environment.ts @@ -0,0 +1,6 @@ +// Stand-in for SvelteKit's `$app/environment`: the widget is a plain Vite app, but +// some shared web-common modules (local storage stores) import this module. +export const browser = true; +export const building = false; +export const dev = import.meta.env.DEV; +export const version = ""; diff --git a/web-mcp-app/src/styles/app.css b/web-mcp-app/src/styles/app.css new file mode 100644 index 000000000000..59f128496398 --- /dev/null +++ b/web-mcp-app/src/styles/app.css @@ -0,0 +1,164 @@ +@import "@rilldata/web-common/colors.css"; +@import "@rilldata/web-common/styles/chart-base.css"; +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* + * Rill's semantic tokens mapped onto the MCP Apps host style variables + * (--color-background-*, --color-text-*, --color-border-*, --font-sans, --border-radius-md). + * Every mapping has a light-dark() fallback for hosts that send no variables. + * `color-scheme` is switched by the `dark` class, which the host theme toggles. + */ +@layer base { + :root { + color-scheme: light; + + --white: #ffffff; + --radius: var(--border-radius-md, 0.5rem); + --font-sans-fallback: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", + Arial, "Noto Sans", sans-serif; + + --surface-base: transparent; + --surface-background: var( + --color-background-primary, + light-dark(#ffffff, #1f1f1f) + ); + --surface-card: var( + --color-background-primary, + light-dark(#ffffff, #1f1f1f) + ); + --surface-subtle: var( + --color-background-secondary, + light-dark(#f5f5f5, #262626) + ); + --surface-hover: var( + --color-background-secondary, + light-dark(#f5f5f5, #262626) + ); + --surface-active: var( + --color-background-tertiary, + light-dark(#ececec, #2e2e2e) + ); + --surface-overlay: var( + --color-background-tertiary, + light-dark(#ececec, #2e2e2e) + ); + --surface-muted: var( + --color-background-secondary, + light-dark(#f5f5f5, #262626) + ); + + --fg-primary: var(--color-text-primary, light-dark(#171717, #ededed)); + --fg-secondary: var(--color-text-secondary, light-dark(#404040, #c7c7c7)); + --fg-tertiary: var(--color-text-tertiary, light-dark(#737373, #a3a3a3)); + --fg-muted: var(--color-text-tertiary, light-dark(#737373, #a3a3a3)); + --fg-disabled: var(--color-text-disabled, light-dark(#a3a3a3, #6b6b6b)); + /* Text on the surfaces that stay dark in both modes: Vega tooltips and the error badge. */ + --fg-inverse: #fafafa; + --fg-accent: var(--color-primary-900); + + --accent-primary: var(--color-primary-500); + --accent-primary-action: var(--color-primary-500); + --accent-secondary: var(--color-primary-500); + --accent-secondary-action: var(--color-secondary-500); + + --icon-default: var(--color-text-secondary, light-dark(#404040, #c7c7c7)); + --icon-muted: var(--color-text-tertiary, light-dark(#737373, #a3a3a3)); + --icon-disabled: var(--color-text-disabled, light-dark(#a3a3a3, #6b6b6b)); + --icon-accent: var(--color-primary-500); + + --border: var( + --color-border-primary, + light-dark(rgb(0 0 0 / 0.1), rgb(255 255 255 / 0.12)) + ); + --input: var( + --color-border-secondary, + light-dark(rgb(0 0 0 / 0.08), rgb(255 255 255 / 0.1)) + ); + --ring-focus: var(--color-ring-primary, var(--color-theme-300)); + --ring-offset: transparent; + + --dimension: var(--color-primary-100); + --dimension-foreground: var(--color-primary-800); + --dimension-border: var(--color-primary-800); + --measure: var(--color-secondary-100); + --measure-foreground: var(--color-secondary-800); + --measure-border: var(--color-secondary-800); + + --tooltip: var(--color-rill-gray-dark-500); + + --destructive: var(--color-background-danger, light-dark(#dc2626, #ef4444)); + --destructive-foreground: var(--color-text-inverse, #ffffff); + --kpi-positive: var(--fg-secondary); + --kpi-negative: var(--color-red-500); + + --popover: var(--surface-overlay); + --popover-accent: var(--surface-hover); + --popover-foreground: var(--fg-primary); + --popover-footer: var(--surface-subtle); + } + + :root.dark { + color-scheme: dark; + + --fg-accent: var(--color-primary-light-100); + --accent-primary: var(--color-primary-light-600); + --accent-primary-action: var(--color-primary-light-400); + --accent-secondary: var(--color-primary-light-700); + --accent-secondary-action: var(--color-secondary-light-400); + --icon-accent: var(--color-primary-light-400); + --ring-focus: var(--color-ring-primary, var(--color-primary-light-500)); + --dimension: var(--color-primary-light-950); + --dimension-foreground: var(--color-primary-light-100); + --dimension-border: var(--color-primary-light-800); + --measure: var(--color-secondary-light-950); + --measure-foreground: var(--color-secondary-light-100); + --measure-border: var(--color-secondary-light-800); + } +} + +html, +body { + margin: 0; + padding: 0; + background: transparent; + /* Inline cards never scroll on their own; the host sizes the frame to the content. */ + overflow: hidden; +} + +body { + color: var(--fg-primary); + font-family: var(--font-sans, var(--font-sans-fallback)); + font-size: 13px; + line-height: 1.4; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +#app { + width: 100%; +} + +/* Keyboard focus follows the host's ring color; pointer focus stays quiet. */ +:focus-visible { + outline: 2px solid var(--ring-focus); + outline-offset: 2px; +} + +/* + * Vega tooltips (web-common's vega.css) assume Rill's Inter font and keep a dark surface in both + * modes. The widget bundles no font, so they take the host's, and in dark mode a hairline sets the + * tooltip off from the host's dark background. + */ +:root #vg-tooltip-element, +:root #rill-vg-tooltip { + font-family: var(--font-sans, var(--font-sans-fallback)); +} +:root.dark #vg-tooltip-element, +:root.dark #rill-vg-tooltip { + box-shadow: + 0 0 0 1px rgb(255 255 255 / 0.14), + 0 4px 12px rgb(0 0 0 / 0.45); +} diff --git a/web-mcp-app/svelte.config.js b/web-mcp-app/svelte.config.js new file mode 100644 index 000000000000..1d07739f41ca --- /dev/null +++ b/web-mcp-app/svelte.config.js @@ -0,0 +1,8 @@ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +/** @type {import('@sveltejs/vite-plugin-svelte').SvelteConfig} */ +const config = { + preprocess: vitePreprocess(), +}; + +export default config; diff --git a/web-mcp-app/tailwind.config.ts b/web-mcp-app/tailwind.config.ts new file mode 100644 index 000000000000..f721daded4ae --- /dev/null +++ b/web-mcp-app/tailwind.config.ts @@ -0,0 +1,17 @@ +import type { Config } from "tailwindcss"; +import webCommonPreset from "../web-common/tailwind.config"; + +// The content list is explicit on purpose: the widget reuses only a handful of +// web-common components, and scanning all of web-common would ship unused utilities +// into the single-file bundle. +export default { + presets: [webCommonPreset], + content: [ + "./widget.html", + "./src/**/*.{svelte,ts}", + "../web-common/src/features/components/charts/Chart.svelte", + "../web-common/src/components/vega/VegaLiteRenderer.svelte", + "../web-common/src/features/components/ComponentError.svelte", + "../web-common/src/features/entity-management/Spinner.svelte", + ], +} satisfies Config; diff --git a/web-mcp-app/tests/fake-host.ts b/web-mcp-app/tests/fake-host.ts new file mode 100644 index 000000000000..1b60d7fdb88e --- /dev/null +++ b/web-mcp-app/tests/fake-host.ts @@ -0,0 +1,146 @@ +// Minimal MCP Apps host rendered as a standalone page for the Playwright smoke tests. +// Mirrors the protocol subset of harness/host.ts: initialize handshake, tool-input and +// tool-result push, host-context changes, and answers to the widget's requests. +// Every message from the widget is recorded on `window.__messages`. + +export interface FakeHostOptions { + fixture: unknown; + theme?: "light" | "dark"; + displayMode?: "inline" | "fullscreen"; + locale?: string; + width?: number; + styles?: Record; + /** Result returned for `tools/call`; defaults to the fixture's data fields. */ + chartDataResult?: unknown; +} + +declare global { + interface Window { + __messages: Array<{ method?: string; id?: unknown; params?: unknown }>; + __host: { + setTheme(theme: "light" | "dark"): void; + setDisplayMode(mode: "inline" | "fullscreen"): void; + setContext(partial: Record): void; + }; + } +} + +export function fakeHostPage(options: FakeHostOptions): string { + const serialized = JSON.stringify(options).replace(/ + + + + + Fake MCP Apps host + + + + + + + +`; +} diff --git a/web-mcp-app/tests/widget.spec.ts b/web-mcp-app/tests/widget.spec.ts new file mode 100644 index 000000000000..39991ac0685a --- /dev/null +++ b/web-mcp-app/tests/widget.spec.ts @@ -0,0 +1,544 @@ +import { expect, test, type Page } from "@playwright/test"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { fakeHostPage, type FakeHostOptions } from "./fake-host"; + +const ORIGIN = "https://rill-widget.test"; +const distPath = fileURLToPath(new URL("../dist/widget.html", import.meta.url)); +const fixturesDir = fileURLToPath( + new URL("../harness/fixtures/", import.meta.url), +); + +interface Fixture { + name: string; + expect: "canvas" | "empty" | "error"; + arguments: Record; + result: { structuredContent?: Record }; +} + +function loadFixture(name: string): Fixture { + return JSON.parse( + readFileSync(`${fixturesDir}${name}.json`, "utf8"), + ) as Fixture; +} + +function widgetHtml(): string { + if (!existsSync(distPath)) { + throw new Error( + "dist/widget.html is missing; run `npm run build -w web-mcp-app` before the e2e tests", + ); + } + return readFileSync(distPath, "utf8"); +} + +async function mountWidget(page: Page, options: FakeHostOptions) { + const errors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("pageerror", (error) => errors.push(error.message)); + const html = widgetHtml(); + await page.route(`${ORIGIN}/**`, async (route) => { + const path = new URL(route.request().url()).pathname; + if (path === "/widget.html") { + return route.fulfill({ contentType: "text/html", body: html }); + } + if (path === "/") { + return route.fulfill({ + contentType: "text/html", + body: fakeHostPage(options), + }); + } + return route.fulfill({ status: 404, body: "not found" }); + }); + await page.goto(`${ORIGIN}/`); + return { frame: page.frameLocator("#widget"), errors }; +} + +function sentMessages(page: Page, method: string) { + return page.evaluate( + (name) => window.__messages.filter((message) => message.method === name), + method, + ); +} + +const canonical = loadFixture("bar_chart"); +const canonicalTitle = canonical.result.structuredContent!.title as string; +const openUrl = canonical.result.structuredContent!.open_url as string; + +test("renders the canonical bar chart with title, size report and no console errors", async ({ + page, +}) => { + const { frame, errors } = await mountWidget(page, { fixture: canonical }); + + await expect(frame.getByText(canonicalTitle)).toBeVisible(); + const canvas = frame.locator("canvas").first(); + await expect(canvas).toBeVisible({ timeout: 15_000 }); + const box = await canvas.boundingBox(); + expect(box?.width ?? 0).toBeGreaterThan(0); + expect(box?.height ?? 0).toBeGreaterThan(0); + + await expect + .poll(() => sentMessages(page, "ui/notifications/size-changed")) + .not.toHaveLength(0); + const sizes = (await sentMessages( + page, + "ui/notifications/size-changed", + )) as Array<{ + params: { height: number }; + }>; + expect(sizes.at(-1)?.params.height ?? 0).toBeGreaterThan(100); + + await expect( + frame.getByText("Sep 9 – Sep 15, 2023", { exact: false }), + ).toBeVisible(); + expect(errors).toEqual([]); +}); + +test("has no horizontal overflow at 375px", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 720 }); + const { frame, errors } = await mountWidget(page, { + fixture: canonical, + width: 351, + }); + await expect(frame.locator("canvas").first()).toBeVisible({ + timeout: 15_000, + }); + const overflow = await frame + .locator("html") + .evaluate((root) => root.scrollWidth - root.clientWidth); + expect(overflow).toBeLessThanOrEqual(0); + expect(errors).toEqual([]); +}); + +test("follows the host theme and style variables", async ({ page }) => { + const { frame } = await mountWidget(page, { + fixture: canonical, + theme: "light", + styles: { + "--color-text-primary": "rgb(12, 34, 56)", + "--font-sans": "Georgia, serif", + }, + }); + const root = frame.locator("html"); + await expect(root).not.toHaveClass(/dark/); + await expect(frame.locator("canvas").first()).toBeVisible({ + timeout: 15_000, + }); + // Tailwind text colors go through color-mix(), so compare the resolved tokens instead. + const tokens = await root.evaluate((el) => { + const style = getComputedStyle(el); + return { + host: style.getPropertyValue("--color-text-primary").trim(), + rill: style.getPropertyValue("--fg-primary").trim(), + font: style.getPropertyValue("--font-sans").trim(), + }; + }); + expect(tokens.host).toBe("rgb(12, 34, 56)"); + expect(tokens.rill).toBe("rgb(12, 34, 56)"); + expect(tokens.font).toBe("Georgia, serif"); + const titleFont = await frame + .getByText(canonicalTitle) + .evaluate((el) => getComputedStyle(el).fontFamily); + expect(titleFont).toBe("Georgia, serif"); + + await page.evaluate(() => window.__host.setTheme("dark")); + await expect(root).toHaveClass(/dark/); + await expect(root).toHaveAttribute("data-theme", "dark"); + await expect(frame.locator("canvas").first()).toBeVisible(); +}); + +test("requests fullscreen and shows the data table", async ({ page }) => { + const { frame, errors } = await mountWidget(page, { fixture: canonical }); + await expect(frame.locator("canvas").first()).toBeVisible({ + timeout: 15_000, + }); + + await frame.getByRole("button", { name: "Fullscreen" }).click(); + await expect + .poll(() => sentMessages(page, "ui/request-display-mode")) + .toEqual([expect.objectContaining({ params: { mode: "fullscreen" } })]); + + const dataTab = frame.getByRole("radio", { name: "Data" }); + await expect(dataTab).toBeVisible(); + await dataTab.click(); + await expect( + frame.getByRole("columnheader", { name: "Advertiser Name" }), + ).toBeVisible(); + await expect(frame.getByRole("cell", { name: "Acme Media" })).toBeVisible(); + + // Interactive controls are offered because the fake host advertises serverTools. + // The top-N control starts on the limit the result's spec carries (the backend applied 10). + const topValues = frame.getByRole("radiogroup", { name: "Top values" }); + await expect(topValues).toBeVisible(); + await expect( + topValues.getByRole("radio", { name: "10", exact: true }), + ).toHaveAttribute("aria-checked", "true"); + await topValues.getByRole("radio", { name: "5", exact: true }).click(); + // Without a `project` in the tool input, none is forwarded (the runtime's own MCP server). + await expect + .poll(() => sentMessages(page, "tools/call")) + .toEqual([ + expect.objectContaining({ + params: expect.objectContaining({ + name: "chart_data", + arguments: { chart_type: "bar_chart", spec: expect.any(Object) }, + }), + }), + ]); + // The model is told every control value, not only the ones the chart offers. + await expect + .poll(() => sentMessages(page, "ui/update-model-context"), { + timeout: 5_000, + }) + .toEqual([ + expect.objectContaining({ + params: expect.objectContaining({ + content: [ + { + type: "text", + text: expect.stringMatching( + /^Chart now shows Total Bids per Advertiser Name: time grain n\/a, top 5 values, comparison off, Sep 9 – Sep 15, 2023\.$/, + ), + }, + ], + }), + }), + ]); + + await frame.getByRole("button", { name: "Exit fullscreen" }).click(); + await expect(frame.getByRole("button", { name: "Fullscreen" })).toBeVisible(); + expect(errors).toEqual([]); +}); + +test("forwards the project of a unified MCP server call to chart_data", async ({ + page, +}) => { + // Rill Cloud's unified server routes every tool call by a `project` argument. + const { frame } = await mountWidget(page, { + fixture: { + ...canonical, + arguments: { ...canonical.arguments, project: "demo/bids" }, + }, + }); + await expect(frame.locator("canvas").first()).toBeVisible({ + timeout: 15_000, + }); + await frame.getByRole("button", { name: "Fullscreen" }).click(); + await frame.getByRole("radio", { name: "5" }).click(); + await expect + .poll(() => sentMessages(page, "tools/call")) + .toEqual([ + expect.objectContaining({ + params: expect.objectContaining({ + name: "chart_data", + arguments: { + project: "demo/bids", + chart_type: "bar_chart", + spec: expect.any(Object), + }, + }), + }), + ]); +}); + +test("offers the result's top-N limit when it is not a preset", async ({ + page, +}) => { + // The funnel's stage channel carries the limit the backend applied (15), so the model is + // told the real value and the control adds it to the presets. + const { frame, errors } = await mountWidget(page, { + fixture: loadFixture("funnel_chart"), + }); + await expect(frame.locator("canvas").first()).toBeVisible({ + timeout: 15_000, + }); + await frame.getByRole("button", { name: "Fullscreen" }).click(); + const topValues = frame.getByRole("radiogroup", { name: "Top values" }); + await expect(topValues.getByRole("radio")).toHaveText([ + "5", + "10", + "15", + "20", + ]); + await expect( + topValues.getByRole("radio", { name: "15", exact: true }), + ).toHaveAttribute("aria-checked", "true"); + + await topValues.getByRole("radio", { name: "10", exact: true }).click(); + await expect + .poll(() => sentMessages(page, "tools/call")) + .toEqual([ + expect.objectContaining({ + params: expect.objectContaining({ + name: "chart_data", + arguments: { + chart_type: "funnel_chart", + spec: expect.objectContaining({ + stage: expect.objectContaining({ limit: 10 }), + }), + }, + }), + }), + ]); + await expect + .poll(() => sentMessages(page, "ui/update-model-context"), { + timeout: 5_000, + }) + .toEqual([ + expect.objectContaining({ + params: expect.objectContaining({ + content: [ + { + type: "text", + text: "Chart now shows Total Bids funnel by Campaign Stage: time grain n/a, top 10 values, comparison off, Sep 9 – Sep 15, 2023.", + }, + ], + }), + }), + ]); + expect(errors).toEqual([]); +}); + +test("opens the deep link through the host", async ({ page }) => { + const { frame } = await mountWidget(page, { fixture: canonical }); + await frame.getByRole("button", { name: "Open in Rill" }).click(); + await expect + .poll(() => sentMessages(page, "ui/open-link")) + .toEqual([expect.objectContaining({ params: { url: openUrl } })]); +}); + +test("shows an error with Open in Rill when the result has no data", async ({ + page, +}) => { + const { frame } = await mountWidget(page, { + fixture: loadFixture("no_data"), + }); + await expect( + frame.getByText("did not include chart data", { exact: false }), + ).toBeVisible(); + await expect( + frame.getByRole("button", { name: "Open in Rill" }), + ).toBeVisible(); + await expect(frame.locator("canvas")).toHaveCount(0); +}); + +test("falls back to an error for an unsupported widget mode", async ({ + page, +}) => { + const { frame } = await mountWidget(page, { + fixture: loadFixture("unsupported_widget"), + }); + await expect( + frame.getByText("needs a newer version", { exact: false }), + ).toBeVisible(); + await expect( + frame.getByRole("button", { name: "Open in Rill" }), + ).toBeVisible(); +}); + +test("shows the tool error text", async ({ page }) => { + const { frame } = await mountWidget(page, { + fixture: loadFixture("tool_error"), + }); + await expect(frame.getByText("not found", { exact: false })).toBeVisible(); +}); + +for (const [name, note] of [ + ["truncated", "Showing the top 10 values."], + ["truncated_rows", "Showing the most recent periods."], +] as const) { + test(`shows the truncation note for the ${name} fixture`, async ({ + page, + }) => { + const { frame } = await mountWidget(page, { fixture: loadFixture(name) }); + const banner = frame.getByRole("note"); + await expect(banner).toHaveText(note); + // The server's own explanation stays available as the tooltip. + await expect(banner).toHaveAttribute( + "title", + /incomplete|Lower the x limit/, + ); + }); +} + +test("renders the comparison caption", async ({ page }) => { + const { frame } = await mountWidget(page, { + fixture: loadFixture("comparison"), + }); + await expect(frame.locator("canvas").first()).toBeVisible({ + timeout: 15_000, + }); + await expect( + frame.getByText("vs. previous period", { exact: false }), + ).toBeVisible(); +}); + +test("localizes the actions from the host locale", async ({ page }) => { + const { frame } = await mountWidget(page, { + fixture: canonical, + locale: "es-ES", + }); + await expect( + frame.getByRole("button", { name: "Abrir en Rill" }), + ).toBeVisible(); +}); + +const flintBar = loadFixture("flint_bar_chart"); + +test("re-queries a Flint chart through chart_data with the Flint arguments", async ({ + page, +}) => { + const { frame, errors } = await mountWidget(page, { fixture: flintBar }); + await expect(frame.locator("canvas").first()).toBeVisible({ + timeout: 15_000, + }); + await frame.getByRole("button", { name: "Fullscreen" }).click(); + + const topValues = frame.getByRole("radiogroup", { name: "Top values" }); + await expect(topValues).toBeVisible(); + // The limit the server put in effect (10) is selected. + await expect( + topValues.getByRole("radio", { name: "10", exact: true }), + ).toHaveAttribute("aria-checked", "true"); + await topValues.getByRole("radio", { name: "5", exact: true }).click(); + await expect + .poll(() => sentMessages(page, "tools/call")) + .toEqual([ + expect.objectContaining({ + params: expect.objectContaining({ + name: "chart_data", + arguments: { + chart_spec: expect.objectContaining({ + chartType: "Bar Chart", + encodings: expect.objectContaining({ + x: expect.objectContaining({ + field: "advertiser_name", + limit: 5, + }), + }), + }), + data: expect.objectContaining({ metrics_view: "bids_metrics" }), + }, + }), + }), + ]); + await expect + .poll(() => sentMessages(page, "ui/update-model-context"), { + timeout: 5_000, + }) + .toEqual([ + expect.objectContaining({ + params: expect.objectContaining({ + content: [ + { + type: "text", + text: "Chart now shows Total Bids by Advertiser Name: Bar Chart with x = advertiser_name, y = total_bids; time grain n/a, top 5 values, comparison off, Sep 9 – Sep 15, 2023.", + }, + ], + }), + }), + ]); + await expect(frame.locator("canvas").first()).toBeVisible(); + expect(errors).toEqual([]); +}); + +test("recompiles a Flint chart client-side when a presentation control changes", async ({ + page, +}) => { + const { frame, errors } = await mountWidget(page, { fixture: flintBar }); + await expect(frame.locator("canvas").first()).toBeVisible({ + timeout: 15_000, + }); + await frame.getByRole("button", { name: "Fullscreen" }).click(); + + const chartTypes = frame.getByRole("radiogroup", { name: "Chart type" }); + await expect(chartTypes).toBeVisible(); + await expect( + chartTypes.getByRole("radio", { name: "Bar Chart", exact: true }), + ).toHaveAttribute("aria-checked", "true"); + const alternativeName = + (await chartTypes.getByRole("radio").nth(1).textContent())?.trim() ?? ""; + expect(alternativeName).not.toBe(""); + await chartTypes + .getByRole("radio", { name: alternativeName, exact: true }) + .click(); + // The list stays put: the result's type first, the chosen alternative selected. + await expect( + chartTypes.getByRole("radio", { name: alternativeName, exact: true }), + ).toHaveAttribute("aria-checked", "true"); + await expect(chartTypes.getByRole("radio").first()).toHaveText("Bar Chart"); + + // No server round trip: the chart is recompiled locally and the model is told. + await expect + .poll(() => sentMessages(page, "ui/update-model-context"), { + timeout: 5_000, + }) + .toEqual([ + expect.objectContaining({ + params: expect.objectContaining({ + content: [ + { + type: "text", + text: expect.stringContaining( + `Chart now shows Total Bids by Advertiser Name: ${alternativeName} with`, + ), + }, + ], + }), + }), + ]); + expect(await sentMessages(page, "tools/call")).toEqual([]); + await expect(frame.locator("canvas").first()).toBeVisible({ + timeout: 15_000, + }); + expect(errors).toEqual([]); +}); + +test("renders the Flint comparison fold with its caption", async ({ page }) => { + const { frame, errors } = await mountWidget(page, { + fixture: loadFixture("flint_line_comparison"), + }); + await expect(frame.locator("canvas").first()).toBeVisible({ + timeout: 15_000, + }); + await expect( + frame.getByText("vs. previous period", { exact: false }), + ).toBeVisible(); + expect(errors).toEqual([]); +}); + +const renderable = readdirSync(fixturesDir) + .filter((file) => file.endsWith(".json")) + .map((file) => file.replace(".json", "")) + .filter((name) => loadFixture(name).expect === "canvas"); + +for (const name of renderable) { + test(`renders a canvas for the ${name} fixture`, async ({ page }) => { + const { frame, errors } = await mountWidget(page, { + fixture: loadFixture(name), + }); + const canvas = frame.locator("canvas").first(); + await expect(canvas).toBeVisible({ timeout: 15_000 }); + await expect + .poll(async () => (await canvas.boundingBox())?.width ?? 0) + .toBeGreaterThan(0); + expect(errors).toEqual([]); + }); +} + +const empty = readdirSync(fixturesDir) + .filter((file) => file.endsWith(".json")) + .map((file) => file.replace(".json", "")) + .filter((name) => loadFixture(name).expect === "empty"); + +for (const name of empty) { + test(`shows the empty state for the ${name} fixture`, async ({ page }) => { + const { frame, errors } = await mountWidget(page, { + fixture: loadFixture(name), + }); + await expect(frame.getByText("No data to display")).toBeVisible(); + await expect(frame.locator("canvas")).toHaveCount(0); + expect(errors).toEqual([]); + }); +} diff --git a/web-mcp-app/tsconfig.json b/web-mcp-app/tsconfig.json new file mode 100644 index 000000000000..4aa99943969d --- /dev/null +++ b/web-mcp-app/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "types": ["vite/client", "svelte"], + "paths": { + "@rilldata/web-common/*": ["../web-common/src/*"], + "$app/environment": ["./src/stubs/app-environment.ts"] + } + }, + "include": [ + "src/**/*.ts", + "src/**/*.svelte", + "harness/**/*.ts", + "tests/**/*.ts", + "vite.config.ts", + "playwright.config.ts", + "tailwind.config.ts", + "vitest-setup.ts", + "scripts/**/*.mjs" + ] +} diff --git a/web-mcp-app/vite.config.ts b/web-mcp-app/vite.config.ts new file mode 100644 index 000000000000..9289ecccde49 --- /dev/null +++ b/web-mcp-app/vite.config.ts @@ -0,0 +1,54 @@ +import { svelte } from "@sveltejs/vite-plugin-svelte"; +import { fileURLToPath } from "node:url"; +import { viteSingleFile } from "vite-plugin-singlefile"; +import { defineConfig } from "vitest/config"; + +const here = (path: string) => fileURLToPath(new URL(path, import.meta.url)); + +// Modules that must never be kept for their side effects alone. Type-only imports of the +// runtime client are erased by TypeScript, but any stray value import of the barrel would +// otherwise drag the proto, Connect RPC and TanStack layers into the widget. These rules are +// a backstop; the real fix is keeping those imports out of the chart stack. +const sideEffectFreeModules = [ + /\/proto\/gen\//, + /\/runtime-client\/v2\/gen\//, + /node_modules\/@bufbuild\//, + /node_modules\/@connectrpc\//, + /node_modules\/@tanstack\//, +]; + +export default defineConfig({ + plugins: [svelte(), viteSingleFile({ removeViteModuleLoader: true })], + resolve: { + alias: { + "@rilldata/web-common": here("../web-common/src"), + "$app/environment": here("./src/stubs/app-environment.ts"), + }, + }, + build: { + target: "es2022", + outDir: "dist", + emptyOutDir: true, + modulePreload: { polyfill: false }, + cssCodeSplit: false, + rolldownOptions: { + input: here("./widget.html"), + treeshake: { + moduleSideEffects: sideEffectFreeModules.map((test) => ({ + test, + sideEffects: false, + })), + }, + }, + }, + server: { + port: 5178, + strictPort: false, + }, + test: { + environment: "jsdom", + globals: true, + setupFiles: ["./vitest-setup.ts"], + include: ["src/**/*.spec.ts"], + }, +}); diff --git a/web-mcp-app/vitest-setup.ts b/web-mcp-app/vitest-setup.ts new file mode 100644 index 000000000000..7ab0be54c472 --- /dev/null +++ b/web-mcp-app/vitest-setup.ts @@ -0,0 +1,15 @@ +import { vi } from "vitest"; + +// jsdom has no matchMedia; web-common's theme control reads it at module load. +Object.defineProperty(window, "matchMedia", { + writable: true, + enumerable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); diff --git a/web-mcp-app/widget.html b/web-mcp-app/widget.html new file mode 100644 index 000000000000..ccdedbb2cdef --- /dev/null +++ b/web-mcp-app/widget.html @@ -0,0 +1,16 @@ + + + + + + + Rill chart + + +
+ + +