From 40796f485f408a85c223621015fa95a1bd2ecf0a Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 13 Oct 2026 14:44:35 -0500 Subject: [PATCH 01/13] feat(py): opt-in "maps" capability for visualizations Pass tools=("default", "maps") to let the LLM draw choropleths (via the builtin ggsql:world dataset) and point maps through ggsql's spatial support: - New ggsql-spatial prompt partial, rendered into the system prompt when maps are enabled. - The "default" tools entry expands to the default tool set, so extra capabilities no longer require restating every tool. - The DuckDB spatial extension is pre-warmed when the viz reader is first created with maps enabled; if setup fails, the session continues without maps. - Spatial-extension errors are translated into actionable guidance for the LLM (do not retry with DRAW spatial / ggsql:world; suggest a non-map visualization instead) when maps are disabled or setup failed. --- pkg-py/src/querychat/_querychat_base.py | 21 ++- pkg-py/src/querychat/_system_prompt.py | 1 + pkg-py/src/querychat/_viz_ggsql.py | 61 ++++++- pkg-py/src/querychat/_viz_tools.py | 25 ++- pkg-py/src/querychat/_viz_utils.py | 5 + pkg-py/src/querychat/prompts/ggsql-spatial.md | 36 +++++ pkg-py/src/querychat/prompts/prompt.md | 6 + pkg-py/tests/test_ggsql.py | 29 ++++ pkg-py/tests/test_viz_tools.py | 151 ++++++++++++++++++ 9 files changed, 328 insertions(+), 7 deletions(-) create mode 100644 pkg-py/src/querychat/prompts/ggsql-spatial.md diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index 5abd1ad52..1d1568845 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -58,7 +58,7 @@ from ._data_dict import DataDict from ._viz_tools import VisualizeData -TOOL_GROUPS = Literal["filter", "update", "query", "visualize"] +TOOL_GROUPS = Literal["filter", "update", "query", "visualize", "maps", "default"] DEFAULT_TOOLS: tuple[TOOL_GROUPS, ...] = ("filter", "query", "visualize") TABLE_NAME_PATTERN = r"^[a-zA-Z][a-zA-Z0-9_]*$" @@ -312,7 +312,12 @@ def _create_session_client( if "visualize" in resolved_tools: viz_fn = visualize or (lambda _: None) chat.register_tool( - tool_visualize(executor, viz_fn, multi_table=multi_table) + tool_visualize( + executor, + viz_fn, + multi_table=multi_table, + maps="maps" in resolved_tools, + ) ) return chat @@ -826,6 +831,17 @@ def normalize_tools( resolved = set(tools) if resolved is not None: resolved = {"update" if t == "filter" else t for t in resolved} + # Expand the "default" sentinel to the caller's default tool set. + if "default" in resolved: + resolved.discard("default") + resolved |= set(default) if default is not None else set() + resolved = {"update" if t == "filter" else t for t in resolved} + # "maps" is a capability modifier of the visualize tool. + if "maps" in resolved and "visualize" not in resolved: + raise ValueError( + 'The "maps" capability requires the "visualize" tool. ' + 'Use tools=("default", "maps") or add "visualize".' + ) if not check_deps: return resolved if resolved is not None and has_viz_tool(resolved) and not has_viz_deps(): @@ -836,6 +852,7 @@ def normalize_tools( stacklevel=2, ) resolved.discard("visualize") + resolved.discard("maps") return resolved diff --git a/pkg-py/src/querychat/_system_prompt.py b/pkg-py/src/querychat/_system_prompt.py index b53626827..5552d50b4 100644 --- a/pkg-py/src/querychat/_system_prompt.py +++ b/pkg-py/src/querychat/_system_prompt.py @@ -212,6 +212,7 @@ def render( "has_tool_update": "update" in tools if tools else False, "has_tool_query": "query" in tools if tools else False, "has_tool_visualize": has_viz_tool(tools), + "has_tool_maps": tools is not None and "maps" in tools, "include_query_guidelines": len(tools or ()) > 0, "multi_table": len(self._data_sources) > 1, "handoff_available": handoff_available, diff --git a/pkg-py/src/querychat/_viz_ggsql.py b/pkg-py/src/querychat/_viz_ggsql.py index ff7e9bc82..193157015 100644 --- a/pkg-py/src/querychat/_viz_ggsql.py +++ b/pkg-py/src/querychat/_viz_ggsql.py @@ -3,11 +3,12 @@ from __future__ import annotations import warnings -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from ._utils import as_narwhals if TYPE_CHECKING: + import ggsql import pyarrow as pa from ._datasource import DataSource @@ -57,3 +58,61 @@ def unregister(self, name) -> None: "write to the primary reader, which is a no-op by design.", stacklevel=2, ) + + +SPATIAL_SMOKE_QUERY = ( + "INSTALL spatial; LOAD spatial; " + "VISUALISE FROM ggsql:world DRAW spatial FILTER iso_a3 = 'FRA'" +) + + +def prewarm_spatial(reader: ggsql.CachingReader) -> bool: + """ + Install and load the DuckDB spatial extension for the reader's cache. + + The first ``INSTALL`` downloads the extension binary (network required); + afterwards it is cached on disk and this is cheap. Returns ``True`` when + spatial support is ready. On failure, warns and returns ``False`` so the + caller can treat the ``"maps"`` capability as unavailable. + """ + try: + reader.execute(SPATIAL_SMOKE_QUERY) + except Exception as e: + warnings.warn( + "The 'maps' capability could not be enabled (spatial extension " + f"setup failed: {e}). Continuing without maps.", + stacklevel=2, + ) + return False + return True + + +def translate_spatial_error( + error: Exception, *, maps_state: Literal["enabled", "disabled", "setup_failed"] +) -> str | None: + """ + Translate ggsql's spatial-extension failure into actionable guidance. + + Returns the translated message, or ``None`` when the error is not a + spatial setup failure (or maps are fully enabled, making the message + moot). ``maps_state`` is ``"enabled"`` when the pre-warm succeeded, + ``"setup_failed"`` when it ran but failed, and ``"disabled"`` when the + session never opted into maps. + """ + msg = str(error).lower() + if maps_state == "enabled" or "spatial" not in msg or "extension" not in msg: + return None + if maps_state == "setup_failed": + return ( + "This query requires map/spatial support, but spatial extension " + "setup failed earlier in this session (see the warning shown at " + "setup time, likely a failed extension download). Do not retry " + "with DRAW spatial, PROJECT TO , or ggsql:world; suggest a " + "non-map visualization instead." + ) + return ( + "This query requires map/spatial support, which is not enabled in " + "this session. Do not retry with DRAW spatial, PROJECT TO , or " + "ggsql:world; suggest a non-map visualization instead. The app " + 'developer can enable maps with tools=("default", "maps").' + ) diff --git a/pkg-py/src/querychat/_viz_tools.py b/pkg-py/src/querychat/_viz_tools.py index e38ba688f..f271d95fc 100644 --- a/pkg-py/src/querychat/_viz_tools.py +++ b/pkg-py/src/querychat/_viz_tools.py @@ -22,7 +22,7 @@ from ._tool_names import TOOL_VISUALIZE from ._utils import querychat_tool_starts_open, read_prompt_template, truncate_error from ._viz_altair_widget import AltairWidget, fit_chart_to_container -from ._viz_ggsql import ExecutorReader +from ._viz_ggsql import ExecutorReader, prewarm_spatial, translate_spatial_error if TYPE_CHECKING: from collections.abc import Callable @@ -62,6 +62,7 @@ def tool_visualize( update_fn: Callable[[VisualizeData], None], *, multi_table: bool = False, + maps: bool = False, ) -> Tool: """ Create a tool that executes a ggsql query and renders the visualization. @@ -74,6 +75,9 @@ def tool_visualize( Callback function to call with VisualizeData when visualization succeeds multi_table Whether multiple tables are registered. + maps + Whether the maps capability is enabled (pre-warms the DuckDB + spatial extension when the viz reader is first created). Returns ------- @@ -81,7 +85,7 @@ def tool_visualize( A tool that can be registered with chatlas """ - impl = visualize_impl(executor, update_fn) + impl = visualize_impl(executor, update_fn, maps=maps) impl.__doc__ = read_prompt_template( "tool-visualize.md", db_type=executor.get_db_type(), @@ -186,6 +190,8 @@ def warn_viz_url_bookmarking() -> bool: def visualize_impl( executor: QueryExecutor, update_fn: Callable[[VisualizeData], None], + *, + maps: bool = False, ) -> Callable[[str, str], ContentToolResult]: """Create the visualize implementation function.""" from ggsql import CachingReader, VegaLiteWriter, validate @@ -195,13 +201,14 @@ def visualize_impl( # the reader's cache is freed when the client is dropped. Created lazily # so clients that never visualize don't pay for an idle DuckDB instance. reader = None + maps_available = maps def visualize( ggsql: str, title: str, ) -> ContentToolResult: """Execute a ggsql query and render the visualization.""" - nonlocal reader + nonlocal reader, maps_available markdown = f"```sql\n{ggsql}\n```" try: @@ -219,6 +226,8 @@ def visualize( if reader is None: reader = CachingReader(ExecutorReader(executor)) + if maps_available: + maps_available = prewarm_spatial(reader) spec = reader.execute(ggsql) raw_chart = VegaLiteWriter().render_chart(spec) @@ -244,7 +253,15 @@ def visualize( ) except Exception as e: - error_msg = truncate_error(str(e)) + maps_state = ( + "enabled" + if maps_available + else ("setup_failed" if maps else "disabled") + ) + translated = translate_spatial_error(e, maps_state=maps_state) + error_msg = ( + translated if translated is not None else truncate_error(str(e)) + ) markdown += f"\n\n> Error: {error_msg}" return ContentToolResult(value=markdown, error=Exception(error_msg)) diff --git a/pkg-py/src/querychat/_viz_utils.py b/pkg-py/src/querychat/_viz_utils.py index 839f1a091..8d91e208a 100644 --- a/pkg-py/src/querychat/_viz_utils.py +++ b/pkg-py/src/querychat/_viz_utils.py @@ -17,6 +17,11 @@ def has_viz_tool(tools: set[str] | None) -> bool: return tools is not None and "visualize" in tools +def has_maps_tool(tools: set[str] | None) -> bool: + """Check if the maps capability is among the configured tools.""" + return tools is not None and "maps" in tools + + def has_viz_deps() -> bool: """Check whether visualization dependencies (ggsql, altair, shinywidgets, vl-convert-python) are installed.""" if any( diff --git a/pkg-py/src/querychat/prompts/ggsql-spatial.md b/pkg-py/src/querychat/prompts/ggsql-spatial.md new file mode 100644 index 000000000..17d35a94f --- /dev/null +++ b/pkg-py/src/querychat/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-py/src/querychat/prompts/prompt.md b/pkg-py/src/querychat/prompts/prompt.md index bf4734919..b0fc77b2d 100644 --- a/pkg-py/src/querychat/prompts/prompt.md +++ b/pkg-py/src/querychat/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. diff --git a/pkg-py/tests/test_ggsql.py b/pkg-py/tests/test_ggsql.py index 1f59718ca..2887a689d 100644 --- a/pkg-py/tests/test_ggsql.py +++ b/pkg-py/tests/test_ggsql.py @@ -505,3 +505,32 @@ def test_invalid_entries_are_skipped_with_warning(self, register_spy): assert [e["widget_id"] for e in restored] == ["good"] assert register_spy == ["good"] + + +class TestPrewarmSpatial: + def test_success_runs_setup_query(self): + from querychat._viz_ggsql import prewarm_spatial + + calls = [] + + class FakeReader: + def execute(self, query): + calls.append(query) + return object() + + assert prewarm_spatial(FakeReader()) is True + assert len(calls) == 1 + assert "INSTALL spatial" in calls[0] + assert "LOAD spatial" in calls[0] + + def test_failure_warns_and_returns_false(self): + from querychat._viz_ggsql import prewarm_spatial + + class FailReader: + def execute(self, query): + raise RuntimeError( + "IO Error: Extension spatial.duckdb_extension not found" + ) + + with pytest.warns(UserWarning, match="maps"): + assert prewarm_spatial(FailReader()) is False diff --git a/pkg-py/tests/test_viz_tools.py b/pkg-py/tests/test_viz_tools.py index f5364be96..59668df55 100644 --- a/pkg-py/tests/test_viz_tools.py +++ b/pkg-py/tests/test_viz_tools.py @@ -58,6 +58,53 @@ def test_check_deps_false_skips_check(self, monkeypatch): assert result == {"visualize"} +class TestMapsToolModifier: + def test_default_sentinel_expands(self): + """The "default" entry expands to the default tool set.""" + from querychat._querychat_base import DEFAULT_TOOLS, normalize_tools + + resolved = normalize_tools( + ("default", "maps"), default=DEFAULT_TOOLS, check_deps=False + ) + assert "visualize" in resolved + assert "query" in resolved + assert "maps" in resolved + assert "default" not in resolved + + def test_default_sentinel_alone(self): + """tools="default" alone resolves to the default tool set.""" + from querychat._querychat_base import DEFAULT_TOOLS, normalize_tools + + resolved = normalize_tools("default", default=DEFAULT_TOOLS, check_deps=False) + assert "visualize" in resolved + assert "query" in resolved + + def test_maps_requires_visualize(self): + """"maps" without "visualize" is a hard error.""" + from querychat._querychat_base import DEFAULT_TOOLS, normalize_tools + + with pytest.raises(ValueError, match=r"maps.*visualize"): + normalize_tools(("query", "maps"), default=DEFAULT_TOOLS, check_deps=False) + + def test_maps_dropped_with_visualize_when_deps_missing(self, monkeypatch): + """When viz deps are missing, maps is dropped alongside visualize.""" + real_find_spec = importlib.util.find_spec + + def mock_find_spec(name, *args, **kwargs): + if name == "ggsql": + return None + return real_find_spec(name, *args, **kwargs) + + monkeypatch.setattr(importlib.util, "find_spec", mock_find_spec) + + from querychat._querychat_base import DEFAULT_TOOLS, normalize_tools + + with pytest.warns(UserWarning, match="pip install querychat\\[viz\\]"): + resolved = normalize_tools(("default", "maps"), default=DEFAULT_TOOLS) + assert "visualize" not in resolved + assert "maps" not in resolved + + def test_ggsql_syntax_reference_uses_range_not_errorbar(): syntax = (PROMPTS_DIR / "ggsql-syntax.md").read_text() assert "`range`" in syntax @@ -100,6 +147,30 @@ def test_main_prompt_render_includes_ggsql_reference(data_source): assert "{{> ggsql-syntax}}" not in prompt +def test_main_prompt_render_includes_spatial_reference_with_maps(data_source): + system_prompt = QueryChatSystemPrompt( + prompt_template=PROMPTS_DIR / "prompt.md", + data_source=data_source, + ) + + prompt = system_prompt.render({"update", "query", "visualize", "maps"}) + + assert "" in prompt + assert "ggsql:world" in prompt + + +def test_main_prompt_render_omits_spatial_reference_without_maps(data_source): + system_prompt = QueryChatSystemPrompt( + prompt_template=PROMPTS_DIR / "prompt.md", + data_source=data_source, + ) + + prompt = system_prompt.render({"update", "query", "visualize"}) + + assert "" not in prompt + assert "ggsql:world" not in prompt + + @pytest.fixture def sample_df(): return pl.DataFrame( @@ -376,3 +447,83 @@ def test_compound_chart_returns_bytes(self): result = render_chart_to_png(chart) assert isinstance(result, bytes) assert result[:8] == b"\x89PNG\r\n\x1a\n" + + +class TestMapsPrewarmWiring: + @pytest.mark.ggsql + def test_prewarms_spatial_once_when_maps_enabled(self, data_source, monkeypatch): + import querychat._viz_tools as viz_tools + + calls = [] + monkeypatch.setattr( + viz_tools, "prewarm_spatial", lambda reader: calls.append(reader) or True + ) + tool = tool_visualize(data_source, lambda _: None, maps=True) + tool.func( + ggsql="SELECT x, y FROM test_data VISUALISE x, y DRAW point", title="t1" + ) + tool.func( + ggsql="SELECT x, y FROM test_data VISUALISE x, y DRAW point", title="t2" + ) + assert len(calls) == 1 + + @pytest.mark.ggsql + def test_no_prewarm_when_maps_disabled(self, data_source, monkeypatch): + import querychat._viz_tools as viz_tools + + calls = [] + monkeypatch.setattr( + viz_tools, "prewarm_spatial", lambda reader: calls.append(reader) or True + ) + tool = tool_visualize(data_source, lambda _: None) + tool.func( + ggsql="SELECT x, y FROM test_data VISUALISE x, y DRAW point", title="t1" + ) + assert calls == [] + + +class TestTranslateSpatialError: + SPATIAL_ERR = Exception( + 'IO Error: Extension "/home/u/.duckdb/extensions/v1.5.2/linux_amd64/' + 'spatial.duckdb_extension" not found' + ) + + def test_disabled_message_points_to_tools(self): + from querychat._viz_ggsql import translate_spatial_error + + msg = translate_spatial_error(self.SPATIAL_ERR, maps_state="disabled") + assert msg is not None + assert "maps" in msg + assert 'tools=("default", "maps")' in msg + + def test_setup_failed_message(self): + from querychat._viz_ggsql import translate_spatial_error + + msg = translate_spatial_error(self.SPATIAL_ERR, maps_state="setup_failed") + assert msg is not None + assert "setup failed" in msg + + def test_enabled_or_nonspatial_returns_none(self): + from querychat._viz_ggsql import translate_spatial_error + + assert translate_spatial_error(self.SPATIAL_ERR, maps_state="enabled") is None + assert ( + translate_spatial_error(Exception("boom"), maps_state="disabled") is None + ) + + @pytest.mark.ggsql + def test_tool_translates_spatial_error_when_maps_disabled( + self, data_source, monkeypatch + ): + err = str(self.SPATIAL_ERR) + + def boom(reader, query): + raise RuntimeError(err) + + monkeypatch.setattr("ggsql.CachingReader.execute", boom) + tool = tool_visualize(data_source, lambda _: None) + result = tool.func( + ggsql="SELECT x, y FROM test_data VISUALISE x, y DRAW point", title="t" + ) + assert result.error is not None + assert 'tools=("default", "maps")' in str(result.error) From 6dca9b22075d800d072f62a19d33e10218a2cfb4 Mon Sep 17 00:00:00 2001 From: Carson Date: Tue, 13 Oct 2026 14:44:36 -0500 Subject: [PATCH 02/13] docs: maps capability --- pkg-py/CHANGELOG.md | 2 ++ pkg-py/docs/visualize.qmd | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index 403df5432..6be9ada76 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **Faster iterative visualization, and support for layer-level data sources.** Visualization queries now execute as a whole through `ggsql.CachingReader`: the underlying SQL runs against your data source once and the result is memoized for the chat session, so re-rendering a chart with a different encoding no longer re-queries the source (a win for remote databases especially). Visualization layers can also draw from other relations in the query — e.g. raw points from the final `SELECT` with a summary line from a CTE — via `DRAW ... MAPPING ... FROM `. Caching can be tuned or disabled with the `GGSQL_CACHE_TTL`, `GGSQL_CACHE_MAX_BYTES`, and `GGSQL_CACHE_DISABLED` environment variables. (#321) +* New opt-in `"maps"` capability: pass `tools=("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. + ### Bug fixes * JSON pins now fall back to `pin_read()` when DuckDB's JSON extension cannot be installed or loaded, including on builds that disable extension loading. Native DuckDB JSON reading is retained when the extension is available. diff --git a/pkg-py/docs/visualize.qmd b/pkg-py/docs/visualize.qmd index 1a708fb70..4f3c863aa 100644 --- a/pkg-py/docs/visualize.qmd +++ b/pkg-py/docs/visualize.qmd @@ -114,6 +114,20 @@ Tune or disable the cache with environment variables (set before the app starts) 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: + +```{.python} +qc = QueryChat(titanic(), "titanic", tools=("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.qmd) — Understand what querychat can do under the hood From 306ef03872270bb20be956d141c8fee299deeaf1 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 14 Oct 2026 10:31:23 -0500 Subject: [PATCH 03/13] fix(py): run spatial prewarm on the CachingReader cache connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit prewarm_spatial routed INSTALL/LOAD spatial through execute(), which targets the user's primary data source. Those connections are typically locked down (filesystem access disabled), so the install failed and the "maps" capability was silently disabled — and even where it succeeded it would take no effect on the connection that matters. Spatial SQL actually executes on the CachingReader's private in-memory cache connection (the compute surface), so install/load the extension there via the new ggsql.CachingReader.execute_sql_cached binding (posit-dev/ggsql-python#14) and keep the smoke query on execute(), where it exercises the full spatial path: extension load, builtin ggsql:world registration, spatial layer execution. Without a ggsql build that has execute_sql_cached, prewarm fails with a warning and maps stay unavailable, so this degrades gracefully. --- pkg-py/src/querychat/_viz_ggsql.py | 21 +++++++++++++-------- pkg-py/tests/test_ggsql.py | 23 +++++++++++++++++------ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/pkg-py/src/querychat/_viz_ggsql.py b/pkg-py/src/querychat/_viz_ggsql.py index 193157015..7797b1772 100644 --- a/pkg-py/src/querychat/_viz_ggsql.py +++ b/pkg-py/src/querychat/_viz_ggsql.py @@ -60,22 +60,27 @@ def unregister(self, name) -> None: ) -SPATIAL_SMOKE_QUERY = ( - "INSTALL spatial; LOAD spatial; " - "VISUALISE FROM ggsql:world DRAW spatial FILTER iso_a3 = 'FRA'" -) +SPATIAL_SMOKE_QUERY = "VISUALISE FROM ggsql:world DRAW spatial FILTER iso_a3 = 'FRA'" def prewarm_spatial(reader: ggsql.CachingReader) -> bool: """ Install and load the DuckDB spatial extension for the reader's cache. - The first ``INSTALL`` downloads the extension binary (network required); - afterwards it is cached on disk and this is cheap. Returns ``True`` when - spatial support is ready. On failure, warns and returns ``False`` so the - caller can treat the ``"maps"`` capability as unavailable. + Spatial SQL runs on the CachingReader's private in-memory DuckDB (the + "compute surface"), never on the user's data source, so the extension + must be installed there — routing ``INSTALL spatial`` through the + primary reader would hit the user's (typically locked-down) connection + and fail. The first ``INSTALL`` downloads the extension binary (network + required); afterwards it is cached on disk and this is cheap. The smoke + query then exercises the full spatial path (extension load, builtin + ``ggsql:world`` registration, spatial layer execution). Returns ``True`` + when spatial support is ready. On failure, warns and returns ``False`` + so the caller can treat the ``"maps"`` capability as unavailable. """ try: + reader.execute_sql_cached("INSTALL spatial") + reader.execute_sql_cached("LOAD spatial") reader.execute(SPATIAL_SMOKE_QUERY) except Exception as e: warnings.warn( diff --git a/pkg-py/tests/test_ggsql.py b/pkg-py/tests/test_ggsql.py index 2887a689d..a5d85cedf 100644 --- a/pkg-py/tests/test_ggsql.py +++ b/pkg-py/tests/test_ggsql.py @@ -509,24 +509,35 @@ def test_invalid_entries_are_skipped_with_warning(self, register_spy): class TestPrewarmSpatial: def test_success_runs_setup_query(self): - from querychat._viz_ggsql import prewarm_spatial + from querychat._viz_ggsql import SPATIAL_SMOKE_QUERY, prewarm_spatial - calls = [] + cached_calls = [] + execute_calls = [] class FakeReader: + def execute_sql_cached(self, sql): + cached_calls.append(sql) + return object() + def execute(self, query): - calls.append(query) + execute_calls.append(query) return object() assert prewarm_spatial(FakeReader()) is True - assert len(calls) == 1 - assert "INSTALL spatial" in calls[0] - assert "LOAD spatial" in calls[0] + # The extension is installed/loaded on the cache (compute) connection, + # never on the user's data source. + assert cached_calls == ["INSTALL spatial", "LOAD spatial"] + assert execute_calls == [SPATIAL_SMOKE_QUERY] def test_failure_warns_and_returns_false(self): from querychat._viz_ggsql import prewarm_spatial class FailReader: + def execute_sql_cached(self, sql): + raise RuntimeError( + "IO Error: Extension spatial.duckdb_extension not found" + ) + def execute(self, query): raise RuntimeError( "IO Error: Extension spatial.duckdb_extension not found" From 992d982cc240e53d77ceeb9ea168c4aab82d9a2d Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 14 Oct 2026 10:31:24 -0500 Subject: [PATCH 04/13] docs(py): clarify point-map mappings in spatial prompt; add maps demo app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The LLM kept putting lon/lat/size in global VISUALISE mappings, which every layer inherits — and the spatial background layer only accepts geometry/fill/stroke/opacity, so the query failed with "Layer 'spatial' does not support the `lat`, `size`, `lon` mappings". The prompt now states explicitly that point aesthetics belong on DRAW point MAPPING. Also adds examples/13-maps-app.py: a Shiny Express demo with two live datasets (USGS real-time earthquakes, World Bank GDP) exercising the point map, choropleth, and orthographic globe patterns. Note: the globe suggestion currently triggers a suspected OOM in ggsql's orthographic projection of ggsql:world (under investigation); it is kept here as a repro. --- pkg-py/examples/13-maps-app.py | 69 +++++++++++++++++++ pkg-py/src/querychat/prompts/ggsql-spatial.md | 2 + 2 files changed, 71 insertions(+) create mode 100644 pkg-py/examples/13-maps-app.py diff --git a/pkg-py/examples/13-maps-app.py b/pkg-py/examples/13-maps-app.py new file mode 100644 index 000000000..7ba639998 --- /dev/null +++ b/pkg-py/examples/13-maps-app.py @@ -0,0 +1,69 @@ +# QueryChat maps capability demo using Shiny Express. +# +# Two real-world tables are registered: +# +# - ``earthquakes``: every magnitude 2.5+ earthquake worldwide from the past +# month, from the USGS real-time feed (lon/lat point data). +# - ``gdp``: GDP by country (World Bank, via the plotly datasets repo) with +# ISO 3166-1 alpha-3 codes, suitable for choropleths joined to the builtin +# ``ggsql:world`` country boundaries. +# +# The opt-in ``"maps"`` capability lets the LLM draw choropleths and point maps +# through ggsql's spatial support. Try asking: +# +# - "Map the earthquakes from the past month, sized by magnitude" +# - "Draw a choropleth of GDP by country" +# - "Show a globe of earthquake epicenters" +# +# Usage: +# cd pkg-py +# uv run shiny run examples/13-maps-app.py + +import pandas as pd +from querychat.express import QueryChat +from shiny.express import render, ui + +# Real-time USGS feed: all magnitude 2.5+ earthquakes, past 30 days +earthquakes = pd.read_csv( + "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_month.csv" +) + +# World Bank GDP (current US$) by country with ISO alpha-3 codes +gdp = pd.read_csv( + "https://raw.githubusercontent.com/plotly/datasets/master/2014_world_gdp_with_codes.csv" +) +gdp.columns = ["country", "gdp_billions_usd", "iso_a3"] + +greeting = r""" +Welcome! I'm chatting with two live datasets: + +* **earthquakes** — every magnitude 2.5+ earthquake worldwide from the past + month (USGS real-time feed) +* **gdp** — GDP by country (World Bank), with ISO country codes + +Maps are enabled! Try asking: + +* Map the earthquakes from the past month, sized by magnitude +* Draw a choropleth of GDP by country +* Map earthquakes in the Pacific on an orthographic globe +""" + +qc = QueryChat(earthquakes, "earthquakes", greeting=greeting, tools=("default", "maps")) +qc.add_table(gdp, "gdp") +qc.sidebar() + +with ui.navset_card_underline(): + with ui.nav_panel("Earthquakes"): + + @render.data_frame + def earthquakes_table(): + return qc.table("earthquakes").df() + + with ui.nav_panel("GDP"): + + @render.data_frame + def gdp_table(): + return qc.table("gdp").df() + + +ui.page_opts(title="QueryChat Maps Demo", fillable=True) diff --git a/pkg-py/src/querychat/prompts/ggsql-spatial.md b/pkg-py/src/querychat/prompts/ggsql-spatial.md index 17d35a94f..708e63d2f 100644 --- a/pkg-py/src/querychat/prompts/ggsql-spatial.md +++ b/pkg-py/src/querychat/prompts/ggsql-spatial.md @@ -29,6 +29,8 @@ DRAW point MAPPING lon, lat, passengers AS size PROJECT TO natural ``` +**Always put `lon`/`lat`/`size` mappings on the `DRAW point` layer, never in the global `VISUALISE` mappings.** Global mappings are inherited by every layer, and the `spatial` background layer only accepts `geometry`/`fill`/`stroke`/`opacity` — a global `lon`/`lat`/`size` mapping fails with "Layer 'spatial' does not support the `lat`, `size`, `lon` mappings". If you see that error, move the point aesthetics out of `VISUALISE` and onto `DRAW point MAPPING ...`. + ### 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. From c766a0cbc8e2f41c9a19d8b31db22e6566d3359d Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 14 Oct 2026 11:54:50 -0500 Subject: [PATCH 05/13] feat(py): prewarm ICU extension on the viz compute surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ggsql's bundled DuckDB starts extension-free, while the DuckDB CLI and Python client load ICU by default — so SQL that works everywhere else (e.g. `TIMESTAMPTZ - INTERVAL`, the standard way to write temporal filters) failed on the viz engine with a binder error. Seen in the wild: an LLM-generated earthquake map query failed repeatedly on `-(TIMESTAMP WITH TIME ZONE, INTERVAL)` before recovering. prewarm_spatial (now prewarm_compute_surface, since it is no longer spatial-only) also installs/loads ICU on the CachingReader's cache connection, alongside spatial. Cost: a one-time ~15 MB download per machine per DuckDB version, then ~60 ms per startup. Unlike spatial, ICU failure is non-fatal (maps don't need it): warn and continue. --- pkg-py/src/querychat/_viz_ggsql.py | 56 ++++++++++++++++++++++-------- pkg-py/src/querychat/_viz_tools.py | 8 +++-- pkg-py/tests/test_ggsql.py | 53 ++++++++++++++++++++++------ pkg-py/tests/test_viz_tools.py | 8 +++-- 4 files changed, 95 insertions(+), 30 deletions(-) diff --git a/pkg-py/src/querychat/_viz_ggsql.py b/pkg-py/src/querychat/_viz_ggsql.py index 7797b1772..1a5ed5715 100644 --- a/pkg-py/src/querychat/_viz_ggsql.py +++ b/pkg-py/src/querychat/_viz_ggsql.py @@ -63,24 +63,50 @@ def unregister(self, name) -> None: SPATIAL_SMOKE_QUERY = "VISUALISE FROM ggsql:world DRAW spatial FILTER iso_a3 = 'FRA'" -def prewarm_spatial(reader: ggsql.CachingReader) -> bool: +def install_extension(reader: ggsql.CachingReader, name: str) -> None: + """Install and load a DuckDB extension on the reader's cache connection.""" + reader.execute_sql_cached(f"INSTALL {name}") + reader.execute_sql_cached(f"LOAD {name}") + + +def prewarm_compute_surface(reader: ggsql.CachingReader) -> bool: """ - Install and load the DuckDB spatial extension for the reader's cache. - - Spatial SQL runs on the CachingReader's private in-memory DuckDB (the - "compute surface"), never on the user's data source, so the extension - must be installed there — routing ``INSTALL spatial`` through the - primary reader would hit the user's (typically locked-down) connection - and fail. The first ``INSTALL`` downloads the extension binary (network - required); afterwards it is cached on disk and this is cheap. The smoke - query then exercises the full spatial path (extension load, builtin - ``ggsql:world`` registration, spatial layer execution). Returns ``True`` - when spatial support is ready. On failure, warns and returns ``False`` - so the caller can treat the ``"maps"`` capability as unavailable. + Prepare the CachingReader's private cache connection (the compute surface). + + All ggsql-generated derived SQL runs on the cache connection, never on + the user's data source, so any DuckDB extension that SQL may depend on + must be installed there — routing ``INSTALL`` through the primary + reader would hit the user's (typically locked-down) connection and + fail. The first ``INSTALL`` of an extension downloads its binary + (network required); afterwards it is cached on disk and loading costs + milliseconds. + + Two extensions are set up: + + - ``icu``: time zone support (e.g. interval arithmetic on TIMESTAMPTZ) + and locale-aware collations. DuckDB's CLI and Python client load ICU + by default, so SQL written against "DuckDB" — by users or LLMs — + assumes it. Failure is non-fatal: warns and continues. + - ``spatial``: required for the ``"maps"`` capability. A smoke query + exercises the full spatial path (extension load, builtin + ``ggsql:world`` registration, spatial layer execution). Failure warns + and returns ``False`` so the caller can treat ``"maps"`` as + unavailable. + + Returns ``True`` when spatial support is ready. """ try: - reader.execute_sql_cached("INSTALL spatial") - reader.execute_sql_cached("LOAD spatial") + install_extension(reader, "icu") + except Exception as e: + warnings.warn( + "Could not set up the DuckDB ICU extension on the visualization " + f"engine ({e}). Continuing without it: time zone operations " + "(e.g. interval arithmetic on timestamptz columns) and " + "locale-aware collations will be unavailable in visualizations.", + stacklevel=2, + ) + try: + install_extension(reader, "spatial") reader.execute(SPATIAL_SMOKE_QUERY) except Exception as e: warnings.warn( diff --git a/pkg-py/src/querychat/_viz_tools.py b/pkg-py/src/querychat/_viz_tools.py index f271d95fc..bfcf1c43b 100644 --- a/pkg-py/src/querychat/_viz_tools.py +++ b/pkg-py/src/querychat/_viz_tools.py @@ -22,7 +22,11 @@ from ._tool_names import TOOL_VISUALIZE from ._utils import querychat_tool_starts_open, read_prompt_template, truncate_error from ._viz_altair_widget import AltairWidget, fit_chart_to_container -from ._viz_ggsql import ExecutorReader, prewarm_spatial, translate_spatial_error +from ._viz_ggsql import ( + ExecutorReader, + prewarm_compute_surface, + translate_spatial_error, +) if TYPE_CHECKING: from collections.abc import Callable @@ -227,7 +231,7 @@ def visualize( if reader is None: reader = CachingReader(ExecutorReader(executor)) if maps_available: - maps_available = prewarm_spatial(reader) + maps_available = prewarm_compute_surface(reader) spec = reader.execute(ggsql) raw_chart = VegaLiteWriter().render_chart(spec) diff --git a/pkg-py/tests/test_ggsql.py b/pkg-py/tests/test_ggsql.py index a5d85cedf..2e82d9fe3 100644 --- a/pkg-py/tests/test_ggsql.py +++ b/pkg-py/tests/test_ggsql.py @@ -507,9 +507,9 @@ def test_invalid_entries_are_skipped_with_warning(self, register_spy): assert register_spy == ["good"] -class TestPrewarmSpatial: +class TestPrewarmComputeSurface: def test_success_runs_setup_query(self): - from querychat._viz_ggsql import SPATIAL_SMOKE_QUERY, prewarm_spatial + from querychat._viz_ggsql import SPATIAL_SMOKE_QUERY, prewarm_compute_surface cached_calls = [] execute_calls = [] @@ -523,20 +523,27 @@ def execute(self, query): execute_calls.append(query) return object() - assert prewarm_spatial(FakeReader()) is True - # The extension is installed/loaded on the cache (compute) connection, + assert prewarm_compute_surface(FakeReader()) is True + # Extensions are installed/loaded on the cache (compute) connection, # never on the user's data source. - assert cached_calls == ["INSTALL spatial", "LOAD spatial"] + assert cached_calls == [ + "INSTALL icu", + "LOAD icu", + "INSTALL spatial", + "LOAD spatial", + ] assert execute_calls == [SPATIAL_SMOKE_QUERY] - def test_failure_warns_and_returns_false(self): - from querychat._viz_ggsql import prewarm_spatial + def test_spatial_failure_warns_and_returns_false(self): + from querychat._viz_ggsql import prewarm_compute_surface class FailReader: def execute_sql_cached(self, sql): - raise RuntimeError( - "IO Error: Extension spatial.duckdb_extension not found" - ) + if "spatial" in sql: + raise RuntimeError( + "IO Error: Extension spatial.duckdb_extension not found" + ) + return object() def execute(self, query): raise RuntimeError( @@ -544,4 +551,28 @@ def execute(self, query): ) with pytest.warns(UserWarning, match="maps"): - assert prewarm_spatial(FailReader()) is False + assert prewarm_compute_surface(FailReader()) is False + + def test_icu_failure_warns_but_continues(self): + from querychat._viz_ggsql import prewarm_compute_surface + + cached_calls = [] + + class IcuFailReader: + def execute_sql_cached(self, sql): + cached_calls.append(sql) + if "icu" in sql: + raise RuntimeError("IO Error: could not download extension") + return object() + + def execute(self, query): + return object() + + # ICU is not required for maps: warn, keep going, report success. + with pytest.warns(UserWarning, match="ICU"): + assert prewarm_compute_surface(IcuFailReader()) is True + assert cached_calls == [ + "INSTALL icu", + "INSTALL spatial", + "LOAD spatial", + ] diff --git a/pkg-py/tests/test_viz_tools.py b/pkg-py/tests/test_viz_tools.py index 59668df55..3807c7142 100644 --- a/pkg-py/tests/test_viz_tools.py +++ b/pkg-py/tests/test_viz_tools.py @@ -456,7 +456,9 @@ def test_prewarms_spatial_once_when_maps_enabled(self, data_source, monkeypatch) calls = [] monkeypatch.setattr( - viz_tools, "prewarm_spatial", lambda reader: calls.append(reader) or True + viz_tools, + "prewarm_compute_surface", + lambda reader: calls.append(reader) or True, ) tool = tool_visualize(data_source, lambda _: None, maps=True) tool.func( @@ -473,7 +475,9 @@ def test_no_prewarm_when_maps_disabled(self, data_source, monkeypatch): calls = [] monkeypatch.setattr( - viz_tools, "prewarm_spatial", lambda reader: calls.append(reader) or True + viz_tools, + "prewarm_compute_surface", + lambda reader: calls.append(reader) or True, ) tool = tool_visualize(data_source, lambda _: None) tool.func( From fbf224bc80d883200d38f9d8d66adb4f5125355d Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 14 Oct 2026 19:28:54 -0500 Subject: [PATCH 06/13] docs(py): rework maps demo as chat-first page with nav panels --- pkg-py/examples/13-maps-app.py | 48 +++++++++++++++++++++++----------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/pkg-py/examples/13-maps-app.py b/pkg-py/examples/13-maps-app.py index 7ba639998..9832990df 100644 --- a/pkg-py/examples/13-maps-app.py +++ b/pkg-py/examples/13-maps-app.py @@ -1,4 +1,4 @@ -# QueryChat maps capability demo using Shiny Express. +# QueryChat maps capability demo — chat-forward, using Shiny Express. # # Two real-world tables are registered: # @@ -13,7 +13,6 @@ # # - "Map the earthquakes from the past month, sized by magnitude" # - "Draw a choropleth of GDP by country" -# - "Show a globe of earthquake epicenters" # # Usage: # cd pkg-py @@ -22,6 +21,7 @@ import pandas as pd from querychat.express import QueryChat from shiny.express import render, ui +from shinychat import chat_nav_panel # Real-time USGS feed: all magnitude 2.5+ earthquakes, past 30 days earthquakes = pd.read_csv( @@ -45,25 +45,43 @@ * Map the earthquakes from the past month, sized by magnitude * Draw a choropleth of GDP by country -* Map earthquakes in the Pacific on an orthographic globe """ +# 1. Provide data sources to QueryChat, with the opt-in "maps" capability qc = QueryChat(earthquakes, "earthquakes", greeting=greeting, tools=("default", "maps")) qc.add_table(gdp, "gdp") -qc.sidebar() -with ui.navset_card_underline(): - with ui.nav_panel("Earthquakes"): +# 2. Hold the reactive data views for the secondary pages (qc.page() owns the +# entire page, so outputs can't live at the top level) +with ui.hold() as earthquakes_view, ui.card(fill=True): - @render.data_frame - def earthquakes_table(): - return qc.table("earthquakes").df() + @render.data_frame + def earthquakes_table(): + return qc.table("earthquakes").df() - with ui.nav_panel("GDP"): +with ui.hold() as gdp_view, ui.card(fill=True): - @render.data_frame - def gdp_table(): - return qc.table("gdp").df() + @render.data_frame + def gdp_table(): + return qc.table("gdp").df() - -ui.page_opts(title="QueryChat Maps Demo", fillable=True) +# 3. Create a chat-first page (the chat owns the full window) +qc.page( + "QueryChat Maps Demo", + pages_navbar=[ + chat_nav_panel( + "Earthquakes", + earthquakes_view, + value="earthquakes", + sidebar=False, + content_width="100%", + ), + chat_nav_panel( + "GDP", + gdp_view, + value="gdp", + sidebar=False, + content_width="100%", + ), + ], +) From 224a652dcda83207135ca66f5184e130efa721b5 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 14 Oct 2026 19:28:54 -0500 Subject: [PATCH 07/13] docs(py): refine spatial prompt (viz-path ST_*, spatial joins, framing) --- pkg-py/src/querychat/prompts/ggsql-spatial.md | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/pkg-py/src/querychat/prompts/ggsql-spatial.md b/pkg-py/src/querychat/prompts/ggsql-spatial.md index 708e63d2f..0f9c2d5cd 100644 --- a/pkg-py/src/querychat/prompts/ggsql-spatial.md +++ b/pkg-py/src/querychat/prompts/ggsql-spatial.md @@ -1,6 +1,8 @@ ## 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. +The `maps` capability is enabled: the DuckDB `spatial` extension is already installed and loaded on the ggsql compute connection. Do NOT write `INSTALL spatial` or `LOAD spatial` — querychat has handled spatial setup. + +The two query paths differ in what SQL is available. `querychat_query` runs on the user's database, where `ST_*` spatial functions may not exist — keep spatial logic out of data queries. Visualization queries run on ggsql's DuckDB connection, where the spatial extension IS loaded, so `ST_*` functions (e.g. `ST_Within`, `ST_Point`) are available in the SELECT clause of a `VISUALISE` query when ggsql clauses alone can't express the logic (e.g. point-in-polygon joins). ### Choropleths from `ggsql:world` @@ -17,6 +19,8 @@ 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. +`ggsql:world` is reachable only from visualization queries — the data-query tool cannot read it. To answer questions about map coverage (e.g. "which countries are missing from this map?"), don't refuse: use a visualization query with an anti-join (`LEFT JOIN ... WHERE ... IS NULL`) against `ggsql:world`. + ### 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: @@ -31,8 +35,31 @@ PROJECT TO natural **Always put `lon`/`lat`/`size` mappings on the `DRAW point` layer, never in the global `VISUALISE` mappings.** Global mappings are inherited by every layer, and the `spatial` background layer only accepts `geometry`/`fill`/`stroke`/`opacity` — a global `lon`/`lat`/`size` mapping fails with "Layer 'spatial' does not support the `lat`, `size`, `lon` mappings". If you see that error, move the point aesthetics out of `VISUALISE` and onto `DRAW point MAPPING ...`. +### Spatial joins (point-in-polygon) + +To aggregate point data by country (e.g. earthquake counts per country), spatially join against `ggsql:world` in the SELECT clause. One gotcha: when `ggsql:world` is staged for a join, its `geom` column arrives as WKB `BLOB`, not `GEOMETRY` — wrap it with `ST_GeomFromWKB()` or the join fails with `No function matches ... 'ST_Within(GEOMETRY, BLOB)'`. Aggregate first, then join the geometry on last: + +```sql +WITH counts AS ( + SELECT w.iso_a3, COUNT(*) AS quakes + FROM ggsql:world w + JOIN earthquakes e + ON ST_Within(ST_Point(e.longitude, e.latitude), ST_GeomFromWKB(w.geom)) + GROUP BY w.iso_a3 +) +SELECT w.name, w.geom, c.quakes +FROM ggsql:world w +JOIN counts c ON w.iso_a3 = c.iso_a3 +VISUALISE +DRAW spatial MAPPING quakes AS fill +``` + ### 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. +`SETTING origin` re-centers the map but does NOT zoom — a regional map with only `origin` still shows the whole world. To frame a region, also bound the view with `SCALE lon FROM (min, max)` and `SCALE lat FROM (min, max)`. + +The ocean (sphere) background and graticule are drawn by the renderer and cannot be restyled — no setting is exposed for them, so explain the limitation rather than guessing one. + 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. From cb58e624c7baae750abd90fe30f5274a787e6420 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 14 Oct 2026 19:29:54 -0500 Subject: [PATCH 08/13] feat(py): prewarm ICU for all viz sessions, decoupled from maps --- pkg-py/src/querychat/_viz_ggsql.py | 38 ++++++++------- pkg-py/src/querychat/_viz_tools.py | 6 ++- pkg-py/tests/test_ggsql.py | 78 ++++++++++++++---------------- pkg-py/tests/test_viz_tools.py | 34 +++++++++---- 4 files changed, 85 insertions(+), 71 deletions(-) diff --git a/pkg-py/src/querychat/_viz_ggsql.py b/pkg-py/src/querychat/_viz_ggsql.py index 1a5ed5715..09966ed42 100644 --- a/pkg-py/src/querychat/_viz_ggsql.py +++ b/pkg-py/src/querychat/_viz_ggsql.py @@ -69,31 +69,22 @@ def install_extension(reader: ggsql.CachingReader, name: str) -> None: reader.execute_sql_cached(f"LOAD {name}") -def prewarm_compute_surface(reader: ggsql.CachingReader) -> bool: +def prewarm_icu(reader: ggsql.CachingReader) -> None: """ - Prepare the CachingReader's private cache connection (the compute surface). + Set up the DuckDB ICU extension on the reader's cache connection. All ggsql-generated derived SQL runs on the cache connection, never on the user's data source, so any DuckDB extension that SQL may depend on must be installed there — routing ``INSTALL`` through the primary reader would hit the user's (typically locked-down) connection and - fail. The first ``INSTALL`` of an extension downloads its binary - (network required); afterwards it is cached on disk and loading costs + fail. The first ``INSTALL`` downloads the extension binary (network + required); afterwards it is cached on disk and loading costs milliseconds. - Two extensions are set up: - - - ``icu``: time zone support (e.g. interval arithmetic on TIMESTAMPTZ) - and locale-aware collations. DuckDB's CLI and Python client load ICU - by default, so SQL written against "DuckDB" — by users or LLMs — - assumes it. Failure is non-fatal: warns and continues. - - ``spatial``: required for the ``"maps"`` capability. A smoke query - exercises the full spatial path (extension load, builtin - ``ggsql:world`` registration, spatial layer execution). Failure warns - and returns ``False`` so the caller can treat ``"maps"`` as - unavailable. - - Returns ``True`` when spatial support is ready. + ICU provides time zone support (e.g. interval arithmetic on + TIMESTAMPTZ) and locale-aware collations. DuckDB's CLI and Python + client load ICU by default, so SQL written against "DuckDB" — by + users or LLMs — assumes it. Failure is non-fatal: warns and continues. """ try: install_extension(reader, "icu") @@ -105,6 +96,19 @@ def prewarm_compute_surface(reader: ggsql.CachingReader) -> bool: "locale-aware collations will be unavailable in visualizations.", stacklevel=2, ) + + +def prewarm_spatial(reader: ggsql.CachingReader) -> bool: + """ + Set up the DuckDB spatial extension on the reader's cache connection. + + Required for the ``"maps"`` capability. A smoke query exercises the + full spatial path (extension load, builtin ``ggsql:world`` + registration, spatial layer execution). Failure warns and returns + ``False`` so the caller can treat ``"maps"`` as unavailable. + + Returns ``True`` when spatial support is ready. + """ try: install_extension(reader, "spatial") reader.execute(SPATIAL_SMOKE_QUERY) diff --git a/pkg-py/src/querychat/_viz_tools.py b/pkg-py/src/querychat/_viz_tools.py index bfcf1c43b..22a6fe352 100644 --- a/pkg-py/src/querychat/_viz_tools.py +++ b/pkg-py/src/querychat/_viz_tools.py @@ -24,7 +24,8 @@ from ._viz_altair_widget import AltairWidget, fit_chart_to_container from ._viz_ggsql import ( ExecutorReader, - prewarm_compute_surface, + prewarm_icu, + prewarm_spatial, translate_spatial_error, ) @@ -230,8 +231,9 @@ def visualize( if reader is None: reader = CachingReader(ExecutorReader(executor)) + prewarm_icu(reader) if maps_available: - maps_available = prewarm_compute_surface(reader) + maps_available = prewarm_spatial(reader) spec = reader.execute(ggsql) raw_chart = VegaLiteWriter().render_chart(spec) diff --git a/pkg-py/tests/test_ggsql.py b/pkg-py/tests/test_ggsql.py index 2e82d9fe3..60f56b8a7 100644 --- a/pkg-py/tests/test_ggsql.py +++ b/pkg-py/tests/test_ggsql.py @@ -507,72 +507,66 @@ def test_invalid_entries_are_skipped_with_warning(self, register_spy): assert register_spy == ["good"] -class TestPrewarmComputeSurface: - def test_success_runs_setup_query(self): - from querychat._viz_ggsql import SPATIAL_SMOKE_QUERY, prewarm_compute_surface +class TestPrewarmIcu: + def test_installs_and_loads_icu(self): + from querychat._viz_ggsql import prewarm_icu cached_calls = [] - execute_calls = [] class FakeReader: def execute_sql_cached(self, sql): cached_calls.append(sql) return object() - def execute(self, query): - execute_calls.append(query) - return object() - - assert prewarm_compute_surface(FakeReader()) is True + assert prewarm_icu(FakeReader()) is None # Extensions are installed/loaded on the cache (compute) connection, # never on the user's data source. - assert cached_calls == [ - "INSTALL icu", - "LOAD icu", - "INSTALL spatial", - "LOAD spatial", - ] - assert execute_calls == [SPATIAL_SMOKE_QUERY] + assert cached_calls == ["INSTALL icu", "LOAD icu"] - def test_spatial_failure_warns_and_returns_false(self): - from querychat._viz_ggsql import prewarm_compute_surface + def test_icu_failure_warns_and_continues(self): + from querychat._viz_ggsql import prewarm_icu class FailReader: def execute_sql_cached(self, sql): - if "spatial" in sql: - raise RuntimeError( - "IO Error: Extension spatial.duckdb_extension not found" - ) - return object() + raise RuntimeError("IO Error: could not download extension") - def execute(self, query): - raise RuntimeError( - "IO Error: Extension spatial.duckdb_extension not found" - ) + with pytest.warns(UserWarning, match="ICU"): + assert prewarm_icu(FailReader()) is None - with pytest.warns(UserWarning, match="maps"): - assert prewarm_compute_surface(FailReader()) is False - def test_icu_failure_warns_but_continues(self): - from querychat._viz_ggsql import prewarm_compute_surface +class TestPrewarmSpatial: + def test_success_runs_smoke_query(self): + from querychat._viz_ggsql import SPATIAL_SMOKE_QUERY, prewarm_spatial cached_calls = [] + execute_calls = [] - class IcuFailReader: + class FakeReader: def execute_sql_cached(self, sql): cached_calls.append(sql) - if "icu" in sql: - raise RuntimeError("IO Error: could not download extension") return object() def execute(self, query): + execute_calls.append(query) return object() - # ICU is not required for maps: warn, keep going, report success. - with pytest.warns(UserWarning, match="ICU"): - assert prewarm_compute_surface(IcuFailReader()) is True - assert cached_calls == [ - "INSTALL icu", - "INSTALL spatial", - "LOAD spatial", - ] + assert prewarm_spatial(FakeReader()) is True + assert cached_calls == ["INSTALL spatial", "LOAD spatial"] + assert execute_calls == [SPATIAL_SMOKE_QUERY] + + def test_spatial_failure_warns_and_returns_false(self): + from querychat._viz_ggsql import prewarm_spatial + + class FailReader: + def execute_sql_cached(self, sql): + raise RuntimeError( + "IO Error: Extension spatial.duckdb_extension not found" + ) + + def execute(self, query): + raise RuntimeError( + "IO Error: Extension spatial.duckdb_extension not found" + ) + + with pytest.warns(UserWarning, match="maps"): + assert prewarm_spatial(FailReader()) is False diff --git a/pkg-py/tests/test_viz_tools.py b/pkg-py/tests/test_viz_tools.py index 3807c7142..cf85faebb 100644 --- a/pkg-py/tests/test_viz_tools.py +++ b/pkg-py/tests/test_viz_tools.py @@ -451,14 +451,20 @@ def test_compound_chart_returns_bytes(self): class TestMapsPrewarmWiring: @pytest.mark.ggsql - def test_prewarms_spatial_once_when_maps_enabled(self, data_source, monkeypatch): + def test_prewarms_icu_and_spatial_once_when_maps_enabled( + self, data_source, monkeypatch + ): import querychat._viz_tools as viz_tools - calls = [] + icu_calls = [] + spatial_calls = [] + monkeypatch.setattr( + viz_tools, "prewarm_icu", lambda reader: icu_calls.append(reader) + ) monkeypatch.setattr( viz_tools, - "prewarm_compute_surface", - lambda reader: calls.append(reader) or True, + "prewarm_spatial", + lambda reader: spatial_calls.append(reader) or True, ) tool = tool_visualize(data_source, lambda _: None, maps=True) tool.func( @@ -467,23 +473,31 @@ def test_prewarms_spatial_once_when_maps_enabled(self, data_source, monkeypatch) tool.func( ggsql="SELECT x, y FROM test_data VISUALISE x, y DRAW point", title="t2" ) - assert len(calls) == 1 + assert len(icu_calls) == 1 + assert len(spatial_calls) == 1 @pytest.mark.ggsql - def test_no_prewarm_when_maps_disabled(self, data_source, monkeypatch): + def test_prewarms_icu_but_not_spatial_when_maps_disabled( + self, data_source, monkeypatch + ): import querychat._viz_tools as viz_tools - calls = [] + icu_calls = [] + spatial_calls = [] + monkeypatch.setattr( + viz_tools, "prewarm_icu", lambda reader: icu_calls.append(reader) + ) monkeypatch.setattr( viz_tools, - "prewarm_compute_surface", - lambda reader: calls.append(reader) or True, + "prewarm_spatial", + lambda reader: spatial_calls.append(reader) or True, ) tool = tool_visualize(data_source, lambda _: None) tool.func( ggsql="SELECT x, y FROM test_data VISUALISE x, y DRAW point", title="t1" ) - assert calls == [] + assert len(icu_calls) == 1 + assert spatial_calls == [] class TestTranslateSpatialError: From fe71634572261e15b4721622b1a42132dc7fb130 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 14 Oct 2026 19:30:41 -0500 Subject: [PATCH 09/13] refactor(py): simplify spatial error translation to setup-failed case --- pkg-py/src/querychat/_viz_ggsql.py | 42 ++++++++++----------- pkg-py/src/querychat/_viz_tools.py | 16 ++++---- pkg-py/tests/test_viz_tools.py | 60 +++++++++++++++++++++--------- 3 files changed, 70 insertions(+), 48 deletions(-) diff --git a/pkg-py/src/querychat/_viz_ggsql.py b/pkg-py/src/querychat/_viz_ggsql.py index 09966ed42..93ea6ce12 100644 --- a/pkg-py/src/querychat/_viz_ggsql.py +++ b/pkg-py/src/querychat/_viz_ggsql.py @@ -3,7 +3,7 @@ from __future__ import annotations import warnings -from typing import TYPE_CHECKING, Literal +from typing import TYPE_CHECKING from ._utils import as_narwhals @@ -122,32 +122,30 @@ def prewarm_spatial(reader: ggsql.CachingReader) -> bool: return True -def translate_spatial_error( - error: Exception, *, maps_state: Literal["enabled", "disabled", "setup_failed"] -) -> str | None: +def translate_spatial_error(error: Exception) -> str | None: """ Translate ggsql's spatial-extension failure into actionable guidance. - Returns the translated message, or ``None`` when the error is not a - spatial setup failure (or maps are fully enabled, making the message - moot). ``maps_state`` is ``"enabled"`` when the pre-warm succeeded, - ``"setup_failed"`` when it ran but failed, and ``"disabled"`` when the - session never opted into maps. + Only call this when maps was requested but spatial setup failed earlier + in the session (see ``prewarm_spatial``). Returns the translated + message, or ``None`` when the error is not a spatial setup failure. + + Matches DuckDB error wording as of DuckDB 1.5.5 — both the + extension-not-found form and the catalog error from calling an + ``st_*`` function after a failed LOAD. If a DuckDB upgrade rewords + these errors, update the patterns and tests together. """ msg = str(error).lower() - if maps_state == "enabled" or "spatial" not in msg or "extension" not in msg: + is_extension_missing = "spatial" in msg and "extension" in msg + is_st_function_missing = ( + "scalar function" in msg and "st_" in msg and "does not exist" in msg + ) + if not (is_extension_missing or is_st_function_missing): return None - if maps_state == "setup_failed": - return ( - "This query requires map/spatial support, but spatial extension " - "setup failed earlier in this session (see the warning shown at " - "setup time, likely a failed extension download). Do not retry " - "with DRAW spatial, PROJECT TO , or ggsql:world; suggest a " - "non-map visualization instead." - ) return ( - "This query requires map/spatial support, which is not enabled in " - "this session. Do not retry with DRAW spatial, PROJECT TO , or " - "ggsql:world; suggest a non-map visualization instead. The app " - 'developer can enable maps with tools=("default", "maps").' + "This query requires map/spatial support, but spatial extension " + "setup failed earlier in this session (see the warning shown at " + "setup time, likely a failed extension download). Do not retry " + "with DRAW spatial, PROJECT TO , or ggsql:world; suggest a " + "non-map visualization instead." ) diff --git a/pkg-py/src/querychat/_viz_tools.py b/pkg-py/src/querychat/_viz_tools.py index 22a6fe352..5fef83ad4 100644 --- a/pkg-py/src/querychat/_viz_tools.py +++ b/pkg-py/src/querychat/_viz_tools.py @@ -259,15 +259,15 @@ def visualize( ) except Exception as e: - maps_state = ( - "enabled" - if maps_available - else ("setup_failed" if maps else "disabled") - ) - translated = translate_spatial_error(e, maps_state=maps_state) - error_msg = ( - translated if translated is not None else truncate_error(str(e)) + # Maps requested but prewarm failed: translate spatial setup + # errors into "stop trying maps" guidance. (Maps disabled is + # covered by the system prompt's maps-disabled note; maps + # enabled-and-working means a spatial error is a real bug worth + # seeing raw.) + translated = ( + translate_spatial_error(e) if (maps and not maps_available) else None ) + error_msg = translated if translated is not None else truncate_error(str(e)) markdown += f"\n\n> Error: {error_msg}" return ContentToolResult(value=markdown, error=Exception(error_msg)) diff --git a/pkg-py/tests/test_viz_tools.py b/pkg-py/tests/test_viz_tools.py index cf85faebb..1aa0c0e87 100644 --- a/pkg-py/tests/test_viz_tools.py +++ b/pkg-py/tests/test_viz_tools.py @@ -502,46 +502,70 @@ def test_prewarms_icu_but_not_spatial_when_maps_disabled( class TestTranslateSpatialError: SPATIAL_ERR = Exception( - 'IO Error: Extension "/home/u/.duckdb/extensions/v1.5.2/linux_amd64/' + 'IO Error: Extension "/home/u/.duckdb/extensions/v1.5.5/linux_amd64/' 'spatial.duckdb_extension" not found' ) + ST_FUNC_ERR = Exception( + "Catalog Error: Scalar Function with name st_within does not exist!" + ) - def test_disabled_message_points_to_tools(self): + def test_extension_error_message(self): from querychat._viz_ggsql import translate_spatial_error - msg = translate_spatial_error(self.SPATIAL_ERR, maps_state="disabled") + msg = translate_spatial_error(self.SPATIAL_ERR) assert msg is not None - assert "maps" in msg - assert 'tools=("default", "maps")' in msg + assert "setup failed" in msg - def test_setup_failed_message(self): + def test_st_function_error_message(self): + # A failed LOAD followed by an st_* call surfaces as a catalog + # error, not an extension error — same recovery guidance applies. from querychat._viz_ggsql import translate_spatial_error - msg = translate_spatial_error(self.SPATIAL_ERR, maps_state="setup_failed") + msg = translate_spatial_error(self.ST_FUNC_ERR) assert msg is not None assert "setup failed" in msg - def test_enabled_or_nonspatial_returns_none(self): + def test_nonspatial_returns_none(self): from querychat._viz_ggsql import translate_spatial_error - assert translate_spatial_error(self.SPATIAL_ERR, maps_state="enabled") is None - assert ( - translate_spatial_error(Exception("boom"), maps_state="disabled") is None - ) + assert translate_spatial_error(Exception("boom")) is None @pytest.mark.ggsql - def test_tool_translates_spatial_error_when_maps_disabled( + def test_tool_translates_spatial_error_after_setup_failure( self, data_source, monkeypatch ): - err = str(self.SPATIAL_ERR) + import querychat._viz_tools as viz_tools - def boom(reader, query): - raise RuntimeError(err) + monkeypatch.setattr(viz_tools, "prewarm_icu", lambda reader: None) + monkeypatch.setattr(viz_tools, "prewarm_spatial", lambda reader: False) + monkeypatch.setattr( + "ggsql.CachingReader.execute", + lambda reader, query: (_ for _ in ()).throw( + RuntimeError(str(self.SPATIAL_ERR)) + ), + ) + tool = tool_visualize(data_source, lambda _: None, maps=True) + result = tool.func( + ggsql="SELECT x, y FROM test_data VISUALISE x, y DRAW point", title="t" + ) + assert result.error is not None + assert "setup failed" in str(result.error) - monkeypatch.setattr("ggsql.CachingReader.execute", boom) + @pytest.mark.ggsql + def test_tool_does_not_translate_when_maps_disabled( + self, data_source, monkeypatch + ): + # With maps disabled, the system prompt tells the LLM not to + # attempt spatial features; a raw error is the acceptable backstop. + monkeypatch.setattr( + "ggsql.CachingReader.execute", + lambda reader, query: (_ for _ in ()).throw( + RuntimeError(str(self.SPATIAL_ERR)) + ), + ) tool = tool_visualize(data_source, lambda _: None) result = tool.func( ggsql="SELECT x, y FROM test_data VISUALISE x, y DRAW point", title="t" ) assert result.error is not None - assert 'tools=("default", "maps")' in str(result.error) + assert "setup failed" not in str(result.error) From a904548b21bb5f0e0fb0a831423592f000f637ec Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 14 Oct 2026 19:31:20 -0500 Subject: [PATCH 10/13] feat(py): tell the LLM how to handle map requests when maps is disabled --- pkg-py/src/querychat/_system_prompt.py | 2 ++ pkg-py/src/querychat/prompts/prompt.md | 4 +++ pkg-py/tests/test_viz_tools.py | 38 +++++++++++++++++++++++++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/pkg-py/src/querychat/_system_prompt.py b/pkg-py/src/querychat/_system_prompt.py index 5552d50b4..fdc67a21b 100644 --- a/pkg-py/src/querychat/_system_prompt.py +++ b/pkg-py/src/querychat/_system_prompt.py @@ -213,6 +213,8 @@ def render( "has_tool_query": "query" in tools if tools else False, "has_tool_visualize": has_viz_tool(tools), "has_tool_maps": tools is not None and "maps" in tools, + "maps_disabled": has_viz_tool(tools) + and not (tools is not None and "maps" in tools), "include_query_guidelines": len(tools or ()) > 0, "multi_table": len(self._data_sources) > 1, "handoff_available": handoff_available, diff --git a/pkg-py/src/querychat/prompts/prompt.md b/pkg-py/src/querychat/prompts/prompt.md index b0fc77b2d..8f4f02b9d 100644 --- a/pkg-py/src/querychat/prompts/prompt.md +++ b/pkg-py/src/querychat/prompts/prompt.md @@ -202,6 +202,10 @@ Match the chart type to what the user is trying to understand: {{> ggsql-spatial}} {{/has_tool_maps}} +{{#maps_disabled}} + +**Map visualizations are available but not enabled.** querychat can draw choropleths and point maps, but the `maps` capability is opt-in and this session has not enabled it. Do NOT attempt `DRAW spatial`, `ggsql:world`, `ST_*` functions, or `PROJECT TO ` — they will fail. If the user asks for a map, explain that maps are an opt-in capability the app developer can enable with `tools=("default", "maps")`, and offer a fallback such as a scatter plot of longitude/latitude with `DRAW point`. +{{/maps_disabled}} {{#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. diff --git a/pkg-py/tests/test_viz_tools.py b/pkg-py/tests/test_viz_tools.py index 1aa0c0e87..bb20e3897 100644 --- a/pkg-py/tests/test_viz_tools.py +++ b/pkg-py/tests/test_viz_tools.py @@ -168,7 +168,43 @@ def test_main_prompt_render_omits_spatial_reference_without_maps(data_source): prompt = system_prompt.render({"update", "query", "visualize"}) assert "" not in prompt - assert "ggsql:world" not in prompt + + +def test_main_prompt_render_shows_maps_disabled_note_without_maps(data_source): + system_prompt = QueryChatSystemPrompt( + prompt_template=PROMPTS_DIR / "prompt.md", + data_source=data_source, + ) + + prompt = system_prompt.render({"update", "query", "visualize"}) + + assert "not enabled" in prompt + assert 'tools=("default", "maps")' in prompt + # The note must not leak the spatial syntax reference + assert "" not in prompt + + +def test_main_prompt_render_hides_maps_disabled_note_with_maps(data_source): + system_prompt = QueryChatSystemPrompt( + prompt_template=PROMPTS_DIR / "prompt.md", + data_source=data_source, + ) + + prompt = system_prompt.render({"update", "query", "visualize", "maps"}) + + assert "" in prompt + assert "Map visualizations" not in prompt + + +def test_main_prompt_render_hides_maps_disabled_note_without_visualize(data_source): + system_prompt = QueryChatSystemPrompt( + prompt_template=PROMPTS_DIR / "prompt.md", + data_source=data_source, + ) + + prompt = system_prompt.render({"update", "query"}) + + assert "Map visualizations" not in prompt @pytest.fixture From f446e3df2a4543cd2b9b812f924d17323a49fefb Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 14 Oct 2026 19:34:23 -0500 Subject: [PATCH 11/13] chore(py): maps review cleanup (dead code, changelog PR ref) --- pkg-py/CHANGELOG.md | 7 ++++--- pkg-py/src/querychat/_viz_utils.py | 5 ----- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index 6be9ada76..dae1b7fdc 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **Faster iterative visualization, and support for layer-level data sources.** Visualization queries now execute as a whole through `ggsql.CachingReader`: the underlying SQL runs against your data source once and the result is memoized for the chat session, so re-rendering a chart with a different encoding no longer re-queries the source (a win for remote databases especially). Visualization layers can also draw from other relations in the query — e.g. raw points from the final `SELECT` with a summary line from a CTE — via `DRAW ... MAPPING ... FROM `. Caching can be tuned or disabled with the `GGSQL_CACHE_TTL`, `GGSQL_CACHE_MAX_BYTES`, and `GGSQL_CACHE_DISABLED` environment variables. (#321) -* New opt-in `"maps"` capability: pass `tools=("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. +* New opt-in `"maps"` capability: pass `tools=("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. (#323) ### Bug fixes @@ -61,6 +61,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ```python qc = QueryChat(None, table_name="my_table") + def server(input, output, session): qc.server(data_source=conn.table("my_table"), client=chat_client) ``` @@ -89,8 +90,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 # Or, register all tables from a SQLAlchemy engine or Ibis backend at once: qc = QueryChat() - qc.add_tables(engine) # SQLAlchemy engine - qc.add_tables(ibis_backend) # Ibis backend + qc.add_tables(engine) # SQLAlchemy engine + qc.add_tables(ibis_backend) # Ibis backend qc_vals = qc.server() qc_vals.table("orders").df() diff --git a/pkg-py/src/querychat/_viz_utils.py b/pkg-py/src/querychat/_viz_utils.py index 8d91e208a..839f1a091 100644 --- a/pkg-py/src/querychat/_viz_utils.py +++ b/pkg-py/src/querychat/_viz_utils.py @@ -17,11 +17,6 @@ def has_viz_tool(tools: set[str] | None) -> bool: return tools is not None and "visualize" in tools -def has_maps_tool(tools: set[str] | None) -> bool: - """Check if the maps capability is among the configured tools.""" - return tools is not None and "maps" in tools - - def has_viz_deps() -> bool: """Check whether visualization dependencies (ggsql, altair, shinywidgets, vl-convert-python) are installed.""" if any( From 0cf1f8f7031962bb6b485cbdddc4ccff2ba58c1d Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 14 Oct 2026 19:46:01 -0500 Subject: [PATCH 12/13] docs(py): trim overexplaining docstrings in viz prewarm/error code --- pkg-py/src/querychat/_viz_ggsql.py | 38 +++++++++++++----------------- pkg-py/src/querychat/_viz_tools.py | 20 +++++++++------- pkg-py/tests/test_ggsql.py | 2 -- 3 files changed, 28 insertions(+), 32 deletions(-) diff --git a/pkg-py/src/querychat/_viz_ggsql.py b/pkg-py/src/querychat/_viz_ggsql.py index 93ea6ce12..c96961bf2 100644 --- a/pkg-py/src/querychat/_viz_ggsql.py +++ b/pkg-py/src/querychat/_viz_ggsql.py @@ -64,27 +64,25 @@ def unregister(self, name) -> None: def install_extension(reader: ggsql.CachingReader, name: str) -> None: - """Install and load a DuckDB extension on the reader's cache connection.""" + """ + Install and load a DuckDB extension on the reader's cache connection. + + All ggsql-generated derived SQL runs on the cache connection, never on + the user's (typically locked-down) data source, so extensions must be + installed here. The first ``INSTALL`` downloads the binary (network + required); afterwards it is cached on disk. + """ reader.execute_sql_cached(f"INSTALL {name}") reader.execute_sql_cached(f"LOAD {name}") def prewarm_icu(reader: ggsql.CachingReader) -> None: """ - Set up the DuckDB ICU extension on the reader's cache connection. + Set up the DuckDB ICU extension (time zones, locale-aware collations). - All ggsql-generated derived SQL runs on the cache connection, never on - the user's data source, so any DuckDB extension that SQL may depend on - must be installed there — routing ``INSTALL`` through the primary - reader would hit the user's (typically locked-down) connection and - fail. The first ``INSTALL`` downloads the extension binary (network - required); afterwards it is cached on disk and loading costs - milliseconds. - - ICU provides time zone support (e.g. interval arithmetic on - TIMESTAMPTZ) and locale-aware collations. DuckDB's CLI and Python - client load ICU by default, so SQL written against "DuckDB" — by - users or LLMs — assumes it. Failure is non-fatal: warns and continues. + DuckDB's CLI and Python client load ICU by default, so SQL written + against "DuckDB" — by users or LLMs — assumes it. Failure is + non-fatal: warns and continues. """ try: install_extension(reader, "icu") @@ -100,14 +98,12 @@ def prewarm_icu(reader: ggsql.CachingReader) -> None: def prewarm_spatial(reader: ggsql.CachingReader) -> bool: """ - Set up the DuckDB spatial extension on the reader's cache connection. - - Required for the ``"maps"`` capability. A smoke query exercises the - full spatial path (extension load, builtin ``ggsql:world`` - registration, spatial layer execution). Failure warns and returns - ``False`` so the caller can treat ``"maps"`` as unavailable. + Set up the DuckDB spatial extension for the ``"maps"`` capability. - Returns ``True`` when spatial support is ready. + The smoke query exercises the full spatial path (extension load, + builtin ``ggsql:world`` registration, spatial layer execution). + Failure warns and returns ``False`` so the caller can treat ``"maps"`` + as unavailable. """ try: install_extension(reader, "spatial") diff --git a/pkg-py/src/querychat/_viz_tools.py b/pkg-py/src/querychat/_viz_tools.py index 5fef83ad4..7ac65a4c9 100644 --- a/pkg-py/src/querychat/_viz_tools.py +++ b/pkg-py/src/querychat/_viz_tools.py @@ -17,10 +17,14 @@ from shiny import ui -from .__version import __version__ from ._icons import bs_icon from ._tool_names import TOOL_VISUALIZE -from ._utils import querychat_tool_starts_open, read_prompt_template, truncate_error +from ._utils import ( + dep_version, + querychat_tool_starts_open, + read_prompt_template, + truncate_error, +) from ._viz_altair_widget import AltairWidget, fit_chart_to_container from ._viz_ggsql import ( ExecutorReader, @@ -82,7 +86,7 @@ def tool_visualize( Whether multiple tables are registered. maps Whether the maps capability is enabled (pre-warms the DuckDB - spatial extension when the viz reader is first created). + spatial extension on the viz reader). Returns ------- @@ -259,11 +263,9 @@ def visualize( ) except Exception as e: - # Maps requested but prewarm failed: translate spatial setup - # errors into "stop trying maps" guidance. (Maps disabled is - # covered by the system prompt's maps-disabled note; maps - # enabled-and-working means a spatial error is a real bug worth - # seeing raw.) + # Only the setup-failed state needs translation: maps-disabled + # is covered by the prompt's maps-disabled note, and a spatial + # error with maps working is a real bug worth seeing raw. translated = ( translate_spatial_error(e) if (maps and not maps_available) else None ) @@ -301,7 +303,7 @@ def viz_dep() -> HTMLDependency: """HTMLDependency for viz-specific CSS and JS assets.""" return HTMLDependency( "querychat-viz", - __version__, + dep_version(), source={ "package": "querychat", "subdir": "static", diff --git a/pkg-py/tests/test_ggsql.py b/pkg-py/tests/test_ggsql.py index 60f56b8a7..e6f1c11f3 100644 --- a/pkg-py/tests/test_ggsql.py +++ b/pkg-py/tests/test_ggsql.py @@ -519,8 +519,6 @@ def execute_sql_cached(self, sql): return object() assert prewarm_icu(FakeReader()) is None - # Extensions are installed/loaded on the cache (compute) connection, - # never on the user's data source. assert cached_calls == ["INSTALL icu", "LOAD icu"] def test_icu_failure_warns_and_continues(self): From c9b3967fb3720aa462013cd9a01c2112e8f7ddeb Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 15 Oct 2026 10:09:40 -0500 Subject: [PATCH 13/13] fix(py): restore __version__ in viz_dep to unbreak _viz_tools import A previous commit on this branch made viz_dep() use a dep_version() helper whose definition was never committed, so importing querychat._viz_tools raised ImportError and the Python CI job on #323 failed at collection time. Restore the direct __version__ usage. --- pkg-py/src/querychat/_viz_tools.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/pkg-py/src/querychat/_viz_tools.py b/pkg-py/src/querychat/_viz_tools.py index 7ac65a4c9..684d33ade 100644 --- a/pkg-py/src/querychat/_viz_tools.py +++ b/pkg-py/src/querychat/_viz_tools.py @@ -17,14 +17,10 @@ from shiny import ui +from .__version import __version__ from ._icons import bs_icon from ._tool_names import TOOL_VISUALIZE -from ._utils import ( - dep_version, - querychat_tool_starts_open, - read_prompt_template, - truncate_error, -) +from ._utils import querychat_tool_starts_open, read_prompt_template, truncate_error from ._viz_altair_widget import AltairWidget, fit_chart_to_container from ._viz_ggsql import ( ExecutorReader, @@ -303,7 +299,7 @@ def viz_dep() -> HTMLDependency: """HTMLDependency for viz-specific CSS and JS assets.""" return HTMLDependency( "querychat-viz", - dep_version(), + __version__, source={ "package": "querychat", "subdir": "static",