
Ask Overture in Plain Language
Source:vignettes/articles/ai-assisted-queries.Rmd
ai-assisted-queries.RmdYou can point a language model at overtureR and ask it questions in
plain English. This article shows how. You type a request like
“buildings taller than five stories in downtown Chicago”; a local R
agent writes the matching open_curtain() pipeline, runs it,
and hands you back an sf object you can map. Every step
below reports what it cost you in data and in time, so you can see where
the work happens.
The agent does not guess at the API. overtureR ships a skill
that teaches a model the package: the lazy-then-collect()
workflow, how to reach nested struct columns, and the mistakes to avoid.
The btw package discovers
that skill and feeds it to the model, so the code the model writes is
code that works.
The pieces fit together like this:
your question
|
v
ellmer chat <--- btw feeds it the overtureR skill + your R session
|
v
open_curtain(...) |> filter(...) |> collect() (a real overtureR pipeline)
|
v
sf object ---> interactive map
Three packages do the work. ellmer talks to the model, btw supplies context, and mapgl draws the result.
install.packages(c("overtureR", "ellmer", "btw", "mapgl"))Why the skill matters
Ask a model to query Overture Maps without the skill and it writes
plausible code that fails. It does not know Overture’s nested columns,
and it forgets that open_curtain() is lazy, so it tries to
pull the planet into memory.
Here is the kind of answer you get without the skill:
# Without the skill: three common failures in four lines
open_curtain("building") |> # no spatial_filter: scans the globe
collect() |> # collect() first: downloads everything
filter(sql("names.primary") == "x") |> # raw SQL for a struct field
mutate(floors = height / 3)Here is what the model writes with the skill loaded:
# With the skill: filter in the database, then collect
bbox <- c(xmin = -87.65, ymin = 41.87, xmax = -87.61, ymax = 41.89)
open_curtain("building", spatial_filter = bbox) |> # prune to the area first
filter(height > 15) |> # filter in DuckDB
transmute(id, height, name = names$primary, geometry) |> # keep geometry
collect() # only now pull to RThe skill turns confident-but-wrong code into a pipeline that runs. That is the whole point of wiring it in.
Wire up the agent
btw_client() builds an ellmer chat that already carries
your project context, including any skills it can find. Add one tool
that runs a pipeline and returns a short summary, and the agent can both
write queries and see their results.
library(ellmer)
library(btw)
# A tool the model can call to run one overtureR pipeline
run_overture <- tool(
function(code) {
result <- eval(parse(text = code))
paste(utils::capture.output(print(result)), collapse = "\n")
},
name = "run_overture_query",
description = paste(
"Run one overtureR pipeline built with open_curtain() and dplyr verbs,",
"ending in collect(). Returns a text summary of the resulting sf object."
),
arguments = list(
code = type_string("R code for a single overtureR pipeline.")
)
)
# btw_client() discovers the overtureR skill and your session;
# then we add the query-runner tool.
chat <- btw_client()
chat$set_tools(c(chat$get_tools(), list(run_overture)))One question, end to end
Now ask a question:
chat$chat("Show me buildings taller than five stories in downtown Chicago.")A well-primed agent answers in two steps, and you can watch each one’s cost.
Step 1: resolve the place
Overture carries administrative areas in the
division_area type, so the agent looks Chicago up by name
rather than guessing coordinates. Timing the call shows how long a name
lookup takes:
place_time <- system.time(
chicago <- open_curtain("division_area") |>
filter(
subtype == "locality",
names$primary == "Chicago",
region == "US-IL"
) |>
transmute(name = names$primary, geometry) |> # keep the geometry column
collect()
)
sprintf(
"Resolved %d boundary in %.1f seconds.",
nrow(chicago),
place_time[["elapsed"]]
)
#> [1] "Resolved 1 boundary in 9.5 seconds."Place-name lookup is a manual step today: you filter
division_area on names$primary. A future
call_place() helper will fold this into one call, but the
pattern above already works and is what the agent uses now. Here is the
boundary it found:
maplibre(
style = carto_style("positron"),
center = c(-87.73, 41.83),
zoom = 9,
height = "360px"
) |>
add_fill_layer(
id = "boundary",
source = chicago,
fill_color = "#3182bd",
fill_opacity = 0.3
)Step 2: fetch the buildings
The agent then queries buildings inside a tight downtown box and keeps the tall ones. Five stories is roughly 15 meters. The spatial filter is what keeps this fast: DuckDB prunes to the area before it reads any geometry.
loop <- c(xmin = -87.645, ymin = 41.875, xmax = -87.615, ymax = 41.895)
building_time <- system.time(
tall_buildings <- open_curtain("building", spatial_filter = loop) |>
filter(height > 15) |>
transmute(id, height, name = names$primary, geometry) |>
collect()
)
sprintf(
"Fetched %d buildings over 15 m in %.1f seconds; tallest is %.0f m.",
nrow(tall_buildings),
building_time[["elapsed"]],
max(tall_buildings$height)
)
#> [1] "Fetched 1023 buildings over 15 m in 53.5 seconds; tallest is 340 m."The tallest few give a sense of the data:
tall_buildings |>
st_drop_geometry() |>
arrange(desc(height)) |>
slice_head(n = 5) |>
knitr::kable(col.names = c("id", "height (m)", "name"), digits = 0)| id | height (m) | name |
|---|---|---|
| aef295ec-4ca7-4a15-9e0a-1b3a980d34f1 | 340 | Aon Center |
| c27c351c-332b-4e0e-9ecf-d904eb25024e | 307 | Franklin Center North Tower |
| e366709e-ed23-4ad3-9eb6-3e5edba512ea | 303 | Two Prudential Plaza |
| e2f8889a-572a-4259-a61c-514aab39839f | 278 | One Prudential Plaza |
| 5fa211b4-b64a-48a3-a7d3-42dbce854be8 | 267 | 400 Lake Shore - North Tower |
The result is an ordinary sf object, so you can map it.
mapgl draws the footprints as 3D blocks, extruded and colored by
height:
maplibre(
style = carto_style("positron"),
center = c(-87.63, 41.885),
zoom = 14,
pitch = 55,
bearing = -20,
height = "520px"
) |>
add_fill_extrusion_layer(
id = "buildings",
source = tall_buildings,
fill_extrusion_height = get_column("height"),
fill_extrusion_color = interpolate(
column = "height",
values = c(15, 100, 300),
stops = c("#fde0dd", "#fa9fb5", "#7a0177")
),
fill_extrusion_opacity = 0.9
)You asked in English and got a map. The model wrote the pipeline; overtureR and DuckDB did the fetching, in the few seconds the timings above report.
Fan out across cities with subagents
One chat answers one question at a time. To compare several places at
once, run a subagent per place. ellmer’s
parallel_chat_structured() sends many prompts concurrently
and returns tidy, typed results, so each subagent turns a plain-language
request into a structured query specification.
library(ellmer)
# The shape we want back from every subagent
query_spec <- type_object(
city = type_string("City name."),
xmin = type_number("Western longitude of a downtown box."),
ymin = type_number("Southern latitude of a downtown box."),
xmax = type_number("Eastern longitude of a downtown box."),
ymax = type_number("Northern latitude of a downtown box.")
)
requests <- list(
"downtown Chicago",
"downtown Denver",
"downtown Austin"
)
# One subagent per city, all at once
specs <- parallel_chat_structured(
chat = btw_client(),
prompts = requests,
type = query_spec
)specs comes back as a data frame, one row per city. From
there the fetching is deterministic overtureR: map the specification
over open_curtain(), combine, and quantify. To keep this
article reproducible, we run the same shape with fixed boxes rather than
a live model:
downtowns <- tibble::tribble(
~city, ~xmin, ~ymin, ~xmax, ~ymax,
"Chicago", -87.645, 41.875, -87.615, 41.895,
"Denver", -105.005, 39.735, -104.985, 39.755,
"Austin", -97.755, 30.260, -97.735, 30.280
)
fanout_time <- system.time(
tall_by_city <- downtowns |>
purrr::pmap(function(city, xmin, ymin, xmax, ymax) {
bbox <- c(xmin = xmin, ymin = ymin, xmax = xmax, ymax = ymax)
open_curtain("building", spatial_filter = bbox) |>
filter(height > 15) |>
transmute(height, city = !!city, geometry) |>
collect()
}) |>
bind_rows()
)
sprintf(
"Fetched %d tall buildings across %d cities in %.1f seconds.",
nrow(tall_by_city),
n_distinct(tall_by_city$city),
fanout_time[["elapsed"]]
)
#> [1] "Fetched 1676 tall buildings across 3 cities in 3.9 seconds."The count per city is the comparison you asked for:
tall_by_city |>
st_drop_geometry() |>
count(city, name = "tall_buildings") |>
arrange(desc(tall_buildings)) |>
knitr::kable()| city | tall_buildings |
|---|---|
| Chicago | 1023 |
| Austin | 327 |
| Denver | 326 |
The subagents handle the ambiguous part, reading each request and naming a box. overtureR handles the exact part, fetching the same clean query for every city. Splitting the work this way keeps the language model where it is strong and the database where it is precise.
Practical notes
-
Pin a release for reproducible work.
open_curtain()defaults to the latest Overture release, and releases expire after about 60 days. Setoptions(overturer_release = )so a script returns the same data next month. - Attribute Overture. The data is open, but many themes are ODbL and require crediting OpenStreetMap contributors. Credit Overture in anything you publish.
-
Watch the cost. Every
chat$chat()call spends tokens; runellmer::token_usage()to see the running total. Let the model draft the pipeline, then rerun the query yourself when you need it often. - Keep a human in the loop. The model writes code that runs, not code that is guaranteed correct. Read the pipeline before you trust the map. ```