Skip to content

Commit 8696fca

Browse files
cpsievertclaude
andcommitted
refactor: drop visualize_dashboard, switch visualize_query to two-stage ggsql API
Remove the visualize_dashboard tool to focus on visualize_query. Switch from the one-shot ggsql.render_altair() API to the two-stage API (reader.execute → writer.render), gaining access to ggsql's writer- independent Spec object for structured metadata access. The new execute_ggsql() helper uses a hybrid approach: DataSource handles SQL execution (preserving database pushdown), then a ggsql DuckDBReader processes the VISUALISE portion to produce a Spec. Title extraction now reads from the rendered Vega-Lite JSON rather than regex-parsing the VISUALISE string. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ac299be commit 8696fca

16 files changed

Lines changed: 171 additions & 561 deletions

pkg-py/src/querychat/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@
22
from ._deprecated import mod_server as server
33
from ._deprecated import mod_ui as ui
44
from ._shiny import QueryChat
5-
from .tools import VisualizeDashboardData, VisualizeQueryData
5+
from .tools import VisualizeQueryData
66

77
__all__ = (
88
"QueryChat",
9-
"VisualizeDashboardData",
109
"VisualizeQueryData",
1110
# TODO(lifecycle): Remove these deprecated functions when we reach v1.0
1211
"greeting",

pkg-py/src/querychat/_ggsql.py

Lines changed: 68 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,84 @@
1-
"""
2-
Helpers for ggsql integration.
3-
4-
These are workarounds for functionality not yet exposed by the ggsql package.
5-
When ggsql adds native title/metadata extraction, these should be replaced.
6-
"""
1+
"""Helpers for ggsql integration."""
72

83
from __future__ import annotations
94

10-
import re
5+
import json
6+
from typing import TYPE_CHECKING
7+
8+
import narwhals.stable.v1 as nw
9+
10+
if TYPE_CHECKING:
11+
import ggsql
12+
import polars as pl
13+
from narwhals.stable.v1.typing import IntoFrame
14+
15+
from ._datasource import DataSource
16+
1117

18+
def to_polars(data: IntoFrame) -> pl.DataFrame:
19+
"""Convert any narwhals-compatible frame to a polars DataFrame."""
20+
nw_df = nw.from_native(data)
21+
if isinstance(nw_df, nw.LazyFrame):
22+
nw_df = nw_df.collect()
23+
return nw_df.to_polars()
1224

13-
def extract_title(viz_spec: str) -> str | None:
25+
26+
def execute_ggsql(data_source: DataSource, query: str) -> ggsql.Spec:
1427
"""
15-
Extract the title from a VISUALISE spec's LABEL clause.
28+
Execute a full ggsql query against a DataSource, returning a Spec.
1629
17-
.. note::
18-
This is a workaround. Ideally ggsql would expose title extraction
19-
from its ``Validated`` or ``Spec`` objects. This regex will break if
20-
ggsql's LABEL syntax changes (e.g., escaped quotes, multi-line values).
21-
TODO: File ggsql issue for native title extraction API.
30+
Uses ggsql.validate() to split SQL from VISUALISE, executes the SQL
31+
through DataSource (preserving database pushdown), then feeds the result
32+
into a ggsql DuckDBReader to produce a Spec.
2233
2334
Parameters
2435
----------
25-
viz_spec
26-
The VISUALISE portion of a ggsql query.
36+
data_source
37+
The querychat DataSource to execute the SQL portion against.
38+
query
39+
A full ggsql query (SQL + VISUALISE).
2740
2841
Returns
2942
-------
30-
str | None
31-
The title if found, otherwise None.
43+
ggsql.Spec
44+
The writer-independent plot specification.
3245
3346
"""
34-
# Match LABEL title => 'value' or LABEL title => "value"
35-
pattern = r"LABEL\s+title\s*=>\s*['\"]([^'\"]+)['\"]"
36-
match = re.search(pattern, viz_spec, re.IGNORECASE)
37-
if match:
38-
return match.group(1)
47+
import ggsql as _ggsql
48+
49+
validated = _ggsql.validate(query)
50+
pl_df = to_polars(data_source.execute_query(validated.sql()))
51+
52+
reader = _ggsql.DuckDBReader("duckdb://memory")
53+
reader.register("_data", pl_df)
54+
return reader.execute(f"SELECT * FROM _data {validated.visual()}")
55+
56+
57+
def spec_to_altair(spec: ggsql.Spec) -> ggsql.AltairChart:
58+
"""Render a ggsql Spec to an Altair chart via VegaLiteWriter."""
59+
import ggsql as _ggsql
60+
61+
writer = _ggsql.VegaLiteWriter()
62+
vegalite_json = writer.render(spec)
63+
64+
import altair as alt
65+
66+
return alt.Chart.from_json(vegalite_json)
67+
68+
69+
def extract_title(spec: ggsql.Spec) -> str | None:
70+
"""
71+
Extract the title from a ggsql Spec's rendered Vega-Lite JSON.
72+
73+
TODO: Replace with ``spec.title()`` once ggsql exposes this natively.
74+
"""
75+
import ggsql as _ggsql
76+
77+
writer = _ggsql.VegaLiteWriter()
78+
vl: dict[str, object] = json.loads(writer.render(spec))
79+
title = vl.get("title")
80+
if isinstance(title, str):
81+
return title
82+
if isinstance(title, dict):
83+
return title.get("text")
3984
return None

pkg-py/src/querychat/_querychat_base.py

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,10 @@
2525
from ._utils import MISSING, MISSING_TYPE, is_ibis_table
2626
from .tools import (
2727
UpdateDashboardData,
28-
VisualizeDashboardData,
2928
VisualizeQueryData,
3029
tool_query,
3130
tool_reset_dashboard,
3231
tool_update_dashboard,
33-
tool_visualize_dashboard,
3432
tool_visualize_query,
3533
)
3634

@@ -39,16 +37,15 @@
3937

4038
from narwhals.stable.v1.typing import IntoFrame
4139

42-
TOOL_GROUPS = Literal["update", "query", "visualize_dashboard", "visualize_query"]
40+
TOOL_GROUPS = Literal["update", "query", "visualize_query"]
4341
DEFAULT_TOOLS: tuple[TOOL_GROUPS, ...] = ("update", "query")
4442
ALL_TOOLS: tuple[TOOL_GROUPS, ...] = (
4543
"update",
4644
"query",
47-
"visualize_dashboard",
4845
"visualize_query",
4946
)
5047

51-
VIZ_TOOLS: tuple[TOOL_GROUPS, ...] = ("visualize_dashboard", "visualize_query")
48+
VIZ_TOOLS: tuple[TOOL_GROUPS, ...] = ("visualize_query",)
5249

5350

5451
def check_viz_dependencies(tools: tuple[TOOL_GROUPS, ...] | None) -> None:
@@ -163,7 +160,6 @@ def client(
163160
tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None | MISSING_TYPE = MISSING,
164161
update_dashboard: Callable[[UpdateDashboardData], None] | None = None,
165162
reset_dashboard: Callable[[], None] | None = None,
166-
visualize_dashboard: Callable[[VisualizeDashboardData], None] | None = None,
167163
visualize_query: Callable[[VisualizeQueryData], None] | None = None,
168164
) -> chatlas.Chat:
169165
"""
@@ -172,14 +168,12 @@ def client(
172168
Parameters
173169
----------
174170
tools
175-
Which tools to include: `"update"`, `"query"`, `"visualize_dashboard"`,
176-
`"visualize_query"`, or a combination.
171+
Which tools to include: `"update"`, `"query"`, `"visualize_query"`,
172+
or a combination.
177173
update_dashboard
178174
Callback when update_dashboard tool succeeds.
179175
reset_dashboard
180176
Callback when reset_dashboard tool is invoked.
181-
visualize_dashboard
182-
Callback when visualize_dashboard tool succeeds.
183177
visualize_query
184178
Callback when visualize_query tool succeeds.
185179
@@ -210,10 +204,6 @@ def client(
210204
if "query" in tools:
211205
chat.register_tool(tool_query(data_source))
212206

213-
if "visualize_dashboard" in tools:
214-
viz_fn = visualize_dashboard or (lambda _: None)
215-
chat.register_tool(tool_visualize_dashboard(self._data_source, viz_fn))
216-
217207
if "visualize_query" in tools:
218208
query_viz_fn = visualize_query or (lambda _: None)
219209
chat.register_tool(tool_visualize_query(self._data_source, query_viz_fn))

pkg-py/src/querychat/_shiny.py

Lines changed: 14 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,11 @@ class QueryChat(QueryChatBase[IntoFrameT]):
9898
tools
9999
Which querychat tools to include in the chat client by default. Can be:
100100
- A single tool string: `"update"` or `"query"`
101-
- A tuple of tools: `("update", "query", "visualize_dashboard", "visualize_query")`
101+
- A tuple of tools: `("update", "query", "visualize_query")`
102102
- `None` or `()` to disable all tools
103103
104-
Default is `("update", "query")`. Visualization tools (`"visualize_dashboard"`,
105-
`"visualize_query"`) can be opted into by including them in the tuple.
104+
Default is `("update", "query")`. The visualization tool (`"visualize_query"`)
105+
can be opted into by including it in the tuple.
106106
107107
Set to `"update"` to prevent the LLM from accessing data values, only
108108
allowing dashboard filtering without answering questions.
@@ -250,9 +250,8 @@ def app(
250250
Creates a Shiny app with a chat sidebar and tabbed view -- providing a
251251
quick-and-easy way to start chatting with your data.
252252
253-
The app includes three tabs:
253+
The app includes two tabs:
254254
- **Data**: Shows the filtered data table
255-
- **Filter Plot**: Shows the persistent dashboard visualization
256255
- **Query Plot**: Shows the most recent query visualization
257256
258257
Parameters
@@ -278,7 +277,6 @@ def app(
278277
if isinstance(self.tools, str)
279278
else (self.tools or ())
280279
)
281-
has_filter_viz = "visualize_dashboard" in tools_tuple
282280
has_query_viz = "visualize_query" in tools_tuple
283281

284282
def app_ui(request):
@@ -291,13 +289,6 @@ def app_ui(request):
291289
),
292290
),
293291
]
294-
if has_filter_viz:
295-
nav_panels.append(
296-
ui.nav_panel(
297-
"Filter Plot",
298-
ui.output_ui("filter_plot_container"),
299-
)
300-
)
301292
if has_query_viz:
302293
nav_panels.append(
303294
ui.nav_panel(
@@ -374,37 +365,6 @@ def sql_output():
374365
width="100%",
375366
)
376367

377-
if has_filter_viz:
378-
379-
@render.ui
380-
def filter_plot_container():
381-
from shinywidgets import output_widget, render_altair
382-
383-
chart = vals.filter_viz_chart()
384-
if chart is None:
385-
return ui.card(
386-
ui.card_body(
387-
ui.p(
388-
"No filter visualization yet. "
389-
"Use the chat to create one."
390-
),
391-
class_="text-muted text-center py-5",
392-
),
393-
)
394-
395-
@render_altair
396-
def filter_chart():
397-
return chart
398-
399-
return ui.card(
400-
ui.card_header(
401-
bs_icon("bar-chart-fill"),
402-
" ",
403-
vals.filter_viz_title.get() or "Filter Visualization",
404-
),
405-
output_widget("filter_chart"),
406-
)
407-
408368
if has_query_viz:
409369

410370
@render.ui
@@ -971,68 +931,38 @@ def title(self, value: Optional[str] = None) -> str | None | bool:
971931
else:
972932
return self._vals.title.set(value)
973933

974-
def ggvis(self, source: Literal["filter", "query"] = "filter") -> alt.Chart | None:
934+
def ggvis(self) -> alt.Chart | None:
975935
"""
976-
Get the visualization chart.
977-
978-
Parameters
979-
----------
980-
source
981-
Which visualization to return:
982-
- "filter": Chart from visualize_dashboard (updates with filter changes)
983-
- "query": Chart from visualize_query (most recent inline visualization)
936+
Get the visualization chart from the most recent visualize_query call.
984937
985938
Returns
986939
-------
987940
:
988941
The Altair chart, or None if no visualization exists.
989942
990943
"""
991-
if source == "filter":
992-
return self._vals.filter_viz_chart()
993-
else:
994-
return self._vals.query_viz_chart()
944+
return self._vals.query_viz_chart()
995945

996-
def ggsql(self, source: Literal["filter", "query"] = "filter") -> str | None:
946+
def ggsql(self) -> str | None:
997947
"""
998-
Get the ggsql specification.
999-
1000-
Parameters
1001-
----------
1002-
source
1003-
Which specification to return:
1004-
- "filter": VISUALISE spec only (from visualize_dashboard)
1005-
- "query": Full ggsql query (from visualize_query)
948+
Get the full ggsql query from the most recent visualize_query call.
1006949
1007950
Returns
1008951
-------
1009952
:
1010-
The ggsql specification, or None if no visualization exists.
953+
The ggsql query string, or None if no visualization exists.
1011954
1012955
"""
1013-
if source == "filter":
1014-
return self._vals.filter_viz_spec.get()
1015-
else:
1016-
return self._vals.query_viz_ggsql.get()
956+
return self._vals.query_viz_ggsql.get()
1017957

1018-
def ggtitle(self, source: Literal["filter", "query"] = "filter") -> str | None:
958+
def ggtitle(self) -> str | None:
1019959
"""
1020-
Get the visualization title.
1021-
1022-
Parameters
1023-
----------
1024-
source
1025-
Which title to return:
1026-
- "filter": Title from visualize_dashboard
1027-
- "query": Title from visualize_query
960+
Get the visualization title from the most recent visualize_query call.
1028961
1029962
Returns
1030963
-------
1031964
:
1032965
The title, or None if no visualization exists.
1033966
1034967
"""
1035-
if source == "filter":
1036-
return self._vals.filter_viz_title.get()
1037-
else:
1038-
return self._vals.query_viz_title.get()
968+
return self._vals.query_viz_title.get()

0 commit comments

Comments
 (0)