Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pkg-r/DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions pkg-r/NEWS.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
27 changes: 23 additions & 4 deletions pkg-r/R/QueryChat.R
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -816,7 +816,14 @@ 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)
Expand Down Expand Up @@ -1646,8 +1653,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) {
Expand All @@ -1664,7 +1683,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.
Expand Down
12 changes: 12 additions & 0 deletions pkg-r/R/QueryChatSystemPrompt.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
},
Expand Down
36 changes: 36 additions & 0 deletions pkg-r/inst/prompts/ggsql-spatial.md
Original file line number Diff line number Diff line change
@@ -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 <projection>`) 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 <name>` 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.
6 changes: 6 additions & 0 deletions pkg-r/inst/prompts/prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,12 @@ Match the chart type to what the user is trying to understand:
<ggsql-syntax-reference>
{{> ggsql-syntax}}
</ggsql-syntax-reference>
{{#has_tool_maps}}

<ggsql-spatial-reference>
{{> ggsql-spatial}}
</ggsql-spatial-reference>
{{/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.
Expand Down
24 changes: 24 additions & 0 deletions pkg-r/tests/testthat/test-QueryChat.R
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
15 changes: 15 additions & 0 deletions pkg-r/tests/testthat/test-QueryChatSystemPrompt.R
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
17 changes: 17 additions & 0 deletions pkg-r/vignettes/visualize.Rmd
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading