From 61a24c7e67e4d5ce6e7165573720e4c84bc27804 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 20 Mar 2026 19:10:10 -0500 Subject: [PATCH 1/3] feat: add ggsql visualization tool Add ggsql-powered visualization support including: - Visualization tool with inline chart rendering via Altair/ipywidgets - ggsql prompt, syntax reference, and filter-awareness guidance - Visualization tests and example app - UI components (CSS/JS) for chart display, save, and query toggle Co-Authored-By: Claude Opus 4.6 --- pkg-py/examples/10-viz-app.py | 22 + pkg-py/src/querychat/_icons.py | 22 +- pkg-py/src/querychat/_querychat_base.py | 41 +- pkg-py/src/querychat/_shiny.py | 124 +++- pkg-py/src/querychat/_shiny_module.py | 94 ++- pkg-py/src/querychat/_system_prompt.py | 7 +- pkg-py/src/querychat/_utils.py | 18 +- pkg-py/src/querychat/_viz_altair_widget.py | 190 ++++++ pkg-py/src/querychat/_viz_ggsql.py | 66 +++ pkg-py/src/querychat/_viz_tools.py | 291 ++++++++++ pkg-py/src/querychat/_viz_utils.py | 54 ++ pkg-py/src/querychat/prompts/prompt.md | 29 +- pkg-py/src/querychat/prompts/tool-query.md | 1 + .../querychat/prompts/tool-visualize-query.md | 540 ++++++++++++++++++ pkg-py/src/querychat/static/css/viz.css | 141 +++++ pkg-py/src/querychat/static/js/viz.js | 129 +++++ pkg-py/src/querychat/tools.py | 9 + pkg-py/src/querychat/types/__init__.py | 3 + pkg-py/tests/conftest.py | 32 ++ pkg-py/tests/playwright/conftest.py | 28 + pkg-py/tests/playwright/test_10_viz_inline.py | 123 ++++ pkg-py/tests/playwright/test_11_viz_footer.py | 180 ++++++ .../playwright/test_visualization_tabs.py | 38 ++ pkg-py/tests/test_ggsql.py | 108 ++++ pkg-py/tests/test_tools.py | 4 + pkg-py/tests/test_viz_footer.py | 194 +++++++ pkg-py/tests/test_viz_tools.py | 131 +++++ pkg-r/inst/prompts/prompt.md | 23 +- pkg-r/inst/prompts/tool-query.md | 1 + pyproject.toml | 11 +- 30 files changed, 2607 insertions(+), 47 deletions(-) create mode 100644 pkg-py/examples/10-viz-app.py create mode 100644 pkg-py/src/querychat/_viz_altair_widget.py create mode 100644 pkg-py/src/querychat/_viz_ggsql.py create mode 100644 pkg-py/src/querychat/_viz_tools.py create mode 100644 pkg-py/src/querychat/_viz_utils.py create mode 100644 pkg-py/src/querychat/prompts/tool-visualize-query.md create mode 100644 pkg-py/src/querychat/static/css/viz.css create mode 100644 pkg-py/src/querychat/static/js/viz.js create mode 100644 pkg-py/tests/conftest.py create mode 100644 pkg-py/tests/playwright/test_10_viz_inline.py create mode 100644 pkg-py/tests/playwright/test_11_viz_footer.py create mode 100644 pkg-py/tests/playwright/test_visualization_tabs.py create mode 100644 pkg-py/tests/test_ggsql.py create mode 100644 pkg-py/tests/test_viz_footer.py create mode 100644 pkg-py/tests/test_viz_tools.py diff --git a/pkg-py/examples/10-viz-app.py b/pkg-py/examples/10-viz-app.py new file mode 100644 index 000000000..ee38c8c02 --- /dev/null +++ b/pkg-py/examples/10-viz-app.py @@ -0,0 +1,22 @@ +from querychat import QueryChat +from querychat.data import titanic + +from shiny import App, ui + +# Omits "update" tool — this demo focuses on query + visualization only +qc = QueryChat( + titanic(), + "titanic", + tools=("query", "visualize_query"), +) + +app_ui = ui.page_fillable( + qc.ui(), +) + + +def server(input, output, session): + qc.server() + + +app = App(app_ui, server) diff --git a/pkg-py/src/querychat/_icons.py b/pkg-py/src/querychat/_icons.py index 2b7683da0..fc484c9c0 100644 --- a/pkg-py/src/querychat/_icons.py +++ b/pkg-py/src/querychat/_icons.py @@ -2,19 +2,35 @@ from shiny import ui -ICON_NAMES = Literal["arrow-counterclockwise", "funnel-fill", "terminal-fill", "table"] +ICON_NAMES = Literal[ + "arrow-counterclockwise", + "bar-chart-fill", + "chevron-down", + "download", + "funnel-fill", + "graph-up", + "terminal-fill", + "table", +] -def bs_icon(name: ICON_NAMES) -> ui.HTML: +def bs_icon(name: ICON_NAMES, cls: str = "") -> ui.HTML: """Get Bootstrap icon SVG by name.""" if name not in BS_ICONS: raise ValueError(f"Unknown Bootstrap icon: {name}") - return ui.HTML(BS_ICONS[name]) + svg = BS_ICONS[name] + if cls: + svg = svg.replace('class="', f'class="{cls} ', 1) + return ui.HTML(svg) BS_ICONS = { "arrow-counterclockwise": '', + "bar-chart-fill": '', + "chevron-down": '', + "download": '', "funnel-fill": '', + "graph-up": '', "terminal-fill": '', "table": '', } diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index e8a7c7f15..58a5e47c6 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -23,11 +23,14 @@ from ._shiny_module import GREETING_PROMPT from ._system_prompt import QueryChatSystemPrompt from ._utils import MISSING, MISSING_TYPE, is_ibis_table +from ._viz_utils import has_viz_deps, has_viz_tool from .tools import ( UpdateDashboardData, + VisualizeQueryData, tool_query, tool_reset_dashboard, tool_update_dashboard, + tool_visualize_query, ) if TYPE_CHECKING: @@ -35,8 +38,8 @@ from narwhals.stable.v1.typing import IntoFrame -TOOL_GROUPS = Literal["update", "query"] - +TOOL_GROUPS = Literal["update", "query", "visualize_query"] +DEFAULT_TOOLS: tuple[TOOL_GROUPS, ...] = ("update", "query") class QueryChatBase(Generic[IntoFrameT]): """ @@ -58,7 +61,7 @@ def __init__( *, greeting: Optional[str | Path] = None, client: Optional[str | chatlas.Chat] = None, - tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("update", "query"), + tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = DEFAULT_TOOLS, data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -72,7 +75,7 @@ def __init__( "Table name must begin with a letter and contain only letters, numbers, and underscores", ) - self.tools = normalize_tools(tools, default=("update", "query")) + self.tools = normalize_tools(tools, default=DEFAULT_TOOLS) self.greeting = greeting.read_text() if isinstance(greeting, Path) else greeting # Store init parameters for deferred system prompt building @@ -132,6 +135,7 @@ def client( tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None | MISSING_TYPE = MISSING, update_dashboard: Callable[[UpdateDashboardData], None] | None = None, reset_dashboard: Callable[[], None] | None = None, + visualize_query: Callable[[VisualizeQueryData], None] | None = None, ) -> chatlas.Chat: """ Create a chat client with registered tools. @@ -139,11 +143,14 @@ def client( Parameters ---------- tools - Which tools to include: `"update"`, `"query"`, or both. + Which tools to include: `"update"`, `"query"`, `"visualize_query"`, + or a combination. update_dashboard Callback when update_dashboard tool succeeds. reset_dashboard Callback when reset_dashboard tool is invoked. + visualize_query + Callback when visualize_query tool succeeds. Returns ------- @@ -172,6 +179,10 @@ def client( if "query" in tools: chat.register_tool(tool_query(data_source)) + if "visualize_query" in tools: + query_viz_fn = visualize_query or (lambda _: None) + chat.register_tool(tool_visualize_query(data_source, query_viz_fn)) + return chat def generate_greeting(self, *, echo: Literal["none", "output"] = "none") -> str: @@ -278,14 +289,24 @@ def normalize_client(client: str | chatlas.Chat | None) -> chatlas.Chat: def normalize_tools( tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None | MISSING_TYPE, default: tuple[TOOL_GROUPS, ...] | None, + *, + check_deps: bool = True, ) -> tuple[TOOL_GROUPS, ...] | None: if tools is None or tools == (): - return None + result = None elif isinstance(tools, MISSING_TYPE): - return default + result = default elif isinstance(tools, str): - return (tools,) + result = (tools,) elif isinstance(tools, tuple): - return tools + result = tools else: - return tuple(tools) + result = tuple(tools) + if not check_deps: + return result + if has_viz_tool(result) and not has_viz_deps(): + raise ImportError( + "Visualization tools require ggsql, altair, and shinywidgets. " + "Install them with: pip install querychat[viz]" + ) + return result diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index c1dcc9a19..56176081b 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -10,13 +10,15 @@ from shiny import App, Inputs, Outputs, Session, reactive, render, req, ui from ._icons import bs_icon -from ._querychat_base import TOOL_GROUPS, QueryChatBase +from ._querychat_base import DEFAULT_TOOLS, TOOL_GROUPS, QueryChatBase from ._shiny_module import ServerValues, mod_server, mod_ui from ._utils import as_narwhals +from ._viz_utils import has_viz_tool if TYPE_CHECKING: from pathlib import Path + import altair as alt import chatlas import ibis import narwhals.stable.v1 as nw @@ -97,10 +99,11 @@ class QueryChat(QueryChatBase[IntoFrameT]): tools Which querychat tools to include in the chat client by default. Can be: - A single tool string: `"update"` or `"query"` - - A tuple of tools: `("update", "query")` + - A tuple of tools: `("update", "query", "visualize_query")` - `None` or `()` to disable all tools - Default is `("update", "query")` (both tools enabled). + Default is `("update", "query")`. The visualization tool (`"visualize_query"`) + can be opted into by including it in the tuple. Set to `"update"` to prevent the LLM from accessing data values, only allowing dashboard filtering without answering questions. @@ -156,7 +159,7 @@ def __init__( id: Optional[str] = None, greeting: Optional[str | Path] = None, client: Optional[str | chatlas.Chat] = None, - tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("update", "query"), + tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = DEFAULT_TOOLS, data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -172,7 +175,7 @@ def __init__( id: Optional[str] = None, greeting: Optional[str | Path] = None, client: Optional[str | chatlas.Chat] = None, - tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("update", "query"), + tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = DEFAULT_TOOLS, data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -188,7 +191,7 @@ def __init__( id: Optional[str] = None, greeting: Optional[str | Path] = None, client: Optional[str | chatlas.Chat] = None, - tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("update", "query"), + tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = DEFAULT_TOOLS, data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -204,7 +207,7 @@ def __init__( id: Optional[str] = None, greeting: Optional[str | Path] = None, client: Optional[str | chatlas.Chat] = None, - tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("update", "query"), + tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = DEFAULT_TOOLS, data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -219,7 +222,7 @@ def __init__( id: Optional[str] = None, greeting: Optional[str | Path] = None, client: Optional[str | chatlas.Chat] = None, - tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("update", "query"), + tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = DEFAULT_TOOLS, data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -245,9 +248,13 @@ def app( """ Quickly chat with a dataset. - Creates a Shiny app with a chat sidebar and data table view -- providing a + Creates a Shiny app with a chat sidebar and tabbed view -- providing a quick-and-easy way to start chatting with your data. + The app includes two tabs: + - **Data**: Shows the filtered data table + - **Query Plot**: Shows the most recent query visualization + Parameters ---------- bookmark_store @@ -266,7 +273,28 @@ def app( enable_bookmarking = bookmark_store != "disable" table_name = data_source.table_name + tools_tuple = ( + (self.tools,) if isinstance(self.tools, str) else (self.tools or ()) + ) + has_query_viz = has_viz_tool(tools_tuple) + def app_ui(request): + nav_panels = [ + ui.nav_panel( + "Data", + ui.card( + ui.card_header(bs_icon("table"), " Data"), + ui.output_data_frame("dt"), + ), + ), + ] + if has_query_viz: + nav_panels.append( + ui.nav_panel( + "Query Plot", + ui.output_ui("query_plot_container"), + ) + ) return ui.page_sidebar( self.sidebar(), ui.card( @@ -285,10 +313,7 @@ def app_ui(request): fill=False, style="max-height: 33%;", ), - ui.card( - ui.card_header(bs_icon("table"), " Data"), - ui.output_data_frame("dt"), - ), + ui.navset_tab(*nav_panels, id="main_tabs"), title=ui.span("querychat with ", ui.code(table_name)), class_="bslib-page-dashboard", fillable=True, @@ -301,6 +326,7 @@ def app_server(input: Inputs, output: Outputs, session: Session): greeting=self.greeting, client=self._client, enable_bookmarking=enable_bookmarking, + tools=self.tools, ) @render.text @@ -338,6 +364,36 @@ def sql_output(): width="100%", ) + if has_query_viz: + from shinywidgets import output_widget, render_altair + + @render_altair + def query_chart(): + return vals.viz_widget() + + @render.ui + def query_plot_container(): + chart = vals.viz_widget() + if chart is None: + return ui.card( + ui.card_body( + ui.p( + "No query visualization yet. " + "Use the chat to create one." + ), + class_="text-muted text-center py-5", + ), + ) + + return ui.card( + ui.card_header( + bs_icon("bar-chart-fill"), + " ", + vals.viz_title.get() or "Query Visualization", + ), + output_widget("query_chart"), + ) + return App(app_ui, app_server, bookmark_store=bookmark_store) def sidebar( @@ -399,7 +455,7 @@ def ui(self, *, id: Optional[str] = None, **kwargs): A UI component. """ - return mod_ui(id or self.id, **kwargs) + return mod_ui(id or self.id, preload_viz=has_viz_tool(self.tools), **kwargs) def server( self, @@ -493,6 +549,7 @@ def title(): greeting=self.greeting, client=self.client, enable_bookmarking=enable_bookmarking, + tools=self.tools, ) @@ -730,6 +787,7 @@ def __init__( greeting=self.greeting, client=self._client, enable_bookmarking=enable, + tools=self.tools, ) def sidebar( @@ -791,7 +849,7 @@ def ui(self, *, id: Optional[str] = None, **kwargs): A UI component. """ - return mod_ui(id or self.id, **kwargs) + return mod_ui(id or self.id, preload_viz=has_viz_tool(self.tools), **kwargs) def df(self) -> IntoFrameT: """ @@ -870,3 +928,39 @@ def title(self, value: Optional[str] = None) -> str | None | bool: return self._vals.title() else: return self._vals.title.set(value) + + def ggvis(self) -> alt.JupyterChart | None: + """ + Get the visualization chart from the most recent visualize_query call. + + Returns + ------- + : + The Altair chart, or None if no visualization exists. + + """ + return self._vals.viz_widget() + + def ggsql(self) -> str | None: + """ + Get the full ggsql query from the most recent visualize_query call. + + Returns + ------- + : + The ggsql query string, or None if no visualization exists. + + """ + return self._vals.viz_ggsql.get() + + def ggtitle(self) -> str | None: + """ + Get the visualization title from the most recent visualize_query call. + + Returns + ------- + : + The title, or None if no visualization exists. + + """ + return self._vals.viz_title.get() diff --git a/pkg-py/src/querychat/_shiny_module.py b/pkg-py/src/querychat/_shiny_module.py index 335f6803a..fde34502a 100644 --- a/pkg-py/src/querychat/_shiny_module.py +++ b/pkg-py/src/querychat/_shiny_module.py @@ -13,17 +13,25 @@ from shiny import module, reactive, ui from ._querychat_core import GREETING_PROMPT -from .tools import tool_query, tool_reset_dashboard, tool_update_dashboard +from ._viz_utils import has_viz_tool, preload_viz_deps_server, preload_viz_deps_ui +from .tools import ( + tool_query, + tool_reset_dashboard, + tool_update_dashboard, + tool_visualize_query, +) if TYPE_CHECKING: from collections.abc import Callable + import altair as alt from shiny.bookmark import BookmarkState, RestoreState from shiny import Inputs, Outputs, Session from ._datasource import DataSource - from .types import UpdateDashboardData + from ._querychat_base import TOOL_GROUPS + from .tools import UpdateDashboardData, VisualizeQueryData ReactiveString = reactive.Value[str] """A reactive string value.""" @@ -34,20 +42,25 @@ @module.ui -def mod_ui(**kwargs): +def mod_ui(*, preload_viz: bool = False, **kwargs): css_path = Path(__file__).parent / "static" / "css" / "styles.css" js_path = Path(__file__).parent / "static" / "js" / "querychat.js" tag = shinychat.chat_ui(CHAT_ID, **kwargs) tag.add_class("querychat") - return ui.TagList( + children: list[ui.TagChild] = [ ui.head_content( ui.include_css(css_path), ui.include_js(js_path), ), tag, - ) + ] + + if preload_viz: + children.append(preload_viz_deps_ui()) + + return ui.TagList(*children) @dataclass @@ -79,6 +92,17 @@ class ServerValues(Generic[IntoFrameT]): The session-specific chat client instance. This is a deep copy of the base client configured for this specific session, containing the chat history and tool registrations for this session only. + viz_ggsql + A reactive Value containing the full ggsql query from visualize_query. + Returns `None` if no visualization has been created. + viz_title + A reactive Value containing the title from visualize_query. + Returns `None` if no visualization has been created. + viz_widget + A callable returning the rendered Altair chart from visualize_query. + Returns `None` if no visualization has been created. The chart is + re-rendered on each call using ``execute_ggsql()`` and + ``AltairWidget.from_ggsql()``. """ @@ -86,6 +110,10 @@ class ServerValues(Generic[IntoFrameT]): sql: ReactiveStringOrNone title: ReactiveStringOrNone client: chatlas.Chat + # Visualization state + viz_ggsql: ReactiveStringOrNone + viz_title: ReactiveStringOrNone + viz_widget: Callable[[], alt.JupyterChart | None] @module.server @@ -98,12 +126,17 @@ def mod_server( greeting: str | None, client: chatlas.Chat | Callable, enable_bookmarking: bool, + tools: tuple[TOOL_GROUPS, ...] | None = None, ) -> ServerValues[IntoFrameT]: # Reactive values to store state sql = ReactiveStringOrNone(None) title = ReactiveStringOrNone(None) has_greeted = reactive.value[bool](False) # noqa: FBT003 + # Visualization state - store only specs, render on demand + viz_ggsql = ReactiveStringOrNone(None) + viz_title = ReactiveStringOrNone(None) + # Short-circuit for stub sessions (e.g. 1st run of an Express app) # data_source may be None during stub session for deferred pattern if session.is_stub_session(): @@ -116,6 +149,9 @@ def _stub_df(): sql=sql, title=title, client=client if isinstance(client, chatlas.Chat) else client(), + viz_ggsql=viz_ggsql, + viz_title=viz_title, + viz_widget=lambda: None, ) # Real session requires data_source @@ -133,11 +169,17 @@ def reset_dashboard(): sql.set(None) title.set(None) + def update_query_viz(data: VisualizeQueryData): + viz_ggsql.set(data["ggsql"]) + viz_title.set(data["title"]) + # Set up the chat object for this session # Support both a callable that creates a client and legacy instance pattern if callable(client) and not isinstance(client, chatlas.Chat): chat = client( - update_dashboard=update_dashboard, reset_dashboard=reset_dashboard + update_dashboard=update_dashboard, + reset_dashboard=reset_dashboard, + visualize_query=update_query_viz, ) else: # Legacy pattern: client is Chat instance @@ -147,12 +189,30 @@ def reset_dashboard(): chat.register_tool(tool_query(data_source)) chat.register_tool(tool_reset_dashboard(reset_dashboard)) + if has_viz_tool(tools): + chat.register_tool(tool_visualize_query(data_source, update_query_viz)) + + if has_viz_tool(tools): + preload_viz_deps_server() + # Execute query when SQL changes @reactive.calc def filtered_df(): query = sql.get() - df = data_source.get_data() if not query else data_source.execute_query(query) - return df + return data_source.get_data() if not query else data_source.execute_query(query) + + # Render query visualization on demand + @reactive.calc + def render_viz_widget(): + from ._viz_altair_widget import AltairWidget + from ._viz_ggsql import execute_ggsql + + ggsql_query = viz_ggsql.get() + if ggsql_query is None: + return None + + spec = execute_ggsql(data_source, ggsql_query) + return AltairWidget.from_ggsql(spec).widget # Chat UI logic chat_ui = shinychat.Chat(CHAT_ID) @@ -209,6 +269,8 @@ def _on_bookmark(x: BookmarkState) -> None: vals["querychat_sql"] = sql.get() vals["querychat_title"] = title.get() vals["querychat_has_greeted"] = has_greeted.get() + vals["querychat_viz_ggsql"] = viz_ggsql.get() + vals["querychat_viz_title"] = viz_title.get() @session.bookmark.on_restore def _on_restore(x: RestoreState) -> None: @@ -219,8 +281,20 @@ def _on_restore(x: RestoreState) -> None: title.set(vals["querychat_title"]) if "querychat_has_greeted" in vals: has_greeted.set(vals["querychat_has_greeted"]) - - return ServerValues(df=filtered_df, sql=sql, title=title, client=chat) + if "querychat_viz_ggsql" in vals: + viz_ggsql.set(vals["querychat_viz_ggsql"]) + if "querychat_viz_title" in vals: + viz_title.set(vals["querychat_viz_title"]) + + return ServerValues( + df=filtered_df, + sql=sql, + title=title, + client=chat, + viz_ggsql=viz_ggsql, + viz_title=viz_title, + viz_widget=render_viz_widget, + ) class GreetWarning(Warning): diff --git a/pkg-py/src/querychat/_system_prompt.py b/pkg-py/src/querychat/_system_prompt.py index 5a8445e93..b742ce4d4 100644 --- a/pkg-py/src/querychat/_system_prompt.py +++ b/pkg-py/src/querychat/_system_prompt.py @@ -6,7 +6,9 @@ import chevron -_SCHEMA_TAG_RE = re.compile(r"\{\{[{#^/]?\s*schema\b") +from ._viz_utils import has_viz_tool + +SCHEMA_TAG_RE = re.compile(r"\{\{[{#^/]?\s*schema\b") if TYPE_CHECKING: from ._datasource import DataSource @@ -50,7 +52,7 @@ def __init__( else: self.extra_instructions = extra_instructions - if _SCHEMA_TAG_RE.search(self.template): + if SCHEMA_TAG_RE.search(self.template): self.schema = data_source.get_schema( categorical_threshold=categorical_threshold ) @@ -83,6 +85,7 @@ def render(self, tools: tuple[TOOL_GROUPS, ...] | None) -> str: "extra_instructions": self.extra_instructions, "has_tool_update": "update" in tools if tools else False, "has_tool_query": "query" in tools if tools else False, + "has_tool_visualize_query": has_viz_tool(tools), "include_query_guidelines": len(tools or ()) > 0, } diff --git a/pkg-py/src/querychat/_utils.py b/pkg-py/src/querychat/_utils.py index 555e8e376..aec6aecef 100644 --- a/pkg-py/src/querychat/_utils.py +++ b/pkg-py/src/querychat/_utils.py @@ -14,6 +14,8 @@ import ibis import pandas as pd + import polars as pl + from narwhals.stable.v1.typing import IntoFrame class MISSING_TYPE: # noqa: N801 @@ -171,14 +173,18 @@ def get_tool_details_setting() -> Optional[Literal["expanded", "collapsed", "def return setting_lower -def querychat_tool_starts_open(action: Literal["update", "query", "reset"]) -> bool: +def querychat_tool_starts_open( + action: Literal[ + "update", "query", "reset", "visualize_query" + ], +) -> bool: """ Determine whether a tool card should be open based on action and setting. Parameters ---------- action : str - The action type ('update', 'query', or 'reset') + The action type ('update', 'query', 'reset', or 'visualize_query') Returns ------- @@ -290,3 +296,11 @@ def df_to_html(df, maxrows: int = 5) -> str: table_html += f"\n\n*(Showing {maxrows} of {nrow_full} rows)*\n" return table_html + + +def to_polars(data: IntoFrame) -> pl.DataFrame: + """Convert any narwhals-compatible frame to a polars DataFrame.""" + nw_df = nw.from_native(data) + if isinstance(nw_df, nw.LazyFrame): + nw_df = nw_df.collect() + return nw_df.to_polars() diff --git a/pkg-py/src/querychat/_viz_altair_widget.py b/pkg-py/src/querychat/_viz_altair_widget.py new file mode 100644 index 000000000..eec22884f --- /dev/null +++ b/pkg-py/src/querychat/_viz_altair_widget.py @@ -0,0 +1,190 @@ +"""Altair chart wrapper for responsive display in Shiny.""" + +from __future__ import annotations + +import functools +from typing import TYPE_CHECKING, Any, cast +from uuid import uuid4 + +from shiny.session import get_current_session + +from shiny import reactive + +if TYPE_CHECKING: + import altair as alt + import ggsql + +@functools.cache +def get_compound_chart_types() -> tuple[type, ...]: + import altair as alt + + return ( + alt.FacetChart, + alt.ConcatChart, + alt.HConcatChart, + alt.VConcatChart, + ) + + +class AltairWidget: + """ + An Altair chart wrapped in ``alt.JupyterChart`` for display in Shiny. + + Always produces a ``JupyterChart`` so that ``shinywidgets`` receives + a consistent widget type and doesn't call ``chart.properties(width=...)`` + (which fails on compound specs). + + Simple charts use native ``width/height: "container"`` sizing. + Compound charts (facet, concat) get calculated cell dimensions + that are reactively updated when the output container resizes. + """ + + widget: alt.JupyterChart + widget_id: str + + def __init__(self, chart: alt.TopLevelMixin) -> None: + import altair as alt + + is_compound = isinstance(chart, get_compound_chart_types()) + + # Workaround: Vega-Lite's width/height: "container" doesn't work for + # compound specs (facet, concat, etc.), so we inject pixel dimensions + # and reconstruct the chart. Remove this branch when ggsql handles it + # natively: https://github.com/posit-dev/ggsql/issues/238 + if is_compound: + chart = inject_compound_sizes( + chart, DEFAULT_COMPOUND_WIDTH, DEFAULT_COMPOUND_HEIGHT + ) + else: + chart = chart.properties(width="container", height="container") + + self.widget = alt.JupyterChart(chart) + self.widget_id = f"querychat_viz_{uuid4().hex[:8]}" + + # Reactively update compound cell sizes when the container resizes. + # Also part of the compound sizing workaround (issue #238). + if is_compound: + self._setup_reactive_sizing(self.widget, self.widget_id) + + @classmethod + def from_ggsql(cls, spec: ggsql.Spec) -> AltairWidget: + from ggsql import VegaLiteWriter + + writer = VegaLiteWriter() + return cls(writer.render_chart(spec)) + + @staticmethod + def _setup_reactive_sizing(widget: alt.JupyterChart, widget_id: str) -> None: + session = get_current_session() + if session is None: + return + + @reactive.effect + def _sizing_effect(): + width = session.clientdata.output_width(widget_id) + height = session.clientdata.output_height(widget_id) + if width is None or height is None: + return + chart = widget.chart + if chart is None: + return + chart = cast("alt.Chart", chart) + chart2 = inject_compound_sizes(chart, int(width), int(height)) + # Must set widget.spec (a new dict) rather than widget.chart, + # because traitlets won't fire change events when the same + # chart object is assigned back after in-place mutation. + widget.spec = chart2.to_dict() + + # Clean up the effect when the session ends to avoid memory leaks + session.on_ended(_sizing_effect.destroy) + + +# --------------------------------------------------------------------------- +# Compound chart sizing helpers +# +# Vega-Lite's `width/height: "container"` doesn't work for compound specs +# (facet, concat, etc.), so we manually inject cell dimensions. Ideally ggsql +# will handle this natively: https://github.com/posit-dev/ggsql/issues/238 +# --------------------------------------------------------------------------- + +DEFAULT_COMPOUND_WIDTH = 900 +DEFAULT_COMPOUND_HEIGHT = 450 + +LEGEND_CHANNELS = frozenset( + {"color", "colour", "fill", "stroke", "shape", "size", "opacity"} +) +LEGEND_WIDTH = 120 # approximate space for a right-side legend + + +def inject_compound_sizes( + chart: alt.TopLevelMixin, + container_width: int, + container_height: int, +) -> alt.TopLevelMixin: + """ + Set cell ``width``/``height`` on a compound spec via in-place mutation. + + The chart is mutated in-place **and** returned. Callers that need to + trigger traitlets change detection should serialize the returned chart + (e.g., ``chart.to_dict()``) rather than reassigning ``widget.chart``, + because traitlets won't fire events for the same object after mutation. + + For faceted charts, divides the container width by the number of columns. + For hconcat/concat, divides by the number of sub-specs. + For vconcat, each sub-spec gets the full width. + + Subtracts padding estimates so the rendered cells fill the container, + including space for legends when present. + """ + import altair as alt + + # Approximate padding; will be replaced when ggsql handles compound sizing + # natively (https://github.com/posit-dev/ggsql/issues/238). + padding_x = 80 # y-axis labels + title padding + padding_y = 120 # facet headers, x-axis labels + title, bottom padding + if has_legend(chart.to_dict()): + padding_x += LEGEND_WIDTH + usable_w = max(container_width - padding_x, 100) + usable_h = max(container_height - padding_y, 100) + + if isinstance(chart, alt.FacetChart): + ncol = chart.columns if isinstance(chart.columns, int) else 1 + cell_w = usable_w // max(ncol, 1) + chart.spec.width = cell_w + chart.spec.height = usable_h + elif isinstance(chart, alt.HConcatChart): + cell_w = usable_w // max(len(chart.hconcat), 1) + for sub in chart.hconcat: + sub.width = cell_w + sub.height = usable_h + elif isinstance(chart, alt.ConcatChart): + ncol = chart.columns if isinstance(chart.columns, int) else len(chart.concat) + cell_w = usable_w // max(ncol, 1) + for sub in chart.concat: + sub.width = cell_w + sub.height = usable_h + elif isinstance(chart, alt.VConcatChart): + cell_h = usable_h // max(len(chart.vconcat), 1) + for sub in chart.vconcat: + sub.width = usable_w + sub.height = cell_h + + return chart + + +def has_legend(vl: dict[str, object]) -> bool: + """Check if any encoding in the VL spec uses a legend-producing channel with a field.""" + specs: list[dict[str, Any]] = [] + if "spec" in vl: + specs.append(vl["spec"]) # type: ignore[arg-type] + for key in ("hconcat", "vconcat", "concat"): + if key in vl: + specs.extend(vl[key]) # type: ignore[arg-type] + + for spec in specs: + for layer in spec.get("layer", [spec]): # type: ignore[union-attr] + enc = layer.get("encoding", {}) # type: ignore[union-attr] + for ch in LEGEND_CHANNELS: + if ch in enc and "field" in enc[ch]: # type: ignore[operator] + return True + return False diff --git a/pkg-py/src/querychat/_viz_ggsql.py b/pkg-py/src/querychat/_viz_ggsql.py new file mode 100644 index 000000000..8cfd0cb68 --- /dev/null +++ b/pkg-py/src/querychat/_viz_ggsql.py @@ -0,0 +1,66 @@ +"""Helpers for ggsql integration.""" + +from __future__ import annotations + +import re +from typing import TYPE_CHECKING + +from ._utils import to_polars + +if TYPE_CHECKING: + import ggsql + + from ._datasource import DataSource + + +def execute_ggsql(data_source: DataSource, query: str) -> ggsql.Spec: + """ + Execute a full ggsql query against a DataSource, returning a Spec. + + Uses ggsql.validate() to split SQL from VISUALISE, executes the SQL + through DataSource (preserving database pushdown), then feeds the result + into a ggsql DuckDBReader to produce a Spec. + + Parameters + ---------- + data_source + The querychat DataSource to execute the SQL portion against. + query + A full ggsql query (SQL + VISUALISE). + + Returns + ------- + ggsql.Spec + The writer-independent plot specification. + + """ + import ggsql as _ggsql + + validated = _ggsql.validate(query) + pl_df = to_polars(data_source.execute_query(validated.sql())) + + reader = _ggsql.DuckDBReader("duckdb://memory") + visual = validated.visual() + table = extract_visualise_table(visual) + + if table is not None: + # VISUALISE [mappings] FROM — register data under the + # referenced table name and execute the visual part directly. + reader.register(table.strip('"'), pl_df) + return reader.execute(visual) + else: + # SELECT ... VISUALISE — no FROM in VISUALISE clause, so register + # under a synthetic name and prepend a SELECT. + reader.register("_data", pl_df) + return reader.execute(f"SELECT * FROM _data {visual}") + + +def extract_visualise_table(visual: str) -> str | None: + """Extract the table name from ``VISUALISE ... FROM
`` if present.""" + # Only look at the VISUALISE clause (before the first DRAW) to avoid + # matching layer-level FROM (e.g., DRAW bar MAPPING ... FROM summary). + draw_pos = re.search(r"\bDRAW\b", visual, re.IGNORECASE) + vis_clause = visual[: draw_pos.start()] if draw_pos else visual + # Matches double-quoted or bare identifiers (the only forms ggsql supports). + m = re.search(r'\bFROM\s+("[^"]+?"|\S+)', vis_clause, re.IGNORECASE) + return m.group(1) if m else None diff --git a/pkg-py/src/querychat/_viz_tools.py b/pkg-py/src/querychat/_viz_tools.py new file mode 100644 index 000000000..bc86279ba --- /dev/null +++ b/pkg-py/src/querychat/_viz_tools.py @@ -0,0 +1,291 @@ +"""Visualization tool definitions for querychat.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, TypedDict +from uuid import uuid4 + +from chatlas import ContentToolResult, Tool +from htmltools import HTMLDependency, TagList, tags +from shinychat.types import ToolResultDisplay + +from shiny import ui + +from .__version import __version__ +from ._icons import bs_icon + +if TYPE_CHECKING: + from collections.abc import Callable + + from ipywidgets.widgets.widget import Widget + + from ._datasource import DataSource + + +class VisualizeQueryData(TypedDict): + """ + Data passed to visualize_query callback. + + This TypedDict defines the structure of data passed to the + `tool_visualize_query` callback function when the LLM creates an + exploratory visualization from a ggsql query. + + Attributes + ---------- + ggsql + The full ggsql query string (SQL + VISUALISE). + title + A descriptive title for the visualization, or None if not provided. + + """ + + ggsql: str + title: str | None + + +def tool_visualize_query( + data_source: DataSource, + update_fn: Callable[[VisualizeQueryData], None], +) -> Tool: + """ + Create a tool that executes a ggsql query and renders the visualization. + + Parameters + ---------- + data_source + The data source to query against + update_fn + Callback function to call with VisualizeQueryData when visualization succeeds + + Returns + ------- + Tool + A tool that can be registered with chatlas + + """ + impl = visualize_query_impl(data_source, update_fn) + impl.__doc__ = read_prompt_template( + "tool-visualize-query.md", + db_type=data_source.get_db_type(), + ) + + return Tool.from_func( + impl, + name="querychat_visualize_query", + annotations={"title": "Query Visualization"}, + ) + + +class VisualizeQueryResult(ContentToolResult): + """Tool result that registers an ipywidget and embeds it inline via shinywidgets.""" + + def __init__( + self, + widget_id: str, + widget: Widget, + ggsql_str: str, + title: str | None, + row_count: int, + col_count: int, + **kwargs: Any, + ): + from shinywidgets import output_widget, register_widget + + register_widget(widget_id, widget) + + title_display = f" - {title}" if title else "" + markdown = f"```sql\n{ggsql_str}\n```" + markdown += f"\n\nVisualization created{title_display}." + markdown += f"\n\nData: {row_count} rows, {col_count} columns." + + footer = build_viz_footer(ggsql_str, title, widget_id) + + widget_html = output_widget(widget_id, fill=True, fillable=True) + widget_html.add_class("querychat-viz-container") + widget_html.append(viz_dep()) + + extra = { + "display": ToolResultDisplay( + html=widget_html, + title=title or "Query Visualization", + show_request=False, + open=True, + full_screen=True, + icon=bs_icon("graph-up"), + footer=footer, + ), + } + + super().__init__(value=markdown, extra=extra, **kwargs) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def visualize_query_impl( + data_source: DataSource, + update_fn: Callable[[VisualizeQueryData], None], +) -> Callable[[str, str | None], ContentToolResult]: + """Create the visualize_query implementation function.""" + import ggsql as ggsql_pkg + + from ._viz_altair_widget import AltairWidget + from ._viz_ggsql import execute_ggsql + + def visualize_query( + ggsql: str, + title: str | None = None, + ) -> ContentToolResult: + """Execute a ggsql query and render the visualization.""" + markdown = f"```sql\n{ggsql}\n```" + + try: + validated = ggsql_pkg.validate(ggsql) + if not validated.has_visual(): + has_keyword = "VISUALISE" in ggsql.upper() or "VISUALIZE" in ggsql.upper() + if has_keyword: + raise ValueError( + "VISUALISE clause was not recognized. " + "VISUALISE and MAPPING accept column names only — " + "no SQL expressions, CAST(), or functions. " + "Move all data transformations to the SELECT clause, " + "then reference the resulting column by name in VISUALISE." + ) + raise ValueError( + "Query must include a VISUALISE clause. " + "Use querychat_query for queries without visualization." + ) + + spec = execute_ggsql(data_source, ggsql) + altair_widget = AltairWidget.from_ggsql(spec) + + metadata = spec.metadata() + row_count = metadata["rows"] + col_count = len(metadata["columns"]) + + update_fn({"ggsql": ggsql, "title": title}) + + return VisualizeQueryResult( + widget_id=altair_widget.widget_id, + widget=altair_widget.widget, + ggsql_str=ggsql, + title=title, + row_count=row_count, + col_count=col_count, + ) + + except Exception as e: + error_msg = str(e) + markdown += f"\n\n> Error: {error_msg}" + return ContentToolResult(value=markdown, error=e) + + return visualize_query + + +def read_prompt_template(filename: str, **kwargs: object) -> str: + """Read and interpolate a prompt template file.""" + from pathlib import Path + + import chevron + + template_path = Path(__file__).parent / "prompts" / filename + template = template_path.read_text() + return chevron.render(template, kwargs) + + +def viz_dep() -> HTMLDependency: + """HTMLDependency for viz-specific CSS and JS assets.""" + return HTMLDependency( + "querychat-viz", + __version__, + source={ + "package": "querychat", + "subdir": "static", + }, + stylesheet=[{"href": "css/viz.css"}], + script=[{"src": "js/viz.js"}], + ) + + +def build_viz_footer( + ggsql_str: str, + title: str | None, + widget_id: str, +) -> TagList: + """Build footer HTML for visualization tool results.""" + footer_id = f"querychat_footer_{uuid4().hex[:8]}" + query_section_id = f"{footer_id}_query" + code_editor_id = f"{footer_id}_code" + + # Read-only code editor for query display + code_editor = ui.input_code_editor( + id=code_editor_id, + value=ggsql_str, + language="ggsql", + read_only=True, + line_numbers=False, + height="auto", + theme_dark="github-dark", + ) + + # Query section (hidden by default) + query_section = tags.div( + {"class": "querychat-query-section", "id": query_section_id}, + code_editor, + ) + + # Footer buttons row + buttons_row = tags.div( + {"class": "querychat-footer-buttons"}, + # Left: Show Query toggle + tags.div( + {"class": "querychat-footer-left"}, + tags.button( + { + "class": "querychat-show-query-btn", + "data-target": query_section_id, + }, + tags.span({"class": "querychat-query-chevron"}, "\u25b6"), + tags.span({"class": "querychat-query-label"}, "Show Query"), + ), + ), + # Right: Save dropdown + tags.div( + {"class": "querychat-footer-right"}, + tags.div( + {"class": "querychat-save-dropdown"}, + tags.button( + { + "class": "querychat-save-btn", + "data-widget-id": widget_id, + }, + bs_icon("download", cls="querychat-icon"), + "Save", + bs_icon("chevron-down", cls="querychat-dropdown-chevron"), + ), + tags.div( + {"class": "querychat-save-menu"}, + tags.button( + { + "class": "querychat-save-png-btn", + "data-widget-id": widget_id, + "data-title": title or "chart", + }, + "Save as PNG", + ), + tags.button( + { + "class": "querychat-save-svg-btn", + "data-widget-id": widget_id, + "data-title": title or "chart", + }, + "Save as SVG", + ), + ), + ), + ), + ) + + return TagList(buttons_row, query_section) diff --git a/pkg-py/src/querychat/_viz_utils.py b/pkg-py/src/querychat/_viz_utils.py new file mode 100644 index 000000000..cf7be61e7 --- /dev/null +++ b/pkg-py/src/querychat/_viz_utils.py @@ -0,0 +1,54 @@ +"""Shared visualization utilities.""" + +from __future__ import annotations + + +def has_viz_tool(tools: tuple[str, ...] | None) -> bool: + """Check if visualize_query is among the configured tools.""" + return tools is not None and "visualize_query" in tools + + +_viz_deps_available: bool | None = None + + +def has_viz_deps() -> bool: + """Check whether visualization dependencies (ggsql, altair, shinywidgets) are installed.""" + global _viz_deps_available # noqa: PLW0603 + if _viz_deps_available is None: + try: + import altair as alt # noqa: F401 + import ggsql # noqa: F401 + import shinywidgets # noqa: F401 + except ImportError: + _viz_deps_available = False + else: + _viz_deps_available = True + return _viz_deps_available + + + +PRELOAD_WIDGET_ID = "__querychat_preload_viz__" + + +def preload_viz_deps_ui(): + """Return a hidden widget output that triggers eager JS dependency loading.""" + from htmltools import tags + from shinywidgets import output_widget + return tags.div( + output_widget(PRELOAD_WIDGET_ID), + style="position:absolute; left:-9999px; width:1px; height:1px;", + **{"aria-hidden": "true"}, + ) + + +def preload_viz_deps_server() -> None: + """Register a minimal Altair widget to trigger full JS dependency loading.""" + from shinywidgets import register_widget + register_widget(PRELOAD_WIDGET_ID, mock_altair_widget()) + + +def mock_altair_widget(): + """Create a minimal Altair JupyterChart suitable for preloading JS dependencies.""" + import altair as alt + chart = alt.Chart({"values": [{"x": 0}]}).mark_point().encode(x="x:Q") + return alt.JupyterChart(chart) diff --git a/pkg-py/src/querychat/prompts/prompt.md b/pkg-py/src/querychat/prompts/prompt.md index 8c6ff97bc..f15d6edb0 100644 --- a/pkg-py/src/querychat/prompts/prompt.md +++ b/pkg-py/src/querychat/prompts/prompt.md @@ -1,4 +1,4 @@ -You are a data dashboard chatbot that operates in a sidebar interface. Your role is to help users interact with their data through filtering, sorting, and answering questions. +You are a data dashboard chatbot that operates in a sidebar interface. Your role is to help users interact with their data through filtering, sorting, answering questions, and exploring data visually. You have access to a {{db_type}} SQL database with the following schema: @@ -117,12 +117,24 @@ Response: "The average revenue is $X." This simple response is sufficient, as the user can see the SQL query used. +{{#has_tool_visualize_query}} +**Choosing between query and visualization:** Use `querychat_query` for questions with single-value answers (averages, counts, totals, specific lookups). Use `visualize_query` when the answer is better shown as a chart — comparisons across categories, distributions, trends over time, or when the user explicitly asks for a plot/chart. When in doubt, prefer the simpler tabular query. +{{/has_tool_visualize_query}} + {{/has_tool_query}} {{^has_tool_query}} +{{^has_tool_visualize_query}} ### Questions About Data You cannot query or analyze the data. If users ask questions about data values, statistics, or calculations (e.g., "What is the average ____?" or "How many ____ are there?"), explain that you're not able to run queries on this data. Do not attempt to answer based on your own knowledge or assumptions about the data, even if the dataset seems familiar. +{{/has_tool_visualize_query}} +{{#has_tool_visualize_query}} +### Questions About Data + +You cannot run tabular data queries directly. If users ask questions about specific data values, statistics, or calculations, explain that you can create visualizations but cannot return raw query results. Suggest a visualization if the question lends itself to a chart. + +{{/has_tool_visualize_query}} {{/has_tool_query}} ### Providing Suggestions for Next Steps @@ -153,6 +165,15 @@ You might want to explore the advanced features * Show records from the year … * Sort the ____ by ____ … ``` +{{#has_tool_visualize_query}} + +**Visualization suggestions:** +```md +* Visualize the data + * Show a bar chart of … + * Plot the trend of … over time +``` +{{/has_tool_visualize_query}} #### When to Include Suggestions @@ -180,6 +201,12 @@ You might want to explore the advanced features - Never use generic phrases like "If you'd like to..." or "Would you like to explore..." — instead, provide concrete suggestions - Never refer to suggestions as "prompts" – call them "suggestions" or "ideas" or similar +{{#has_tool_visualize_query}} +## Visualization with ggsql + +You can create visualizations using the `visualize_query` tool, which uses ggsql — a SQL extension for declarative data visualization. The tool description contains the full ggsql syntax reference. Always consult it when constructing visualization queries. +{{/has_tool_visualize_query}} + ## Important Guidelines - **Ask for clarification** if any request is unclear or ambiguous diff --git a/pkg-py/src/querychat/prompts/tool-query.md b/pkg-py/src/querychat/prompts/tool-query.md index 0fcdec4b3..ef07bde3a 100644 --- a/pkg-py/src/querychat/prompts/tool-query.md +++ b/pkg-py/src/querychat/prompts/tool-query.md @@ -15,6 +15,7 @@ Always use SQL for counting, averaging, summing, and other calculations—NEVER **Important guidelines:** +- This tool always queries the full (unfiltered) dataset. If the dashboard is currently filtered (via a prior `querychat_update_dashboard` call), consider whether the user's question relates to the filtered subset or the full dataset. When it relates to the filtered view, incorporate the same filter conditions into your SQL WHERE clause. If it's ambiguous, ask the user whether they mean the filtered data or the full dataset - Queries must be valid {{db_type}} SQL SELECT statements - Optimize for readability over efficiency—use clear column aliases and SQL comments to explain complex logic - Subqueries and CTEs are acceptable and encouraged for complex calculations diff --git a/pkg-py/src/querychat/prompts/tool-visualize-query.md b/pkg-py/src/querychat/prompts/tool-visualize-query.md new file mode 100644 index 000000000..4c7295c90 --- /dev/null +++ b/pkg-py/src/querychat/prompts/tool-visualize-query.md @@ -0,0 +1,540 @@ +Run an exploratory visualization query inline in the chat. + +## When to Use + +- The user asks an exploratory question that benefits from visualization +- You want to show a one-off chart without affecting the dashboard filter +- You need to visualize data with specific SQL transformations + +## Behavior + +- Executes the SQL query against the data source +- Renders the visualization inline in the chat +- The chart is also accessible via the Query Plot tab +- Does NOT affect the dashboard filter or filtered data — and always queries the full (unfiltered) dataset +- If the dashboard is currently filtered (via a prior `querychat_update_dashboard` call), consider whether the user's visualization request relates to the filtered subset or the full dataset. When it relates to the filtered view, incorporate the same filter conditions into your SQL WHERE clause so the chart is consistent with what the user sees in the dashboard. If it's ambiguous, ask the user whether they want to visualize the filtered data or the full dataset. This keeps every query fully self-contained and reproducible +- Each call replaces the previous query visualization +- The `title` parameter is displayed as the card header above the chart — do NOT also put a title in the ggsql query via `LABEL title => ...` as it will be redundant +- Always provide the `title` parameter with a brief, descriptive title for the visualization +- Keep visualizations simple and readable: limit facets to 4-6 panels, prefer fewer series/legend entries, and avoid dense text annotations +- For large datasets, aggregate in the SQL portion before visualizing — avoid plotting raw rows when the table has many thousands of records. Use GROUP BY, sampling, or binning to keep charts readable and responsive +- If a visualization fails, read the error message carefully and retry with a corrected query. Common fixes: correcting column names, adding `SCALE DISCRETE` for integer categories, using single quotes for strings, moving SQL expressions out of VISUALISE into the SELECT clause. If the error persists, fall back to `querychat_query` for a tabular answer + +## ggsql Syntax Reference + +### Quick Reference + +```sql +[WITH cte AS (...), ...] +[SELECT columns FROM table WHERE conditions] +VISUALISE [mappings] [FROM source] +DRAW geom_type + [MAPPING col AS aesthetic, ... FROM source] + [REMAPPING stat AS aesthetic, ...] + [SETTING param => value, ...] + [FILTER sql_condition] + [PARTITION BY col, ...] + [ORDER BY col [ASC|DESC], ...] +[SCALE [TYPE] aesthetic [FROM ...] [TO ...] [VIA ...] [SETTING ...] [RENAMING ...]] +[PROJECT [aesthetics] TO coord_system [SETTING ...]] +[FACET var | row_var BY col_var [SETTING free => 'x'|'y'|['x','y'], ncol => N, nrow => N]] +[PLACE geom_type SETTING param => value, ...] +[LABEL x => '...', y => '...', ...] +[THEME name [SETTING property => value, ...]] +``` + +### VISUALISE Clause + +Entry point for visualization. Marks where SQL ends and visualization begins. Mappings in VISUALISE and MAPPING accept **column names only** — no SQL expressions, functions, or casts. All data transformations must happen in the SELECT clause. + +```sql +-- After SELECT (most common) +SELECT date, revenue, region FROM sales +VISUALISE date AS x, revenue AS y, region AS color +DRAW line + +-- Shorthand with FROM (auto-generates SELECT * FROM) +VISUALISE FROM sales +DRAW bar MAPPING region AS x, total AS y +``` + +### Mapping Styles + +| Style | Syntax | Use When | +|-------|--------|----------| +| Explicit | `date AS x` | Column name differs from aesthetic | +| Implicit | `x` | Column name equals aesthetic name | +| Wildcard | `*` | Map all matching columns automatically | +| Literal | `'string' AS color` | Use a literal value (for legend labels in multi-layer plots) | + +### DRAW Clause (Layers) + +Multiple DRAW clauses create layered visualizations. + +```sql +DRAW geom_type + [MAPPING col AS aesthetic, ... FROM source] + [REMAPPING stat AS aesthetic, ...] + [SETTING param => value, ...] + [FILTER sql_condition] + [PARTITION BY col, ...] + [ORDER BY col [ASC|DESC], ...] +``` + +**Geom types:** + +| Category | Types | +|----------|-------| +| Basic | `point`, `line`, `path`, `bar`, `area`, `rect`, `polygon`, `ribbon` | +| Statistical | `histogram`, `density`, `smooth`, `boxplot`, `violin` | +| Annotation | `text`, `segment`, `arrow`, `rule`, `linear`, `errorbar` | + +- `path` is like `line` but preserves data order instead of sorting by x. +- `rect` draws rectangles for heatmaps or range indicators. Map `x`/`y` for center (defaults to width/height of 1), or use `xmin`/`xmax`/`ymin`/`ymax` for explicit bounds. +- `smooth` fits a trendline to data. Settings: `method` (`'nw'` default for kernel regression, `'ols'` for linear, `'tls'` for total least squares), `bandwidth`, `adjust`, `kernel`. +- `text` renders text labels. Map `label` for the text content. Settings: `format` (template string for label formatting), `offset` (pixel offset as `[x, y]`). +- `arrow` draws arrows between two points. Requires `x`, `y`, `xend`, `yend` aesthetics. +- `rule` draws full-span reference lines. Map a value to `y` for a horizontal line or `x` for a vertical line. +- `linear` draws diagonal reference lines from `coef` (slope) and `intercept` aesthetics: y = intercept + coef * x. + +**Aesthetics (MAPPING):** + +| Category | Aesthetics | +|----------|------------| +| Position | `x`, `y`, `xmin`, `xmax`, `ymin`, `ymax`, `xend`, `yend` | +| Color | `color`/`colour`, `fill`, `stroke`, `opacity` | +| Size/Shape | `size`, `shape`, `linewidth`, `linetype`, `width`, `height` | +| Text | `label`, `typeface`, `fontweight`, `italic`, `fontsize`, `hjust`, `vjust`, `rotation` | +| Aggregation | `weight` (for histogram/bar/density/violin) | +| Linear | `coef`, `intercept` (for `linear` layer only) | + +**Layer-specific data source:** Each layer can use a different data source: + +```sql +WITH summary AS (SELECT region, SUM(sales) as total FROM sales GROUP BY region) +SELECT * FROM sales +VISUALISE date AS x, amount AS y +DRAW line +DRAW bar MAPPING region AS x, total AS y FROM summary +``` + +**PARTITION BY** groups data without visual encoding (useful for separate lines per group without color): + +```sql +DRAW line PARTITION BY category +``` + +**ORDER BY** controls row ordering within a layer: + +```sql +DRAW line ORDER BY date ASC +``` + +### PLACE Clause (Annotations) + +`PLACE` creates annotation layers with literal values only — no data mappings. Use it for reference lines, text labels, and other fixed annotations. All aesthetics are set via `SETTING` and bypass scaling. + +```sql +PLACE geom_type SETTING param => value, ... +``` + +**Examples:** +```sql +-- Horizontal reference line +PLACE rule SETTING y => 100 + +-- Vertical reference line +PLACE rule SETTING x => '2024-06-01' + +-- Multiple reference lines (array values) +PLACE rule SETTING y => [50, 75, 100] + +-- Text annotation +PLACE text SETTING x => 10, y => 50, label => 'Threshold' + +-- Diagonal reference line +PLACE linear SETTING coef => 0.4, intercept => -1 +``` + +`PLACE` supports any geom type but is most useful with `rule`, `linear`, `text`, `segment`, and `rect`. Unlike `DRAW`, `PLACE` has no `MAPPING`, `FILTER`, `PARTITION BY`, or `ORDER BY` sub-clauses. + +### Statistical Layers and REMAPPING + +Some layers compute statistics. Use REMAPPING to access computed values: + +| Layer | Computed Stats | Default Remapping | +|-------|---------------|-------------------| +| `bar` (y unmapped) | `count`, `proportion` | `count AS y` | +| `histogram` | `count`, `density` | `count AS y` | +| `density` | `density`, `intensity` | `density AS y` | +| `violin` | `density`, `intensity` | `density AS offset` | +| `smooth` | `intensity` | `intensity AS y` | +| `boxplot` | `value`, `type` | `value AS y` | + +`boxplot` displays box-and-whisker plots. Settings: `outliers` (`true` default — show outlier points), `coef` (`1.5` default — whisker fence coefficient), `width` (`0.9` default — box width, 0–1). + +`smooth` fits a trendline to data. Settings: `method` (`'nw'` or `'nadaraya-watson'` default kernel regression, `'ols'` for OLS linear, `'tls'` for total least squares). NW-only settings: `bandwidth` (numeric), `adjust` (multiplier, default 1), `kernel` (`'gaussian'` default, `'epanechnikov'`, `'triangular'`, `'rectangular'`, `'uniform'`, `'biweight'`, `'quartic'`, `'cosine'`). + +`density` computes a KDE from a continuous `x`. Settings: `bandwidth` (numeric), `adjust` (multiplier, default 1), `kernel` (`'gaussian'` default, `'epanechnikov'`, `'triangular'`, `'rectangular'`, `'uniform'`, `'biweight'`, `'quartic'`, `'cosine'`), `stacking` (`'off'` default, `'on'`, `'fill'`). Use `REMAPPING intensity AS y` to show unnormalized density that reflects group size differences. + +`violin` displays mirrored KDE curves for groups. Requires both `x` (categorical) and `y` (continuous). Accepts the same bandwidth/adjust/kernel settings as density. Use `REMAPPING intensity AS offset` to reflect group size differences. + +**Examples:** + +```sql +-- Density histogram (instead of count) +VISUALISE FROM products +DRAW histogram MAPPING price AS x REMAPPING density AS y + +-- Bar showing proportion +VISUALISE FROM sales +DRAW bar MAPPING region AS x REMAPPING proportion AS y + +-- Overlay histogram and density on the same scale +VISUALISE FROM measurements +DRAW histogram MAPPING value AS x SETTING opacity => 0.5 +DRAW density MAPPING value AS x REMAPPING intensity AS y SETTING opacity => 0.5 + +-- Violin plot +SELECT department, salary FROM employees +VISUALISE department AS x, salary AS y +DRAW violin +``` + +### SCALE Clause + +Configures how data maps to visual properties. All sub-clauses are optional; type and transform are auto-detected from data when omitted. + +```sql +SCALE [TYPE] aesthetic [FROM range] [TO output] [VIA transform] [SETTING prop => value, ...] [RENAMING ...] +``` + +**Type identifiers** (optional — auto-detected if omitted): + +| Type | Description | +|------|-------------| +| `CONTINUOUS` | Numeric data on a continuous axis | +| `DISCRETE` | Categorical/nominal data | +| `BINNED` | Pre-bucketed data | +| `ORDINAL` | Ordered categories with interpolated output | +| `IDENTITY` | Data values are already visual values (e.g., literal hex colors) | + +**Important — integer columns used as categories:** When an integer column represents categories (e.g., a 0/1 `survived` column), ggsql will treat it as continuous by default. This causes errors when mapping to `fill`, `color`, `shape`, or using it in `FACET`. Two fixes: +- **Preferred:** Cast to string in the SELECT clause: `SELECT CAST(survived AS VARCHAR) AS survived ...`, then map the column by name in VISUALISE: `survived AS fill` +- **Alternative:** Declare the scale: `SCALE DISCRETE fill` or `SCALE fill VIA bool` + +**FROM** — input domain: +```sql +SCALE x FROM [0, 100] -- explicit min and max +SCALE x FROM [0, null] -- explicit min, auto max +SCALE DISCRETE x FROM ['A', 'B', 'C'] -- explicit category order +``` + +**TO** — output range or palette: +```sql +SCALE color TO sequential -- default continuous palette (derived from navia) +SCALE color TO viridis -- other continuous: viridis, plasma, inferno, magma, cividis, navia, batlow +SCALE color TO vik -- diverging: vik, rdbu, rdylbu, spectral, brbg, berlin, roma +SCALE DISCRETE color TO ggsql10 -- discrete (default: ggsql10): tableau10, category10, set1, set2, set3, dark2, paired, kelly +SCALE color TO ['red', 'blue'] -- explicit color array +SCALE size TO [1, 10] -- numeric output range +``` + +**VIA** — transformation: +```sql +SCALE x VIA date -- date axis (auto-detected from Date columns) +SCALE x VIA datetime -- datetime axis +SCALE y VIA log10 -- base-10 logarithm +SCALE y VIA sqrt -- square root +``` + +| Category | Transforms | +|----------|------------| +| Logarithmic | `log10`, `log2`, `log` (natural) | +| Power | `sqrt`, `square` | +| Exponential | `exp`, `exp2`, `exp10` | +| Other | `asinh`, `pseudo_log` | +| Temporal | `date`, `datetime`, `time` | +| Type coercion | `integer`, `string`, `bool` | + +**SETTING** — additional properties: +```sql +SCALE x SETTING breaks => 5 -- number of tick marks +SCALE x SETTING breaks => '2 months' -- interval-based breaks +SCALE x SETTING expand => 0.05 -- expand scale range by 5% +SCALE x SETTING reverse => true -- reverse direction +``` + +**RENAMING** — custom axis/legend labels: +```sql +SCALE DISCRETE x RENAMING 'A' => 'Alpha', 'B' => 'Beta' +SCALE CONTINUOUS x RENAMING * => '{} units' -- template for all labels +SCALE x VIA date RENAMING * => '{:time %b %Y}' -- date label formatting +``` + +### Date/Time Axes + +Temporal transforms are auto-detected from column data types, including after `DATE_TRUNC`. + +**Break intervals:** +```sql +SCALE x SETTING breaks => 'month' -- one break per month +SCALE x SETTING breaks => '2 weeks' -- every 2 weeks +SCALE x SETTING breaks => '3 months' -- quarterly +SCALE x SETTING breaks => 'year' -- yearly +``` + +Valid units: `day`, `week`, `month`, `year` (for date); also `hour`, `minute`, `second` (for datetime/time). + +**Date label formatting** (strftime syntax): +```sql +SCALE x VIA date RENAMING * => '{:time %b %Y}' -- "Jan 2024" +SCALE x VIA date RENAMING * => '{:time %B %d, %Y}' -- "January 15, 2024" +SCALE x VIA date RENAMING * => '{:time %b %d}' -- "Jan 15" +``` + +### PROJECT Clause + +Sets coordinate system. Use `PROJECT ... TO` to specify coordinates. + +**Coordinate systems:** `cartesian` (default), `polar`. + +**Polar aesthetics:** In polar coordinates, positional aesthetics use `angle` and `radius` (instead of `x` and `y`). Variants `anglemin`, `anglemax`, `angleend`, `radiusmin`, `radiusmax`, `radiusend` are also available. Typically you map to `x`/`y` and let `PROJECT TO polar` handle the conversion, but you can use `angle`/`radius` explicitly when needed. + +```sql +PROJECT TO cartesian -- explicit default (usually omitted) +PROJECT y, x TO cartesian -- flip axes (maps y to horizontal, x to vertical) +PROJECT TO polar -- pie/radial charts +PROJECT TO polar SETTING start => 90 -- start at 3 o'clock +PROJECT TO polar SETTING inner => 0.5 -- donut chart (50% hole) +PROJECT TO polar SETTING start => -90, end => 90 -- half-circle gauge +``` + +**Cartesian settings:** +- `clip` — clip out-of-bounds data (default `true`) +- `ratio` — enforce aspect ratio between axes + +**Polar settings:** +- `start` — starting angle in degrees (0 = 12 o'clock, 90 = 3 o'clock) +- `end` — ending angle in degrees (default: start + 360; use for partial arcs/gauges) +- `inner` — inner radius as proportion 0–1 (0 = full pie, 0.5 = donut with 50% hole) +- `clip` — clip out-of-bounds data (default `true`) + +**Axis flipping:** To create horizontal bar charts or flip axes, use `PROJECT y, x TO cartesian`. This maps anything on `y` to the horizontal axis and `x` to the vertical axis. + +### FACET Clause + +Creates small multiples (subplots by category). + +```sql +FACET category -- Single variable, wrapped layout +FACET row_var BY col_var -- Grid layout (rows x columns) +FACET category SETTING free => 'y' -- Independent y-axes +FACET category SETTING free => ['x', 'y'] -- Independent both axes +FACET category SETTING ncol => 4 -- Control number of columns +FACET category SETTING nrow => 2 -- Control number of rows (mutually exclusive with ncol) +``` + +Custom strip labels via SCALE: +```sql +FACET region +SCALE panel RENAMING 'N' => 'North', 'S' => 'South' +``` + +### LABEL Clause + +Use LABEL for axis labels only. Do NOT use `title =>` — the tool's `title` parameter handles chart titles. + +```sql +LABEL x => 'X Axis Label', y => 'Y Axis Label' +``` + +### THEME Clause + +Available themes: `minimal`, `classic`, `gray`/`grey`, `bw`, `dark`, `light`, `void` + +```sql +THEME minimal +THEME dark +THEME classic SETTING background => '#f5f5f5' +``` + +## Complete Examples + +**Line chart with multiple series:** +```sql +SELECT date, revenue, region FROM sales WHERE year = 2024 +VISUALISE date AS x, revenue AS y, region AS color +DRAW line +SCALE x VIA date +LABEL x => 'Date', y => 'Revenue ($)' +THEME minimal +``` + +**Bar chart (auto-count):** +```sql +VISUALISE FROM products +DRAW bar MAPPING category AS x +``` + +**Horizontal bar chart:** +```sql +SELECT region, COUNT(*) as n FROM sales GROUP BY region +VISUALISE region AS y, n AS x +DRAW bar +PROJECT y, x TO cartesian +``` + +**Scatter plot with trend line:** +```sql +SELECT mpg, hp, cylinders FROM cars +VISUALISE mpg AS x, hp AS y +DRAW point MAPPING cylinders AS color +DRAW smooth +``` + +**Histogram with density overlay:** +```sql +VISUALISE FROM measurements +DRAW histogram MAPPING value AS x SETTING bins => 20, opacity => 0.5 +DRAW density MAPPING value AS x REMAPPING intensity AS y SETTING opacity => 0.5 +``` + +**Density plot with groups:** +```sql +VISUALISE FROM measurements +DRAW density MAPPING value AS x, category AS color SETTING opacity => 0.7 +``` + +**Heatmap with rect:** +```sql +SELECT day, month, temperature FROM weather +VISUALISE day AS x, month AS y, temperature AS color +DRAW rect +``` + +**Threshold reference lines (using PLACE):** +```sql +SELECT date, temperature FROM sensor_data +VISUALISE date AS x, temperature AS y +DRAW line +PLACE rule SETTING y => 100, stroke => 'red', linetype => 'dashed' +LABEL y => 'Temperature (F)' +``` + +**Faceted chart:** +```sql +SELECT month, sales, region FROM data +VISUALISE month AS x, sales AS y +DRAW line +DRAW point +FACET region +SCALE x VIA date +``` + +**CTE with aggregation and date formatting:** +```sql +WITH monthly AS ( + SELECT DATE_TRUNC('month', order_date) as month, SUM(amount) as total + FROM orders GROUP BY 1 +) +VISUALISE month AS x, total AS y FROM monthly +DRAW line +DRAW point +SCALE x VIA date SETTING breaks => 'month' RENAMING * => '{:time %b %Y}' +LABEL y => 'Revenue ($)' +``` + +**Ribbon / confidence band:** +```sql +WITH daily AS ( + SELECT DATE_TRUNC('day', timestamp) as day, + AVG(temperature) as avg_temp, + MIN(temperature) as min_temp, + MAX(temperature) as max_temp + FROM sensor_data + GROUP BY DATE_TRUNC('day', timestamp) +) +VISUALISE day AS x FROM daily +DRAW ribbon MAPPING min_temp AS ymin, max_temp AS ymax SETTING opacity => 0.3 +DRAW line MAPPING avg_temp AS y +SCALE x VIA date +LABEL y => 'Temperature' +``` + +**Text labels on bars:** +```sql +SELECT region, COUNT(*) AS n FROM sales GROUP BY region +VISUALISE region AS x, n AS y +DRAW bar +DRAW text MAPPING n AS label SETTING offset => [0, -11], fill => 'white' +``` + +**Donut chart:** +```sql +VISUALISE FROM products +DRAW bar MAPPING category AS fill +PROJECT TO polar SETTING inner => 0.5 +``` + +## Important Notes + +1. **Numeric columns as categories**: Integer columns representing categories (e.g., 0/1 `survived`) are treated as continuous by default, causing errors with `fill`, `color`, `shape`, and `FACET`. Fix by casting in SQL or declaring the scale: + ```sql + -- WRONG: integer fill without discrete scale — causes validation error + SELECT sex, survived FROM titanic + VISUALISE sex AS x, survived AS fill + DRAW bar + + -- CORRECT: cast to string in SQL (preferred) + SELECT sex, CAST(survived AS VARCHAR) AS survived FROM titanic + VISUALISE sex AS x, survived AS fill + DRAW bar + + -- ALSO CORRECT: declare the scale as discrete + SELECT sex, survived FROM titanic + VISUALISE sex AS x, survived AS fill + DRAW bar + SCALE DISCRETE fill + ``` +2. **Do not mix `VISUALISE FROM` with a preceding `SELECT`**: `VISUALISE FROM table` is shorthand that auto-generates `SELECT * FROM table`. If you already have a `SELECT`, use `SELECT ... VISUALISE` instead: + ```sql + -- WRONG: VISUALISE FROM after SELECT + SELECT * FROM titanic + VISUALISE FROM titanic + DRAW bar MAPPING class AS x + + -- CORRECT: use VISUALISE (without FROM) after SELECT + SELECT * FROM titanic + VISUALISE class AS x + DRAW bar + + -- ALSO CORRECT: use VISUALISE FROM without any SELECT + VISUALISE FROM titanic + DRAW bar MAPPING class AS x + ``` +3. **String values use single quotes**: In SETTING, LABEL, and RENAMING clauses, always use single quotes for string values. Double quotes cause parse errors. +4. **Column casing**: VISUALISE validates column references case-sensitively. The column name in VISUALISE/MAPPING must exactly match the column name from the SQL result. If a column is aliased as `MyCol`, reference it as `MyCol`, not `mycol` or `MYCOL`. +5. **Charts vs Tables**: For visualizations use VISUALISE with DRAW. For tabular data use plain SQL without VISUALISE. +6. **Statistical layers**: When using `histogram`, `bar` (without y), `density`, `smooth`, `violin`, or `boxplot`, the layer computes statistics. Use REMAPPING to access `density`, `intensity`, `proportion`, etc. +7. **Bar position adjustments**: Bars stack automatically when `fill` is mapped. Use `SETTING position => 'dodge'` for side-by-side bars, or `position => 'fill'` for proportional stacking: + ```sql + DRAW bar MAPPING category AS x, subcategory AS fill -- stacked (default) + DRAW bar MAPPING category AS x, subcategory AS fill SETTING position => 'dodge' -- side-by-side + ``` +8. **Date columns**: Date/time columns are auto-detected as temporal, including after `DATE_TRUNC`. Use `RENAMING * => '{:time ...}'` on the scale to customize date label formatting for readable axes. +9. **Multiple layers**: Use multiple DRAW clauses for overlaid visualizations. +10. **CTEs work**: Use `WITH ... SELECT ... VISUALISE` or shorthand `WITH ... VISUALISE FROM cte_name`. +11. **Axis flipping**: Use `PROJECT y, x TO cartesian` to flip axes (e.g., for horizontal bar charts). This maps `y` to the horizontal axis and `x` to the vertical axis. + +Parameters +---------- +ggsql : + A full ggsql query with SELECT and VISUALISE clauses. The SELECT portion follows standard {{db_type}} SQL syntax. The VISUALISE portion specifies the chart configuration. Do NOT include `LABEL title => ...` in the query — use the `title` parameter instead. +title : + Always provide this. A brief, user-friendly title for this visualization. This is displayed as the card header above the chart. + +Returns +------- +: + The visualization rendered inline in the chat, or the error that occurred. The chart will also be accessible in the Query Plot tab. Does not affect the dashboard filter state. diff --git a/pkg-py/src/querychat/static/css/viz.css b/pkg-py/src/querychat/static/css/viz.css new file mode 100644 index 000000000..fa1faf50d --- /dev/null +++ b/pkg-py/src/querychat/static/css/viz.css @@ -0,0 +1,141 @@ +/* Hide Vega's built-in action dropdown (we have our own save button) */ +.querychat-viz-container details:has(> .vega-actions) { + display: none !important; +} + +/* ---- Visualization container ---- */ + +.querychat-viz-container { + aspect-ratio: 4 / 2; + width: 100%; +} + +/* In full-screen mode, let the chart fill the available space */ +.bslib-full-screen-container .querychat-viz-container { + aspect-ratio: unset; +} + +/* ---- Visualization footer ---- */ + +.querychat-footer-buttons { + display: flex; + justify-content: space-between; + align-items: center; +} + +.querychat-footer-left, +.querychat-footer-right { + display: flex; + align-items: center; + gap: 4px; +} + +.querychat-show-query-btn, +.querychat-save-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + height: 28px; + border: none; + border-radius: var(--bs-border-radius, 4px); + background: transparent; + color: var(--bs-secondary-color, #6c757d); + font-size: 0.75rem; + cursor: pointer; + white-space: nowrap; +} + +.querychat-show-query-btn:hover, +.querychat-save-btn:hover { + color: var(--bs-body-color, #212529); + background-color: rgba(var(--bs-emphasis-color-rgb, 0, 0, 0), 0.05); +} + +.querychat-query-chevron { + font-size: 0.625rem; + transition: transform 150ms; + display: inline-block; +} + +.querychat-query-chevron--expanded { + transform: rotate(90deg); +} + +.querychat-icon { + width: 14px; + height: 14px; +} + +.querychat-dropdown-chevron { + width: 12px; + height: 12px; + margin-left: 2px; +} + +.querychat-save-dropdown { + position: relative; +} + +.querychat-save-menu { + display: none; + position: absolute; + right: 0; + bottom: 100%; + margin-bottom: 4px; + z-index: 20; + background: var(--bs-body-bg, #fff); + border: 1px solid var(--bs-border-color, #dee2e6); + border-radius: var(--bs-border-radius, 4px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + padding: 4px 0; + min-width: 120px; +} + +.querychat-save-menu--visible { + display: block; +} + +.querychat-save-menu button { + display: block; + width: 100%; + padding: 6px 12px; + border: none; + background: transparent; + color: var(--bs-body-color, #212529); + font-size: 0.75rem; + text-align: left; + cursor: pointer; +} + +.querychat-save-menu button:hover { + background-color: rgba(var(--bs-emphasis-color-rgb, 0, 0, 0), 0.05); +} + +.querychat-query-section { + display: none; + position: relative; + border-top: 1px solid var(--bs-border-color, #dee2e6); + margin: 8px -16px -8px; +} + +.querychat-query-section--visible { + display: block; +} + + +/* shinychat sets max-height:500px on all cards, which is too small for viz+editor */ +.card:has(.querychat-viz-container) { + max-height: 700px; + overflow: hidden; +} + +.querychat-query-section bslib-code-editor .code-editor { + margin: 1em; +} + +.querychat-query-section bslib-code-editor .prism-code-editor { + background-color: var(--bs-light, #f8f8f8); + max-height: 200px; + overflow-y: auto; +} \ No newline at end of file diff --git a/pkg-py/src/querychat/static/js/viz.js b/pkg-py/src/querychat/static/js/viz.js new file mode 100644 index 000000000..a04475173 --- /dev/null +++ b/pkg-py/src/querychat/static/js/viz.js @@ -0,0 +1,129 @@ +// Helper: find a native vega-embed action link inside a widget container. +// vega-embed renders a hidden
with tags for "Save as SVG", +// "Save as PNG", etc. We find them by matching the download attribute suffix. +// +// Why not use the Vega View API (view.toSVG(), view.toImageURL()) directly? +// Altair renders charts via its anywidget ESM, which calls vegaEmbed() and +// stores the resulting View in a closure — it's never exposed on the DOM or +// any accessible object. vega-embed v7 also doesn't set __vega_embed__ on +// the element. The only code with access to the View is vega-embed's own +// action handlers, so we delegate to them. +function findVegaAction(container, extension) { + return container.querySelector( + '.vega-actions a[download$=".' + extension + '"]' + ); +} + +// Helper: find a widget container by its base ID. +// Shiny module namespacing may prefix the ID (e.g. "mod-querychat_viz_abc"), +// so we match elements whose ID ends with the base widget ID. +function findWidgetContainer(widgetId) { + return document.getElementById(widgetId) + || document.querySelector('[id$="' + CSS.escape(widgetId) + '"]'); +} + +// Helper: trigger a vega-embed export action link. +// vega-embed attaches an async mousedown handler that calls +// view.toImageURL() and sets the link's href to a data URL. +// We dispatch mousedown, then use a MutationObserver to detect +// when href changes from "#" to a data URL, and click the link. +function triggerVegaAction(link, filename) { + link.download = filename; + + // If href is already a data URL (unlikely but possible), click immediately. + if (link.href && link.href !== "#" && !link.href.endsWith("#")) { + link.click(); + return; + } + + var observer = new MutationObserver(function () { + if (link.href && link.href !== "#" && !link.href.endsWith("#")) { + observer.disconnect(); + clearTimeout(timeout); + link.click(); + } + }); + + observer.observe(link, { attributes: true, attributeFilter: ["href"] }); + + var timeout = setTimeout(function () { + observer.disconnect(); + console.error("Timed out waiting for vega-embed to generate image"); + }, 5000); + + link.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); +} + +function closeAllSaveMenus() { + document.querySelectorAll(".querychat-save-menu--visible").forEach(function (menu) { + menu.classList.remove("querychat-save-menu--visible"); + }); +} + +function handleShowQuery(event, btn) { + event.stopPropagation(); + var targetId = btn.dataset.target; + var section = document.getElementById(targetId); + if (!section) return; + var isVisible = section.classList.toggle("querychat-query-section--visible"); + var label = btn.querySelector(".querychat-query-label"); + var chevron = btn.querySelector(".querychat-query-chevron"); + if (label) label.textContent = isVisible ? "Hide Query" : "Show Query"; + if (chevron) chevron.classList.toggle("querychat-query-chevron--expanded", isVisible); +} + +function handleSaveToggle(event, btn) { + event.stopPropagation(); + var menu = btn.parentElement.querySelector(".querychat-save-menu"); + if (menu) menu.classList.toggle("querychat-save-menu--visible"); +} + +function handleSaveExport(event, btn, extension) { + event.stopPropagation(); + var widgetId = btn.dataset.widgetId; + var title = btn.dataset.title || "chart"; + var menu = btn.closest(".querychat-save-menu"); + if (menu) menu.classList.remove("querychat-save-menu--visible"); + + var container = findWidgetContainer(widgetId); + if (!container) return; + var link = findVegaAction(container, extension); + if (!link) return; + triggerVegaAction(link, title + "." + extension); +} + +function handleCopy(event, btn) { + event.stopPropagation(); + var query = btn.dataset.query; + if (!query) return; + navigator.clipboard.writeText(query).then(function () { + var original = btn.textContent; + btn.textContent = "Copied!"; + setTimeout(function () { btn.textContent = original; }, 2000); + }).catch(function (err) { + console.error("Failed to copy:", err); + }); +} + +// Single delegated click handler for all querychat viz footer buttons. +window.addEventListener("click", function (event) { + var target = event.target; + + var btn = target.closest(".querychat-show-query-btn"); + if (btn) { handleShowQuery(event, btn); return; } + + btn = target.closest(".querychat-save-png-btn"); + if (btn) { handleSaveExport(event, btn, "png"); return; } + + btn = target.closest(".querychat-save-svg-btn"); + if (btn) { handleSaveExport(event, btn, "svg"); return; } + + btn = target.closest(".querychat-copy-btn"); + if (btn) { handleCopy(event, btn); return; } + + btn = target.closest(".querychat-save-btn"); + if (btn) { handleSaveToggle(event, btn); return; } + + // Click outside any button — close open save menus + closeAllSaveMenus(); +}); diff --git a/pkg-py/src/querychat/tools.py b/pkg-py/src/querychat/tools.py index 67ea453f5..9c5b2949e 100644 --- a/pkg-py/src/querychat/tools.py +++ b/pkg-py/src/querychat/tools.py @@ -9,6 +9,15 @@ from ._icons import bs_icon from ._utils import as_narwhals, df_to_html, querychat_tool_starts_open +from ._viz_tools import VisualizeQueryData, tool_visualize_query + +__all__ = [ + "VisualizeQueryData", + "tool_query", + "tool_reset_dashboard", + "tool_update_dashboard", + "tool_visualize_query", +] if TYPE_CHECKING: from collections.abc import Callable diff --git a/pkg-py/src/querychat/types/__init__.py b/pkg-py/src/querychat/types/__init__.py index f9a8163df..87b284325 100644 --- a/pkg-py/src/querychat/types/__init__.py +++ b/pkg-py/src/querychat/types/__init__.py @@ -9,6 +9,7 @@ from .._querychat_core import AppStateDict from .._shiny_module import ServerValues from .._utils import UnsafeQueryError +from .._viz_tools import VisualizeQueryData, VisualizeQueryResult from ..tools import UpdateDashboardData __all__ = ( @@ -22,4 +23,6 @@ "ServerValues", "UnsafeQueryError", "UpdateDashboardData", + "VisualizeQueryData", + "VisualizeQueryResult", ) diff --git a/pkg-py/tests/conftest.py b/pkg-py/tests/conftest.py new file mode 100644 index 000000000..95d586937 --- /dev/null +++ b/pkg-py/tests/conftest.py @@ -0,0 +1,32 @@ +"""Shared pytest fixtures for querychat unit tests.""" + +import polars as pl +import pytest + + +def _ggsql_render_works() -> bool: + """Check if ggsql.render_altair() is functional (build can be broken in some envs).""" + try: + import ggsql + + df = pl.DataFrame({"x": [1, 2], "y": [3, 4]}) + result = ggsql.render_altair(df, "VISUALISE x, y DRAW point") + spec = result.to_dict() + return "$schema" in spec + except (ValueError, ImportError): + return False + + +_ggsql_available = _ggsql_render_works() + + +def pytest_collection_modifyitems(config, items): + """Auto-skip tests marked with @pytest.mark.ggsql when ggsql is broken.""" + if _ggsql_available: + return + skip = pytest.mark.skip( + reason="ggsql.render_altair() not functional (build environment issue)" + ) + for item in items: + if "ggsql" in item.keywords: + item.add_marker(skip) diff --git a/pkg-py/tests/playwright/conftest.py b/pkg-py/tests/playwright/conftest.py index 6febfd4e8..961af01f3 100644 --- a/pkg-py/tests/playwright/conftest.py +++ b/pkg-py/tests/playwright/conftest.py @@ -592,3 +592,31 @@ def dash_cleanup(_thread, server): yield url finally: _stop_dash_server(server) + + +@pytest.fixture(scope="module") +def app_10_viz() -> Generator[str, None, None]: + """Start the 10-viz-app.py Shiny server for testing.""" + app_path = str(EXAMPLES_DIR / "10-viz-app.py") + + def start_factory(): + port = _find_free_port() + url = f"http://localhost:{port}" + return url, lambda: _start_shiny_app_threaded(app_path, port) + + def shiny_cleanup(_thread, server): + _stop_shiny_server(server) + + url, _thread, server = _start_server_with_retry( + start_factory, shiny_cleanup, timeout=30.0 + ) + try: + yield url + finally: + _stop_shiny_server(server) + + +@pytest.fixture +def chat_10_viz(page: Page) -> ChatControllerType: + """Create a ChatController for the 10-viz-app chat component.""" + return _create_chat_controller(page, "titanic") diff --git a/pkg-py/tests/playwright/test_10_viz_inline.py b/pkg-py/tests/playwright/test_10_viz_inline.py new file mode 100644 index 000000000..d174f47c9 --- /dev/null +++ b/pkg-py/tests/playwright/test_10_viz_inline.py @@ -0,0 +1,123 @@ +""" +Playwright tests for inline visualization and fullscreen behavior. + +These tests verify that: +1. The visualize_query tool renders Altair charts inline in tool result cards +2. The fullscreen toggle button appears on visualization tool results +3. Fullscreen mode works (expand and collapse via button and Escape key) +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from playwright.sync_api import expect + +if TYPE_CHECKING: + from playwright.sync_api import Page + from shinychat.playwright import ChatController + + +class TestInlineVisualization: + """Tests for inline chart rendering in tool result cards.""" + + @pytest.fixture(autouse=True) + def setup( + self, page: Page, app_10_viz: str, chat_10_viz: ChatController + ) -> None: + """Navigate to the viz app before each test.""" + page.goto(app_10_viz) + page.wait_for_selector("shiny-chat-container", timeout=30000) + self.page = page + self.chat = chat_10_viz + + def test_app_loads_with_query_plot_tab(self) -> None: + """VIZ-INIT: App with visualize_query has a Query Plot tab.""" + expect(self.page.get_by_role("tab", name="Query Plot")).to_be_visible() + + def test_viz_tool_renders_inline_chart(self) -> None: + """VIZ-INLINE: Visualization tool result contains an inline chart widget.""" + self.chat.set_user_input( + "Create a scatter plot of age vs fare for the titanic passengers" + ) + self.chat.send_user_input(method="click") + + # Wait for a tool result card with full-screen attribute (viz results have it) + tool_card = self.page.locator("shiny-tool-result[full-screen]") + expect(tool_card).to_be_visible(timeout=90000) + + # The card should contain a widget output (Altair chart) + widget_output = tool_card.locator(".jupyter-widgets") + expect(widget_output).to_be_visible(timeout=10000) + + def test_fullscreen_button_visible_on_viz_card(self) -> None: + """VIZ-FS-BTN: Fullscreen toggle button appears on visualization cards.""" + self.chat.set_user_input( + "Make a bar chart showing count of passengers by class" + ) + self.chat.send_user_input(method="click") + + # Wait for viz tool result + tool_card = self.page.locator("shiny-tool-result[full-screen]") + expect(tool_card).to_be_visible(timeout=90000) + + # Fullscreen toggle should be visible + fs_button = tool_card.locator(".tool-fullscreen-toggle") + expect(fs_button).to_be_visible() + + def test_fullscreen_toggle_expands_card(self) -> None: + """VIZ-FS-EXPAND: Clicking fullscreen button expands the card.""" + self.chat.set_user_input( + "Plot a histogram of passenger ages from the titanic data" + ) + self.chat.send_user_input(method="click") + + # Wait for viz tool result + tool_result = self.page.locator("shiny-tool-result[full-screen]") + expect(tool_result).to_be_visible(timeout=90000) + + # Click fullscreen toggle + fs_button = tool_result.locator(".tool-fullscreen-toggle") + fs_button.click() + + # The .shiny-tool-card inside should now have fullscreen attribute + card = tool_result.locator(".shiny-tool-card[fullscreen]") + expect(card).to_be_visible() + + def test_escape_closes_fullscreen(self) -> None: + """VIZ-FS-ESC: Pressing Escape closes fullscreen mode.""" + self.chat.set_user_input( + "Create a visualization of survival rate by passenger class" + ) + self.chat.send_user_input(method="click") + + # Wait for viz tool result + tool_result = self.page.locator("shiny-tool-result[full-screen]") + expect(tool_result).to_be_visible(timeout=90000) + + # Enter fullscreen + fs_button = tool_result.locator(".tool-fullscreen-toggle") + fs_button.click() + + card = tool_result.locator(".shiny-tool-card[fullscreen]") + expect(card).to_be_visible() + + # Press Escape + self.page.keyboard.press("Escape") + + # Fullscreen should be removed + expect(card).not_to_be_visible() + + def test_non_viz_tool_results_have_no_fullscreen(self) -> None: + """VIZ-NO-FS: Non-visualization tool results don't have fullscreen.""" + self.chat.set_user_input("Show me passengers who survived") + self.chat.send_user_input(method="click") + + # Wait for a tool result (any) + tool_result = self.page.locator("shiny-tool-result").first + expect(tool_result).to_be_visible(timeout=90000) + + # Non-viz tool results should NOT have full-screen attribute + fs_results = self.page.locator("shiny-tool-result[full-screen]") + expect(fs_results).to_have_count(0) diff --git a/pkg-py/tests/playwright/test_11_viz_footer.py b/pkg-py/tests/playwright/test_11_viz_footer.py new file mode 100644 index 000000000..09691e198 --- /dev/null +++ b/pkg-py/tests/playwright/test_11_viz_footer.py @@ -0,0 +1,180 @@ +""" +Playwright tests for visualization footer interactions (Show Query, Save dropdown). + +These tests verify the client-side JS behavior in viz.js: +1. Show Query toggle reveals/hides the query section +2. Save dropdown opens/closes on click +3. Clicking outside the Save dropdown closes it +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from playwright.sync_api import expect + +if TYPE_CHECKING: + from playwright.sync_api import Page + from shinychat.playwright import ChatController + + +VIZ_PROMPT = "Use the visualize tool to create a scatter plot of age vs fare" +TOOL_RESULT_TIMEOUT = 90_000 + + +@pytest.fixture(autouse=True) +def _send_viz_prompt( + page: Page, app_10_viz: str, chat_10_viz: ChatController +) -> None: + """Navigate to the viz app and trigger a visualization before each test.""" + page.goto(app_10_viz) + page.wait_for_selector("shiny-chat-container", timeout=30_000) + + chat_10_viz.set_user_input(VIZ_PROMPT) + chat_10_viz.send_user_input(method="click") + + # Wait for the viz tool result card with fullscreen support + page.locator("shiny-tool-result[full-screen]").wait_for( + state="visible", timeout=TOOL_RESULT_TIMEOUT + ) + # Wait for the footer buttons to appear inside the card + page.locator(".querychat-footer-buttons").wait_for( + state="visible", timeout=10_000 + ) + + +class TestShowQueryToggle: + """Tests for the Show Query / Hide Query toggle button.""" + + def test_query_section_hidden_by_default(self, page: Page) -> None: + """The query section should be hidden initially.""" + section = page.locator(".querychat-query-section") + expect(section).to_be_attached() + expect(section).not_to_be_visible() + + def test_click_show_query_reveals_section(self, page: Page) -> None: + """Clicking 'Show Query' should reveal the query section.""" + btn = page.locator(".querychat-show-query-btn") + btn.click() + + section = page.locator(".querychat-query-section--visible") + expect(section).to_be_visible() + + def test_label_changes_to_hide_query(self, page: Page) -> None: + """After clicking, the label should change to 'Hide Query'.""" + btn = page.locator(".querychat-show-query-btn") + label = btn.locator(".querychat-query-label") + + expect(label).to_have_text("Show Query") + btn.click() + expect(label).to_have_text("Hide Query") + + def test_chevron_rotates_on_expand(self, page: Page) -> None: + """The chevron should get the --expanded class when query is shown.""" + btn = page.locator(".querychat-show-query-btn") + chevron = btn.locator(".querychat-query-chevron") + + expect(chevron).not_to_have_class("querychat-query-chevron--expanded") + btn.click() + expect(chevron).to_have_class("querychat-query-chevron querychat-query-chevron--expanded") + + def test_toggle_hides_section_again(self, page: Page) -> None: + """Clicking the button a second time should hide the query section.""" + btn = page.locator(".querychat-show-query-btn") + btn.click() # show + btn.click() # hide + + section = page.locator(".querychat-query-section") + expect(section).not_to_have_class("querychat-query-section--visible") + + label = btn.locator(".querychat-query-label") + expect(label).to_have_text("Show Query") + + def test_query_section_contains_code(self, page: Page) -> None: + """The revealed query section should contain the ggsql code.""" + btn = page.locator(".querychat-show-query-btn") + btn.click() + + section = page.locator(".querychat-query-section--visible") + expect(section).to_be_visible() + + # The code editor should contain VISUALISE (ggsql keyword) + code = section.locator(".code-editor") + expect(code).to_be_visible() + + +class TestSaveDropdown: + """Tests for the Save button dropdown menu.""" + + def test_save_menu_hidden_by_default(self, page: Page) -> None: + """The save dropdown menu should be hidden initially.""" + menu = page.locator(".querychat-save-menu") + expect(menu).to_be_attached() + expect(menu).not_to_be_visible() + + def test_click_save_opens_menu(self, page: Page) -> None: + """Clicking the Save button should reveal the dropdown menu.""" + btn = page.locator(".querychat-save-btn") + btn.click() + + menu = page.locator(".querychat-save-menu--visible") + expect(menu).to_be_visible() + + def test_menu_has_png_and_svg_options(self, page: Page) -> None: + """The save menu should contain 'Save as PNG' and 'Save as SVG' options.""" + btn = page.locator(".querychat-save-btn") + btn.click() + + menu = page.locator(".querychat-save-menu--visible") + expect(menu.locator(".querychat-save-png-btn")).to_be_visible() + expect(menu.locator(".querychat-save-svg-btn")).to_be_visible() + + def test_click_outside_closes_menu(self, page: Page) -> None: + """Clicking outside the dropdown should close it.""" + btn = page.locator(".querychat-save-btn") + btn.click() + + menu = page.locator(".querychat-save-menu") + expect(menu).to_have_class("querychat-save-menu querychat-save-menu--visible") + + # Click somewhere else on the page body + page.locator("body").click(position={"x": 10, "y": 10}) + + expect(menu).not_to_have_class("querychat-save-menu--visible") + + def test_toggle_save_menu(self, page: Page) -> None: + """Clicking Save twice should open then close the menu.""" + btn = page.locator(".querychat-save-btn") + btn.click() + menu = page.locator(".querychat-save-menu") + expect(menu).to_have_class("querychat-save-menu querychat-save-menu--visible") + + btn.click() + expect(menu).not_to_have_class("querychat-save-menu--visible") + + +class TestVizFooterScreenshots: + """Screenshot tests for visual verification of footer rendering.""" + + def test_footer_default_state(self, page: Page) -> None: + """Screenshot: footer in default state (query hidden, menu closed).""" + card = page.locator("shiny-tool-result[full-screen]") + card.screenshot(path="test-results/viz-footer-default.png") + + def test_footer_query_expanded(self, page: Page) -> None: + """Screenshot: footer with query section expanded.""" + btn = page.locator(".querychat-show-query-btn") + btn.click() + page.wait_for_timeout(300) # wait for CSS transition + + card = page.locator("shiny-tool-result[full-screen]") + card.screenshot(path="test-results/viz-footer-query-expanded.png") + + def test_footer_save_menu_open(self, page: Page) -> None: + """Screenshot: footer with save dropdown open.""" + btn = page.locator(".querychat-save-btn") + btn.click() + + card = page.locator("shiny-tool-result[full-screen]") + card.screenshot(path="test-results/viz-footer-save-menu-open.png") diff --git a/pkg-py/tests/playwright/test_visualization_tabs.py b/pkg-py/tests/playwright/test_visualization_tabs.py new file mode 100644 index 000000000..48c8ba7b6 --- /dev/null +++ b/pkg-py/tests/playwright/test_visualization_tabs.py @@ -0,0 +1,38 @@ +""" +Playwright tests for visualization tab behavior based on tools config. + +These tests verify that the Query Plot tab is only present when the +visualize_query tool is enabled. With default tools ("update", "query"), +only the Data tab should appear. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from playwright.sync_api import expect + +if TYPE_CHECKING: + from playwright.sync_api import Page + + +# Shiny Tests +class TestShinyVisualizationTabs: + """Tests for tab behavior in Shiny app with default tools (no viz).""" + + @pytest.fixture(autouse=True) + def setup(self, page: Page, app_01_hello: str) -> None: + page.goto(app_01_hello) + page.wait_for_selector("table", timeout=30000) + self.page = page + + def test_only_data_tab_present_without_viz_tools(self) -> None: + """With default tools, only the Data tab should be visible.""" + tabs = self.page.locator('[role="tab"]') + expect(tabs).to_have_count(1) + expect(self.page.get_by_role("tab", name="Data")).to_be_visible() + + def test_no_query_plot_tab(self) -> None: + """Query Plot tab should not exist without visualize_query tool.""" + expect(self.page.get_by_role("tab", name="Query Plot")).to_have_count(0) diff --git a/pkg-py/tests/test_ggsql.py b/pkg-py/tests/test_ggsql.py new file mode 100644 index 000000000..2a4713542 --- /dev/null +++ b/pkg-py/tests/test_ggsql.py @@ -0,0 +1,108 @@ +"""Tests for ggsql integration helpers.""" + +import ggsql +import narwhals.stable.v1 as nw +import polars as pl +import pytest +from querychat._datasource import DataFrameSource +from querychat._viz_altair_widget import AltairWidget +from querychat._viz_ggsql import execute_ggsql + + +class TestGgsqlValidate: + """Tests for ggsql.validate() usage (split SQL and VISUALISE).""" + + def test_splits_query_with_visualise(self): + query = "SELECT x, y FROM data VISUALISE x, y DRAW point" + validated = ggsql.validate(query) + assert validated.sql() == "SELECT x, y FROM data" + assert validated.visual() == "VISUALISE x, y DRAW point" + assert validated.has_visual() + + def test_returns_empty_viz_without_visualise(self): + query = "SELECT x, y FROM data" + validated = ggsql.validate(query) + assert validated.sql() == "SELECT x, y FROM data" + assert validated.visual() == "" + assert not validated.has_visual() + + def test_handles_complex_query(self): + query = """ + SELECT date, SUM(revenue) as total + FROM sales + GROUP BY date + VISUALISE date AS x, total AS y + DRAW line + LABEL title => 'Revenue Over Time' + """ + validated = ggsql.validate(query) + assert "SELECT date, SUM(revenue)" in validated.sql() + assert "GROUP BY date" in validated.sql() + assert "VISUALISE date AS x" in validated.visual() + assert "LABEL title" in validated.visual() + + + +@pytest.fixture(autouse=True) +def _allow_widget_outside_session(monkeypatch): + """Allow JupyterChart (an ipywidget) to be constructed without a Shiny session.""" + from ipywidgets.widgets.widget import Widget + + monkeypatch.setattr(Widget, "_widget_construction_callback", lambda _w: None) + + +class TestAltairWidget: + @pytest.mark.ggsql + def test_produces_jupyter_chart(self): + import altair as alt + import ggsql + + reader = ggsql.DuckDBReader("duckdb://memory") + df = pl.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]}) + reader.register("data", df) + spec = reader.execute("SELECT * FROM data VISUALISE x, y DRAW point") + altair_widget = AltairWidget.from_ggsql(spec) + assert isinstance(altair_widget.widget, alt.JupyterChart) + result = altair_widget.widget.chart.to_dict() + assert "$schema" in result + assert "vega-lite" in result["$schema"] + + +class TestExecuteGgsql: + @pytest.mark.ggsql + def test_full_pipeline(self): + nw_df = nw.from_native(pl.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]})) + ds = DataFrameSource(nw_df, "test_data") + spec = execute_ggsql(ds, "SELECT * FROM test_data VISUALISE x, y DRAW point") + altair_widget = AltairWidget.from_ggsql(spec) + result = altair_widget.widget.chart.to_dict() + assert "$schema" in result + + @pytest.mark.ggsql + def test_with_filtered_query(self): + nw_df = nw.from_native( + pl.DataFrame({"x": [1, 2, 3, 4, 5], "y": [10, 20, 30, 40, 50]}) + ) + ds = DataFrameSource(nw_df, "test_data") + spec = execute_ggsql( + ds, "SELECT * FROM test_data WHERE x > 2 VISUALISE x, y DRAW point" + ) + assert spec.metadata()["rows"] == 3 + + @pytest.mark.ggsql + def test_spec_has_visual(self): + nw_df = nw.from_native(pl.DataFrame({"x": [1, 2], "y": [3, 4]})) + ds = DataFrameSource(nw_df, "test_data") + spec = execute_ggsql(ds, "SELECT * FROM test_data VISUALISE x, y DRAW point") + assert "VISUALISE" in spec.visual() + + @pytest.mark.ggsql + def test_with_pandas_dataframe(self): + import pandas as pd + + nw_df = nw.from_native(pd.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]})) + ds = DataFrameSource(nw_df, "test_data") + spec = execute_ggsql(ds, "SELECT * FROM test_data VISUALISE x, y DRAW point") + altair_widget = AltairWidget.from_ggsql(spec) + result = altair_widget.widget.chart.to_dict() + assert "$schema" in result diff --git a/pkg-py/tests/test_tools.py b/pkg-py/tests/test_tools.py index 682f259cf..94d8e3c64 100644 --- a/pkg-py/tests/test_tools.py +++ b/pkg-py/tests/test_tools.py @@ -12,6 +12,7 @@ def test_querychat_tool_starts_open_default_behavior(monkeypatch): assert querychat_tool_starts_open("query") is True assert querychat_tool_starts_open("update") is True assert querychat_tool_starts_open("reset") is False + assert querychat_tool_starts_open("visualize_query") is True def test_querychat_tool_starts_open_expanded(monkeypatch): @@ -21,6 +22,7 @@ def test_querychat_tool_starts_open_expanded(monkeypatch): assert querychat_tool_starts_open("query") is True assert querychat_tool_starts_open("update") is True assert querychat_tool_starts_open("reset") is True + assert querychat_tool_starts_open("visualize_query") is True def test_querychat_tool_starts_open_collapsed(monkeypatch): @@ -30,6 +32,7 @@ def test_querychat_tool_starts_open_collapsed(monkeypatch): assert querychat_tool_starts_open("query") is False assert querychat_tool_starts_open("update") is False assert querychat_tool_starts_open("reset") is False + assert querychat_tool_starts_open("visualize_query") is False def test_querychat_tool_starts_open_default_setting(monkeypatch): @@ -39,6 +42,7 @@ def test_querychat_tool_starts_open_default_setting(monkeypatch): assert querychat_tool_starts_open("query") is True assert querychat_tool_starts_open("update") is True assert querychat_tool_starts_open("reset") is False + assert querychat_tool_starts_open("visualize_query") is True def test_querychat_tool_starts_open_case_insensitive(monkeypatch): diff --git a/pkg-py/tests/test_viz_footer.py b/pkg-py/tests/test_viz_footer.py new file mode 100644 index 000000000..1911c9a38 --- /dev/null +++ b/pkg-py/tests/test_viz_footer.py @@ -0,0 +1,194 @@ +""" +Tests for visualization footer (Save dropdown, Show Query). + +The footer HTML (containing Save dropdown and Show Query toggle) is built by +_build_viz_footer() and passed as the `footer` parameter to ToolResultDisplay. +shinychat renders this in the card footer area. +""" + +from unittest.mock import MagicMock + +import narwhals.stable.v1 as nw +import polars as pl +import pytest +from htmltools import TagList, tags +from querychat._datasource import DataFrameSource +from querychat.types import VisualizeQueryResult + +FOOTER_SENTINEL = tags.div( + {"class": "querychat-footer-buttons"}, + tags.div( + {"class": "querychat-footer-left"}, + tags.button({"class": "querychat-show-query-btn"}, "Show Query"), + ), + tags.div( + {"class": "querychat-footer-right"}, + tags.div( + {"class": "querychat-save-dropdown"}, + tags.button({"class": "querychat-save-btn"}, "Save"), + ), + ), +) + + +@pytest.fixture +def sample_df(): + return pl.DataFrame( + {"x": [1, 2, 3, 4, 5], "y": [10, 20, 15, 25, 30]} + ) + + +@pytest.fixture +def data_source(sample_df): + nw_df = nw.from_native(sample_df) + return DataFrameSource(nw_df, "test_data") + + +def _mock_output_widget(widget_id, **kwargs): + return tags.div(id=widget_id) + + +@pytest.fixture(autouse=True) +def _patch_deps(monkeypatch): + monkeypatch.setattr( + "shinywidgets.register_widget", lambda _widget_id, _chart: None + ) + monkeypatch.setattr("shinywidgets.output_widget", _mock_output_widget) + + mock_spec = MagicMock() + mock_spec.metadata.return_value = {"rows": 5, "columns": ["x", "y"]} + mock_chart = MagicMock() + mock_chart.properties.return_value = mock_chart + + mock_altair_widget = MagicMock() + mock_altair_widget.widget = mock_chart + mock_altair_widget.widget_id = "querychat_viz_test1234" + mock_altair_widget.is_compound = False + + monkeypatch.setattr( + "querychat._viz_ggsql.execute_ggsql", lambda _ds, _q: mock_spec + ) + monkeypatch.setattr( + "querychat._viz_altair_widget.AltairWidget.from_ggsql", + staticmethod(lambda _spec: mock_altair_widget), + ) + monkeypatch.setattr( + "querychat._viz_tools.build_viz_footer", + lambda _ggsql, _title, _wid: TagList(FOOTER_SENTINEL), + ) + + +def _make_viz_result(data_source): + """Create a VisualizeQueryResult for testing.""" + from querychat.tools import tool_visualize_query + + tool = tool_visualize_query(data_source, lambda _d: None) + return tool.func( + ggsql="SELECT x, y FROM test_data VISUALISE x, y DRAW point", + title="Test Chart", + ) + + +def _render_footer(display) -> str: + """Render the footer field of a ToolResultDisplay to an HTML string.""" + rendered = TagList(display.footer).render() + return rendered["html"] + + +class TestVizFooter: + @pytest.mark.ggsql + def test_save_dropdown_present_in_footer(self, data_source): + """The save dropdown HTML must be present in the display footer.""" + result = _make_viz_result(data_source) + + assert isinstance(result, VisualizeQueryResult) + display = result.extra["display"] + footer_html = _render_footer(display) + + assert "querychat-save-dropdown" in footer_html + + @pytest.mark.ggsql + def test_show_query_button_present_in_footer(self, data_source): + """The Show Query toggle must be present in the display footer.""" + result = _make_viz_result(data_source) + + assert isinstance(result, VisualizeQueryResult) + display = result.extra["display"] + footer_html = _render_footer(display) + + assert "querychat-show-query-btn" in footer_html + + +class TestVizJsNoShadowDOM: + """Verify viz.js doesn't contain dead Shadow DOM workarounds.""" + + def test_no_shadow_dom_references(self): + """viz.js should not reference composedPath, shadowRoot, or deepTarget.""" + from pathlib import Path + + js_path = ( + Path(__file__).parent.parent + / "src" + / "querychat" + / "static" + / "js" + / "viz.js" + ) + js_code = js_path.read_text() + + for pattern in ["composedPath", "shadowRoot", "deepTarget"]: + assert pattern not in js_code, ( + f"viz.js still references '{pattern}' — shinychat uses light DOM, " + "so Shadow DOM workarounds should be removed." + ) + + +class TestVizFooterIcons: + """Verify Bootstrap icons used in viz footer are defined in _icons.py.""" + + def test_download_icon_exists(self): + from querychat._icons import bs_icon + + html = str(bs_icon("download")) + assert "svg" in html + assert "bi-download" in html + + def test_chevron_down_icon_exists(self): + from querychat._icons import bs_icon + + html = str(bs_icon("chevron-down")) + assert "svg" in html + assert "bi-chevron-down" in html + + def test_cls_parameter_injects_class(self): + from querychat._icons import bs_icon + + html = str(bs_icon("download", cls="querychat-icon")) + assert "querychat-icon" in html + + +class TestVizJsUseMutationObserver: + """Verify viz.js uses MutationObserver instead of setInterval for vega export.""" + + def test_uses_mutation_observer(self): + """TriggerVegaAction should use MutationObserver to watch href changes.""" + from pathlib import Path + + js_path = ( + Path(__file__).parent.parent + / "src" + / "querychat" + / "static" + / "js" + / "viz.js" + ) + js_code = js_path.read_text() + + assert "MutationObserver" in js_code, ( + "viz.js should use MutationObserver to detect when vega-embed " + "updates the href, instead of polling with setInterval." + ) + assert "setInterval" not in js_code, ( + "viz.js should not use setInterval for polling — " + "use MutationObserver instead." + ) diff --git a/pkg-py/tests/test_viz_tools.py b/pkg-py/tests/test_viz_tools.py new file mode 100644 index 000000000..711d4830d --- /dev/null +++ b/pkg-py/tests/test_viz_tools.py @@ -0,0 +1,131 @@ +"""Tests for visualization tool functions.""" + +import builtins + +import narwhals.stable.v1 as nw +import polars as pl +import pytest +from querychat._datasource import DataFrameSource +from querychat.tools import tool_visualize_query +from querychat.types import VisualizeQueryData, VisualizeQueryResult + + +class TestVizDependencyCheck: + def test_missing_ggsql_raises_helpful_error(self, monkeypatch): + """Requesting viz tools without ggsql installed should fail early.""" + real_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == "ggsql": + raise ImportError("No module named 'ggsql'") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", mock_import) + + from querychat._querychat_base import normalize_tools + + with pytest.raises(ImportError, match="pip install querychat\\[viz\\]"): + normalize_tools(("visualize_query",), default=None) + + def test_no_error_without_viz_tools(self): + """Non-viz tool configs should not check for ggsql.""" + from querychat._querychat_base import normalize_tools + + # Should not raise + normalize_tools(("update", "query"), default=None) + normalize_tools(None, default=None) + + def test_check_deps_false_skips_check(self, monkeypatch): + """check_deps=False should skip the dependency check.""" + real_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == "ggsql": + raise ImportError("No module named 'ggsql'") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", mock_import) + + from querychat._querychat_base import normalize_tools + + # Should not raise even though ggsql is missing + result = normalize_tools(("visualize_query",), default=None, check_deps=False) + assert result == ("visualize_query",) + + +@pytest.fixture +def sample_df(): + return pl.DataFrame( + { + "x": [1, 2, 3, 4, 5], + "y": [10, 20, 15, 25, 30], + "category": ["A", "B", "A", "B", "A"], + } + ) + + +@pytest.fixture +def data_source(sample_df): + nw_df = nw.from_native(sample_df) + return DataFrameSource(nw_df, "test_data") + + +class TestToolVisualizeQuery: + def test_creates_tool(self, data_source): + callback_data = {} + + def update_fn(data: VisualizeQueryData): + callback_data.update(data) + + tool = tool_visualize_query(data_source, update_fn) + assert tool.name == "querychat_visualize_query" + + @pytest.mark.ggsql + def test_tool_executes_sql_and_renders(self, data_source, monkeypatch): + callback_data = {} + + def update_fn(data: VisualizeQueryData): + callback_data.update(data) + + from unittest.mock import MagicMock + + from ipywidgets.widgets.widget import Widget + + monkeypatch.setattr("shinywidgets.register_widget", lambda _widget_id, _chart: None) + monkeypatch.setattr( + "shinywidgets.output_widget", lambda _widget_id, **_kwargs: MagicMock() + ) + # Must be AFTER shinywidgets patches above (importing shinywidgets resets this) + monkeypatch.setattr(Widget, "_widget_construction_callback", lambda _w: None) + + tool = tool_visualize_query(data_source, update_fn) + impl = tool.func + + result = impl( + ggsql="SELECT x, y FROM test_data WHERE x > 2 VISUALISE x, y DRAW point", + title="Filtered Scatter", + ) + + assert "ggsql" in callback_data + assert "title" in callback_data + assert callback_data["title"] == "Filtered Scatter" + + assert isinstance(result, VisualizeQueryResult) + display = result.extra["display"] + assert display.full_screen is True + assert display.open is True + + @pytest.mark.ggsql + def test_tool_handles_query_without_visualise(self, data_source): + callback_data = {} + + def update_fn(data: VisualizeQueryData): + callback_data.update(data) + + tool = tool_visualize_query(data_source, update_fn) + impl = tool.func + + result = impl(ggsql="SELECT x, y FROM test_data", title="No Viz") + + assert result.error is not None + assert "VISUALISE" in str(result.error) diff --git a/pkg-r/inst/prompts/prompt.md b/pkg-r/inst/prompts/prompt.md index 8c6ff97bc..455f60a63 100644 --- a/pkg-r/inst/prompts/prompt.md +++ b/pkg-r/inst/prompts/prompt.md @@ -1,4 +1,4 @@ -You are a data dashboard chatbot that operates in a sidebar interface. Your role is to help users interact with their data through filtering, sorting, and answering questions. +You are a data dashboard chatbot that operates in a sidebar interface. Your role is to help users interact with their data through filtering, sorting, answering questions, and exploring data visually. You have access to a {{db_type}} SQL database with the following schema: @@ -117,12 +117,24 @@ Response: "The average revenue is $X." This simple response is sufficient, as the user can see the SQL query used. +{{#has_tool_visualize_query}} +**Choosing between query and visualization:** Use `querychat_query` for questions with single-value answers (averages, counts, totals, specific lookups). Use `visualize_query` when the answer is better shown as a chart — comparisons across categories, distributions, trends over time, or when the user explicitly asks for a plot/chart. When in doubt, prefer the simpler tabular query. +{{/has_tool_visualize_query}} + {{/has_tool_query}} {{^has_tool_query}} +{{^has_tool_visualize_query}} ### Questions About Data You cannot query or analyze the data. If users ask questions about data values, statistics, or calculations (e.g., "What is the average ____?" or "How many ____ are there?"), explain that you're not able to run queries on this data. Do not attempt to answer based on your own knowledge or assumptions about the data, even if the dataset seems familiar. +{{/has_tool_visualize_query}} +{{#has_tool_visualize_query}} +### Questions About Data + +You cannot run tabular data queries directly. If users ask questions about specific data values, statistics, or calculations, explain that you can create visualizations but cannot return raw query results. Suggest a visualization if the question lends itself to a chart. + +{{/has_tool_visualize_query}} {{/has_tool_query}} ### Providing Suggestions for Next Steps @@ -153,6 +165,15 @@ You might want to explore the advanced features * Show records from the year … * Sort the ____ by ____ … ``` +{{#has_tool_visualize_query}} + +**Visualization suggestions:** +```md +* Visualize the data + * Show a bar chart of … + * Plot the trend of … over time +``` +{{/has_tool_visualize_query}} #### When to Include Suggestions diff --git a/pkg-r/inst/prompts/tool-query.md b/pkg-r/inst/prompts/tool-query.md index 20e1dbb53..9dcc28b9c 100644 --- a/pkg-r/inst/prompts/tool-query.md +++ b/pkg-r/inst/prompts/tool-query.md @@ -17,6 +17,7 @@ Always use SQL for counting, averaging, summing, and other calculations—NEVER **Important guidelines:** +- This tool always queries the full (unfiltered) dataset. If the dashboard is currently filtered (via a prior `querychat_update_dashboard` call), consider whether the user's question relates to the filtered subset or the full dataset. When it relates to the filtered view, incorporate the same filter conditions into your SQL WHERE clause. If it's ambiguous, ask the user whether they mean the filtered data or the full dataset - Queries must be valid {{db_type}} SQL SELECT statements - Optimize for readability over efficiency—use clear column aliases and SQL comments to explain complex logic - Subqueries and CTEs are acceptable and encouraged for complex calculations diff --git a/pyproject.toml b/pyproject.toml index 8bf5ddd60..64e5cedfe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,8 +21,8 @@ maintainers = [ ] dependencies = [ "duckdb", - "shiny>=1.5.1", - "shinychat>=0.2.8", + "shiny @ git+https://github.com/posit-dev/py-shiny.git@feat/ggsql-language", + "shinychat @ git+https://github.com/posit-dev/shinychat.git@feat/react-migration", "htmltools", "chatlas>=0.13.2", "narwhals", @@ -48,6 +48,8 @@ ibis = ["ibis-framework>=9.0.0", "pandas"] # pandas required for ibis .execute( streamlit = ["streamlit>=1.30"] gradio = ["gradio>=6.0"] dash = ["dash-ag-grid>=31.0", "dash[async]>=3.1", "dash-bootstrap-components>=2.0", "pandas"] +# Visualization with ggsql +viz = ["ggsql>=0.1.8", "altair>=5.0", "shinywidgets>=0.3.0"] [project.urls] Homepage = "https://github.com/posit-dev/querychat" # TODO update when we have docs @@ -76,7 +78,7 @@ git_describe_command = "git describe --dirty --tags --long --match 'py/v*'" version-file = "pkg-py/src/querychat/__version.py" [dependency-groups] -dev = ["ruff>=0.6.5", "pyright>=1.1.401", "tox-uv>=1.11.4", "pytest>=8.4.0", "polars>=1.0.0", "pyarrow>=14.0.0", "ibis-framework[duckdb]>=9.0.0"] +dev = ["ruff>=0.6.5", "pyright>=1.1.401", "tox-uv>=1.11.4", "pytest>=8.4.0", "polars>=1.0.0", "pyarrow>=14.0.0", "ibis-framework[duckdb]>=9.0.0", "ggsql>=0.1.8", "altair>=5.0", "shinywidgets>=0.3.0"] docs = ["quartodoc>=0.11.1", "griffe<2", "nbformat", "nbclient", "ipykernel"] examples = [ "openai", @@ -230,6 +232,9 @@ line-ending = "auto" docstring-code-format = true docstring-code-line-length = "dynamic" +[tool.pytest.ini_options] +markers = ["ggsql: requires working ggsql.render_altair()"] + [tool.pyright] include = ["pkg-py/src/querychat"] From e5287f1b6b4c9b5360cdd8d4fb5d6a8136e83cf4 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 20 Mar 2026 19:10:21 -0500 Subject: [PATCH 2/3] test: improve ggsql test coverage and clean up exports - Add unit tests for extract_visualise_table() covering bare/quoted identifiers, no-FROM, DRAW-level FROM false positive, CTE names, and case insensitivity - Add integration test for the VISUALISE FROM code path - Document upstream gaps in ggsql Python bindings - Stop re-exporting VisualizeQueryData from tools.py Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/_querychat_base.py | 3 +- pkg-py/src/querychat/_shiny_module.py | 3 +- pkg-py/src/querychat/_viz_ggsql.py | 10 +++++- pkg-py/src/querychat/_viz_tools.py | 6 ++++ pkg-py/src/querychat/tools.py | 3 +- pkg-py/tests/test_ggsql.py | 46 ++++++++++++++++++++++++- 6 files changed, 65 insertions(+), 6 deletions(-) diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index 58a5e47c6..280d645d1 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -26,7 +26,6 @@ from ._viz_utils import has_viz_deps, has_viz_tool from .tools import ( UpdateDashboardData, - VisualizeQueryData, tool_query, tool_reset_dashboard, tool_update_dashboard, @@ -38,6 +37,8 @@ from narwhals.stable.v1.typing import IntoFrame + from ._viz_tools import VisualizeQueryData + TOOL_GROUPS = Literal["update", "query", "visualize_query"] DEFAULT_TOOLS: tuple[TOOL_GROUPS, ...] = ("update", "query") diff --git a/pkg-py/src/querychat/_shiny_module.py b/pkg-py/src/querychat/_shiny_module.py index fde34502a..133f89649 100644 --- a/pkg-py/src/querychat/_shiny_module.py +++ b/pkg-py/src/querychat/_shiny_module.py @@ -31,7 +31,8 @@ from ._datasource import DataSource from ._querychat_base import TOOL_GROUPS - from .tools import UpdateDashboardData, VisualizeQueryData + from ._viz_tools import VisualizeQueryData + from .tools import UpdateDashboardData ReactiveString = reactive.Value[str] """A reactive string value.""" diff --git a/pkg-py/src/querychat/_viz_ggsql.py b/pkg-py/src/querychat/_viz_ggsql.py index 8cfd0cb68..bf119b4b1 100644 --- a/pkg-py/src/querychat/_viz_ggsql.py +++ b/pkg-py/src/querychat/_viz_ggsql.py @@ -56,7 +56,15 @@ def execute_ggsql(data_source: DataSource, query: str) -> ggsql.Spec: def extract_visualise_table(visual: str) -> str | None: - """Extract the table name from ``VISUALISE ... FROM
`` if present.""" + """ + Extract the table name from ``VISUALISE ... FROM
`` if present. + + This regex reimplements part of ggsql's parser because the Python bindings + don't expose the parsed table name. Internally, ggsql stores it as + ``Plot.source: Option`` (see ``ggsql/src/plot/types.rs``). + If ggsql ever exposes a ``source_table()`` or ``visual_table()`` method + on ``Validated`` or ``Spec``, this function should be replaced. + """ # Only look at the VISUALISE clause (before the first DRAW) to avoid # matching layer-level FROM (e.g., DRAW bar MAPPING ... FROM summary). draw_pos = re.search(r"\bDRAW\b", visual, re.IGNORECASE) diff --git a/pkg-py/src/querychat/_viz_tools.py b/pkg-py/src/querychat/_viz_tools.py index bc86279ba..a21d49658 100644 --- a/pkg-py/src/querychat/_viz_tools.py +++ b/pkg-py/src/querychat/_viz_tools.py @@ -144,6 +144,12 @@ def visualize_query( try: validated = ggsql_pkg.validate(ggsql) if not validated.has_visual(): + # When VISUALISE contains SQL expressions (e.g., CAST()), + # ggsql silently treats the entire query as plain SQL: + # valid()=True, has_visual()=False, no errors. This + # heuristic catches that case so we can guide the LLM. + # Remove when ggsql reports this as a parse error: + # https://github.com/posit-dev/ggsql/issues/256 has_keyword = "VISUALISE" in ggsql.upper() or "VISUALIZE" in ggsql.upper() if has_keyword: raise ValueError( diff --git a/pkg-py/src/querychat/tools.py b/pkg-py/src/querychat/tools.py index 9c5b2949e..f8c3c2842 100644 --- a/pkg-py/src/querychat/tools.py +++ b/pkg-py/src/querychat/tools.py @@ -9,10 +9,9 @@ from ._icons import bs_icon from ._utils import as_narwhals, df_to_html, querychat_tool_starts_open -from ._viz_tools import VisualizeQueryData, tool_visualize_query +from ._viz_tools import tool_visualize_query __all__ = [ - "VisualizeQueryData", "tool_query", "tool_reset_dashboard", "tool_update_dashboard", diff --git a/pkg-py/tests/test_ggsql.py b/pkg-py/tests/test_ggsql.py index 2a4713542..b1cb9af1b 100644 --- a/pkg-py/tests/test_ggsql.py +++ b/pkg-py/tests/test_ggsql.py @@ -6,7 +6,43 @@ import pytest from querychat._datasource import DataFrameSource from querychat._viz_altair_widget import AltairWidget -from querychat._viz_ggsql import execute_ggsql +from querychat._viz_ggsql import execute_ggsql, extract_visualise_table + + +class TestExtractVisualiseTable: + """Tests for extract_visualise_table() regex parsing.""" + + def test_bare_identifier(self): + assert extract_visualise_table("VISUALISE x, y FROM mytable DRAW point") == "mytable" + + def test_quoted_identifier(self): + assert ( + extract_visualise_table('VISUALISE x FROM "my table" DRAW point') + == '"my table"' + ) + + def test_no_from_returns_none(self): + assert extract_visualise_table("VISUALISE x, y DRAW point") is None + + def test_ignores_draw_level_from(self): + visual = "VISUALISE x, y DRAW bar MAPPING z AS fill FROM summary" + assert extract_visualise_table(visual) is None + + def test_cte_name(self): + assert ( + extract_visualise_table("VISUALISE month AS x, total AS y FROM monthly DRAW line") + == "monthly" + ) + + def test_from_only_no_mappings(self): + assert extract_visualise_table("VISUALISE FROM products DRAW bar") == "products" + + def test_case_insensitive_from(self): + assert extract_visualise_table("VISUALISE x from mytable DRAW point") == "mytable" + + def test_case_insensitive_draw(self): + visual = "VISUALISE x, y draw bar MAPPING z AS fill FROM summary" + assert extract_visualise_table(visual) is None class TestGgsqlValidate: @@ -96,6 +132,14 @@ def test_spec_has_visual(self): spec = execute_ggsql(ds, "SELECT * FROM test_data VISUALISE x, y DRAW point") assert "VISUALISE" in spec.visual() + @pytest.mark.ggsql + def test_visualise_from_path(self): + nw_df = nw.from_native(pl.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]})) + ds = DataFrameSource(nw_df, "test_data") + spec = execute_ggsql(ds, "VISUALISE x, y FROM test_data DRAW point") + assert spec.metadata()["rows"] == 3 + assert "VISUALISE" in spec.visual() + @pytest.mark.ggsql def test_with_pandas_dataframe(self): import pandas as pd From 847808292d87e226dde7264ef4d382065c5a5ff7 Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 20 Mar 2026 19:10:30 -0500 Subject: [PATCH 3/3] refactor: remove developer-facing viz API from public surface Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/_shiny.py | 98 +------------------ pkg-py/src/querychat/_shiny_module.py | 22 ----- pkg-py/tests/playwright/test_10_viz_inline.py | 4 - .../playwright/test_visualization_tabs.py | 38 ------- 4 files changed, 5 insertions(+), 157 deletions(-) delete mode 100644 pkg-py/tests/playwright/test_visualization_tabs.py diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index 56176081b..ed3555ae7 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -18,7 +18,6 @@ if TYPE_CHECKING: from pathlib import Path - import altair as alt import chatlas import ibis import narwhals.stable.v1 as nw @@ -248,13 +247,9 @@ def app( """ Quickly chat with a dataset. - Creates a Shiny app with a chat sidebar and tabbed view -- providing a + Creates a Shiny app with a chat sidebar and data view -- providing a quick-and-easy way to start chatting with your data. - The app includes two tabs: - - **Data**: Shows the filtered data table - - **Query Plot**: Shows the most recent query visualization - Parameters ---------- bookmark_store @@ -273,28 +268,7 @@ def app( enable_bookmarking = bookmark_store != "disable" table_name = data_source.table_name - tools_tuple = ( - (self.tools,) if isinstance(self.tools, str) else (self.tools or ()) - ) - has_query_viz = has_viz_tool(tools_tuple) - def app_ui(request): - nav_panels = [ - ui.nav_panel( - "Data", - ui.card( - ui.card_header(bs_icon("table"), " Data"), - ui.output_data_frame("dt"), - ), - ), - ] - if has_query_viz: - nav_panels.append( - ui.nav_panel( - "Query Plot", - ui.output_ui("query_plot_container"), - ) - ) return ui.page_sidebar( self.sidebar(), ui.card( @@ -313,7 +287,10 @@ def app_ui(request): fill=False, style="max-height: 33%;", ), - ui.navset_tab(*nav_panels, id="main_tabs"), + ui.card( + ui.card_header(bs_icon("table"), " Data"), + ui.output_data_frame("dt"), + ), title=ui.span("querychat with ", ui.code(table_name)), class_="bslib-page-dashboard", fillable=True, @@ -364,36 +341,6 @@ def sql_output(): width="100%", ) - if has_query_viz: - from shinywidgets import output_widget, render_altair - - @render_altair - def query_chart(): - return vals.viz_widget() - - @render.ui - def query_plot_container(): - chart = vals.viz_widget() - if chart is None: - return ui.card( - ui.card_body( - ui.p( - "No query visualization yet. " - "Use the chat to create one." - ), - class_="text-muted text-center py-5", - ), - ) - - return ui.card( - ui.card_header( - bs_icon("bar-chart-fill"), - " ", - vals.viz_title.get() or "Query Visualization", - ), - output_widget("query_chart"), - ) - return App(app_ui, app_server, bookmark_store=bookmark_store) def sidebar( @@ -929,38 +876,3 @@ def title(self, value: Optional[str] = None) -> str | None | bool: else: return self._vals.title.set(value) - def ggvis(self) -> alt.JupyterChart | None: - """ - Get the visualization chart from the most recent visualize_query call. - - Returns - ------- - : - The Altair chart, or None if no visualization exists. - - """ - return self._vals.viz_widget() - - def ggsql(self) -> str | None: - """ - Get the full ggsql query from the most recent visualize_query call. - - Returns - ------- - : - The ggsql query string, or None if no visualization exists. - - """ - return self._vals.viz_ggsql.get() - - def ggtitle(self) -> str | None: - """ - Get the visualization title from the most recent visualize_query call. - - Returns - ------- - : - The title, or None if no visualization exists. - - """ - return self._vals.viz_title.get() diff --git a/pkg-py/src/querychat/_shiny_module.py b/pkg-py/src/querychat/_shiny_module.py index 133f89649..17bf75cea 100644 --- a/pkg-py/src/querychat/_shiny_module.py +++ b/pkg-py/src/querychat/_shiny_module.py @@ -24,7 +24,6 @@ if TYPE_CHECKING: from collections.abc import Callable - import altair as alt from shiny.bookmark import BookmarkState, RestoreState from shiny import Inputs, Outputs, Session @@ -93,17 +92,6 @@ class ServerValues(Generic[IntoFrameT]): The session-specific chat client instance. This is a deep copy of the base client configured for this specific session, containing the chat history and tool registrations for this session only. - viz_ggsql - A reactive Value containing the full ggsql query from visualize_query. - Returns `None` if no visualization has been created. - viz_title - A reactive Value containing the title from visualize_query. - Returns `None` if no visualization has been created. - viz_widget - A callable returning the rendered Altair chart from visualize_query. - Returns `None` if no visualization has been created. The chart is - re-rendered on each call using ``execute_ggsql()`` and - ``AltairWidget.from_ggsql()``. """ @@ -111,10 +99,6 @@ class ServerValues(Generic[IntoFrameT]): sql: ReactiveStringOrNone title: ReactiveStringOrNone client: chatlas.Chat - # Visualization state - viz_ggsql: ReactiveStringOrNone - viz_title: ReactiveStringOrNone - viz_widget: Callable[[], alt.JupyterChart | None] @module.server @@ -150,9 +134,6 @@ def _stub_df(): sql=sql, title=title, client=client if isinstance(client, chatlas.Chat) else client(), - viz_ggsql=viz_ggsql, - viz_title=viz_title, - viz_widget=lambda: None, ) # Real session requires data_source @@ -292,9 +273,6 @@ def _on_restore(x: RestoreState) -> None: sql=sql, title=title, client=chat, - viz_ggsql=viz_ggsql, - viz_title=viz_title, - viz_widget=render_viz_widget, ) diff --git a/pkg-py/tests/playwright/test_10_viz_inline.py b/pkg-py/tests/playwright/test_10_viz_inline.py index d174f47c9..e21e35f17 100644 --- a/pkg-py/tests/playwright/test_10_viz_inline.py +++ b/pkg-py/tests/playwright/test_10_viz_inline.py @@ -32,10 +32,6 @@ def setup( self.page = page self.chat = chat_10_viz - def test_app_loads_with_query_plot_tab(self) -> None: - """VIZ-INIT: App with visualize_query has a Query Plot tab.""" - expect(self.page.get_by_role("tab", name="Query Plot")).to_be_visible() - def test_viz_tool_renders_inline_chart(self) -> None: """VIZ-INLINE: Visualization tool result contains an inline chart widget.""" self.chat.set_user_input( diff --git a/pkg-py/tests/playwright/test_visualization_tabs.py b/pkg-py/tests/playwright/test_visualization_tabs.py deleted file mode 100644 index 48c8ba7b6..000000000 --- a/pkg-py/tests/playwright/test_visualization_tabs.py +++ /dev/null @@ -1,38 +0,0 @@ -""" -Playwright tests for visualization tab behavior based on tools config. - -These tests verify that the Query Plot tab is only present when the -visualize_query tool is enabled. With default tools ("update", "query"), -only the Data tab should appear. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import pytest -from playwright.sync_api import expect - -if TYPE_CHECKING: - from playwright.sync_api import Page - - -# Shiny Tests -class TestShinyVisualizationTabs: - """Tests for tab behavior in Shiny app with default tools (no viz).""" - - @pytest.fixture(autouse=True) - def setup(self, page: Page, app_01_hello: str) -> None: - page.goto(app_01_hello) - page.wait_for_selector("table", timeout=30000) - self.page = page - - def test_only_data_tab_present_without_viz_tools(self) -> None: - """With default tools, only the Data tab should be visible.""" - tabs = self.page.locator('[role="tab"]') - expect(tabs).to_have_count(1) - expect(self.page.get_by_role("tab", name="Data")).to_be_visible() - - def test_no_query_plot_tab(self) -> None: - """Query Plot tab should not exist without visualize_query tool.""" - expect(self.page.get_by_role("tab", name="Query Plot")).to_have_count(0)