From ebcd22d2eb0c65f866a829eca118ba7b7556fca4 Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 12 Oct 2026 17:59:43 -0500 Subject: [PATCH 1/6] feat(prompts): add ggsql-spatial partial for maps capability --- pkg-r/inst/prompts/ggsql-spatial.md | 36 +++++++++++++++++++++++++++++ pkg-r/inst/prompts/prompt.md | 6 +++++ 2 files changed, 42 insertions(+) create mode 100644 pkg-r/inst/prompts/ggsql-spatial.md diff --git a/pkg-r/inst/prompts/ggsql-spatial.md b/pkg-r/inst/prompts/ggsql-spatial.md new file mode 100644 index 000000000..17d35a94f --- /dev/null +++ b/pkg-r/inst/prompts/ggsql-spatial.md @@ -0,0 +1,36 @@ +## Maps with ggsql (spatial) + +The `maps` capability is enabled: the DuckDB `spatial` extension is already installed and loaded. Do NOT write `INSTALL spatial`, `LOAD spatial`, or `ST_*` function calls — querychat has handled spatial setup, and spatial SQL functions may not exist on the user's database. Keep spatial logic in ggsql clauses. + +### Choropleths from `ggsql:world` + +`ggsql:world` is a builtin dataset of country boundaries (Natural Earth 1:110m) with columns: `name`, `iso_a3`, `continent`, `subregion`, `income_group`, `population`, `gdp`, `geom` (geometry). It is resolved by ggsql, not the user's database — but you can join it with the user's tables in the SELECT clause and ggsql will stage the join itself. + +To map the user's data, join it to `ggsql:world` on `iso_a3` (preferred) or `name`, then `DRAW spatial` with a fill mapping: + +```sql +SELECT w.name, w.geom, s.revenue +FROM ggsql:world w JOIN sales_by_country s ON w.iso_a3 = s.country_code +VISUALISE +DRAW spatial MAPPING revenue AS fill +``` + +The geometry column is auto-detected; declare `MAPPING geom AS geometry` explicitly only if detection fails. Before joining, inspect the user's identifier values (e.g. with the schema or query tool): country names vary ("United States" vs "USA"), and ISO codes only match if the user's column is also ISO 3166-1 alpha-3. Filter to a region with `FILTER continent = 'Europe'` on the DRAW clause when the data only covers one. + +### Point maps + +For lon/lat columns, map them to `lon`/`lat` (or `x`/`y` with `PROJECT x, y TO `) and draw `point` over a `ggsql:world` background: + +```sql +SELECT city, lon, lat, passengers FROM airports +VISUALISE +DRAW spatial MAPPING null AS fill FROM ggsql:world +DRAW point MAPPING lon, lat, passengers AS size +PROJECT TO natural +``` + +### Projections + +`PROJECT TO ` sets the map projection. Useful named projections: `geographic` (unprojected lon/lat), `mercator`, `natural` (Natural Earth), `robinson`, `equal_earth`, `orthographic`, `albers` (US-centric). Set the view center with `SETTING origin => (lon, lat)`, e.g. `PROJECT TO orthographic SETTING origin => (-98, 39)` for a US globe. For anything else, use `PROJECT TO crs SETTING target => 'EPSG:XXXX'` or a proj string. `SCALE lon`/`SCALE lat` limits transfer to the projection. + +Keep maps simple: one `spatial` layer for boundaries plus at most one or two data layers. Do not put geometry columns in `GROUP BY` — join them on last, in the final SELECT. diff --git a/pkg-r/inst/prompts/prompt.md b/pkg-r/inst/prompts/prompt.md index a7e6c9488..242a3c9f8 100644 --- a/pkg-r/inst/prompts/prompt.md +++ b/pkg-r/inst/prompts/prompt.md @@ -196,6 +196,12 @@ Match the chart type to what the user is trying to understand: {{> ggsql-syntax}} +{{#has_tool_maps}} + + +{{> ggsql-spatial}} + +{{/has_tool_maps}} {{#has_tool_query}} **Avoid redundant expanded results.** If you run a preparatory query before visualizing, or if both a table and chart would show the same data, always pass `collapsed=true` on the query so the user sees the chart prominently, not a duplicate table above it. The user can still expand the table if they want the exact values. From b92acaefdb4e6d639b89c6dfd71955c544b8aade Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 12 Oct 2026 18:02:44 -0500 Subject: [PATCH 2/6] feat(r): add default sentinel and maps modifier to tools --- pkg-r/R/QueryChat.R | 20 ++++++++++++++++---- pkg-r/tests/testthat/test-QueryChat.R | 24 ++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/pkg-r/R/QueryChat.R b/pkg-r/R/QueryChat.R index 91e2ccc18..602af6407 100644 --- a/pkg-r/R/QueryChat.R +++ b/pkg-r/R/QueryChat.R @@ -420,7 +420,7 @@ QueryChat <- R6::R6Class( check_history(history) arg_match( tools, - values = c("filter", "update", "query", "visualize"), + values = c("filter", "update", "query", "visualize", "maps", "default"), multiple = TRUE ) tools <- normalize_tools(tools) @@ -816,7 +816,7 @@ QueryChat <- R6::R6Class( if (!is_na(tools) && !is.null(tools)) { tools <- arg_match( tools, - values = c("filter", "update", "query", "visualize"), + values = c("filter", "update", "query", "visualize", "maps", "default"), multiple = TRUE ) tools <- normalize_tools(tools) @@ -1646,8 +1646,20 @@ normalize_tools <- function(tools) { if (is.null(tools)) { return(NULL) } + # Expand the "default" sentinel to the default tool set. + if ("default" %in% tools) { + tools <- union(c("filter", "query", "visualize"), setdiff(tools, "default")) + } tools[tools == "filter"] <- "update" - unique(tools) + tools <- unique(tools) + # "maps" is a capability modifier of the visualize tool. + if ("maps" %in% tools && !"visualize" %in% tools) { + cli::cli_abort(c( + 'The "maps" capability requires the "visualize" tool.', + "i" = 'Use `tools = c("default", "maps")` or add "visualize".' + )) + } + tools } check_viz_deps <- function(tools) { @@ -1664,7 +1676,7 @@ check_viz_deps <- function(tools) { .frequency = "once", .frequency_id = "querychat_viz_ggsql_missing" ) - setdiff(tools, "visualize") + setdiff(tools, c("visualize", "maps")) } # Runs one teardown step, warning instead of erroring so the rest still runs. diff --git a/pkg-r/tests/testthat/test-QueryChat.R b/pkg-r/tests/testthat/test-QueryChat.R index 2499c17af..94d95922c 100644 --- a/pkg-r/tests/testthat/test-QueryChat.R +++ b/pkg-r/tests/testthat/test-QueryChat.R @@ -2124,3 +2124,27 @@ describe("QueryChat$ui()", { expect_true(grepl("querychat-extras", html, fixed = TRUE)) }) }) + +test_that("normalize_tools expands the default sentinel", { + expect_equal( + sort(normalize_tools(c("default", "maps"))), + sort(c("update", "query", "visualize", "maps")) + ) + expect_equal( + sort(normalize_tools("default")), + sort(c("update", "query", "visualize")) + ) +}) + +test_that("normalize_tools errors when maps lacks visualize", { + expect_error(normalize_tools(c("query", "maps")), "maps.*visualize") +}) + +test_that("check_viz_deps drops maps alongside visualize", { + skip_if(rlang::is_installed("ggsql"), "ggsql is installed") + suppressWarnings( + resolved <- check_viz_deps(c("update", "query", "visualize", "maps")) + ) + expect_false("visualize" %in% resolved) + expect_false("maps" %in% resolved) +}) From f01e2c20de14851ae94b2e0e8a9a620ac4cee91d Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 12 Oct 2026 18:03:22 -0500 Subject: [PATCH 3/6] feat(r): render ggsql-spatial partial when maps enabled --- pkg-r/R/QueryChatSystemPrompt.R | 12 ++++++++++++ pkg-r/tests/testthat/test-QueryChatSystemPrompt.R | 15 +++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/pkg-r/R/QueryChatSystemPrompt.R b/pkg-r/R/QueryChatSystemPrompt.R index 712f521dc..62ab84744 100644 --- a/pkg-r/R/QueryChatSystemPrompt.R +++ b/pkg-r/R/QueryChatSystemPrompt.R @@ -166,6 +166,7 @@ QueryChatSystemPrompt <- R6::R6Class( has_tool_update = if ("update" %in% tools) "true", has_tool_query = if ("query" %in% tools) "true", has_tool_visualize = if ("visualize" %in% tools) "true", + has_tool_maps = if ("maps" %in% tools) "true", include_query_guidelines = if (length(tools) > 0) "true", handoff_available = handoff_available, multi_table = length(self$data_sources) > 1 @@ -183,6 +184,17 @@ QueryChatSystemPrompt <- R6::R6Class( collapse = "\n" ) } + spatial_path <- system.file( + "prompts", + "ggsql-spatial.md", + package = "querychat" + ) + if (nzchar(spatial_path)) { + partials[["ggsql-spatial"]] <- paste( + readLines(spatial_path), + collapse = "\n" + ) + } whisker::whisker.render(self$template, context, partials = partials) }, diff --git a/pkg-r/tests/testthat/test-QueryChatSystemPrompt.R b/pkg-r/tests/testthat/test-QueryChatSystemPrompt.R index 1954aea09..544e5c12d 100644 --- a/pkg-r/tests/testthat/test-QueryChatSystemPrompt.R +++ b/pkg-r/tests/testthat/test-QueryChatSystemPrompt.R @@ -535,6 +535,21 @@ describe("viz prompt conditionals", { expect_no_match(rendered, "ROOM_TYPE", fixed = TRUE) }) + it("includes ggsql spatial reference only with maps tool", { + ds <- local_data_frame_source(new_test_df()) + sp <- QueryChatSystemPrompt$new( + prompt_template = default_prompt(), + data_sources = list(test_table = ds) + ) + rendered_maps <- sp$render(tools = c("visualize", "maps")) + expect_match(rendered_maps, "ggsql-spatial-reference", fixed = TRUE) + expect_match(rendered_maps, "ggsql:world", fixed = TRUE) + + rendered_plain <- sp$render(tools = c("visualize")) + expect_no_match(rendered_plain, "ggsql-spatial-reference", fixed = TRUE) + expect_no_match(rendered_plain, "ggsql:world", fixed = TRUE) + }) + it("includes collapsed guidance when both query and visualize active", { ds <- local_data_frame_source(new_test_df()) sp <- QueryChatSystemPrompt$new( From 06369db8be1f8946680539fe4a88d891c5e2750b Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 12 Oct 2026 18:03:54 -0500 Subject: [PATCH 4/6] docs: maps capability --- pkg-r/NEWS.md | 2 ++ pkg-r/vignettes/visualize.Rmd | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md index 0b2ceb326..97555304f 100644 --- a/pkg-r/NEWS.md +++ b/pkg-r/NEWS.md @@ -1,5 +1,7 @@ # querychat (development version) +* New opt-in `"maps"` capability: pass `tools = c("default", "maps")` to let the LLM draw choropleths (via the builtin `ggsql:world` dataset) and point maps through ggsql's spatial support. The `"default"` tools entry expands to the default tool set, so extra capabilities no longer require restating every tool. + # querychat 0.4.1 ## Bug fixes diff --git a/pkg-r/vignettes/visualize.Rmd b/pkg-r/vignettes/visualize.Rmd index 77c27b2fc..1bd48ee46 100644 --- a/pkg-r/vignettes/visualize.Rmd +++ b/pkg-r/vignettes/visualize.Rmd @@ -146,6 +146,23 @@ They always run against the full dataset. Learn more about the ggsql grammar at [ggsql.org](https://ggsql.org/). +## Maps + +querychat can draw choropleths and point maps through ggsql's spatial support. +Maps are opt-in because enabling them downloads the DuckDB `spatial` extension (a one-time, cached download) the first time they are set up: + +```r +qc <- QueryChat$new( + penguins, + tools = c("default", "maps") +) +``` + +The `"default"` entry expands to the default tool set, so you don't have to restate every tool to add maps. + +With maps enabled, the LLM can answer geographic questions: "map revenue by country" joins your data with the builtin `ggsql:world` country boundaries (on ISO codes or country names), and "map our store locations" draws lon/lat points over a world outline. +If a map query is attempted while maps are not enabled, the tool explains how to enable them. + ## See also - [Tools](tools.html) — Understand what querychat can do under the hood From e384a07a1f43123da79f999d1bbb689a82717606 Mon Sep 17 00:00:00 2001 From: cpsievert Date: Mon, 12 Oct 2026 23:22:33 +0000 Subject: [PATCH 5/6] `air format` (GitHub Actions) --- pkg-r/R/QueryChat.R | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg-r/R/QueryChat.R b/pkg-r/R/QueryChat.R index 602af6407..4341ecb03 100644 --- a/pkg-r/R/QueryChat.R +++ b/pkg-r/R/QueryChat.R @@ -816,7 +816,14 @@ QueryChat <- R6::R6Class( if (!is_na(tools) && !is.null(tools)) { tools <- arg_match( tools, - values = c("filter", "update", "query", "visualize", "maps", "default"), + values = c( + "filter", + "update", + "query", + "visualize", + "maps", + "default" + ), multiple = TRUE ) tools <- normalize_tools(tools) From 4bafa972857eea611337b657442a027444cb4043 Mon Sep 17 00:00:00 2001 From: cpsievert Date: Mon, 12 Oct 2026 23:22:36 +0000 Subject: [PATCH 6/6] `devtools::document()` (GitHub Actions) --- pkg-r/DESCRIPTION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg-r/DESCRIPTION b/pkg-r/DESCRIPTION index a147b9506..e5fe614f0 100644 --- a/pkg-r/DESCRIPTION +++ b/pkg-r/DESCRIPTION @@ -60,7 +60,7 @@ Suggests: withr VignetteBuilder: knitr -Config/roxygen2/version: 8.1.0 +Config/roxygen2/version: 8.1.1 Config/testthat/edition: 3 Config/testthat/parallel: true Encoding: UTF-8