From f8fea5ebc282f68b16229d1f3f2861ea5242a892 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 8 Apr 2026 16:09:23 -0500 Subject: [PATCH 01/31] feat: add ggsql visualization tool Add visualization support to querychat via ggsql, a SQL extension for declarative data visualization. Includes Altair/Vega-Lite rendering, Shiny widget integration, ggsql syntax reference prompt, Playwright tests, and documentation. 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 | 40 +- pkg-py/src/querychat/_shiny.py | 124 +++- pkg-py/src/querychat/_shiny_module.py | 95 ++- 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 | 74 +++ pkg-py/src/querychat/_viz_tools.py | 297 ++++++++++ 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 | 8 + 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 | 152 +++++ 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, 2665 insertions(+), 46 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..280d645d1 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -23,11 +23,13 @@ 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, tool_query, tool_reset_dashboard, tool_update_dashboard, + tool_visualize_query, ) if TYPE_CHECKING: @@ -35,8 +37,10 @@ from narwhals.stable.v1.typing import IntoFrame -TOOL_GROUPS = Literal["update", "query"] + from ._viz_tools import VisualizeQueryData +TOOL_GROUPS = Literal["update", "query", "visualize_query"] +DEFAULT_TOOLS: tuple[TOOL_GROUPS, ...] = ("update", "query") class QueryChatBase(Generic[IntoFrameT]): """ @@ -58,7 +62,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 +76,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 +136,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 +144,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 +180,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 +290,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..133f89649 100644 --- a/pkg-py/src/querychat/_shiny_module.py +++ b/pkg-py/src/querychat/_shiny_module.py @@ -13,17 +13,26 @@ 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 ._viz_tools import VisualizeQueryData + from .tools import UpdateDashboardData ReactiveString = reactive.Value[str] """A reactive string value.""" @@ -34,20 +43,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 +93,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 +111,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 +127,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 +150,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 +170,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 +190,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 +270,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 +282,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..bf119b4b1 --- /dev/null +++ b/pkg-py/src/querychat/_viz_ggsql.py @@ -0,0 +1,74 @@ +"""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. + + 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) + 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..a21d49658 --- /dev/null +++ b/pkg-py/src/querychat/_viz_tools.py @@ -0,0 +1,297 @@ +"""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(): + # 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( + "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..f8c3c2842 100644 --- a/pkg-py/src/querychat/tools.py +++ b/pkg-py/src/querychat/tools.py @@ -9,6 +9,14 @@ from ._icons import bs_icon from ._utils import as_narwhals, df_to_html, querychat_tool_starts_open +from ._viz_tools import tool_visualize_query + +__all__ = [ + "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..b1cb9af1b --- /dev/null +++ b/pkg-py/tests/test_ggsql.py @@ -0,0 +1,152 @@ +"""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, 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: + """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_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 + + 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 d7a322328228835672f211bdbe0884a6d37dc934 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 8 Apr 2026 16:09:28 -0500 Subject: [PATCH 02/31] 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) From 9a87ee61a293b5e656c9e3d015e1cb811da41dd8 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 8 Apr 2026 16:09:52 -0500 Subject: [PATCH 03/31] feat: viz polish, prompt best practices, and collapsed query param Fixes and improvements accumulated after the initial ggsql integration: - Bookmark restore, Shiny tool wiring, and pre-commit fixes - Remove legacy shiny client path - Send PNG thumbnail to LLM in viz tool results - Port visualization best practices from ggsqlbot (axis readability, labels with units, data-ink ratio, overplotting, chart type guide) - Move behavioral guidance from tool description to system prompt - Add interpretation skepticism to guidelines - Add collapsed parameter to query tool for preparatory queries - Add uv required-environments for CI Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 + CLAUDE.md | 6 +- pkg-py/docs/_quarto.yml | 3 + pkg-py/docs/build.qmd | 8 + pkg-py/docs/images/viz-bar-chart.png | Bin 0 -> 16825 bytes pkg-py/docs/images/viz-fullscreen.png | Bin 0 -> 106667 bytes pkg-py/docs/images/viz-scatter.png | Bin 0 -> 51671 bytes pkg-py/docs/images/viz-show-query.png | Bin 0 -> 77475 bytes pkg-py/docs/index.qmd | 5 + pkg-py/docs/tools.qmd | 37 +- pkg-py/docs/visualize.qmd | 104 ++++ pkg-py/examples/10-viz-app.py | 20 +- pkg-py/src/querychat/_shiny.py | 12 +- pkg-py/src/querychat/_shiny_module.py | 186 +++--- pkg-py/src/querychat/_system_prompt.py | 12 +- pkg-py/src/querychat/_utils.py | 11 + pkg-py/src/querychat/_viz_altair_widget.py | 52 +- pkg-py/src/querychat/_viz_ggsql.py | 21 +- pkg-py/src/querychat/_viz_tools.py | 119 +++- pkg-py/src/querychat/_viz_utils.py | 24 +- pkg-py/src/querychat/prompts/ggsql-syntax.md | 506 +++++++++++++++++ pkg-py/src/querychat/prompts/prompt.md | 74 ++- pkg-py/src/querychat/prompts/tool-query.md | 2 + .../querychat/prompts/tool-visualize-query.md | 533 +----------------- pkg-py/src/querychat/static/css/viz.css | 2 +- pkg-py/src/querychat/tools.py | 34 +- .../tests/playwright/apps/viz_bookmark_app.py | 25 + pkg-py/tests/playwright/test_10_viz_inline.py | 20 +- pkg-py/tests/playwright/test_11_viz_footer.py | 8 +- .../tests/playwright/test_12_viz_bookmark.py | 136 +++++ pkg-py/tests/test_ggsql.py | 17 +- pkg-py/tests/test_shiny_viz_regressions.py | 395 +++++++++++++ pkg-py/tests/test_tools.py | 45 ++ pkg-py/tests/test_viz_footer.py | 14 + pkg-py/tests/test_viz_tools.py | 144 ++++- pkg-r/inst/prompts/prompt.md | 23 +- pkg-r/inst/prompts/tool-query.md | 1 - pyproject.toml | 15 +- 38 files changed, 1828 insertions(+), 789 deletions(-) create mode 100644 pkg-py/docs/images/viz-bar-chart.png create mode 100644 pkg-py/docs/images/viz-fullscreen.png create mode 100644 pkg-py/docs/images/viz-scatter.png create mode 100644 pkg-py/docs/images/viz-show-query.png create mode 100644 pkg-py/docs/visualize.qmd create mode 100644 pkg-py/src/querychat/prompts/ggsql-syntax.md create mode 100644 pkg-py/tests/playwright/apps/viz_bookmark_app.py create mode 100644 pkg-py/tests/playwright/test_12_viz_bookmark.py create mode 100644 pkg-py/tests/test_shiny_viz_regressions.py diff --git a/.gitignore b/.gitignore index 740f3993c..a06bad8c9 100644 --- a/.gitignore +++ b/.gitignore @@ -268,6 +268,9 @@ renv.lock # Planning documents (local only) docs/plans/ +# Screenshot capture script (local only) +pkg-py/docs/_screenshots/ + # Playwright MCP .playwright-mcp/ diff --git a/CLAUDE.md b/CLAUDE.md index 4170fa304..98873f00e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,13 +69,15 @@ make py-build make py-docs ``` -Before finishing your implementation or committing any code, you should run: +Before committing any Python code, you must run all three checks and confirm they pass: ```bash uv run ruff check --fix pkg-py --config pyproject.toml +make py-check-types +make py-check-tests ``` -To get help with making sure code adheres to project standards. +Do not commit or push until all three pass. ### R Package diff --git a/pkg-py/docs/_quarto.yml b/pkg-py/docs/_quarto.yml index df2576e49..7574fb787 100644 --- a/pkg-py/docs/_quarto.yml +++ b/pkg-py/docs/_quarto.yml @@ -50,6 +50,7 @@ website: - models.qmd - data-sources.qmd - context.qmd + - visualize.qmd - section: "Build custom apps" contents: - build-intro.qmd @@ -114,6 +115,8 @@ quartodoc: signature_name: short - name: tools.tool_reset_dashboard signature_name: short + - name: tools.tool_visualize_query + signature_name: short filters: - "interlinks" diff --git a/pkg-py/docs/build.qmd b/pkg-py/docs/build.qmd index 3e4cb6de0..88e8cc130 100644 --- a/pkg-py/docs/build.qmd +++ b/pkg-py/docs/build.qmd @@ -31,6 +31,14 @@ from querychat.data import titanic qc = QueryChat(titanic(), "titanic") ``` +::: {.callout-tip} +### Visualization support + +By default, querychat includes a visualization tool that lets the LLM create inline charts. +You can control which tools are available with the `tools` parameter. +See [Visualizations](visualize.qmd) for details. +::: + ::: {.callout-note collapse="true"} ## Quick start with `.app()` diff --git a/pkg-py/docs/images/viz-bar-chart.png b/pkg-py/docs/images/viz-bar-chart.png new file mode 100644 index 0000000000000000000000000000000000000000..0a7033651a09e6f5f8d0d963907ff47cf511f884 GIT binary patch literal 16825 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%0D5Wu*)3FENMbj~^QWAH=0sddk5OYosS2ei}rl z@Epc~!@N+h`%{HgVI!P&bW%^6c0terP9vJc0s2mVvq8WnV8#!Y?VvNRFdYf@%Buv) z|GD+&PqdaC*rVOuUC?h}&BK?G>jv92SEC}2BJ8`s$ZNnxVNr>*N4t;|Jv}{{M_^$7 zK`|~@5E8nL`W|RKSfP4)dSe5iF*m2G%P6)U;NUDSF18AcZ~|Z{{+=a%Z~%!0s!NFa z4;(t?H3J`?b_7QaSP0x%V6rybgT94UTZ2Kzy?e>nxp$A?^qwOLR^OHR0>BeJyGU^< zqYKCBi6eMU0ssh@vQv3|FeH0Hk6Fd3hOnCqBp+LZ4@99A4dz zB$^|jk8E#Bfxc$lo?W}uOkH#No~BOF>c+fsg9s6V$@;EPXfxKb`k5>u}9s_%TVGZ;gw8BWCwkEBI9n44F80BF8ej_F$V*kEsQ`gtm7tEH=@$qAmlj*zgm7u;s zlwd=X%31zFBr?dtTWLx)S2-ZoxsiW4A^qD6;N}L*hR$vVCwTH8nLM5fN%nTzow3 zW#T#n5E!uZ>|j%tgY^t$a}ppnod4<@ShrV}Tmv2YopH20z$szFntYWG`P|R!K~2bG zi7sIBUs&(V@R^jLV3LIrvSx_{J%}HHQ-jj^YD*#uq-uOZf@-b;(xuRnDKHbAT1Sf~ zp@n}^g#$9$ZwN67=Z#@ESd<9Xi%j(ws9RO0BdC7kz$7oP&UfMu!DlyDsW2aZR(B(~ zj9hfa8a??{c=Wy_zWFQ!(-tLMpkY68=oW5KcVWP3j0-AV98Fr$-o}U19fAm`0gnO< z0hZ$6XSoLBEYaZd4j6#S* ze(8E%;?~cA%!4LEDydBccpQfGu_7?`U4fP)S{~rJf4jw058N+9tlJzqqjKyr(ePo% zL{4f0y4Bv=`nnhoS844x!Eh^Triq!E8J+a4l1JL_LaK2YR75_%+)^vR$$=&H7%9JB z>9O7Lr3wYBS9x*>SBanZ;m;$~G%X*mm(hgZ1>VVjq(4xBHg1T3knQV?Vu?tq9OPGJ za@Vb6k1XBY-LdEc2|YbM!7@}bbJ`s9O6}Qcyw;(0N&dkr1+VGQsfI8a0VB-(i6CF6 zTnY3=0-K;g^d;RSzz4H*oyR42f-+?SxSTW7!O-oRs3iv*OJgH+s6FCMR24As66Fui^;Pb!jVjxVe|DmGgko+VH&T!T~z) zW?l!{^?m%;BFPGeC8g#&Kv$6Dx0^yf2HJK(tDg4E;`i_0usEG6OVaHI=iS@y%79Cd zdtP8IX@xTD-&#Z+y!U1VcqaHVxY}SdTD|bRnu40%+k5Yl>(Oj5E`#N4!%c!K#mHCp zEWd-{FeaR~6|kk4)JqKk?iJ2yzM{gfRYD%1d&Bs{)_568{UC6-(OgB8B0@Tb^S_UX zPbux1=CE&Ov}I!`8xu8wHcTH}-gl5StL5O=qv=&bBb`#%-+ZP{UBeaj`sWb4TMxxkEihw;3>lD4&Qz6f_4+Ug*T$qs(JH1 zQA3{8+0jkor(0COpQ>HOTg^M+w3}hhXy%xY0G`C=WjnV8=A% zFjG?As^?RBBcEDPULK7H!UF(|>C99u3z&7+*YAzBz>HyRFU3IhoAvL_WtMU3&c3VC&Fk>WX)u?oiXt3n-ILc1J)Qw z7Y%4b9grenIo12`fsHmWza`+Ck1|1kj4b-jmntw{4$3F#U z4(P!DU2OiZR;vFLn*Xow@Tu}T<6Xb;i_>J^SiDL7kC)Mk7yoxoH}vD`N%UVLJ^ zyf_Vs%Jy!J^P+hGo>U>Iba?4#XmHTi{~VQX7#gZe3@Vf>V0CYW;GQ3jcJ@(yA>}2I zn@o?5!2H$tyDAK{fTU#SX?IWlXwJ+)tq)j|6jW)RqWx<_-wKwtzw|tVvzlG1BF2_L zEOI%7fO^RGtC2JZOq*A+OdB)x#542I)cNxVkl$a(Qf>bNDFht@Jxl77xq$_|+E(w} zIVhdr?s~KeQ#Y%+k+El9GArrg;qF!DN&HqaH}oG3LA942GM)Aw&{!0{o|d{=M;1I^H7U)8p)m+ z640X$egx{R{Ng^%%Xq)*3rvz|jb&!Fz#c+&Qz?`k8U9P&qu%LSg>(LEUV(Hx99nRz z(&CDAmVX-S*!UA4hGYm^&b3wOJ&8YW*iNy`#U>ar%t^ij#`(5ev&*mR%sJ zQeV}oQ9~|y9Z67=cU)0P#$sAR^7_AdHo{w#iC&+hqwNpIN|4a-4|ms&&v$rsv+8O? zOG@O~RY$q(R`kwea*<&!|FmF_Ii9~tz03-qQRidYQ;Uhv+eoP0pSkKyqS}Gfh&SMs zm7Q-CH5T(KoEE)`yW#6GuO)HtHn&3PMvwjsEDUD8)H|ltG;f+X8%P(I*bZFji;=zD zTgq?Ec$7*jeL?8#w%Si=&>lJg%^2%H$|l=q7{tIH>hw<)R;(Jh4rje)%2s+~n6!hD z45`xkL;LlfO{n`26Y9uc94z3Ff090!m7skKrlALwc~GpV{cFV_|>#bh(M0 zmiZLS(oR-b@HowQLu<(R6bHJ~`^(HfqI186wClG8-&J>y4EZRBDz3VBPANPP6dDxN z$>#KD&THF}z2q`o{5oTEpOBci%4E80aB#59l2qzpD3*sr15*+YB`UbKAFd0v8|C4X z&yTMK-%u=SNWchkXjrJQurRr=e;Q_RTeR%WI~PAMVn2Nu9qv95HC#N`*+1RKpECVb zTwdN(_HfVY;<9RZrd$#mA0Pi~BaAcy^y@uOF%6?|>T^_j9~k)JPRHchP!>=Pr^O!w zKhV(oQdwlb@D?iP6A=*LFlpVnRJE^yv64{D;Q*H7?oVZbckP4wJ@*g3<`)&M%)JiZ z6kszv#Q~Ws5@&vt_^wN;4RJdSUOmC5%xC8DcR8M!hWDAEbvPpS@L@U>xKx_;9naSn zCqZT^MIsMt!fR@*hDfJhz-z3-!_mflR3ODmiVtWPmxgF$yL)>2oq?NzBv36>qHD~2 zBk_YBlCo|(Pu9yZ+Ksh79tQE%Gz?|N(RY&z*Flp)y>nmQpmXb8l<3VD_oi zj5qXr-Hx~UcoBE?bi2vgeuN&_Bi<_Azwkp-Q3=D!N#b?{yMdalRP&lv3vF5OT*;8seT|u{Lwv!Q-?&p#6yM#a z$KSh0lzpv}Rav|>d8&no;0d>UAJkuwY2?**Rw1*2w_p6uMFnCV?KG~vea zUzIub_`=n+FAS*m+C0y`=G=*6(5wWb>64CsVehrmeEs@6WTd3ZuH?oq#@cjO&}g{p z^9DFslSpl)g;CF!hYo7iu3bm<243G zx%}937{3a}VG8BWtZ)!qdl0)5fF2v%PVRdG^IF0*oYd?{?nBIxS@UQyZcwKy>^swp zW;xuSOT&Caq{ZO<$1c7<>Oh|wRezjsBbA@v%U^w#mK|=J|LSxvo=No=Vfql*LU^Zp zTro^x6?&LlF*XYBU(5ENJ62ZyjAia9X83|uaXa*<$7K0knYf4{wK-t-gJ?}p)+j*} z^LWhrMsGXMJo&jx%k%JuED{pD2?y=xGhNr?t*FP=f5`bQ&0WN5`mrPwm$JE{(Il(` zKPZK^Qt~igIox$817=C$wS}9WkS&9h`tT8Bklygg$+zj+m44+w7x2^{S4oBBNkxvOk#mwxXPzlb%4?CKVnV2lUw@NyP_xp*OIQ=;G z83+>TQBhSDuSQ4B6B0+!9)oFhCY@6Iwl3wj7lo>QK=xm!0W)!St}rbWD(UOjbKj+U z@cZ*x5O`=6dM)j#Egliu^Y*#GE`@9geetVb|EBti=U}wN6Y?z#+^^>zw{?^i6!rqi z{p(#(eJ0o)VH!XL9Cm2tzcHKl-5qDXPejB4v+1q3KCBb;3R6+sldDVQGUFh66-S@R zd+(tjbQ*yezEB;_Q$Rz77k8MqMm`+kA23wjGZu)-mTHp>t9pqZ+0ccDrM)j)pY}!S-C*r9i`Nh?MLRh;6a`!8F&zdH2U-=)g>h+ z!e$LzcQM`!-yDeUu1tb``@c9$U6Y%e3oXLH7YN^#o+r#kJOnnCC=kE?m?rzCr*?*Z zNAW-o`*h0(P9^yXA0Me+9-6%Tz~8sr)jzQK3^@Df@nc#~+$4C4?Y&#U+quwzd2uDK zdkB*H#>6Wv1#%=3%_=W155CSFxdRz^Md=_S_bv21#Jj;#nJa`=Ps0oTG+2JLx~ZwD zf`Wpr?K4|!K7|ZATWq~EPx!kF*Vn-@Dh%)s0m2w5oYa|v&wS){=HA{d0u_s7rCAQ zQlp?_!&QAOEmglw(PVo~Y}-an?L{zPO6T)E8k!FfbWQ-#3%qp>lpaUy?yg$-vSWN5 z*8qQ-6lJ><6{WjQLyNJ}(oLd0$DY@&-kR*2Ej!CUOSVpSUGksh6T0>8e`e$($Zr07 zVvvTy_Md&G&qqf7Z{U0V+MR#)0}J)HfA(GBGqM~12J006KP=J9{BLP8hjc>*4ypTW z%X(_lQ&Urq?A5`J`bw5sTJZ~lCuuFtbpKxTjG^FZ(9|V<4?Lklg?SX@YhhvG>FF61 zbT02)^cCjS)gvCfHpnEPO!&m;x!mSCk}a;7>p3oLq_2ElZc<}9J3AYXv8R6gZ>{xu z0)a5Cuk;lPRoE!cFUS`W6&Xn8uJUd$Z!+9f7d5L3UZ2!3IQvHBv25M#pcQ+x3!Aqd z;0D(gd;04oF9`mx*9NVGN)}3K!}kH5oCR$4xOe75H0;YJt$URGD_JPrM;p)BRQPQBdBd6$zX zbpI1Qr)+}61Zu902Y7iV4`t}~nJo3WKyXMqjVm9bG2Oa|INpk4#EJweC8oDor-5cP_3C+cOapc_)|IMs87Ri4NW@M5KIqua7sPfSE!d~>`XMG2 zaQVV`EWAV?9{d}x3y-9glzh>_BeV2LfPdKxGBPqMyR@*jZgZVZ(La7|WimT8^=7JJ z|N0k(Z+`}o!_gZ#j8A`&h2bZjGY0)hmbD4ndxE^nExiQGpX_L|m$<;i@VjIsPf?;I zsBDjzvpq-UPvpYzlLI_EX*kiV&j76I+b+lpSsr(RJd~x_A$6v5meahTfv@gSWO=uw zmmiLx4AUd7vKQ*K@nx2ZJ8)v5$sWx0PJCJa0Sn4%;4N-E(E4ef?j;;f^c?01;qo2q zjpgwj?7@!_j%+S%tgVZEYK9AB)M-aVO)+zR+TizOH*rRJL+v|M=lL(T)#1vFy+uVd z`(a|4ho&(pJs4m`I4$XxONB1k9C|Gs1GgUkHVMud(F(Dxi?y;|+K-3|wA>o3+}&#U zy1a|itt0Lg9cIztijQ~ZcYe;)k~6;U@Of$e%d?@Urw0mMY8GP&6Vmlb<*KKbI_kp0 zsz`g%VNeOmt7vyUPhbr$^)1|v{|a?)P&%F2u(7h6YflA2QmB8b!43Xb1c*t)nmvo7 znFvh}?VdeK@n|bgVbFo<3AZxLrh@@eIX*Z0l`23gN2=58bEcuzr*v(>=!_Q_cxD-b zgplv;PVm`(hMxVQwpB&z4)-VmJU{7Lo(v@#bRf9BGBFaQZ?71;x?3&Whj_guJC@eu zn}zY`?t3yQiMsH=rXHI)5eX2TTuv-$v}c@E?qU1&iM5&eS%XFUVH|pzmX?+|Ue&~8 zwny5z)~a0s2n_$Jj2E+M>TYXmtFQN3S~U_%;)T0TSEdPQKy1gji1dfQo--O0%Dugk zsJnOXf@Z-buG8;tavWZz+uXdYZeH_1qh0?Okyww0Wov6|$bPNG6Zg5?{S{UP;@=58 zfez4pvE=SPUq9==S(d8&#VZ84Ia=@o|D5v9HaK(mZ1S?*T9NPRf>{tpN7!?|hh#NY z!W0^o&r8b!)aP(Xg-g~xz9Q(!u-GCcyT=}ZM-Pwq;3qqmQWeh|UU*?xQ^vksbeWk& zSCn}OdI%naU+JRRU$os$fe71CX4$o?iY>{O1{9J>=X^qT(XE=rVn4%&Tu@H19n5LA zL;a04j0NKtIwj#f-ez~5<;#dD|97yV|4>#(FIkClmX$&5b*-h&-xY zIQ+7zYP-2lw4sNMNSK?c4Z8fn&Y-mZK^l1ZHmtqk>qZYAGcSFcofYjx zp-pU9CJ_%(i<)9J*7PdV6-NJpkXaj#(5HFM#YLn!OQ+$T26&owCGLA>MsSHxWVTu8 z&hqtV;mnWORLf69_2{QLb>7?+PvKR8y??iM_rs<9rxu=vU2905*5pjr^Py*tRfcr= zJII0;>a{DlNb)!Y8t1;;2Gm}B`tYG^P3;Yf{WgMG06Yx&z2HT)DXKNWxWE$cU(6u7yZ?Q3wW5N@OvI}|Yz?fn9t$ldKV2NGIg>bIH>~1?a8Txx7ZPX7hJGT%5AA{NAqy4dhoS;FO?1MOzO*+!n`UJ+d%Xdu=EuwF!aB? zubA>k#P4G?%dXPoyc_Q~W9+<8KOc02kv+{yVS~oZ{>zGL64*yW!mk-kCX#VZ zSLsdr|I*+I!x0^9MZP^FF%$7c=X7>c-VN6r3VcB_3(}iqnDUl`3vy${F0x4{NSWYaQ9@fM zhurM%)j3K#0GTLv81fvp)KTCkj#b#2g5C2(zA+r0GBQe8e#f9lZG3+{WK^Vmmfb?{ zqPUD7`{)Rn7xjUtcdNS$N4x7%m!SV3U_bH1;+4Pg%Jzj5eY>k^prp7}yBiNhHv{h~ z>hP>!!9DkHRUEpm#@O_OY0-T`ea0e;ahO6TPZ+76{Pcx-uqU*u6}8C#{^Ro9Z7q~B zJb=KI^3>DWC;|aszm;O@)~2{$K!g+cARB7sf~d8i6gZn>gzMGA%4zq1j`E1J8?Guiwb55h)9WKw$E zg)=U)>fHOZe`YrNhm1Xi4Skz@T!Xb#@f1?vS#ZC#1QovHxqE}Y8ARrFn&Mzi&u|oW zMwiF2rMmUC3~|OJ4!Otxzi!#{DEK@2n}Uuvg>Pbc@-5vlH_!qUgh+f}GuHZP)SDY7 z`x8{(YI0vkBfm!k>clVlpo=;8E40r#em4vI1k&&Jf{HOotY1Q`ZhnUuw6UF9Fb}W~ z!TXexZIfh|bfz%LgOabS5r5x+=tpX67bYv?APFW9CS#P-wxyys0 z7PEu3f=R1!<$!k47s6e{SIH2>^s?rKpi!2Ed{TXOZ8E=<;tS)h5)SXGvM@D&+_&8< zuJeb2%rwMJ90fWIIPeCuTDMZPLOb{2L+?kIk=h*uF?672K3qQhW{e~^+a}GFyQCEV z!;7bzU5lZY2%-7VsNC_|`8}7;E!CURQRK$u;WC8Jn7{~u0@09QoYw;Do8m_MN z`s(8IA&u|&vfVn=V8Tng06KDuGbn^N`t)LQ z*rbuPW1~>H%6mrKVE9>Oe8{_qE#r3=h zZX!DFueDQ4y|;Me^dq?Dhm?)YW=76Ux12tF=|ss}7ImCnOY5ehh`BgA?K(fReW_aC z$@8bG`_|n6>uKjq+Uq`0I)Rm27M!yl65h|xY`Rg`U@3vFY)7Hd<6?OZ# zL5aBy#E(^?wb}1Ekq6Ld5a|;>i^o)MI_!2I&L}YanxXLY9}X|E$@&rr_v0JWMBXX7 zTB!>wmPJ}reJOu`96`Z$&IoDs*jiKnKrn#R!jQjg_?wk@fW70(vx$z%S;YPJVi_6e|* z%M#6Yo2;BWNU;yIa0ylQiZOTfKzi-d8RNEdPm!`~>ws!V_o*_n+%8DaX+u^e@}eaC zT6v}qi^s4cH)uGdaHDvAxRuj!xbEWb7A||q)2nmzYwjUcTFuAet!R2NIN9%PS>2;C zDII72lG*yKyhX&}d#wVnLkwD$$j8bRAS!j&+Ayxs6csIQa^!LZWO=I>WEA*=zQs4P zo@(U2tfEwk8AwiM))LTLKri~O+QmB#)c%2mzJyyM)lyJ~l^@34uQZJmh4H7~hE}@G z)w+V!Z4^Aronz>aB2lyk4Ue#+xi@Rp><~!5@tKdV-5YIQ4yjL zUCkc6bN@l)fAv93EH2&kkEE+g-VvJ?R^1Cy9rkJV$r~@vZXC>ayPw{n0Y`W$a>Ftu z$BVUt`wb8`5-5s6>>8370VFx|IfSlfs;|LDdiMu!e`z;ud7OIB{(I`Fkp1S#I&KT` zfXBOP0kO5>8_^9e%E>DEquIxw_nY|7_MyC%c0<2JN5`9b!QME=sM7~rfF1c>)Q!~_ zcd&oFtwmS#6|G^DNI7EGeWU-P@m27^O?H*}43Zuz?hLAA7R=ItMIu~JJ`WB*Npxt- zvd~;!6ZP;Op~Wg(;}&aVI;VEx`pfKO&q}ywidj=;5^G#mTbc}^XiRJihDvSWJIf`n zf|Q+*ojROIdW2G;=+pP$6&+BVG zEbEG9T@!~Z7P=5%yIuUzu~oXyX7@~3y9ye%}S%^mghmPYg|oHvNz)qq&y4| zDJc*cgZK<-v*fYF+KN!CN!e?D;S?i+*Y&3SDh<(^91Qmu@^ekipB*x1x;`wJyw=P2 z-0>&pa#NrfM*M?tk9;N1R!$ViCboScg|%l?QeH9bdP>}hpns67bb#E^&k9iAM~O(X z+5)Wb$U%x)+$O)@hc7e$H03_f3?0fk=P50v*SaN0{q4a}ND(9E!SoJanoHtLzv7)E z4yPo(j|reJ!#BS<#XJOdb~1j|y=Q!;vjoAKh%6{Yrb7Zlql2C+Y*1DM*k=BdW_acw z`HVm615>#STz4CC!w{{)VHYShw(C*LG z0#6jr?!ULuxQbC!UmN_j%|fpew1hBKf4X1{ZJ!4PbO5^eH8l@4_{ zb!(6UNp(@*!Q392f*Ra^rISM2AG=1NUv^6a=U4|5Lm?dBXl`&^Fyc6SL=@VG?xp>N zlp}Y>S-3vz4ra0@)2HyhEcbe@sB-;f+H=F@_8)!gw`hmmrMN_Yi6`yrvCrmHZ@ZOY7(&i9Ku&p)cj%2WL<-4X7Wv}^ zglul-M@S?5g9=9Ke9Tf`^z#Lt(SOPw$%1+j)G|J7%jLhmr(^URXPWOAKt?L)4Jmoh z8DrxhwKCp~;6f(%19XM_T-p;Twzu8h{vZ_RXP$M4zH0iIVq}J{Y9R8 znZgKEzEnKS=0gtMss9N=%Ld-V`u#mo=`1S&J&`jtc1DG-nC$R!@B|c< zvoGM5DDajY1+?DlFLB?nIO>>R1&`!7-}4i$li$9QOh9S4wh5H5)5fw4lI`F)*>zlN zOtcC-*3ftr3{?y@BMD{GO26~6b}X_ecSp%8wYb($aj$QJrubVZ8wzd+!+V)q>-ili zQ0*?GA%?3ToEici_P?~ZPE_E2sh9Ea>1Rpk{w4EyJHj9%1Xhv)uC^H{Bv`cjfRfb% z>-adhoLrZtE9tR4fN<5f>y**+O}~mLeyZYJ{+Os5WM)|)_UDp?zYI#&YA`#S>zVKy z#;~M);PBMxWOV|68WnlMDg<_g^;dJlud{RuqrUFda$5&zRx&2(+8nFi&=?v9y-Q*f zlFk{hO49}|_|E6=J&7RN=rDPShiAgNK41ooNY8eR8`mozIOV-0H!L4u?4lGr?G<@A zp5NP48S$k(8lSM5w!r^cN2AN1%jk)AgDRj&RpOO^fL6th&JOUYm8g_rJ>qlCjgab) z$OBu5^nEFh!dQ-lmy^Z(_oA3T9nPx-8{G7(Jyg~}g*;kbdOFC3DDwFlbZr6nV$HZj zkA8lpG4gLVhYrBx*3Z#k>WdeVsiwh6&s#G@w!?>4LPp!BNr(d4wxX_sl`*W6zs-j$iA|$6zuio-4~aiKJ_gRo4%OMREba`#&CFz`Mma;bECPjeWAqM zTc4hwE4@6tX3`Czfv55<(D(;~44g1~5+eC4x;EM9Pd)}wC00rPK%L2bE(+V}^LzZU zeN}KYk+lxN=QrS?a+ExS{%dk6bbsLs$_WR2AK>%8#f80isI~fIhSYBIp=`vOdyi3hUZ*uXWQW1VE@U zw)bEjZ>~4VuDr?IO1-lTF?lUackiz3#I)1J01an$Zn%q75j-r(g z6YbOS+|=tV`1W$L$SP(>G;Xx_6?0l?I@*m1W&UCysne`CWoowKNd-{!Y1_)oVJ5F* zl>hD86Uiou)MGi7Mw$VYGsUjc-E3;Wwc5Ue!ai=;$L_=!VOAQ&N|_Cl9WK@xdA;hX z!pt%JS+!(Ito(~}Dj$(kTg?%`b-Fa`VM#B_>26k0qKR$5mMu2fxbYaAg1>h%{?lPK zMC7coBk@i3K7DljsFqM`IUJ`SXjuMHB8P|BTL#9$b%*KZHJO@KT6GYGAo%F+rbUJd z;>6(XR&nn)Uh{kW&*QS-dAdrA$RB!)aV6E|Yg>W>uxq_Nzq3)(8tXSUzBi^mXeOsn z%o=e87S3BJ zc<>-LAyJ5`2N)ZADc;K{2If8-DfoKp}|+T?WAV_g7TNSYY2@Bc0~aTM<-hjkVqWD)N|ij z_le?XrrMvJ!kFPnkDq+W+bH=DoATG zRWIhNbV2i*JuA zcsfW}`f`aKrS-1ASC>;3Q$xc{nGnF-BCa|2%9M;uj@ zjouHoi=mEHlj}W=_@k;m+_CXSI{o`vis^*X4%Q$2=pn~4&oiL-auu$K@Fe~AD-T(Chl&*~^vTL@J_+yi$+tIgKr0xKO z5PXPC9OtvX+ht-{vX^Mt)^5>03VO9^AAiF8ESy0Ak~v4!_8s&fQwAE|H|EeStribnaZtpSb`^7 zEQajX-m`Y%Qi7^KnsY3s@R!N6IZ?4>_q|NaKAeAk#t_No8ywW9B<_-pH@Kt92mcSf z6=0{`sK7Lzxd`lxhbtT_uK13rW4ie9_L6&8=i42>~=Z*sjxR_Or&}_y=t6o>!ar%ENU2J?_*PU6f_IgjV`5f9qha3H^YY-*n+CQ#FukNM%S891mKT!q+EZ zU{lK>{oWkYybhN7!^?re0V6V0x%o)Ng?>E{E$yC4j{cqfa*k^5qwyh+plV}i*>nu} z;j@LHTOJH)tDOBkH%aGFHGCBz>WHLEYezh<&ynCJAdzML$-n{Ju3v&Ze8gfis(Qsw zKptW>Jvu0|&2>vq?m6d`W%xF%;SaBAC z+z&9^q;hg64l%y~$CkWErGA!La9&Eg2$wZe?c6WGyCA2d!xWnU6Jep(aFoS?f+dUx z?k7qpWl6g>iH1aJf>z#H7u6e6mf(eWX8Or5+#A64_uFvo%#6-GsogJ0=$a6yO zbYkqlse14Yv7=Z3F=xi+`!7Gwx$Zx?&v{gR8Y$82cgU_p7XF85#R%xxaDAze|@!R%^Tv%+N*be^8`3k8mjGyF>p=n+P zYW^w)sk%_`x4NLNY>qtc$El-k$IUtNKbmX`ACViCjanW_^pRI^``x&^!3)^3RuN>Q z14P{x&q@sVp>zbQ{Kll*@&WB<5k&V_&B$@A*i)i%$ty^ATRZik zEUPQRVEPXEF}L=RT&K?)12TU6YqpjJW3)^UY9((J2-2T03)S8kv0ZmiAYPyVx`o!= zEztH>mDA|(@6F+aY7V!CLXgU11TAWHCwaWB7N@f1J^kCcrEe8|o$;-M;EB-c?bsc_bZ? zWvrn6)0>p=%r9uPWNg*@Pe3jY%+?2=iLk~^4*+qgcX>a(5_jWnm2t3~`u+PAqei_2E00HQ3@k+h=ou##z0 z?@%LT$c6rX7|-p2l9qz-R6WiS_=hjy$t!#jXM%EmS6}VchqrG~wX{CviL2d;Ohazk zss#Wk!&zO~`N+yjaGN1=DW$8*qpfc%)9XfnoEjzzuS6;c~EZRpMDtlLBg5HSc&n$^OD+fDsX?jRl%Y8H$G=H3sm-M~t_ z9~XRez8g~+b2l>}lP3AWHI0I{-V5VB*$QgQyxthg6~Q47mM$vtfj8M1Jvbfw2GixU zca0JvFdfB$uk1X}UB2+IR@ZbFec$E%FK(Chp&D*mmC5x9^sCE8$*+O%*xr!V$TZ>w zyw~@Fu4;-EZhNX1>x|BNi2Sj)IdxI}t(-?_+jXK>EcmYENOP%H|DlFQWh^w`tz^xU zDd{vI6PW+Jvft4mu7(9NsHSjCwX7R7uO9FlqcujHbsWM8e~c0-4(ax5^vefOed>FD zKt%TM#@GoNFWA388b&KKx#~K@CAQr#p2+i&;>cxgOML9AA$TtKRA1XZKa&2ow z>klm~@8LA*r_o!*r%SY3G{vLbG`n#)Nw17JgvNqQrAk|W z3f3^pN7@GV@wbIZISeJ*H^`CGVHIgH;K-w*pg*Ts96UX)QHrL?H-o{%?TTEx5ZVTVi;1PM_UUYKv zuX6oi-s|k;P)*KPG3NmC>yWy?d@;%5OgaDL(O2$_h&qX+F6oh6+F*%jO)~)^nMb-^ zm2*b6-SJS$I@PkGMTU)U=4C{u>4mPA{<`xChKWs0;Jg?3TjnVp$oyJ%8Utm67}O`T?vu;*EwD{%-%+HL0eUXVuCoPqi)sf4$uZ@d)t4H%nzO0l_JU(k1S_z3-{nBV7hOiO_NNK@UJz`7T(t2Q^gV*LCVDm$_UTqOutOu7Sg119 z78&-R+Q3^-YcP^*FEAup{-7|^P_^gr=ym5XYeEGraAbArz2J(EWcV5TeZ=~eKXn;D zzkJa63e&{8`N#IlAC&KVy6V2)VH1p5i#6^}*^}&}noH$2w$JX7krP!+d-bCG&$?}( z#GR=_9RvM95k|n5m?w{=f4)4cg5UmfWVUoZrF!v8ykTp_&8)UD%6_)A&Ja~%VfMbC zxqlN3dXc+UZ*m!t#xHIDzJaH;kXYFw+c?F!hb~V48IXGI1VU@|>Mf^SFw}4%37&Rwc=8L`~FcG3!Wt`{-H7SZ&lyNbtRC&j9Q8d+Z}wzq6B<&Ey}84UmW z{KGaQc0d!KxJPnG^Q;HeX4iooS{vv z=+}R}1qQlAQmK_|rbcM2f6l!XWuyPrs|<+zfX#M+#c`!>{fMG%1i!8NxXu$wMzG6v zdgiikiDFIFlOJ!E`Vxem738OMPJ0^~A^H4Ct?@9g_+OsBzmU&*R4QU@bl~^ft1hO| zogcp5+B>!8AWr0eqQ&&(j_?IZ4@hY|G+$*qFipTW`A(TJXZL33ObWi-E2k=?D4G7a z?Xnzj#uP_0t>wUM-PvdCOTaOj9@_Ip`PM(zT0x`ZwZi&|g_m1SV^5l(z(MV2$ zUA2#Hv@Av~#_T)=@^Fe>(?~=vC$QuEN+{DLx(8+Bsg-NdH31_C;3KE4+bU+Yq`Xl? zAzCfo*lDNZg_wt-w#n5o^nqD;y z^}%q2}9D1XAXN!$jdEe0kdX3rCt(N3=w$fy)G zKt6x;%>r=|lb#~olIy1P0zu7wQninULEOScgnsQnjS!6!S_HI);P*C&)mjLNLnMT5 z<{F*WwhX(Hc=`pK_$h^@g>!pEb=+U|XR{W%%iQ_6L#fGa`lbod)OFdHYv6+5{BWz~ z&s*7<=p$7>oyNjT>p$f>4G_0x0j!3qb`Yj&?QLX+hhy!s&uFf*&c$<)Xv+%R&u;@# z?YkC zRX%NXgJ9Api=P~g?3X!1-}W_I%iW&e1xTIUef&g?6)i7i#^c*w*M3m8tmLV}^7LV3!9NtK5pOHbx7p09dy?=E8S_X5EjMNcNdOqa6e ziXw+9Om-R*DW=}*eym{|+s=BbArG5-ib}f@G#U3(Fsz&GX!aCkxF)-kz{rQI|B9wR zHLoTP67)|?o^CrJ0+E9yeC%ibCMvju1T9gU@3a+l(musAU>KsBpp$fC zAlk8K&E+w(3d_^{6YxG}X`F@FcDVWL|9Syg7G_SbG`u}Enh*2h>Qmylfm#j)OLk?_ zM8zY5l_gJJ9z^Z_f&(CkJs`C_QqtpfPno{ z8L!}^7o%>FK$xSXh)wRj;U;s_G?~zT5x<-r5A5g#;#D?lJPY~+M*9cV>at<` zzEXDwn%NB1uls=1D91UZx3&@`3Or6d7a?Xt!7a@h0CPV9!hdo4#%U|LO`LH_nv08c zUoBxpVZWPY9YMe(p3VsfO-r^B2&b3szf7c%AUktz+gnYhG+`rJQvTr7M*FQ2V468_ePB$rOC(ujNkjEDk7Nb4f+dc&sdeDIBu0xNay8aWLo+zZi zPLtFW`c~1s9nXNdIS4|3@|Nr#Ne0jH=B1?YKV;2+S!*iip!C@!0r6;S zQf<#8CxhE@taesVf%G_!%e&_x0<0_MJLrTTXj|I}=svwc*HTu;2Wv*Bz$Hp~r&HL} zsV7nSX;W~n`bBBQ+`5+Ky?#TyMOj#4-_88}@z3+Q7 zvYcF;yRi49|E#P^y}pagVqWzth6T<&4|p2>(;JqFI%W%Onrq6O`5M^~!YUx@xal(< zyy0YPMHOHWvvJJr0H0$p1kc?D0CvlpzD9v(>cC5%BxL5Hmrtza?Q)2e>`cL`4r<~BItv)jZ?XfE0KdToHWBq8x z`D`Ki^h=l2%SFm9x~H}0oPve&cite+>(gfq?nQg#NOqkCVdmV{PzbA6ut}|6zXTs? z|FcI}4>Pn+kQ6vwqV7HSZfsFc>uDDT^Ns9+^}6H)fC6QD#_qX4){xFq=T=X2@Ax#% zsqRuSj%DV7-hSLA;7^m57p~T@5CbG(vG-PgXu?>3WxrW5^wHhM)gbnDMqYn6y0O)B zxMbONIagNKS}$&$Q;D5Qzd7h+3TE$0JUX3PT2M)+(cpGY&bt|vkaQatUE)_O$>^MH zx9XaE8*3k-`1))c(TvRfYZY>6l}jRebXX&00~*mLK1&W9g&N|)fqN3QKlzX`%Ri8H z4uP1rdCDuVc02kt&(>bRv`_kUx=XAooWV;NSnC3}DA%(F{;Oi~z0K_pQ*iaf5T%<~ z0TH4*FC}|vBjROmZ&|l=cy5N*#g^kSAdG4579w=j;8EZp-&EJEm8{b?ptX^if3(`Y zdqu~dQt4Cw@) zXm;(LO6nE-z^-yUe={b46Szs%m z{n-sGqU<4O=ST$bh!5ez&9FP$KHaa1C(z*G3_F2EquC7KdUeCPvY{RO76CrnuKMxO zCQQcHTJXvN=dLXm+Nxs>o@Co5SWz^WgY-8lEX+L%a*aZEmIRFuB|0HjR~8;hmGe3|mnV z5b}fn*ijf3m>Whv)}k`xn(9RoV3KSFDHIAE;SFP*9GSr@j+%Q1mL#^iGnz4>$U|IN z$jKUYx)0KOW4597A=k{?X%Z6>^ySajVW+D#t*uvR+|{EoWOjZYVBNibNyY2o=JwY# zzLfLY!VCmR_+-Rz1=aiyvi8hoVq@V7+G+OKX$vQ`SdwQ#@4kz&i*Q?-ss0Drk-bx{ z=&uH^yfOojNvza32IR$mi;0Q(f2UGDBNf25W>>||(Eq@2WZRZV`Y%XD8n*u>u_g>a zS4czkbzRIq;D-!nX?3#>sYv(OjPyTb9F9eyG@!kCjxY%L)&2K)p4p`(h44#47q&+O-kV$?vu8U7 z2-|2junJ7ue#t50F;RfNa*VXXqdZ;AVcX6k@-WZ21opdN&CSzuXRhp$kVC4<3CEx8 z{d37PjeskdqNq4)$DsZ4e3~7i4eBL@5jjacf5!GPKrj37#?yOz%tSVtw{2*sh*d5uLqrY=?mSDnydw4MN3K4tawY$JQenR9$g#8{8 z0)mFA!tn(*iO8MiksdbsRp9x->{hW~+xEQ%*vZ9Kj}Q9dc&A?&piJ8R{IX1;FbKKi zuV__*%+jvFb=A;8#cvM0g*NXkg_LwTh9t5B&If>`4oq3ijeIl=D3AQ+LxdODnacwoCt8 z$i}y2^&j6Kbk)`EeZi8RX*58g!B$nHP)_C4dU_cdnfKs={CrTIA#f=FiMx#F7f-k` z;OQx%2)qLM9WO-K!?r8!e~a34o-nAdiBxzUW3&nhIq-(yyRL{sbk5v(ZHWyq2v7BQ zV;Pr{f1~isd#|fGCI&DkBza2G@23F>#k1;SM z>HdFtu@He|deZWx^cEXqrcg41XNKYI^NP5&~hnnx6;wvJoS%x+D)4NbI`GW zxSrOMs_Zg%?_%rGz!=l&AnG}T;+wfERLvjpwk1_z^L)!@!WGpa&pR=6;w(!DEy=_G z&G*CAkJVqiq+^qq8JFDo&|j%vauA~fcbOWYEa~1~HiK*t3?P3S1)NBz)Nl{Du|fqx zVf!N>?7%S0GInaa0rYEhLh4QLBWR!?W+nzd)|53G5OO_;uIab_|$kqNjGs~ z5s&GzArUsly(fTJ#?r6)xmCph7nf9H`F;c%=~G49p9o_8*<5>!+2|bL?f2a>+z1kM zBtE&-i7yU0rc*kHlk&R43Db2K7{a+X^6I8J6>o3{#1&T0Q0TAu{Q2{_>$ln1QmAjU zv+vlRH>HNZ`FRx*id$!4r@(#2a6(329Z_Vdx6cLYQ9n=kI!{=?LMJB+d=J;PoFN4J zqPlvqL^hab+gIzP>G87;4D|%d)@X{K!&z+>>+`+tamF^I5}e9soJlQ6fTj!kwH#h{v*sv7qTu*`1hqG;!NL zRq>C#bNK19#?7z*eVJc+anPM3jd_c zCf1dmMIW#zlRC%$u3i4#0!{;zf~LyLu4r1wSYbj!LhV-fUR(GsoCML}eiBlGo+kO@ z_ndxTsT6=a;TBO1b96vkD^Nf#% zjKJ}G4ab*9i{oMU2zyy;=k*w^PBa)kse&W*Tqd*IgrZ5r^EoOE96VjAM>;(oIlifo z0DtPedcIYJk6l}9jv{wFCWqsTyw`lEu|Jc!Yw#*ps#Wj|%-`DDy1ljmQk$E$Bb0Cxj3x4wYg{+`B@(FUYCU*uX z*~~lgMbx{RmDjI_zbHU|MpvJsoZU-E$zQ zqnxmf5#Fcg>kd-qgJr3sh4RuH|>U#5Gd!)Hq>09I1RozYipFaF4B_yo3WDytZ;KI;MgB zfe{X$64yyrh^c*w{HvU_{zb&#(Ul?$Twn1&MF#&r?7e4HQ{THT8tejs3J3~cZ_@Yhkeey zV;ny@fUK-F*PQSBywCg0XV$GW4_54PiXQ&a6}8jW)?TUM?e)|pO`5S7lZ^V(1me;bUHesm(Iq5?rFy5E~cxnf) zSnYir)LGD%EVulLC-I4Ua|a!6xiLf>w_4J>!sxzL$wrtaGvMXs<=t3)UlJnzy6xL- z(UtBP$N1p!{*T7=Jo;%|r5-8uHiW6Ad)>?872V3y5H? zQuRxt$fLZIObUNodr9!f$n|YZmDIxu_U8=9SMx3WQuh1Xi=ECxLNtwcoKth&WK!^( zcF-D_qKZue4ek0@#0(6$w5wB_4yGIfs>D1>Yx=eG_Wgo;zZZp671<_;{c&FzNY^&l z`}p>{)1Dg&w^zMxljyU}94^Ps`7u3qHT?kCEnq=Ue>s6juv4_~pU#rBUurF39`LRZ zcN>xEzn;=cX`YuKt|C5bMO+^7cGe$N6vno*KmY#YU{-9sa;JepD*XThHGC^0BlDhW zj2^KR*2?Uo?>hK=+?aaS_Dh0-E$KDZ+=0aD?16CU*s_w@JJt&2$k3>vWTnA>6D zD5MfK#w z488-)h>IJ-ujqP4Z%Tv2+&56`)@a(AbjH$4h2Si?>pePt4PLW~!{-ZC%^lJr{m za-}*Wk0MX>G%vhd-|6-2DIy{!d1AgLxV^LUc-NPH`Vb4Jr@OYo!Ln&txF+Y?DN8N# zwDP?5akT3ImRr=^$vHA2!fTGk9CJd2iu++)J}s&X^&DaE0jr02FgbIgQhD=wCN`=1 zTHav6`f*<*9(Y8AX|kUOZ>SF}wCibnsR}+zDbT=!S)?l}oy)JptecaYqiSA?*so4C z=7Y@g+{iO-ER(7;^b7yc&>$Ch&0s&07rV<p5_XraL?7Xp_+6ZFhd@unEM^W5M zaOgO{*BhETb?acmXD0OdUG^7=yKQ$urX4rxeDDNrv!b%hBzJl38@$!%LDAXo9MzxP z_CkENw>x*37SWH>J3j;15E%V{`B9o$rmlhS$d1=R_68j1+k5Gzs79+e3Tbpsk1@(L z!DqT^$wYakYJ1Xb4m{NQmpfD}Y9ddO>Ifm=0?=w_9_WhrLD#7btUx?T&@cgrj(tVe zQZuLbLwqn)p=DO1z_;UfwIINr8-_<~cFyJ1PefI*HoM_gK6?6U`fzwb^z3FMHKS-L zuCB{5WW~!9lf_bXyzXgxc*GklfM%X;+l+n?`miwzxgrnXBZe-%J zpQF!Xi-9yL;v#Xyz$zemxfg$UT@-?q*sOKz<#_PmD=E(EYH8_dYC0cC958#2z1q8U ziFmk-e7Y=oQpA6A6~D)}aoSK=gphe)qTf z38FOtevC2gpVt#}nq`;Dl0}*G9Ib*1dz;tm#Id1sc+Grrr2!2&YMq(!!dYaZJZA{- z%H2RXv1QS&Ckgp+m~LPXkDS7>8?+Jd^!08>bTgt$-&1|;8+#jObdvSyTZJt3oy*kbZIUj8@Yb@4li0|#-M+tK&Dd2(%G_I{7 zEOzIZLQS+w`yKxssI<^9)r6sU4=> zmt#2`gT>Z=oTeT+dsNaZVmA}R@BA>hKJ+HsndBzaW;&q%)VwuB>KRQv%Ob8Ut)BE zjLc$SGqtSow_}tdBcojS&aRj~yL(l1#Kqpb?!@`!DAx>=V$EPWYNcUlWCSB{IWNnl zygJ3{NQgNEEB?Tx&7P^QA|fJUVR2C|T4_T{S95BA_tDb>K0*uhs83T`kZ}WA3EHuNt=HiC9&1T-z&MZT2ejW z-PoJIu_-$|ZabbgJZykMxquslxXkGO*gA=fjx;z9XCbaGBZX)0x<1_nwlrWdHUjWb zEM3q~w+%H!GuPyEtz{y1HAoBD=oc%5`XS3vo8KO?rA6K#V?F47S(}=UPUXUy@0MG9 z8Ob(?(y4UieA+sdbIWnj;OWn#&rx3Z&&=5Wv#Hdo5@fp4Jq~;t1dt8Nob1b}QZ6z# zdc(iwQ}61VFTWxE>VK0@)qlk;tLG&Q#)VT+RaI424~Lu+-Z!D7T5V@IGZw@ryqhCY zRb8z|Daru_c06rlQPRj{smW6ehhyPcptxr7gqft_K!i=!%aLEPP~e~_gR`m$4GB@;8rq0N=qp&E+$k-G?6;lok7uoTfep2{uJMYv?t|{~4&hz@^@#0w2tc z+~8E9X}8qi-=YcYyQ*;9K=d_UL$CdDbGSo0o9Gkd{)CmsoHB2nhPt{jd2=$A`5Q}1 z+>#K|oit9HzfOblcINV^ZKqRXAfoxMAvsdQE#7vni&K(uL4A)dk$dp3~9c4 z^e6iFp#)Vm?std{e_PCLK1h??Mxd0JVyrXAu=oa_Hdb z7ZHn>(GJ04v5Yrv)Gr<3Mv>D41KV?fytb%n&CJC?6|wGe8wsSTZf2rKcH8xT)*D(5 z*mm_Tj==2dG-oO?**Q+k^|PNDCwj|YHGW2;0s_b#Q>&|SV1!6h25dghPFmPWB6@jy zdpq?|nUB>YYECqrR17YSD7pb&)hx7O1wf#Gf2pL{gv2lMO6_GqS6K6S8ZiM_kf;e7 zoRQJd6&8c{_@R$ty!M8*#XQdu#$sb*K}D)Z@9b<^$;JPj>+XvrNy6uM`Qjlr1T0s_ ziac<{lLO>t(E-Xe>EEBAIx2?(DRtBib&@wIvm6Fwx9607>v(w{O;%6N41P&yxjW8v zB|X}9sLQrLH2f2wwco3n5ggN&8g-28?~Oca_eA z{bpY8t$qa-0syh)*5k!?V6F#V*j)!I zh0QH-G6ajsYLBurnpbN6D}NV$sSmz0*q5~jR%ySG-7g%e0~7+AFt(F#%nI z*P8>!DQL0b;WFU?k$-Uk!-yJ)bchj@6d%6IyhAa3ltoU2?=^feC>u~WG|cCJ`|8yz zVZ_=**_N9Zx-U6}9?g^q*FZKk`O_!_GhI)}??gH~J0Gu>gm^pi86g&P;DfX9=)rXJ z%$?RVq`CSdI_RJ@Nz!uYe!rE|z4$*CQU=jJa_d0SMf&Z3(HIadpz`RXqs*a^i*-CD zdtdRULwYzoR3Oml>PYc%Q&fd-soe0;P?P!;Be-_mJMaR~H*K8VU43*OkCJos);&Tr zT2|q<7S7u_avo5bhuZ8FE!T5Tly~ZcURAQzeKRZ`DZF}~)sPi@+#{9^0zsd34}o*W zRkSr&YQCIZh|Xz9ZUDA)HFrfr2ot?>DT)z6&g3~7ugdP$51D1T$mdVhTzH2m`BH!x3%+nv$eFqZ*Ec8Qb2 zd7-JdAEl{DFWkeI-#hquxlSmOzc^1uV|_LvSise}NJwD&VSly6{3{#hp6{u?3%xWD zqfMQfyd&z~I_z{1{x0p0;MM;FFa-+{8kcvfgMEon58AN_leRJ zry+e@Y6^yVu1M%eM1&&~j zer((66lA+OJZ`sFV5qOfn4qSvUb39%89SM(5HcbqWHWKMk4E?$1GqL*&X2XPt*;AC zo@c@l+T-p$?}^cfn}6ICw|`<60{l#}yK7((H+)WwlAVK4K2jDE7*os8P`Iu`4%6$j zWK10#$v1#$X_?tlv#cl5ek6Yj{W~f_bjm%|uUkv*bLHPd=s+M3-P#lIl41<>w6q|e zG*8*u*bGZ41l?fkqhUBjLPwCgHv@fFUC%hNfBdmma8Hw7Arqaw0Ww6_8i;)Z&9 zoPGahwb>UKjB<1{%$;9g^X;n0%jLS=ohaeCv(()yMXU|}?SmUk%1}w*?2|p$co}Wl z83hPXALryWS^M<8(Hw0Y}x2V|)wBA(q1me?N?aa_7U zx+S!!CfiYTSEAQ^_(r9x#>2Z4To<^qBwcqSwi{~Ecu&B}S!5=-bPJ54C#%8Ma2CAu z4?oJ!klHu>DWI#B;54APWNU969S>;%$n0)B|0Ioo=BJCXwwgcY^x_W`$sDIm>18z0cDm(~yS@9`+@3RTedx-f-v(c4%ERe#d^+=cW`Sp?HYdug zlEBmUrz#}5Ojw9g7U-6=6hBDt-m~N4vIZW^6-pNf;9y>xzpK00IuT1T-QgDCwqCYifiFmmXn>`wWch-(NP76{!?f;b z9?aYfR%RKQNQ$k%7u5LI`9B{%e3-OD5`>UPL28x&v{77pAiVV5*sSaOE8P*B3RDL6 zmk`P+JfDCe-&`Fpakh7B{Co-2>^_IHe&#*#9RIo!_4_C_?~|P*B)}yq5_=kmgI}kq zr(9%FE8prG(N-0z)UUjoOlo46~_9`*IsBN0H7d4qGrvXM~6@IwpPYn-v&X!3p?n zQRWa}yU1rXw>jJRw^4uVfHLtP1VD(foSw^d?;ppPt2JaN-e7dBy#Mp^SkS*ANUT~X zi*l_?xKg=Nrv#n7z z+`DIR7%4g~%E7P72AeQEkrN-k8n>OCdj#Icig^;k1&k`d+_}=d*EgeONbBXDcity@eY6r|>KR z-j;pIJEBKoGe^NENBtq*8b1_NL7)TIG+eMY;7mh}+ohZ4wuclRDg`YOIc z$*AI=bT7>tMi(A2@5JO3`LZK4GW_5FFlFq}I6Yyso$r@g)AriMR*KbY|3aU|KRL^Z zZ0p=~*Ed&ZUb>(6Qn?lZejR`I2I(d}w5_-O8;ZjUP!H5>8j5{WKRJC$pcng7vfrfA z35Y`HXz3832m*g8T@d&J|4srAG4BrSiE385nB!q--8Gx?xFM zJY)R-@6)SvPhpeM*5Q1J)c%+;cC=u$B2vw9xrdYz0eMy##3*FwGHx0?|3Ihk4<@I8 z=fMMU{AMHQZ^}!Qkk$!g;j4@q*);He?G~C5w-~tV=-M4Cpd9s}UtKkNCzww`wJ$qO_X*(zd(AzHXL7jfCi}Xg=+uIAv znwpvd7i5rqfH8N>BcQ)|%KFN7W{$4{B(w%n!MvM*!B~<}dI7ovWHP=Bg^b2;jiB<- z>ziac(Jml=>_Y*HDX87<00?L|;w;6rh2>=-AhOvhEh}4@dn?X7Q|+7+GcYj$sAyAL zIJmTxL`n#n+J&WFv`_s%k|ccv`z9%e(n5buzXhraU;>`tI-O-VIy#C?@;T~+_lPVr zwc2Aan2wGPkn{F-FVfsYCkQa@*IEv9O33{kXNQ(|)W6g-RY1FdXlYBtFH1r1UInA+ z{wSvO0Tzsb7!VHGRV`jKI3A|^GhSj&oM#~llWM_akkwcuK`HgmV4G8BVBhoF`#xktq!sZaVT#G%)S z>et`@eflRs{r8%+|DP!Izc>p0PcOBrP{R0MTmYC6{~d1p|9f9Bs4%9+#@qmbRu2fm zaCWF9h@SvEX0eY952Nf`Z&J|nFiCmikCv0*y1H5H^}7Sg)DpWdSws6ux?}hi{w@TE z&ur)F0UOj4C$s@Va7gR?%u$c4)2^n{ihqdsb{n`%1*k4Md1|`s<3aq4a~gB#fZ z=>K#MP?$Dv-T=YQR*GC8sRRNkN+-&B(xzfXbQAD;X;#SUQ{tZctL#Z$4*yO=MyZP(GnoXQj4uVoGd4$A235mOs>bArnW)4PojOxbx>y4 z9L@#qrGnr-qzRdw+sLf2V|F#Lm_W^r1rIPN&Rgc*?2W- zqFkfg8r3h(9fxwCz?_BrfC{jwZP-}m+E~*-`Z95D7n}f1YGJVgdbQ5m?d*OuGaguO zLP>G48f5HJM6s5HrHuh=7|th8hLXX+n$1$;knZxJ_n2v1!@B zCeP!!piN)0s;!|>p1a;9sb@{f4eo||ReRb{Uf%Y=OI+;*_rGAtH`SGeLO6LDV5(!Y zjxOcwook)1dA+npc!v0NM)HaUyGm|Y#)tX^*s5lYQOs0eq5|h;vkUcFC`=n~SfK(f zrlbppk_;&R3&g*F;l8uEiTr8??ASI`{shkP&{+G%%`VSHR;> z&CPSaC(y?74Z>q(DV1G&u@!eBElx>Gz)10GAoW^6pyfTjR>-JhM|@$680BET{n|FROhZY)_Dh`u;`M&n6Woh%n4u2X zu4x)Nb35Jc?#(?vdhMHn$&8JUO^Tbn_dHOc_c~g*Iz5-l&sCkUS*=L)EQB}_+;d}z z?YQ9-lhR_;qN>aL2h7PIOZASPSw@4;KPT?lP6m4d?K~EKg_IlP!uCcu6+z-kQ#%TgSwR;$AI>?`u*W7@vv3O zw*F{Ifd%0NatT;8g^<4ETxZ^&wU5HR1r#Q-jlR6mzY{kS#}OBIwcB4q*IV9P*Ih;* zI`wjfv--KM&nP$k+NC|&tSD{PosL!K-oW*5liFY0k`=e6r5M&$yGy1LUZ*2x zA`&uZQiq(4A-`-(Un@Jlw{c$?HCI%Pw?kHBsY0aT=y~$Za+^s`E-sS3$s2UB5+o7T zkU}vIg}XN*c!=`jM>s$0iJt)qRR^{2@9H)xraAR~G=-tAt4!&0GYeIKd=xPFVTtF0fm3UdVsamEmaTr;3)|135)hSP=J6#c@|){RctZO z$ssi{J)e%~8rlzcbsJs;-y>Mk`EFt6YNJf#Zy$D#Vvjx&&5 z-+HNYhopAr=I`?d6IKPFgQ&bb`xZy)Z#FbMA^RaDG7l|{noiwTs%3VEb_(*D*l9j~ z5WhSp+iE~uP(%5$9Eh&c1W!!I<0p#3pSu1G;7D=%WIdiw{vo6a=3}ejnKYP?d8Oxz zY;L-1XT}TGg09j`6RU*#xk^EzkG3-;jku9;wEMQQv)YJq{%&0MROj_aFFY^YDI8F%N+8#;jp*FDx2 zw4v3~M}{dq&cVcFsVaQ2Rbs}kjm05DqZqVo`p-0@l{mT|TlepPh zc~pQcv*c-rME=I{=biqgGcNkf?9) zM9k9n<({8-2Z9~@PUV){7jV(Cl*+o+zSkfK=WU$cyaVuJQ3xWpzFyJ__b;jtB%}@M zUzMO02zqMnpw)?;j5cr?uRs#kz|j=g{F>l@h(xpuLZ8gm%E}Y;jKMKU)6@vGEVcON zuTK|g>bD~cnB4xnPr?ml)`M>9@}hmjUta!)`L6w0;n24Q!z;q;Z1M*HjQUd0V;I>J z-CiRQgufZ@pY(}fdKVZN7$UOtJ?UgO6Ho&ptA&m0;HVQIrIP?M&h;?ecovpl`IFOO zUM}NNp4Y71?3+iT((ZtaS?nIJxf4;h%4{`n^&}lMRce%HlFneaJ9&wJ&PxG}7Gbp- zn}0mDEGYyujuQ5u4GQl2^jXMi*pU^+`>1Q%M2W6>?Q{9ix0JC($9)C^!&{JD0gwwu z@NrXCSwZ>pjfyDadriFf5LIXBADG01Vp=u3v-Tv=BjG=uMyd zFJ7#XHa7yF-@wwd$4=C`@2pkK05OCM8Ds-ysybz_G4eEB0|aykTKy4H|ECLkx?@<% z4>cg6JQf~&^zOIWNM%5IM8Lg2Pdh9Wh0h0Z(?f6?mJ6>qWB9JgX6Sv2$jZKc3i58c z^&&fjQqb>fftSfv#v&}1!nisF%9(rv1`4Q~f&nGcrxD%PeLfiMq`lGSwcc^pNXh#> zeykq~zXxy_0(3tqpJ*o*E-QLc2aXpf3#i7MC;4oSIa1%?hU|)1uu&63`7O)qZ(qH{ zi4?CQMyt`N(1J}I*ATj7h5(%dRATNZOT?4f?HybNpO6xO6xrF?ISX-0Vrz~$4jCez zdFL&!yEU{6UGV|;i^}Icz3G^^xekCg9bpYfLSFqtK>x_x)rHi94wjyti<4p)&?@|G zxJXm4lt9FtF@N<)05&z~g4QSa_g_1|?v@F`vdY>2dAaZ+4Jkn|e2Fl&@lYQsxOQY} z*`}fSSbvveyi~^49acKxUu>_k_xv_D_!`^|GKMziks8;r4T5&&`GfKk=$>~L0h#;y z>?sCe6Re=sw6hk$fmlzc{DN4@zr@h50Pw>&1@Uftnoot78s=9)4xaQJINF;7(kdld z5@yH)mQXlPXG>P`7C=$9x<hB_;5m*Xv3qzM2kn1x0S*9s_ho{lpi&Zz0$@~ACjE|$>lZV_thRy( zM#CuXxU&zX+qeT4_ZPF@{OsTwsVOR+PkHmRkRmVr;7h#`cO48_y{_UA{gfAKSj5T1 ziH&N{QwDJXt$xPnmS0xHo9W!wa@Pmv)(yrBk3_rq%iu?#v7f2Lcd-_ucYukBUG=+izzP( zA9>}1Z!renQUTy;nSeZdn@Q^ZpK9sG;EsRZR(D2d-izbUxEGtC>gQko=c~8|(xWBq zX?XG6x&xR>lAzV+pyz;bkD}GuF^hTAXX6ACM5*0YZ99vcv{g)-Zy3<&O z2Q+&r_H}K%j`zHEc72D^sB`d-#!FR8hif`hqq9v*_}A3`Y6+y1Qi3QJ%4-M8$4A9v z_ph~UjaM7cU4k<2pVkz30XZGOzyE#VJzV>2WQs+J1Czkqx27vn>m}Yy7VII1Mf_e- zZ`C~9(WCpm^^at)zPRZD+eP{1|@k9hr(|?{lqQTEf%ZhXV6gNi*a6f9X`k z>4Jzy92zG|Q4pLtt-7^<jEx%zSmp1beMutt=QBGKhD+EGHUt&N~_U^ ziyaHp){UFK46=m~unzEU)jRkwwOM?hIltsv09~~dg`b{PQaIz%#a(_MFMDUzDQ4AJ zc$gji@?@jZcE`4{PlEQma5fgoN^=P$A&pucx=W6o$SpNh@c~}y*WZe-;bjhN+QULQ z3p7@O48`@E$k&8yooebxttQa#A_Pxum)nwK(Q%0p+7DKrSan`~f0C9gP?lbmS9^;& zK}YMdm1BIFGs>UOnJaDZ9z{;WMJ2tIA5$orhtG69=9i*t7)3SER0n(9!l}U8Am<@$5Oa#V;B;#g9}P zXP<4T_g1=s3($}*pfUb*Hmx{QltZ)Uq602^u6FGE_J^oXGznu7&mxza zXVPgx!@Iu6TGA}ifQzAKR3GVKUN9}9oIBV_6VM8*M`ZX7A%%(%Q<#CUT)B04Q6>NW zoA=&JDYC7@Wp^>K@ZjLWj+?Y~bsYAyw~cG|?q3p9RsFd9qAxJsiKC3_tAkSIaBphcy$#{`(HwzBH888XBpk@PLr?0vG%%;JRfFx;~o#Ymnqd=c3$(0)jxCW?q2m@?2LxS3EKRNt7QLA zT;-$x#8uM$iQzZLB!T99teC4UXXHToACgS5vQjuc8K_dTva-OLh-pp8Z(RUZJL?Hn z*lm5E1P?r$9&N~pQ*m}KgPfE6kFwc9_S8(uuo7vKK9nSq1d_oS@MQJ1wc=7z$f6=X zybTOC38bO(TZe!g>5do~8OhG(bUeNqQe}X>f_Umvc)U6MKCvP3>+kmx*FQpP_|Exx zm$B0{pRWcg6QKC(Apug*`#A!;^er;brwZnULC+5~yt=!)_xAQ^?mh1lvl3p_jSk$S z1rq^i-LHR~W%vU`d~k3o>=e+5Xt@d6P6->gP<9sE)~pE{oUdQWh;v%g)_!N+n@Cs) zhuV%6zB<7lfp=DTUHGpysO`$>`cwMg&VjV^d+YNUL7NGIQGG|SN1`V|?BM-*=03$k zAm1I3>J^^<>b|pN0HhFRiGaD%B;5hzZ~qD$l?DJm^mqkqvZ)5VIZ({B1TmUVmM2~f zY|clXco>k4xL&WJCVimF>|6Yq99f_boNMyerSYR?;UWDPQf2<|ywTtPJ^81V|NjG( z^}jdUDLIR6ed}4rzQ#Ns#6OQ01PZ zwn9-93He}~$kP&du~9GqsHkT`WbE#h_cBU?cExb~`^YyMMXzs1j6Li%Ja|3#$Bse_ z4%lE9^xbpsWS$7-mn;)ingZqeABMv7$#b5{M3yZi74LJum+A1{JC`+Rw{=1DWj;CQ z)AnaY2wm%bI4O4h{pO2SjX9!cb5gS326XV!xV*1Bk$eC9nV?O?9+%wgWxB8KT3Y%b zA3FDfW!QHG#2U(L-EmW?(wa+8xuB18=^4J7DCZrs)cN0usE@O};(rcI#R-tWei^Ag zQo6uy6|2UfDXL}kP)+U*DoKS?R8>^*3fHwQ643bjXSZg*it8^MguYRNDwVZsl<=<$ zgYT!)$j(NXOzyu8h<=?OJ`2v3MrmGkgD>it6Pxa+Y>i$Xg@W^z3S@&=wY5Y--eEGXRh4gkW8w> zSbisv7l6{|51I76xLjYKazWn9HbL3ewI!qM@!2b2lh06}3syO2I>$ySBVOY3HMm>b zSg0weAKv7a_3C!6hX+~7c~=48SaL9~wBI45X*FuogYmM^ZTD2lijj9B|@t_6WYZ)9&d6gE?Kji5<)iN?KoozU@_5OQh%g0=05#N3DD%;YtcRZQZ#34gj2{M-+XE0_FW(KGk7k=jZ{Z} zqEq*kHJKMZUSxq;CH2PIbni!Tts9h@7#qJzG|;*XudZ4N@SOIxQ%{AQqs7({C+tq- z%Wdg=HJ{wKTD-HrGeh5TMS?rjq27@=+52FUHJw2%qN1v3&726^-<*4K>U7^EII0>J zF8g6OXFTeDcZfutld@}~ybhybSoOBqKtZxyD@C3&?}Z-+x>kv*TXVMv)f(PamxIf; z1pXQ9lODZY`u=Lo$ggVd>8yrn0nQdFP2SjR{aP#uYb}}4Tx{|YbP)^V%}4uR2Jn^K zK1aLcWRy%=1f^q?4;G!Xi_}T(;G${1V~TRH8V5(t5{~!E0CZ4@TEu?Wh%ncVUeKg% za+YzEHOToTT~a3!!%~vg^_`()Z}$^AYDDzaxb9$DsobO>+3LBxNx5S(UKjoH;foNG zcJ{z^1gM+WA?2(vKw!O{$gN(S{S=i}VhMAUOJ3*Knr#YbI9$pYnP?FtqrB|UdDXNT zHO2VSMSoN_mzzQk>S=}eIQhaPonPHlHBsf|#?T|%*MDMkG{<5cm?HU#xY_P$)4O8b zpB|CWVFm|#ddIINBn}2dqB}5|O{CjN#OmF1H>Ocx66ie+rEN)VWU9wJc&iJ8akcO;(!q z^n*ZoQP~4J$UEjIko66X6}MTFuXEv?Y#N*%@=+ZrZuW_&s19M3Vu6(f-I4FxPryiY z%G?nK*jO9xILxFZ zqOHCv%Pi zB2vpM?y4m4HI%18&RsA^duz?U&9NxCpRH!d4b^^PQp_f@(wLHouttW?)Vjjd|qliB{>LNX*)sPzdkO8_gwEr5`#l)<-Ucq*4pMP-y zLEI-b`8ZrSXU1J~uX|SI^9MJo9jtcdH%30IQzgATnq6r;`|w8^74EawqF#|r)r_O@ zBHO`n7-#M^2^%N(-{#aTKAjX?LXQs{%~~~S*BeZY`+anxrtF+zPS~ee=y+A{P&+0S zR_hIokQ=!cO&^D>Z##ChkXszVpSPRkxYTDOIt5W8Wkm~NvO5q0`mb( z@^@D3ZIkoPs6eWluB#E4^s*rTh}cJ^CLMjOWR#Dy-{?5(m%W~k)0)2#shymCpjzj$ z*LjEi(zumQd&;G~$+&Q1F7o}G20iaCLJ-evVHiVqWqB%SART#)v^D%5=%e<@UifwT zqx$0~Xdf{!d!G@(6wfeuPAT<}jf%F;?v3JfKbt0KK#;8%`f}Bk|wd$_8j#}EP@M5*RY!8;dkp`fmclDX~yq_s{T24O*<6e*arp8@tp>8=QlWm;> zDC5hdf9*1Z9$=tw0$&~SvjH<*iD!gJgY8qhLY;y_AR_K`aKF%OJ~|HOiId2x;Q9Co zC7q$S7~q#@v+>0w&{K0~i#-6py&7}%(tdzn^9b9MiPvq00O(|SkO4LnXd?SUrU)a| zK6OR;x6J69a)&4N65;iW{mpUtq889Nw|J0ARXvpz zo0yns>%QvCLLM4c#L5;iR@|rOVz=GL@^gSMB?P2f9jipZE754q=XIP;2;f*-E)uN2 zEmZpeR*qhDYUxpJz5^f+!v*kCaDkDqIA`2VweQ-7Xdi(xw&8*9pabryx0tU9@bip> zAi~lM*5P}~t@kZB__OTm2rUKi^R`z9Hu?+UgsQ>^s*gIhmcEIe7Rn8&JHj@FjsnIi z&!fxGEvJp^ikA3raf_i>!FTUu{`Et&_0Dfy%{VI>Q z-&7jBkL?`hty1z-BEKrF*;n6?R0eh;u(#LVKnTrYL6_{6Kd)$a2DZN#F!EVpE*1MXPb2PW;)kNH`Bh`=Wu+^jfZsGHN#tzRvtU zi$2xudwpJ}&SZ4@^7un&CR|m6o7@ky^)lal|3WubKvasZIa@8AIWx916Da`2OoAkl zp8_08s_N!s$Uut@_*M47AD5@kx$H-)O_gI|;ipaq58jJUD}B^@UAO$?c6iVU>XU0@ zfP#7Gv_Z@p+L{DFl@#u1$e0Q@*V!z$@A3MogH9r6jdZi};7%nwmzu+1+$jU2x&szf zq#kXE+L0-%;X-~Lp4@Y529!_CZ=>lJC;?e=4kbG{kjNIsSEqM<|b(X$nf7oiy2d5`N{ZSis#A+9(< zawoYp7+~w&4Q_wt%pC$Ia?bvwZom~V#pyw{&S)45Kmm8xuSENS!+;?xfrG7A62~&5fWJcLrK0Y3B4Z3mO8i- z5lokj`gVpri*?87`~!o@3VzFzCoAh=;{EgjJ`iatpczIh)bp8#%vF|IDi7jtBLLJ) z#?ioIC6cR!p+*n~`J3xs#bkyGqAp?6c;ZJ2)MWQn)||~+#KR?5DW#qfI|le4=W09W zsHFzKZArR?`*1@7#s)LtGzfLE%hXFKYW<`hU*u?#qI=FCm5`Yy^Gq2qnm;Jw1KM>3 ze6pAhICU3ER9amK;N#H>pCcFG$DtN>`Q!Cwo>p?Ntdl}bWdr6;oz}A4a2!J4X&P~L z{;FY0qd8ov0vuf{QjZ$XqPXqny_=6hvlD;gr zwrNrq0i1-~hirP_{%0)UbSpU})%q3WZy98Qy}IclZ9jEdOVW=H_Kw(0C(e@WR(4J* zjkRlCFZMJC7je;`E$4@YxQ25wN5&qVJG+snpOHBI?h0 zI)a!?Z9E#i*OhX-%Z~fGZYGIi^fJS^=0Joul!;?}yE4V-xldk&$ZF$?be9{6YTgfr zhXHWTTSz}+#)}VsV|CeAHcex{_;{YzfIH%<^m>tHVt|xezV**5MXVOfusyEV2MHXb z$OqsiJwV;^cH9pCq^q60< zjpy*4Q*9DOnY{E-dE}pkx1O*YF3?^IeX<}ya0VHqkK)3wci*_<e|s_iiM+x8m#VFS(ZNwE&5T+ zo6-?dL!wphxVGlhl}PLIk-DGS!FBs0{S!!qYdTt_Ku|89g%3&OR?9Bqs2UwV)ussq z5>Q^0SMP^Q1^LraPQp^GkllwoAJ0^(g-fyC7_GG6EEtlY+_&$9M4;f_aqsoHrT z{{3y}Qy?_p8qt+g5-THWYkK=ikG28vOM!k4f1&ZBuF8|~qpZ`&b;{WW5guJ+hxZY? zR*GQ()5_fkT)ghTOtU>cqYJ|P4B0ApD?qKec8V=RsVqaDDKXaB7veAw$W@MCACC-v zY2Ru2LPS%}oz!L98~_R*(!P*isp{x@xW+s*VscvZ5fA4*vBmJG8!wwv!xRj71?zd@ zg#14!X$r`$#J=571*C*iLA8dcyty3Tul5m}ePyXImqc|zl^Mq=$L}`7q0pNIeoY;2 zhq!gxFLhpJ1GSNgF>7nS27&dvJw>Pr@e~uqBV>{wEjWp$`Rk^ zRWj+`KfE;GmYBcPz=E5dKcd@YT^rj3bt?wE*UqgZL7$0+OU1`JWaK{yBvT#{=Z>7G z)$&!>kVywS3I(8*cH9;1WxatWfe#pI3^gZmnX0x``S!f}Lqz67Mu|`Dl*=Y|?21Q_ zChJ)PTUj6L^U1T%c=VJnHT5RympeJ8R(bY)58&-ed@1dQjXgg-{qtkKA~Kdu&RMnA z%oB?hn50g4$w304NX1`2Oa<~n>fS~U*Xlmwh`5tp8a-{ri&sAu(YCYlUQL)dzGkam zn2%(Ea>b2Mh$RZB>hkGo-*HH^*5_Ikj~p&}y}aUmnG7|5+~GXZqAl0|R#Q?Y>TA>s z#@D?Fg?l&7dOUM%I9fj)XjLu{!pZghVcHuv#S=!Wa^hjAYs8%N2v64dh?LizKUjZz`x_`lG*V?Lmc*_*Tz)M;Y0jowXhfY>aakVD$T?2&!3R-IIJpqRm6v!cG zJQ@grg4R|l!CCi*(v|b>_Xg{p-LSFsc-1`8$iBaMeeZnwcEe$L#?z6fggq_QHojkw zK4ai8@G)wL`{eVDgW|@mtLcu$xi8c7K;O_|(sNe4dh#5HorIgM+*uXN@h1%KKB%4iO`(@}({HyaFCOKXDBCO+ciJnZ(ToDFuKxv<5 z+8nsHwsCRy+hSZC`Fyz|Qr__SwU*UI*nK0H_T+gkn7WFS5=@DWsf_r2S{!-?vcLd_ z>yIWJoO`4xgp=q9pg_&pWk&{K8+6xo3+o?C8R{-jbMkbDDBgw}*4a3!r)1yf!Lexa zjbE35<$KCB1>hCi%>?k$0w&pQWFw#5M?8t4m#pLrWB6Ag<{0;Nvr9h*in6v?3biF} z&v`7maXc-EzwNoQcjuN3hp84qe}y9;_z&}Z{+tU9H%DGqmVd-fh4x6TR!Lqyn@D!< zpIwAP&y7}#O@mgIEDxvlj6LVPvwX11MiC8Jt8HU`h235|V+g~K3*e=*tyMlVdn}F; ztA2lj_P1K_#MGEz?%nyJJ~{XDH?&seWxJF2v>jU5TviyWyc3_XP2u(n3F_gJtPbz$ z@ZMHiGwH$gXJymEODz*i%Xt);C52+&tE=;Zz_D7^o1+m|_&VFe!!m^bj8oLlSe}{V zke9&$iD!7mGd_3IqHhl>MDgfE+pkzC_(wf;0H9-*Ho+Tt{41<%64pU6FB`C3{+P}C zWT<$D`iA57(5q8V@S7h!3D~`|4O{DHqMW>D35BB!Dn4f+5`hp(O1++Mh+F|^r$$cx zX-}GnXJy%7>$PZ4ml3S^mKjH=Zo(E70}pShoK#forc5$%@#=Wxh~YMMBz%Rovh0!T zo#w#Y&o>M%tE?UQqvLxj=l7)EKhJi6W_o0dRqI^Ss64Ku1$&Rj>`C;Sgh@{X=9oTj zW~fgqcEjhrwcU#~kzA9Ow%z1oVhqxIZ)B+`(%0-~>AHPM+`=e+p~&g$0YTHJ^V$=+sf?kOgFi``YJnGM!q`c1LWy@-W4S!?!NJg z%^iz@bt4h-5%?MV@YJ2!vhw`WTh}CBRuu8-uQq+= zDcK1>_Lfm0M*M`|A@_UhusZ!xm4PVy?brYC4(#Xfw6jpMwo#(wZs+1^vo%BWlvgGU zAFT)P*;u~-ok0&4IwPY#wMS9H+c(S)9|#Gjhh=@h49P@`r3QcPNmb?>^)bOrHwOM+ zl)YnjCT-NU8@prMb~?7r4m)ic=#G2T7K{<42URh?C9u4B%% zN)x5*l5TXj$c>-Ft;U>03j7UAg-wyGO2{~qNcDsYQ0g5+!qEVfy>MHHwUCmpwXM|a zZSleJ(+VYP504^qZvLP0wYgFD1*~p(1{fj#)*c^OYin-;jFURs-M-uX;s2RfzTza+ zaX&2z!FQ*OlfVV6R01p)LF&oHppltuF&b`2#28}Wc8?eME-o(yApZp9+{|I1{9!~G|w@2C%+(5?nEn;8ImPU2bkO2WmV^>#2-bZ&x3C@jtYXm%Az zerwh>)zvdT9Tt!Sq>yt&uL7_RU@ZxbO-Ec#PFLbMV57?arzJP}zdj%vnZf6J`&HZd z%RZ+fwJ)&nvUh^`*TC9CF+gv=qD{q`>#$;C>cM9ya2lK4_C5YbNT>mXM8(8m4xpf~ zm@z?ai14++ya92?NmM*vy74(c^r^bEbf#IYMyHA*8!*Og63k>c!~=-zfB-?Dn{)TW zBfb$Z#sWU=c)V%-wSXL)o^3X``{&!^;NalyCSa7RxVRWFJP;p`%wi1K0}~eLbDE;b zbM*9Yy9vPG4Db4-Ez1iT0Q`RsAOAPeYzj0rJ#AxY33vUssVNGd7chcrv)Q&NV{TXa}oA77FA0XQibn#B8{_g>@6EzQJMbBKEOki~kSG?h}iV7MU8VhUd>i^fk zvikoFEb9rxO=eUk#f$_4Fj95!<#i;72oR?<=TbQ2;os-L1jNIHUnu;P7oLDy1pi~| z9B4X~slUE*-MU?N+0Ul!l>Gn9di@)?2JF;M<@fml*sMGbM>4=bpdlL_J>G9P_Kzs_ z-c2gp+uolkPyYNcgzRJ)h76v|I(H1Mk9ZdEFcznqx29c0ntuVgK4&w)$uAn-h#oXh z({Vf8XmucP-2u)DtZ6%={=AQ5nM!51*&4*qtu##q_YSsgW6G<-NnK#=L`RHm}!wYOOH=!b@%_g-NDW-dZX$_CvTdzPS5O zUkg>pWCej}4*W=NMF1xCxyBG5@~r@9BU*b=eEF}Sum4TwPjO#YmX%=vrW&l)`=ke# zb$vRkfzI0_HzC^u%U1mP$I>bKpqEk&6W+BJYW58<0NAGv<3oo-^4og#Wx;P-0bE$5 z+vqem+YJ41zn_Y_Mx8vOigo%?2`G88$^M=gu&sg$pDy^a;(-3Ev-V6ayy|*Px#Aa_ zBEaSE6z5zjW*7bZ6Q^4oEVIZ9ZlIw4d%dOcFICY3{^H=TV02j5${+V4-a+;Ln{)L5 z?YQxkZMj)|2T1b)WnpFx1L6E%J|?Lwv0iiCcIW2rdwvy#qP@W@y$F#2t@}hg2DwYG zXv|v2#)jkV!tFTPIV&?w(sbkgwv`SG6mc!OO7GXfl}5!umlgn0>z1Kk45RjTbrnIoX+--K_QV*tDo=XtySxnld#J7CW=2N@YM63*R#nro7;y! zhhUh=)7xcnzyU@V;Njo`=m^l^%J`Lod~2fak^Bflg3u}rzRac5QV$)7=J>Ufq57-> zOQ!k(8Vxr+j_NpFgtAjNkD$OPzK_@JOT4$%Rw{}*cG);?IBQDs4C`8{rdfc9`>d`;2F6;=w-AR_ zAN^dGv7EQ@S?;)sIRZoN7z2iLo)lSN))rAxf+IJClo+7;aItg9`-A6}j2y{I6*=-v zq+&BHp%~A*GwEqOTGRtF>lBwD6?tM0(o-{O#5$zwXCw)@ahCSvnUwD7O?$w`hn#I& z7ROXXkLPDf*_P_p@QhbW_Z=?HBQ(Fjt)sLhrB5-n6wIsK#6-459i)qMP^ybTan zD!YhT9!?O^lkasBKSQ&Nhwv$o)3}w!*;qda(HM{bK4mlvBconfV&};SE`6WrY=0*$;xmlUu+mk5NB zk&%kx>BGe_c7}{qQJoG&_DPd{G&nt*pI$8_e5})WpGyT(62t08xq&>|d5r80y}cN| zNvDUYW4KHc6qU*%Uw(J}-_8n$GTKUIIiZnM`sv^6TXva&^?UiIhbiT(@|wf|wrGqY z^=Y56l?c8lC&&(8<>-PyGfnqvJuXbDH9#5Gy)A{PTPxgIMR8L$L6;t)@(#qMfy>$y zup^pl%qwMa@blqM^Baz2$xI}jz6P->p+^CQkP9^4t)T;+URitf7zNosyEzxYQhI!i z=-Sn+b<3p0-$Qx^VT#b2ledon8AEn3(HNHo3=-#aSJW3D(cz9bhj|fkxXa` zP1f&(sPj#(JotoB^hMR2^fyVDRO{}-h|H(fI5$4CDr^{ zs!HZzR~j1f1Sz(Yt>nB-k97TazjZGS3{~JZNiv#7|;Q zcFCpJdOl$N(CKx0=Kgfnx6yjcy$yW7Q3LyaEwl?SLv{aG{eHU_SLNgZ0&62u+<9jJ z(|$?4$wDKUz1T0z=}9e6xVyi=BbIL{v&pfSuF?RDOWO}r9IKC3Hvn7*#&NS7L6#kO zC^v9?Dv^}l9z5oE$9F>oebS@0uaNbmN5#Vmosk(0h9T3kncv%Qj4wo4_SA#Mqb^Yn zSB&J$4rWG{G<@Ok3^rX}V^XzQkDMc-`9IJb*1sLtxT0>W8#4IpwtMSRtEF!0O(grn z&zC3|(wK}_NJ7NFYYVn>d6;^sA_&yi8*7K6bl_*o8%B1}Ee&~`cp63n!$fG*a@BzJ zUI)FVcUB1NlN3|67b_HzL{Sw*kV*)`!CFH1dnkbuxHf)#z;J*ff+`P~Ly^4UZo2y- z;4nGD^l;v2iF$bszz+on4{9ISqA-Om-w;!R zie(S;D!h<*G}zy2gd0Vc-R$#qLX{Zz6(t(}hS(~}H0^$ixk#$j_Q9s`Q@6tfHTFP) z9cx3vJ)2R@D?Mk;JWML(Bg*3F%iZBx(Fc{oSUT+!uL8S#Gr|3v$JOk>W&IsDey4$E zCp-M~Xc^z;eS)mvNTn05DcAQTer}a=CYVKur(bO2Ps>5l4L%b5cbwxF1#i20p(dtl z@&lh6Ie=pRyIVDwsL-}Z3BLWCd(qs8p(gobtOS45SuIZUpELhL?9rf4uz(J;AKC0S zk1V8?pmpvMJ@AaJ))b^`=9kf_$K1T!gd0~H!Ml&z8Iisvg~T95TGj&%H&n2@>QUOi zqd8D-f*;T@yRZ~v*N7#{pOprWCFh<(JWdhzufH@=rzN!dhUVEagnkEIKtR7=4}`B% zrzLIrCjjq2SAGdO!`QQGtP{I2cXu8``2_6e6p3n6~f)^xvWapoG2%;K0p%RyZG@_Wx293`wz$zZWgeY#UC zArg2$yjWIEp3KuTL5{gZamR1a0WQ%M0h}T`lROFEh!N1mpi37*mZ6Owp|;X^17v_1 zNbw@peF)`&Q?NCA)+;ZNb47B8EXWmx=olt(GTt&m9i29ETd)ktGjp!23V|#_*R3+6 zW9n_N9_k>A!To++dyomt_pkY^dQ#<9<~O>gapBzqc{WF{+q+fpixNkC2ll&a<-g2{ z;SAtbKX9NOhMdgW1T|}jKF@b=O_kbyq3+8uscGx;I+ca(2~X&xbw`x|TL_|F33i=g z_}SSG{T39FSDR!ua==%t1 zvb+N97>uH4IVAbs$*~eFfVI=&t|JteOCmaBvLkcf3UQ_6qPwf>bhaAzW36*36ba~#*)>g`A0<1vRZ+< zf#TqQh==s0TPup9rb}DOmIJLT?gIUQ1cwJ*2m3N-)eVEWVCKS|4UjBY%A%P9Q>q;% zZ+_n=H#Q&wkEkpnK>zE564FNQOqvF}-H*#^*49h1h4IaAcRg+EN!l=~XhHwUNDYN4 zaAKs1Rl<)<$&jtV?~BgaIf32k7h{L<=0*cGO1G@W14*&R_INi*fAbUC!$(Z$5*j$nKCNjdi$akAX*gt1Hb>YbKBZn5 zHRfvQb6UNEpZzoGID#@ilvqdT-z(0!Q%GRw2a}*%L4a@(O2ta@PYxqkLLD{~v9_@Go)`OsEkGKnvr;INNa4af5o(?pM;TcEJ zXI|d77Yr8y&K8T6mHBi2mVLhi6oP&G4aa?^`${8b4RZ5v0TJK|;xa_yET+>?1$D<} z+Xn_COMd$$#>HV&1P5hiX$M$)$cZ|Hujld>v8DrT|TCNs61gkF)Fll7~f@kK_El$iNwGZ zVI$2ehimbT9JVuSYCrY7ObPSUJWyj~AJ&TgG;?7tdAF98Bmr3`eX#wZ0mg?PzzA=P@b^U=uteorC9c86Ynoqgc5J8=AK!|>xh(5w5v5dI@w zq-|DGAVy-^+aJzaXkll~utj?1!#jbX>uRFHcYJja+_D$=z1#@q%=b^q|>(6{!vgmFBr z0Xx~HkRz_sf7H7E+@qu989LxOO+K=RD|WMQXHKZzE7s>ah0T9aSkEMDj{7z*^}+lwemy0inrW85yKA?CsMM>mL{ z*R=p98q{1fexL|XZP>uu&5d&^r_eFzuOVM~*^MVw=K28kX#5RI%a;k%J$GV6_z&z; zeOCe|J?p>72jzzK!Yl6%I-h(&wsRefUkni|LP8qxD^3OxP-I+o+JCnA5h+Q}I5_zl z?`y>KrG%*Ctmp;pV&dzC3r0_(xfKO_{9EtXu9TpkcUBOiADHM&=-(+3@ZC;XtumdqM7@=6uvD5l{>VC85A zN9{~#rnf@U0sXLDc7#l?3IZKtL;i%$FCmclGo2)#hYz<8wtF zBHRtEn~2~3Q?^>n?YL&P@`QQeLP?sOe17Te7%QaTJ(7t`AiRjmx;n>_bfXIp!^E5) z9!FNA5ELlid>|9UsxXc;_iABG`q%QQ?Q#4h6mZPWvA@LQ*@=>r3SIVX)!L+TG=K8; z_}>-|vj}Fgp;H%;KxLiWq2ZYmM3a_j6&Eg%Mb>E}$1m`$c(lx{7#~%#E_=g9T?J!M z{!RLAo5=wCo(04tWClg*IFAhF=QPYt!wRkr99^Vb4)PMFuG8-}6PwmRJ|1pYi>EU3{KmAv*NA%pKxu<2ME_xu%mMJsrTxcR|3}xrosNGWuDgJ&~HKIYCRI}b`mgD z(uh0z`YQTg3ex)Ix#oj6FR4Ky;dZ)=Wt!+j14Mvrwruh9x}+w8P3JeZh3rH&u+tLh z=<1D4_(bN|wVk=2gdX5F5Xs#^#rZT1va(p9MX;$Va-_fs+PAwsNc1YQiR1BL_UCSN zA>#R$1x%vEEMu9nWNZ)2z%IOCeN)~)Ere@aWKyV6Kl_;Fr5`X)b$!b5on^8aqVk8T z{GTk8l5=YRC}opqU&YQsgBF@7F(Zh#)pceTx#5Rui%p~_Rfx*aBtwUM_n=@47Wxyw zLo{Zp)QFk#iTGb7IE_H-H!!)FHdt!lo1U zU=HZ$z;QO4nlOmQfnjawH)#dQ;i`hjN)nSX{sPl^=DU|&DGJwzJ;ccNcX*KWpU%tX z7{&?3WK>6`Sgb(?Co0a7(c_W#tkThzIO!_`Mt{j)c>WomCGofSl!&)?9?UXnV%~AD z9oQ7IrTcb=9FEI#tZ9Ixw24vs?e7Mmj)JzyFMkF-ULIDw)Bj{zF}Ns*)_`5C_R>X$ z0#XeesYz#^!}UB7-N5D(W|WB8Tux=jK?K=69mJn06C-L z2JF8gpM81-GvMn6!h~7(@OO4)ZE4O;B?!&7II4zN{M(;YTn<|nBDJ0K7;grI2h~=KuX!2}w96)eS(itPXpaQBqs_?Ui`nUWdBSlZ*R1r_r9Vz% z@oaNDzt-;9?56D;M@>^Asyfu#?>pf7&vT&`jEP(2u{z^PA8v=kGostMrOD;ksrR}I ziHN)jnI53u4iFf4Mv}9lpPKo?NDfB4xLL6{7&=heDsvYWCb6$BqHyfFytA3ee}}!5 zGau3JbikRy9)iiS*j`a3r)|;wr4=(}WL$g^B{C9v8Kzb7!AIAh3X#WjyvmR?Kz)BU z+6TUpSTs3!2)5 zC@+$eHnT-X^shKhk*HhYN8k!dv!1$z-oZaH1$5GxM^OtUe}1`qc1X|sH2l^RNeYuj zVomU`{BdL#CJbYKq@Z45pJS^`w-PR!vb$@=GZd2|R?@TXmX$|#JsqXs87dL!u+xDk zm^>EkfR1?csf0?y9L)%R&IcmdOakT;}9^5AhEW%%# z8FuC{Vxz8z$qvdmXjt{iZ2b;-PayE5r0T;&m&I=km-WeYP)e+ga98RyIF8LwXVMAZ z3E0F-ws^y_-QW{i47Q-u{_r|(nz8~*lQFzAx4RE&Ml}|LZns!>Vy97@NNlRlzL4%! zPRq|4)y_a;_{gO(Pwiv=Ta@RbO*-fT%ACJcxtyy8Av2~!_?`@oHYmFX9m#sK#tq8N@6;INc{V2ee)0y~$TV zwcD^J$z-xHQYnD-L!=Mj$n9^!DeW{cYe}tVEUSDy3-d;-+Dv5#j6;oI#LTsP+MKkY zbP^R)=}NyZ9(sq2D*W4}3L}dk(0y7B9!?x9*zUO=n0v}Xw{@7ldftZD_St~zu%-?w zrrKa4g4hif{X2}M8xcT_!yR|?E{{H%dpFaVBbTZI2p(WSYjt;emT5~b0RK>`2Xg18 z=YdXLtQHmjhJOf#PTMg;e2Yyhe8e~y`l%)1o})*|VzPdUWv7!^=Kg~kY^S5AL6-r* znh+UvV*5gwuW22=CKHBSO<`#G*?gW6>e9mhlGU?bUk07M!fRA&e7%*p?7~J;FkR;a z5wARo-yUhI<&1(b@+|gDvX;|jfsw9?1q`PZ@!qG z(VtwsLLFj9=FUk7XBqqS{Sf@eru+(9c$9GKQ#-;lRTW(cCrS|@>=itr79_-!UNc91 zYro};YI2K$e)4_h1QW+E)h9fU`V zo|$~I3AW-&u;zO$o0Z-oQ{Qiun%~jeqNu_b^yDP3feD|J`7NWWlZ=%@hD46Ti@r-!5dk?Wy8sU|ZC!3I#bx>LNsG$>ekWT-*28OstAi=hfTP2h2*Cdp4sBBc@ z<}cEoc_=`S>bSJmRifaTxF%JV?%;bVgJn3`678<(c3yD6;Ue%;3#t+`3)R))4dYTM z26`O5h+9Lez4q?T?Qn-MK(y?x@Zfg-$I;$1^t5LtCQ^8>ydPt&E{P@G>&z(YgSVZY z5KS`Si`$?8%jtFVWB>cOp|rMAF3qs{XdXw=UxC%Qj~jWQR7F6+wDFh zR1sJSF?o7@slRqBZRJ>k+Fec3rs+i!f~Aw@h*=n|YKgYoo~S7O$*i>r@K}viQ0E*& z2jK$!#g{pD+l%@4up{{uDrT_!VNDYG9L-2i(}@Da5`~1CCtAF%BM>?ddg_Rb$kMEo z#3{FGf3k%(A#(51Gh^c75=zM3a*x3O+`O!e0CItLuRBpy{X0X=-Twh!FL2gD{iX~2 zOS;NwThPN>q)&SShNU%={*%zlIFPLS=LrJ2j2Q1uvaj%8H4^a=@d0QQ}a z0Ca-+p=v*?({)=5*cULQ*2w%@i7lWpJD)Sje{Ld*-c?x~cVF9h1W5h@nT1f?(Qcs5 zyylArO7Jt4T<5pziy@_`QkOgKZcK zYWK?e{KT7CAx2j9$_CoUY(o~jBxRj6x}gUo@wdx$uZ<4xbdlUQ^$(qxWTp4usUBA? z2A8qpX-bbS^!gI)4zilPLs?x(o#@guk?1r3dNDUb`ncLOJp&!W=D6Ibc^!j~BzViA z+Y=pB0>QsZzX5PA-UDzPeSm)O;dD@5ub2wD~d)vUA&a2r;3^uGi+?^X}7Usd_r7F+OtVftLJTVA{Jv zkfu&P76g8eaGj%oUj|CbSzKub2Ds?!6mT!1ElCj%%4XDviOLYzw)|Rq`e7u3rO3Eg zMyNT?@9c?fizNCn9iaD+jOOHGeY(4QPc^!nOPQZW(4LEF3Sh~mK>x|Ea1QMrnl5Cr zbtAH-FumF6&-WQ45E-uPVP5&zz#Km~mG|Vhy_wkrTGYr77I5vh+ zF)Dr=PkA{A`hkjeKt-0e99D#ePHgt{=mW>7FcVsTwzXbqYK<}g-W|-PNyW}g4pJ~uETk^xH?PFKJzft5YnVj4baTlVxv8H z^b-;7jnvc&Daj4M8ELRYqTXBZec+8Mlf-L0q8!%KR-{^`1SpNULF~o8hjMtW6bMz%uZklCN zQffy0ySsRIADNjt)^5F`hjpZ+>7yBQK}m@ZkdIkv94I9+C&PyFR_`=a;_Fm!CoiPi^Z#JY29b}<2XE^t(Ze4pO%~E0*Df%TIp7M} z`@MhZ`G=E1;`66&`r{?hlD*CqOPiguHtrH1R6?MomKB{eiUj>?b#xw77n(+AhA7}K z)?lxx76*J)boW=IZ)yFW zSEUveyw!0ehAgA`c#lcEN|itf%~o5zD*6p5q&VdsDJER6yw=>>NkblS_{%k-6eeL2 zNEW42=qZdoy76c40iXhQw#NpWHvTDEup=l^Ws?Wa`~`(yai(I{c3UCKy8~K&YcvMD zVl0S0kz;-GTcLBNOvWe5ea<_^_2PPmHDDWj(3JiYG2Trzl!x5kkE~ z8dqtq@$VCuY37sP%)hI`3iQVatK%3^gvHcQtRDM@_mg{mdFZDS7A z)-hrV&!m~{e4F=hoGxxwiZo6VCctiI{Hk~P4))PnYp_d=y7P<|EZS1j* zBSJ;e^v(&i{{5jan=$!4ieL{D?CXEpEaUl4z-BnJffk#GvLkfRp`0uB{POywDw$kZ zs<;uJ`6CLUg$&5mUk!e@0qpCiA7aHnfgIKgZ(F=Af3`JRr+aY2ihm2OT^s*_j?dEi zl8uhg?6S=MJT#E`k`9|KCC6Xpl|UYmP!|3lz8dvESBMhnpAD`i{hN z-?S8YelLED%o^eyBJN^HB9_%;`4sgaQ;Iwwf^$FRI^|P;wj??H6&){F>^`qqix2TO zg3XZi({iyyaece)n=qdo-H2=7a?O^BiwX#B*snjAhr|Vij@wQ1d&aiWftEhPRV=Nc z?4Y$sTZ6F57?2wZCh07^mc%_f*z&t0Yjnoril%_wjETJxSMWqFC` z0%YBdAo`BfB4?r{F2B5UqsFo044cvAo8~8ibB)UCo$9z{Q}rezSY4fJ4O3Q4_X<>x zME4U68|vC$GqfQZ&Pp)3H*{px=GmvqfHMPp|{|?1{$Dz}ms2{3JL(Kjpm5VeOl@rAQ{@ZYQZnoQQw)uK80UkG* z)Eu_n1x?D>AZnjF{v8vRa7ijtnN}mrqHAQz!RJ4WpwByy`DlBZ9YE>4L4p4Xn;`*9 zS_BIy2L?*|L~g;NKJ|GX(k_JUhhwd1Hd=}yFo83#KmZU(dLySux)u`rH@-TgvY{KS z-pzWVw_WnNUqT`7LO?9A@e^f07+*cRJEt1*b)c4c4rcw?;r!!TW6`ug4XDRUfD7OE zeF_x;U~f%!-4;g3b%P-~e*%GCIxH5g!xL#jNB>C3+AhHO*cot{)7q<5(w_!NQ#Pn- zJusqvo+(!AorZ-&Z1w?-x!PSk1p^OhSNKk7IhC@w!nq&rLAiau{N#T3-z|iOI}RRP zq`~QU1wB$}6S$a+ahAhICvrV<6z@m2#VdZ3IJPmWM-gvRL}DOXYD)gHm?qpXcFJeV zFqtbf&*E=5$)Eu|g5v*a7EDQ9=_p6zjJtstPr{IbE|$0=Dj`QK%!?W=U56!zt9uE1^*y_8{WHXgB=r$npb8S5J zVC*9)jnwd6<9fgGD}N%jE?}>r`L??`3CQ7S)1|5O<>lLOd0Sq7AMw^4nDfpwZ+|P& zr6)3O>N{31qwcKXq%o3h*aUa@Ev*3Nuu&mWnc*aX1B>HUyk)3>w&D>NKP;J>#p<`O ziSLKU{@iKzX5lf@lsDd@MhGe}gUs?Lpb4azd~k+%dS8W%eki84UnSFLtn_+5jb^VY zZTGaU-n!0EUGVT+&< zs{H#=RR=OcPX-J0ctmrmyuz0HKdx`krdSE04G>P`4~)dWtOTpUuv^p9NNs&b>6003 zSEXG!OBHaaadQd%Cf8WAYfs8TMJ9Y3Kc!dh4J*N8s}ge*o=N(K7;tq{l3duzHTr3I zmNUKVFEC>8PyvFeQ2C&8A8UaP|C!SIT`U>lKEV8&{h7IxjAsqS(qL2)jX=H)!k+KZ zmlpT5e|PGGU!J5m15l~GoJWgpP}voBJDg{$B%Ut_go1#;eCR=s=J5snh5HKL?KOws zLE6Xbb-lHEghjZq_!Hj?KD+f2nKhxwm@i_H!Ph-Q&vvlfq8Vgg{D=)${JwA)Vbmf6 zxfp3Uj@Ss=M7cledTryg7vM;Hou|APm5wTpQf3tg9r{4U1Fs-wPoYVoIj}N5Quh2?pJ6s z?ZbqtA4_#q-uj=sTN+lGlIRZ`QYMWeAHh&I{a(zJyG;ZaonR|w8vEzN!=SwLKdd_R zbWS~$2c#olO_RL7>NUpCc^~>;JF-fOLUTQ2y6zMuOc> z^xQ_hYPN(L|DmlxN1fkSPo(%niv*cn3C#zoYvq24h}x7)P?J;<-PJZ_ueN^3KZOW$ zgdP4$9v>EFqh0Y;iI09aJ8v@kEy#5(?C*bZh(%8pkV(I5#(>X1LOxe$XBoOm4!PrJ zpW4&obboFhH@gYQW90voMn$#Q(!39t2bU#$tFz zUkJO!CK}FR;A|Wt~--+4`ZAE!xM$F=AE7; z!4`c&BLc=x8m-&)wbCnKw)&QX($aw@OXcbqacQSG0}o+%$lWMpTBkc)l}~=rkxm5J z8%8d$!!``=fs)HQ-j+aXi*}P8F?b)T!J9ME)0s0rOWektsebRq76rR4& zJx3N@F0310%Y11<4fgVTD4bne2me=Anw`ZJfZ8)~?i6EK@cq+n48Z^OFH;gT$rX0j zDC#lHY4bpO-5h*FxBxJu;S6`rc8qNNYsX5ur(>LCl)|5G(~qF?8Y~F{6(?6VukbbC z5zo@w7do ze{_Uif4I5!qPW^#r&$9Q`rRAY-%IlX!*{xF{6@co+FNg;&Wn2k>S)W~lef*I@-BK!Rl8N6x_Pm!l#HhZWP4Y(iS&>7D=6{moM+S$p@K2(o~No9@ZM z*VFqGteTIpm?#mrpX?1G?(B*`$=g0k+N=z&LF z<-=%hgN}IgM^NY=t_a{?u&4SJk8KH|tHGnKr92-?T|SwUOd?Bv806Kt>3$2 zqRX5qonCW-oPVJDWu`!9cWr(-WHg zG(__sp(=h~`|Huko_GRSwS&R!%B5gmX6zU`Ti+V_2g6A}@!b-^h zi`D>oya}OyZYuGVgfEv!%I28WvjhQD66Jz$m?ho!Ng)%9ZbXzBTU(6UyyT1GtHIG9 zfuJ3X4EN0XlZJrB@LqvH-JSg!w&z^)5Tt&$$IQ7M&BoIbO1MM;uEiab1tTf^iW;s6 zi}KeUd`NaDc2jmJ$SaZjEPn+VFojrG%c=`>_%HhL%<(Mys&xo4pD$~J;HJVUl2{Nk zIGU{CjVLXtEXv@BC=NE{rARF-oeKBKY@{`;ne6bX4vNN|+mlK|B2QOF%!ZQe7rRkyS*{>GFZ%*2c%MvhCBtmrA@axy!8O-IOO%@;8 zTd8fiZ(n`Wr0_QrYQFsXt6gg#B`Z-0C8d(Cv=!Qh!1oHP4AsN!yHiN6YrkVsY4pb3 zB?iB_>~eE!cN9j+3f)Rgse%yPR2|a&(}}ASvvO#>@~lCl1yVc&isvW}zrp~^suf0- zJAPj~W|I%KfT)$cOf@JeU2%M|{aiO}s>Hz%6Rp`B?e&=`wjNJ~@Oy8E$^8CUaci>? zTL(M_ZR(kdbZ9p$*NtrH_xMFR)2UrZf?dN?0sLLVGz0bkQ_RR_~TvvI&4cx}Z`onfZ zRZr8WV8|xs;Xqh{;}NfbtHPjRq|X_<67nbqx)GjhhQpW+D(e7UfY7`7Bewko!K-F% z(!ph-6oyfADgf~Ruo}EoBwuwUz+!xO3Wo z<}BHbK)`FFR3VFFE>26d_~%plzukI1omn%c2Gf;6-_ga2my3U?%q=td0H;j6SsfiTrG=N`W7*V6$G17PN5-Nq^FAB53`Wg>OiaNB?%l&|C5`@CNtH zqh9GxrORCM&IXfGggrrTB52Yr8#x!YOd1m)IeQ^k5V^|qMERrDwC;W19nS&uSFBBLE^7n*U+9{EUbjR>4R2buZ2_2M9Yj+`i9)8nnGOL~X)r(Ate* z@1ZnFi4sC>Hx)e#AF~z!+U^=8Da>aATyug5ZnFB)C9l~^0|N&n(TRZ z6T{6lz*Ew5bp1eFc}sM8({S`G$-xlgvUY(P?P})pV(NN0R>*UeY5dyJhDF#+TZ)%h08UwH01n|5v^z;(@I!^Ex15? zxa#{KXuj#K-r%%M6X8e}ttMMXGdC#0kXo8wTBLqqCJ1psFu`tMDW~HW!8L=n*m?r~ ztJP=_dD^p1amPI6RuX-s&N0Ps{>*Y)IGo$ce1+?J@AVqdT%q z2nDhM|1F^_3XJE;qL+<+MD;NZBWUXxYoIaG-knN8SSseq*i5z%)Ps_!=9Vqa3&r-6 zbJ{#zNnezav^zH%IZ{ zn~-GO&Yx!H%L{85Vo3+ma<@x+^IZ{2g0EEkNE}w3MS5O#6dcT-%jeJXsoB`%Fkn(8b-RL^mjd@~8SAkkPXG?hJ6uZ5f5bk1hf1O8k4@E zrSsftE*B#z0O{cRP}4+C`LAL_zY;eBpG>L;Fy93tjbtHO66T&v!nRq0bmar|kyg8@ z%ahFG@8o*X%J*GK%~J3)EIRE_IouvD0kL9@T{fqeiM=RO80fy5YV$CC8M*|hy~2G( zX;_kj8}TP<=dI>CABOOU+us&NurPEoPX18w%oun%s7DcX18}lgzlS+GYbo?D-8C?r zIT(}Dh7O`Y>W2n9K!Yp1I^H9B)P@NM4Hr9l9)v8?Jz2)_AcKkX0-+8OQ=$}J|R(d%L?u!p_($q^`emDG2 z@LeXi->%cG0OURiK<=gx(UBt8uKB#lV;LYg2f?B$fKB;gqJ=X|=-)^CR{Bx*Wd1jc zu++NEg>R%A74o=`+nr6zJF-MbGuxU=KQre&VG4;{!P6ha&$(TmLgokrJzu&bOftbp zu%pg&eLhz7I1+rD+j^h39B_($=?Ql`IQ8Sn|LsRhQW7f{#7eCqXw`coqtYYqi8kvO zrQ{qchyV`)9-Z|!GL!Cdea|Nh&~#UDw_vBHLqS)036QcGCmVRt)VNHVn%bedLQGNjGQ z&a{yrLUuZw&U{vD&_uHx2?M!7u3CrNJ6$Hy@)jSTt*e=zA;&1T2p3Xox)dq7?cvIC zcUi(^$fet^r|&Zve`X;sT9Shz@EFP3|9+ilE<=$vX4~UUmaZq8Vmw?M-&%QG=O}!; zp{o>-gt3R72GlY=WkDxef^2Ss;X|sZ)9pCE)BBC$bdfH7Y0E|~YLLwU?kXKH9Sjh0 z-NGUK=rY(&_^j_erSUtegHd>X|Dn7T3{j$6IDvNlr=pN(e_D;}I@Y%fGWb{B_u#L3 z3m`N<1zpD1S4j8(uk0?4BKTK7P!boLb7AnkQA5M-t+nGi=_nl>Mx_wiuZPtuyiP$! z7~A&(aRhkr3N5K$VVz4>I8o*~ZPyBGP<(d2z8|CCxFEzZ9%gr(dNzRUfv(Sm5%xzH zCHhhee{D5cxkdoe5AQzc`%a4t^yRi$M^Fl}Rz6UngiikuFD?tWt+L#`6+ORqtRp<@ zyPi$#S~5QWVjKVn{r!YwGO$DVYDX^Y_%+$+u9STYz|B_I(5mf;_9a?}s^eK>pdn&c z>->?$H~?-0uL8!&v`K~W1=}4UeXx-Xk``>nGW`XUz?+s3{$sX$aVfvAei-;@4uMG? zg(*Qj=FZ*H9gYK9`zuap|K^BotjqrRQ_(EBAFHVLk(I^H!ODI z#;?8$iW{En&4c^lpRNXTmqktyy=QN4#zCy<0LZw%8-oQh^s8g8oHTkU{1zNQ`I!N- zE(f^9LApj5Nt(P*HVdY*bd2{sM2EGxub^x0dZ(uOfx2GdY+|Iu)(*^{fRovMup=Di zIBdxjB>N_#idQG&K#6&I-f({~91uu$ar^u!E@6EM@BD(|q{E7LM-kC7W_%7i;0KRp zWbcPE(CDhS={f@VKhOWn2&kN)G)Vei5Ql^m01B(L95Ac4;`QrcR0KqyWCZbzX+$4Y z7{alB{{x3O5ERE%-!4Y-6wSwHTrf`i4D^j;(lt=afW`$I-> zS){o#`(uI^Jzv!1YV!-MaY)$6*lPmP*W0A80|>=?X9Oya8w6O^D5xO?fB3k|O!^qG zw8~rV+88$8cXYkN`km1U$dbCim$ke9SbwE*vp;hf^|B+q0Y@P89_V^NGGu69xpSgF zsp)#2ZnG(quHn-*i;;|=-O;)|7O7Pv7(xQf52}kVBK*w+w=d}GI!xah9@&!Ow6{8xy9y!?#943#`9wf7*zm(@R%OKnZj+lCC`jpu&;mE@7BccAs& z19PR!$0@@`d&Sw^7a+xw!o*+qNh;t5*WQO_bflo@0QN##{|I^%H-8?syd!N#c`6$d zI(GFOElWeI{dT`D`vH4USJr3aYEu#>rQC5_hSYq^}0GX6`i@0|zhOQtb{##;DyDCvg` zo&MQR7myrpN^8T-=AbD^h0*|Cc>+CGHhz!QN-xmeyihU5Y|YdEhpl&PjI`^bbz`fO zj@hwo+qTiM)3NPzY1+urrO`|NM$KUCdotue=S%}T1fMB-v_I@1{K7C3d$ zTQy4C+iU8 zYx=>~?-xoU1TdluyGpw|HLpdAn77?d>5IEF@#~5HVsg+#DzZL(u2Sc*o_WIy2qx`~mQdh^ zQ@Bt3g!*+fbC3fte<>8>%iKBU1g?dnAR`>-^!_GH$)c&yWn`3oE~khUbSJCSe*WI zs>bolYiIT#jS}kZzW=OW6YlRd66sw_FF#vi_lM1K5BPDrX_VqfS6Ou@>fKa*hJFYf zboQrqgAEeF>feO@l&-*KgVD`smEi}-#2t@ve3RUV5t<|Q&BjXC^;3y9mC%Tv3v{t` z93B4)7@Q(YAa1A19D<{1=bgvoo?4{ z;MoEkIHwW^4mp1PC4Q#^Ho<_i1#c)o70o%20?q4Vybdr?K8q7oFi9zGBT$PYy?GjcRgQjPTV&+ z(LW`3oa6^E3AwGbS@F{hnXEJ$N0l1d#k`dT$7I8*2()s0}ta8Og@Qh&eE!LU!|0(9=37M*7EmFyl(u4Q>M(e zi)%qz6G@po(r3;XL1A$@_w6#JX9OQWqNO`Ayv`c{V%v5OPKVzpV<^VP%7Lx{$noiH1?XR4m9z}h}&+JmLHEP z1XKBxOYBGA*&7hD=-FVMMp|1u-}NO7NptpFkq=epM^TX4ABJQ-mfd@PdliS2YO+HvQW3zLtlKOTG^IcRHLIx5e1*Z?>$u>_F@Z>0@oMEYJ=YCQ0S-Yj+q_c z8gDc@6UA#0AQ{{Lf3MjRmX?o6F;Yw4Ghh9gO?r5QU2u{3k_{k<>)ov4v8J!U>5%kL zGcv|h&=~)cqH;Y+1@8J7;`<=0(Lc%hR^>cS*kb`jyI6{{=)S0+$9&(R;PS?nLxv=L z6Fp(YkgE}(uTF~a2FBGp8Xn2h6E=dJzX-~rP|@~be1ZTM=1Uf!{B#pP*F1+64^5-$ z{1_x6bz$Ky8jBr)o+XhG zm%gkQfkFy5&S)#6If)x;$w{V~6e0ErPuILXIF4psi{2mTl-mLroEC4LzM0Pg$9cTC|!{E2YuAv zFsIKBh~z}t@o6WiUU)qegH zNoPM8uXTambC|pvTu)v!x@5NVdsyYDNjI=Sn7DvDp3!$}C(ZLm`ks*En{kaP5m0YS z+Mv(iq6$b?vb;}K`ptTnuUuP{xa!|{%W~At10bQ^a}Knd+aTmx=1*~`2rS3h7`QFj z80PoF-kjab{@-kx@(Gk5jj{3b3cIo^I5^|4$kbam9J^v5$-Sp9H{SRXVz=8)oJ8_w zIVFnz>UdGOe&)-`oDxZqxg$!B<#6%O1!4&BK0`EPL9%UE=xdSvQWL>sH9{vTZ4^?%oi(=dQBgWp;q|1#9gl z*`n4&B-sT)a2)Lpgfx|2Zr^#Kld%>_W9o9NC*-dv(oi=QNIVM!$%D#{U#$r|bd~UQ z->X7@{GL})4#za!b=QC=FEL5qi^<>bhVPEddQIN_uSZ(vN9cWi?Y8w+1MauOB6)hM z!^Tyf4x~D2HH+35_MH4ZRR7EfYO+vyjtK=`W_|XbnbQQZp>R$b{u)y(-V<7I zfMnqceI{-1HK2z_%yz(l+50@=scNgw#_ulN?2jpby0wh>Th8w~&@Wd2JvBmMDmQgJ z!LS}Our$Q-;c9LFuGzU>)GTV|5-Sd(xXWT8&n0AsTXN`al%M8mD-OHEOzso9xdu2(Pc?iKpO+ z2SQlKEY-l4&bx0zlN)a}&rhcO^zTicm&cKf+0#hYo{R)RczEziJ%^hUV6SOA5`wFz z1NmDo?shiiA|*%gcjWv8Ph3PHgXSs;Zx<)$&#v#@8)FG^p?~5b#yf^4--dzjimV{w-ywU@oXd-sQhY(g?l*nL6SHlOisx;sKc38!iSc11wqb z4C#q+qN=wfvwH-lx}$EjO+Si{ab+t;Bd8^k<-1tZZwQJ{qCO=fWh-JdbktnmttE3b zo|v7;cm@WhgabNR^%+->$ zTmc{ay!P{u({Rwb=ldmn`qGE1-Q_Z{95)qX*{$4~7O5tlN^ZwcCQ~5gowftOx}Q=@ zaey~hsJy0m5aTJRoMdo%d9nalf@}6;!7Uag?Pxm)s7|?xD`_L z{`%&K{wEUH6+5M#mDkGBIqa-m1up|$=fHo$-e7}tNTLu!>TUuUfFU$uA^$e7;OUgRWq7$0u=;j$8l^RWg<)mNlkMvH<3n9-m z;P~M31ifzg(~hIIH+Axl*a*Fd$K#em3)S&!M~N z7o9KPXBZ5_aWniqbjcC8JmH|tQ0sCRvK!D|I-s!-m~J=i1OLVer#Kar%oNs4EL@u6 zhL@vpj=DNS2FH!3KsDRNtbKe&xFcE$yo(-?y5CS}Cd_0kgZ#VWss1^X7?Iv|@Evdw z`<^51hwZcstf~ge3x)ogjH}UQdIuhri1Lj>?KZBjz{*CR3dkBKQB71w2-YUvCF6a%R{2vTqe;2&FD}^EUkhh6-L9elR(T zH#of)k)%^zPskp&%0eNBV(vJw1PEk2!)Q!X!RC020Jsw2yR3*tE7P3-iXPuWm(9se z&&|Nsxs2Y*qKa7>g4OT{ou0@^Qb+as$8ly&N8Qa^TkA`X(k?*$-*2*Br(N|yDlQ0h z>iXhk>rCdCRSSeoJcn1;a!9y^g#&hY#=$ zgQ`f{Tf&Fs)JoHM`|ImTnl8Q9P1I_%98D(1hJA+48pp2Vc~(R-zhHV z<>*&~md0t8lKDS>pE)o{0}&&MrNC_-KB#9QbWisR+ZD6YgKU4y06F|qqz_k}8l4ng z_V~h#H9Xu1pX<}<8C`C)>=clI+enW?WI`O5vIJVg#JV&yw$ycX4o~>?Snl_;(P}@B z-nPH^Ka+5ac`Oc4GPp`?G8DXNhll7hIW4Os!~6r0)Q|b*`t=Mm{Eo|zP%@~q%H;-6 ziy4440acR>Bevr}xCy;CX3o1=oJ!iBX|sytM)}s6Ta5uD>QX;!!?Zb%7@2Y(^Kh4f zYr$JCVdhe%BIKZUcEq#VHN$1A`8uqFZplb08s-lq3I#PzrU>M`TM@}9A_cT-9<^3A zx5tn1FU7mH=|8wH*H|)X5B=#ysnZ@Fk-r+&n<_sEY%JHx<$1|><|%12(Kt)mAaZuP zQC6G)R_7Q|7Q<}7WE#T=@#pu?NJSqWYC(M#9UL~i*O1N6yBB9oZ&$*j-< z&@UvyMY%)lzaz&R_(ifrc4ttd zW2nnP9Py_N7$5VeZ@&yYv1Pp<)NxqyyU+00@y)*kI4st#eps!TGh}MrXQ_pZ#O^sz zVEhzE%mLxWUQT$1&+KV+TRo9p&CX|AVb|AVdw-FaOiQ;gL5_JcT9S}C!k=ICT zvRzN8BxJLe8G6Zo*{?S20D@t!#&#VR!vfEU{2NS2KPxuNbi4AO=Eq!rmeO-N=)MuN zv`MoB_+8_I1w{RB<{*SKH!#M1BfqjCfpxTy1bF3kI+5zR)f7G3h_eO_dQalo;6}bPy=}IS^ zyXKd$9>V8JpaX;ODG`B!Q!=sxH56Hk7$Z=VC@qGN5N@rcBaa zTggV7hwtQOWO&arTVhK6(_xgfEPSRp7W2Q_YG>#FUUAOTxWTLcR;pO|0!t&%F;OoX z-!7z*wXwVa0APXSRCb6rNDX4rL7C97n?!H()0nq2Izs+0En~r-i6tNgeAh!mtl_uP z`go&=`9VWW9G`8U(RNTjHWGy9`{%vIp)$AMw-;k%Q2Rq)J2|S8%=q1N-ObzQ=7Wi3 zfF`T((Rj?Z!8*6l1@j>E$4ABx+s(ZObS3<^Hh=K7z*e;x&IS zdDKv_to!xjsB{{Mvt#UeMej49HGZtfoHGADa;t`09)WZpdaM^J1xw<|QQeJS&SL>s z%wwz$;sJlJX2G z1ZdXp%f_pZ4<;b~bNw}O1qdei0?K1@+f0_NQ`L`S>&jPPwqpO~O|3Fl9x&1v2vuk( zywWq82i#>NRnBCi`g&OId}x}6lIf>b_>Z8ePcJj`xI@<$b!J6!mdtxNbEbkL)S&Cz z^)Fd?IOeFZnIQxhdrVP*+}ODCg$LP&i!%@J>%sC$ETM{78MV6@4c*wA2$YAgyr75+n> z4f?ML-r*Bcc!LinSRF-YlJtJM@u-W;PqrP|AtGAmm^QhQy$G36aP`}ox1J?Aukh|4 zC7&?&RnQ8%Z8tN|k(Yv7GvwcdJMvku-cc4=*XL&aTsdxQVnIw55uj}&39n?DTNGj# zJb5HoZ%4wqTd*q~urWHD$M9UeSf^TG1!u%P1S;~OeLZi#gikd^(rf8I1oGsWE^@b8 ztZx6?6uzn*K6snrqm2fnD!L1kE_Z%nBVS1i2) z+>G&>l4Z+%A(Feme!oq`PlfSYJX&go)8M%{xrWMu!a*X&-WnYG!h^m#WC|E;Wu2i| z;GLqm`a+KYN57W$=$nMW6iUvhHY7aV(JuN%FvfjZuQ=YVte!PW%+60nroq z@Z!W5H_>;jd>*^ix_&w9E4KmNRYU9seivW*VYmD1{0!H`8t`&pyaNI!&}~Y#PwjW% zT|cf{o(F#PD@|27cl)Rf6ej*wUA?9Ic^O2LO@9}b0BW?u0PGt>o0Xu1XSRG;YV;S{ zrBmPyKq_DzVG?l7t6CYX#YG4K&Nq%2>Vf3H&Rp0v^^t~iG2QoJ zc2pkd$7y?mm(Biiy$TC0*zwG#Q+pI-q-*aswz)QZ(X)1p0a0(u_uIHe8l((#`_U&z z8zvkPj+a*V4*+55p!NAj;`?bEME4sf_A&!9**Zf$RBQmE)!Pm-BMK;I{`e{j_yuUj zim)P3OQ9fe(i&tZMG{L8F{&EY5mb32&Ov6ggOn}%(59eNyDD5(>FlIHFaxSAMB54c z4H6l3hET27B+|~4RL_CzDNxtLzXd8BU3(4<^OJhgm`ZDE4aku+kn3SQUj?;Pd66OR7?%<)mOhbv!jbcL)-*A*pBzn{+QEa&tc#HOOJ70nM+h#gu= z5>grW0abc^^>R4Nnh35uR2OLa!Z-Q~8R|hZ>|*V~QNvr%Pr8@7v2wxZFS+0YC4nG8 zMKRzb?%u^FBq;&JpL@eI8P+{RgfcUl2KTWxo`goazy~oH zc^%?u$Sy^myiPTZeW~g(hip(pArM@fMaGyWTkUc_^xEN7!_#=VLu^ z#vQ@vx(U=(SwP_HJc&e)_fVD%5jc-MCQ#^FaUF5F ze?kbBAl5Du(N;Xn*wY3&t^7ZUhh$jRf3g&OZI^UgQ(afMaQHhUv7S8T065t08yM0g zWA_VWa~{H(z;JrP^FJ-xVQ;R!Zh(XdYtuF?sO6T;7exa}JY0X~}D}+U@`s^v1=xLe=9R zHb}5|WVPAs`jPSiy{DaiwiBrY+z2!Lvbb4RnTS1|o`04M+zK7sb)e>YHV({-3%eN8Q0w9HC2to^wc={g2*s8v4Q-`KdzP5C0q=R zfoY`xl&77sbFE%E%=y?H*w~!fd#|H43bddbuyE2To*auHnJqv=iF~#g>v>soU5c_q zbLdW9tGUSUwKbCtGHwLgh{2zgFGgMEB#X{}OhI=buw|%|%H_oTl8;%#P=Sxqx#;D0)s>oz%xEfRI#J~ig@t0Bv*;MxdKjelkIGKqp!Zr}0ej{3F`m)){$Ow5W{DO( zCe!av?+Xk$qMvN;wkvII5;*v-QAL@*4)y4&Qbce6Rp(5`;~Uz}O#5WgnRA&6D-ABva0&2>60qH4tC3~zF~fSSxK zII@cuB%BRoR^%~Pi0y!v@YY?G;dP#3)^2VgzyX##RC?Fm%b=>`!;Ii(Tm`#|fnGg} z2$@Kx*3EdOKNUeI?ka!uZve5jyn|E6$24m!0TtOlRe>bzCv6O3(X6ktXG&io@Cwqz#-!y zcdmY-u%C<&AL*&;Td2V!EjPSs6YSLM?lQC@GFplrOA-8)%mw3u1qXq`uRE`UCyodL z9L?)_E&6bJlT|+BCnv~FKx729N-a^}@5hR%t#3CYawWWFve2Tk-5LN@_=BkcK0!>c zOOSQuQJ3KXc&hx_#i|7>4^&u3|LS8%SG1Q1Qq=8`N#4eNNJ<>drqtY+A!mL7L&0)I zj&lTC78cq2b$=c^0(_lYXcOR4#WB-7WrHD8=XT&xuhx0M7@fe*nG=YS`nzFyCK^ECpEq+Su+hMchs-wYmi>b)~t9S*Vg4!gn6eigc&e?(6z} z8{jf^!x$s(BK8yO*(SrOLKpKV&#!DOhgy7OyZm04g=zJr&{7C~_1)3jDKbLA{|MoK zlKu+T{#)SoZg$-z{LDkq*klslW=jUacL<&})K8F!8J22sibW_WX>60@P;3mf($B%` zDRxWYeT`Bv#&na9I*j;&FJSNblSu)f{k%Bf)Zu;d#NL##lrf}LyOQ(e+1h%^PP23THp6LYRfXj&q#hN=W+S}5lgJD~MbZk^jxAeoL%|6m zXX9IS^Kx4OHdK}G$Or*H6s5p883SpjsJO@^L<}@HIIy(XmDr`?gJIHqI^SM82ojim_7f zMiGPlownq5(JoKu0!(5dULP)rlkH%2k z)1M0*4Fnf|G(qRbL^gfehhQQzgfcwQY&Ha05^LX-y%GVBVt%on{J`YI&RV^oowoY> z^W|lE8LSzCQ6EuEt=X2R5WO#IE8(UnkxT-%npQ%3f1D9vsKGrfJO-5TaE_!VxhSL# z91|egleW3igZdNegln_q8WabY+)C>70$$r(;p#6s;ciB=sXtUqB?Rhad@D=|z^o0z zJ&tDE^h*^V#*jjC`DeP+pBdz;)qg-l_X2C(J>4B3PcmycXea=sv{_yf%4L0eaAwNM zRn6yt3qFItRMxTPyNy|DFaPBqQ}?n$uz7Fy2|d+_wyAY_yR5K%A24IkhSI5>ZTv@U z??;h?C2psy-z3N_1S?IAUT_3NBWVlfP|%O;avjiLGxwPwMl#7!Bp5}Z5z2GKgSE7Y zN3B(TqhU`|=S#LobBZua>9rh&L39E&^qdr^iz^gYJr!abM}_f0!am;9bTxVn4&(ZU zh~5w3-zOFb^0jk7akEf}k7ELF%tK@*|F`HrPbZVQvEZeBpHwJ277ai89-pnCFmd*a z&E|D(UUBpYt)lkZ5a@h5^dgpti&I(oFj{<^h%Zy~oqlcm75B|YUAk6K!mipw+}I|U z7}Eq_qm0iUr%YhtL9kVM4Z`I_MFt$D?=+|+MkbY=1;NCoiE>}0jp0zyS+f-Vc)}2+Hc%&91-VIDT-?0LqNa zS>=aR(}?pwYd}U^c7?vypWM4;K_RXf^UJwo|~|n`fArW zj+)AunhTfm>56fZI!vvj(fHJMZGM?IxmI| z=UHW)>KtsU;}VRRGJ?1_{!Ae`A*~*gQAx$2p?i^GZ;v-BJ|WjtWwCcr>+nGWoyoRHHP)X-A!N@Mn!AfA>XS!5RMJsFGp-X>#Jz(iT` zlcK4Ldz}T-=6xcA!sy{j1uw8+?62U$!Kj?ZGfY3tzehi$H4Ynju6KJ&rDDF|=VSvZ z<^KYDnY5esNMk&Jg2bnaBmQfFFzz-^ssrq%G{!u`YY_I|{b=9FvG{wAG@*Km&KEVm z9alBqvoCC*-x};SS-`^lIr}ampyyJIW;)MMHS}ePL<%`d@v#)@Bhmy>R#Q7P*}n(} z^@$QC!bEKI@LsJP;$Ioxv)bZ@XlTTx_B8x(a;=Gdt*o36z{eA1ymw3}2M}36v*Og_O7XQX1Nzi@YlUc)VZy_Mi{q-FR8EmHr}A~d!u(Wi=oJ3-5nuZTEK5penwjHvi+xePkNkG zJ|=zC9}S{_h;}dQL?VRCFy@<~b>zQ+wJ>>6C7r8XM8mOjb!LmV*glRfNA8`Jt+=}N zulTFI=k_vcF2b0!{PAm_k7I6%)1?(Y`m@eQVF&Gf{4rHG8Q)m>>2V|mA1_8a%^#+v zLI9%p`hEn6jQszAq=h({0$kPxdAz;Dnz$Yw$ zyg_P^JORn?;~tsd^F^%c)$wzL-r^FOOfWJ`Lx5L>(a{cgp+0UoxIP9py7}LGjWqw5 zMD*^1e|%&Z*nYlmXf$K2k{MA}`rO|>Qm~(TuLIN!CBVpQ~ z4O|hO-}YPN`Eyu)7#jYZD9k0Ty}>hs>I5C5I@Qt~q=6gaa`psIEF0srRJbe@rSru! zkMxlsj27=Mq-Ocy$oxszBwVc?wt^R2vriXgiaFzzq?a`TvEKPxKHiSH zDL@xd0%;N7S2@A~=3|ott!$__tPcxvhGvxm*!$-360t2{3Zu^W3l=|pbqwtNinF27 z+RTO-{|lC2y%E^#EGGailsNU)&rgwKFA*%F)d`0KwFuqusRu6>soGdRa02N$^7&mY81e+%kA|u%M3x4nNFu~ zk2+cr^_6ed1AjXHf|+XCz62dDIi#EL4SC;5J#pAusO)p)MH<2U>M^qaPpJEmDrt79 zzazgd_dTn{(1v-&B5wg$t4?Uf!4~c|nM^#mnxL_v4-Zu<>&;)?w>1KlU&jho-?tmX zJym@hLnQ(kpTu!}0c6s$`_n({Uhd8X|Av8>xKj7YXM1i_B~ZKNE$u|hWu>=`%3g_bRvJDTI#u5x z`k#Bktm0b%a+3O3Xy`tI2#pfM`@#@yyo)H$RT_(AA1JbVSsSZoBbuCSwoU5wM9+VKf6sRKzCj5yDb{Wrp z%uP2#>n05bwJxUtxWJL$HU8UJC_R%J9DEj7CeMh#H{gdmrU`Uh zyxzZ8602W2R6&GI!4)SXU*r$GIrszC{e16ISy)5U+DDdqcK>ck`coJVdx$pcnrRaK zAfe?1OYzXG`U@9L-wf=(*yAVF5;1^kb&YO^<$Dmy8?cTu06NjdCl~T)E=WZWiX3de z0+iMv969>(!}+D(_6AFe0}ZNfE^`>z%w4YQ)8}tvH&+l{l$VtcvvtN0e^&i?jhePQBgrzz^%=qGTyXqxHi6&R`a;j`#y9DcO1!rGN&godANfTqAhQ#@t-{lfV|E zYz;cM@k8&JwV$xxfKz!E2KoHq0`KoP$kdrCe+fg&?Dxnv^c$v~Z?KY&qOjOeVk$Q( z0=2t!(pLUZDS^A1&r+0Lozth0m_FR{@v`0xxNWW4Nx$n;B=;!O5u0;Zuy;rX*<>9^ z1y(%qB9*Om+|5O7L9tHdKE6!VWBUs4G%4+N7<8pUKcGK}KRSS^rD5uzFuIq$Z}&3& z01Xn_j|$EZv`{tP5gl@X!BE6YX_dwl1Ib48_61bCD{@R<++&&u3#0LjHt#y(!Z$4%uF?Ta)X63o@QBeXr#Vk-J1+8_PTI_Gc3ZEKGOb4yZl8ZjeTtX3s+%z7`+I&-#!1Ck2TlSCl;(d>`0l8mRly2Z>R(Vp+|JzhGqnA|& zKB)x%oTx=EwT9uU1(N{h_8>W5t1Qq+y7ntPu#^)E1Hg*)!{e)=X#X~7?eoRU)hdvM zTJ0gR-ENVQT$SwLE;t46BpquY4U5~%Ulw|uR$3-6P~P7z4DFL=L8S2{T(*D^+i$~Q zzvB7)v7>2NV0h-$9UxcajyL>c@?4t-9Z-n%5=!aN4If)I$ih1NQvtS0U`O|hi6Em| z!l=qnt>UkfT`rz&pF0@i?t1mmJrCeB44;_kjx$HhlELz58wE$hQnmON;?G0;(LQ7^ zrnn3Mv^BX_zc2L$5?cm#&oSTP;RIMu#N`GDu#PpKK^llh$l-*OzEj`?Dbc+FG=oI` z$^vbPt(tr(00BTTz%RZ1+jk@+^O3k1zk#e>z7!^gy9n~K}Ql{6CwwPS=ztnBV_QL*D@KiZI6Wn zgYDLr#MH?wRKdmu4j7i5WK2Uy9%oAB5lthCrY;VcFU*^A{{YZf^3Dc;Eu_NEK$v*F zF>pDLic62<7g&cZ;FOaURS5XS0S^%&-u<;=q4aWcH3eSBVU zhHVx#OfY1Bb`A-g9XO~$I)aJQX?*&};})y!yQ;?-F0Z(ycU=B4eNvo1MWNDkk%E_y zy^U52<5Txo#>v(=48}aJxGOKQfWnWlYM^)<@fSmt)6NUt1s(An&jMRVlfwCz$0l3N zskg5r;4=oF+?7qQIUzuO^+|=Zp205fe!OZ*2nD7-%I{8S_456vKlZzUcna(F_RybZN) zjjwBtp23qZV7qZ{2FcAjAAD->w;uMUsl&y;-ua6w|Oi{ z=)yrfFPsnlQypY%Sl6X#Ow`BWp)8^mRxM>SKdT2f>(28)qCm?t%c;_W@(^v;TN^|G zsXt)W-7`W;e1xfC^#FuDBjY$=4oL?ed9xbU_TB0w^3PEvIvDB^1=ym+EbXi5!#vlC zB9C^cyKB**`e3+S2pSacv~F7wg03p-lA`En!|i_#%?pp@fL{d5LPBv3V_DA3e|t^M z6pvyLiKSB(J%P|i$3Y0{f>3I40-q&pdmdd418vFAtXQZV)tKpi;>iQ$Di$YvEI4bk zr2_<=*%QU`9hb8L4yFkTC?~_!f;OHJK9?GuDvK-m8Jl5tPs^ z7JpKsf9}gk#3>0PRK;hk&L0#WI`W>Gd|fUekQ_ z`sce+ZvU$G*YbM8wIz;$`8j)bTkS9~*ZaN^cn%BSCFQnP39|VWt=Cb@+#_KjO#&Ik zg02|DWd9TpAU_&VsP(4n!YR)MF{JBB6Z~vHgt7x z%h0oS8?3&wIB6A^C98F-$}$md--jyY6pyK~MeTf!A6m2<(Y^Zh2N>*kmoRUGns+sQ zkc8kg=~RY9pZq~7t&c%XH5^^s?8C^({UjbEI|DtaG+l(aOR*x0{L^Xh=g2Nh1p_L$ zT_D92Xsg`X6ewh~22m zunVl@#Xv=Q4Pn5Jy-;4jlO&j5N#`_!g3IQU8eB>XxFkf9M+fbCmg#M- z#K_Il#^44NMcUmcO{ebRPbaJ$M&SF> z*qMa($4@>`Nlsap99W4;xt|}dV>m>@e>N``yFSj5l24*6YAOptud9`9s}2WO5F@V2Md(qLmbYON ztnQmZR;G}7TpP6dZ+I4q^XU9{rLxf-{Kly@obo`+zQw`2RH?``jtB()LzD)cfLm`A z+!%LkKKpay`l5M1VJ~{OM~b>jDz$0~=(tc}n%^&!53|<`1P~2iwBc;x;$GR07bd*_ zSm)!xG-$jPh0a`N?sC{tgZ}pCfab*wJDjPuK&>|o=+|@ltuR8i$DzksG>&S+7WvEZ zLcV}0)!c&ay|nfcmvfQr_A;UE#0OFh9BQ`xzUWHAEXCbiUb3mPOnDw?k26?wUjxiB zOclK05pp;SUG{ys*DAXt9golXE8Pt_+3tMKgmx#nLe`v~AEg?!Fs3TtF3B?fOI`Lr zLcCSSphOquszH^HixzKE5%m|k-S09o#VQZv`hQu#F}yCucA5lzrH>@PI@_CI2PZQs zK7ut7IJe0!z4V4z{$Amcu6DrcwfR1ifUZ#sH9e(ETWHJPfo5nrjlM}m?>Z-%n0(Me z;t*?`wq16hxHm%aLciN?oBa`{;%$y@#x1>RcJyVn`98NgElO2f+3W$kb&91y3O>nF zcF@MxwBP>E_?qCrXu-#vYFC(U^LQ{<{iEA{S<|Ev!zplH0NT9gxMfO-|iTkdWcTc4C*NeH&JYIX3)uTRn0;VAJio}M|jnwe1u5B-ZX zPp^Fx8l8kK1?8&OOT{6A0xI+rS)QhOpNMiAJm~G+>9z6fZkZH_FY-SnD&(&I#Ph%C ztg0tb?tV=~hz^Ag-+LtEpAWRM{-%0LD%)3fKVb<^EJA8?{@vt%-dWuH)Szz&ju3Q} zWWd6J!hz>_MFQj2)A?ua{i~Z01A0(QVoxnNx7&3t>9_k$;fLu;Z$ONE23BGhBIdL` zXN^k&?gZ1jRpWVeq3LmyYozTsYhI}_4r8O0w)GPAV5wS)8AwLKq`jcD(x)YZ7|Ux9w#HMplUf?=Lu!!E-_@xkDn?raLi8&>6g%%U>Kbe!LCeK5H-1b*tj@^@Kw3gshiP9>zGhT6aS9d5S z7qDnK;37+#PWWhRY{yjcGNuRnm)ygs+FO5t%!grrtKYB@E(^Ag{q2k2){^LcN=!F_ zNfx6kQo>8DV&ubXJ~EZZucSe70Q6w~ADYgAJ<_&qwy|y7wkOFUWje}<{(XO=`U&i=C& z1;kTheOwJKDNZU-zZpNH=7OOHz zS*Xbe%;BCM&l!dTbU$fBPE!}3V)@nyOX9%Ss3&&R; za0A$Qxs`I{Yxk@HA|0|-CDJEKtKpe7sb>{ByxdyN@yU!s`_SJI9i*sGp(p5Tf%ONJ#FO(UOVISWGh;_mUHPkXs^Km|jIW*#bKDZ4 zMRUH@PkD!Gn!5M)2AvBFM1dg8sMQmgjS|?q!99&j(MD<4q;cQ!C5C(? zP&tT(Ot=7Xss2FvkI=&q%-=cMhgDAF!fXMPf!`oc;CAo^ot0G2vU)nlo-bXM+^#Y1 zvD8@HYLm>Qufe#*1!+CcJE@dGJz9}rJ5^Om&Bv#de()Q2YnXCvtn`9sXP&AEPeELE ztW^f%lu8X}jV0OI+cnwR^^M#sgpz$=kTppr()nK3Cgrc|%ZwhMo9jUZN0O(y#y8El@=v1%e(^ z)8}=gf*Y$f&#BaVv#$bpm~X?7lE*BFIy1{zhO)1}!1187??~4NbQ^$mayf1)i|+1Y zPx72Xt^IG|%nY)-(PXXQKx4dd(eI9Co=Y9d1r^UDoh2| z8cU%!GOjL>Hyy^rh1$B6Cx;Re=sC;k*PY^YKIyjN(2@S7RR6)9s~|{d-(@53xpvHl z#r@T8ek0dhs;)hqI+vg&&?;%s;#rXw%caYmutynvHSjrY5Hg-995%;U(8_0mD=gPI zOzH9=!2=vnsh*VqM8*HQv2}uDY#yIn-3R)kgZkjJ*R7fyWSKIxX2?%%zwgI>{V=OM zEF&*ZYyuyfF#o|#I6DTwlRqB0-3K;ks~{69hst3n>CjB@bqFrD`>mGAXbZ6hInHk+ z7mC9jXjSd38*W=!d&V-hqlpOCh1OR-*Dt#+zh1g~M}QOriNd$LKgdHNP2c}3Lj$k%x8Gm##{%*80Q)_RfuZrwhTbS3e0uNKtDP@Tg;PNzV;*V!(h}&}b zg(J|gWvXIO;Jg(nM#XQ#rH*fBneG>nh?oBHe*VLI#7jr5) z><~Wg30D${)IGhz(2Us7RH_%hv@e_PdZInrr%K>niGhyQgdLYHp$t9Ef?28QC*S_! z=~ApPK2%}Zc!e8Yf3{*TUcuW(N?NW^OD|QF2r430z9#0>v>q3HGK~jUn2P2YaEc_% zRdO?j4XLq<-vK5sCXItN^0#ha(PKe6?JjmPhBAp5x7Be6ENIwI%DK3ly7c0B*C)q0 z*1PyuG;X2wWYqz35X?6tqB=dZrIV(pXx*$mzj7O6KP9!ZLjxaRW}XosXQe! znR@*&28PP}Za-TUJ7ikGq9AZ3zLYzofu@u2D`xsDX_+BAF`j{Dsxd9^M;u`pT9AzN z>Ur8?b=#eY%AYWh)pXt6GlD4O&_stlnKJ>vWC;e1p3eE!65$0RRUj<5uK`=n^+pMA zN|Mv%3tw#@4Ew}!T;o#aJC^gHtqNUB5IZD3D$v+aToHlL7&X-fD<|j-^*;NnGprNK zI=OCze0j|tu;6KUYMPhwGq?iJnf~;Sui6Z7i%yP zbMfMAm5c{X*?pE7E70Jt%qF8O{7cK1tnb$jFk`lmbC&O(lB{AurfouGPKn~;L^(a+ zYi$`rR^_Rt2|SzwkT!Oj`dRb13m+Ki&nrv2D)mv#+4S7z$%q|T%EISik$-%!NReX3 zJnvcSDuD|6kP5ldgWY3jo}x8%-l4E6x%eYo8gQEbJ>WrduUFN;&{*H8BvR4xJ9|-j znhwAuXJkcNoHyIEpzzGN&7+z_b(-K{@hhj{Xr5c+G2$Z|g*=VctaPGeMmNRnYp&m<@OQ%&ha84Ca$ z;*wdcVo4C}PL&4?2=Aa9^K8J@JUoOhGn8-f;}9X3C#MEmjs|Zu{7Ia<3nTo@Y7bll zdQUM;V@{XYFO7?lPMc)lENDmH2M`2gY!5B>bKzkU(Z$|F3jIJ!1v7sW5XHt0w&t9L zk`HZ^B3ZG_z^>@UyLFAHpGI91bIolb`R;LKkZ`3Lr5vZ>wTz`|aNCNS!`VLBwcFss%8+0wKd zVzut93e$}_8^X+m;ci_k% zeOe8yJ)_vxQP1yFwB(Pu|2vyYai_@W;5#V%RBgV>uVEoYzXSRocv^7o- z`bu1h)o9?>f%@p{L#|40Fcs*rCM*@t5v_lu$xTV7*=r3_$OVv`{BI zjr2^d1EfOhmUtc5$kgUl@gWH-3yQcbb^C|8no|$gkjQ7vYCeaet}cNy<(qr3|J)f@ zKF1Z<8_;pc**;%hW<5JNt&aNbdm*8!JSp}%?Dh(NWkN-swtF8|&(3Dy8jX7QWRx1L z^}x>W%iTskcvfFaj3?fxX_o2Zly zzf@>x{b%kPEL+-N>Uhk+AC`e&ef?Tb(K3?`>@&uetI7g53@tSgAo}6G%GfqQm)=n< zokMU-jFK}N1UXr1_CU`y1=S2E ztIn0OB-ra2rZZ6X`p1RBOami%4mkKd%37|wO&}-F?{?{|ggkwE+~ReyNJK?oL5h z9Aa=7+I%_xbxHbIf3e(IHrYH9>z%Yo4gFn^qf|9Jet4=MkqfvC!EyJe3UL zohIVeVAJ&9>cP;s3!&CY;@Cy?LOZ%y z9qU7TEJ9|eO3qYefD9U{%*^`S*bDCVcA^=?N<&h~Cj4XXJyf_Jw@CK`Ic zT-V-PE`({;G8K)!sP!~G2hdppWat%y&qMszQ0-rUY0{`5Ms1FEDTV(E<7UcI>2@c%Fs89~l}POD678NGlRk1I_BhKD zu^zIdD?rn%eY)^+)lGb(vLqA+XUfxdeBC(yDhQ|>QZC~5BR>)AE!vGcg)~{bis_a5$Dl6`}K*s9Bz4^*b1m5O+uRar%^zfcz)?Q z-A&p$xcaNrL5+^U$(;pAJ!vnaLRV2F8|Y#2_?dnC1)yUK<2*brl-WLt)Gv)S?}pir zGE3?8#7z!CyCEimf)&Iw`2ExwhMGH|Z^Pg-^L;jU!IkNr2gD=r~!+h08@M{Ya7BN&%--U!>Zk&*4SZD6A-~*FW1W<>MF)VbPbg zC-}7ec%LwM-k%}xI@~X}qtfd6T`La>3Z>ey$X)I6H~ZtME6(>gqB$Otnkgx^&g*=i zxI4eYdkGS_$uwpy1QVd*qU7adz|+J?%L{DY?dlykCKiBG;n$^yx45x=Ci>VgW!H|a zWb}5R0V)kuMA;hqSc`Y=BmOheZQGn)!cqx?0{^JKb^v+OqB9q`&<>IHYI&$g->Ab- zG&o|IG84e!8K{t7^voj*uM2mTne0E*^f?i2spbBoRsAvj?f=o6B6(E)qqZ=oTMSDH z9U-LOJ!ND(vES&UW=Qo_d6WH_zgq(iF#p!2?g1DQ=-|EYHF*^TlQv^a>WI|_)9LQY z6d@qSB*@e#EP;%izFj7Q^Fl=IJz;$5*q~2o-6jpUgjhdwbis_$42?fpu~USV#r27h z7VY(QLq5F|b3pv&W<{`PGBaeteRGugL1Q;CD{J(zhD9AOhOk(2lzGuM@Qp^;q`)$W z{-csmrsFrh4r2qAUa8HvgS%^)s(O6MC%L7cG5RsLG#vq?(`d)F#3}qU29>)@qF<@1 z$z%JW@~Nii4P;CyD=^WuVW{SVLaj9=cjMt$@-@*HnBRd0t++lf2_^wmZ!M{Of^LS^ zZqbIfTowDFZ;dyDt3tqL*snEoNs}n>UY9m!XJ(YCi*$Ic@vZt2QgeKEs;d_ysLX>3 z=u+6`RYeYxm*)cTo<^*0@*foGELR&Fm#M|d=HiPkG)DR?x9_$-*8&}&n1Ok=CwBJh zjfs~}yNWCatDV+Ak5>}obmBXB-us`zi+Ss)41D*9u=3#V^!oXoh(g+sB)xh>9 zC|kQaMk5*#1VIBWt}hV3 zE}Ek5WV&-gfv(4vxWS!QD@ON;a-Pwv{IG{iA;@hW0D94^_v#~8#nE;6Kv-~p&wihM z1q^}AqX`%u1HpS3=R4H{8>rs%j&QV<8Mp_RwN6NC<$sj5NDs>YPT(rQo@FP68Ha9M z(($(w-UNRdOlPO`sW;UK&s-Mc23*N8uuS*96VN-GV22VaU8z-*u1|j0az#=QJ<&w@ z#Z}E=Qjuus>wojHv7a(yty({8OufFfRoq@N!{uO@qF{UrCIrRb9j#GcwUw|CqtRr^#wfS9u!P^+7uJByZl}2b{BP^ngS!9 zXf(D%1H4A+*j3y_yGMR?r`;B;Z`3d_YygAP^L;G~FJZ27le6@@>oa(g*78HeUGcXS z{0{?(gpjHQ6ufr>^aHrw7bRjryK3ENBPskrt%g2(OS|)`+{UM@TKfc`I8iZLd?q#C z8M*!rP17_ztG9N5v*sje$eXHxa;L1Eck=EUx%<6LG5%l@yDNjkso%jm#f|fOk9cC% z6Qs9glmc_mtzs>i14^zSG^MD{VIKI(n;2U1dam%if!tOLRqzZvszO~XB3`~T#S#2e z^JI>AKc# z_jN8~o5e8GEEu0C?dYtHp?zi4{kVP{omdp9TYNcRe4J1As3qOPG}^Wt{m^xE9W>w> zce4^W6H0FJ1-wjU514^&kZTa6X8Kun*w!;YKL7ypMLhyf zFX~oA$CS?(hXq7!J|#ERUzmifSyOd#`Dw{9U7Ib$@6We)PP7kUiIi{+jTU`m$pKnp zXF7ceyDM#zZ5Gz>P|Tn4z-5++6$u6Vs2Qh@5#z|>T@#Kki=sWX62ZxGe1_9qgj>^- zp5H!V$Z*=#8T_a1vw-gZZZK<9hW|IPi@?RqJ|Yd;#dlUz&*OBduJwhMbB_4Dw8SYzwuE49y?4O~YCPaVJK%)MZn!4_j_9Vp zZ?jz_U;W41`yltZtpJ)*MrNcq!t=UD`}4Od%#PzZGIga6+?aL2S(6ly+P~C)A=+i# zU!OXquNM-BGXNS(WJ8&|)%9e!KAAR2o9{PKKfCe_1Q%Y@I!fL!`1eD1`xu+=sAqX? zIZqWi!6Fk7cz&Fe301rk%z0Lussu92qwKW3e?EQX`22L}UijZ)9=NI}60#vFA|#3! z;HWGgfcl!*hyJiJ=Dv9<>gDGdEpS-;h*Z!O5J7{>;??d5+ScDTOcT3d257r^DV)qG zVr-SRncW2#-yoUQRVZvgU+pDltKbXf;2897bK^V>M>~aemMy6%>K8B&@w5GTZWiQG z(FCAQW@{e3j6haHwpsuGhv_H{Q(7sl zDSDhEINCqo{^kX4jR+w~r(WCs`wHA3+Ao~gq!E2Lr`?y7OLI3fU{LbnoFhNwQ%0b1 zuOp1crJ~u;I?M^9fo-mzlafuG>d^E)Af(_O%GzU5{0DRWz}~jYh~yr{e+AHrmqub4 z!AUr6K%P%TH!XSv=|ADME#%-M3xt=oZ$nZ-58{tc(v!UY`UBdodT z!7K;*&{-sPPd~5))XS4=aL@QHL#-5{|9!ev-v3VzLOdp+3aO!(gI4*Kq;ab??cpXwkJowqc}_Iss?YQ% z2&LVeg$-jCTlKJYFTrG$z;K3A12C^qG0tH)gxc6ZUpAQ&j!m@oQ7Iu|CbRN@sz=5z zxhNDf+2MU{|G?>9)J5HI*u_zIxIfj?dkss~Z^#`*?i_rTD;i^#YL1ghgvu(QF!!BB zm^c!gV$0+C`xU|;iUVQWJ?5osXu$X%-V-~xJFSSmm-2^;+r+Az;CE`C%@Lz46%fP% z@KEF*p;Y3H@2{i#Km=JV0G%8ijy^=nQ9FL~@nH*Ci2r@KXxDVq8R2{cU?rm7v&(Hr zf5^Hj`YB_{91xK@=DJhi+R07B`Homz6@N1~92dLUR=iFdMTvy_GSf)79~MU5qR+{l z2fqCl_lZnQ*c3(9DzxKI%zxB#hpkQIJ59%?qyRc{k9d~^ub}`|&Wf;wXUw}vx+2as zcW;_7?W%_MCUStH+d8#rTBsDorbJTGb;Vi;+;&#ElCfzg|?0&=veV1@h_cVQ-kZp9B`Kp39WWMM6!16ECRskNW>W&6r>Y zJ$XJmFs*rjdkv$sUXh2*QvR*Z0z2vP4X-D}z;i?<=4@8kMBKyJIDH;)to954ZM*aL z**E!}t|?VKKjoIMc4rVEW>NpQaQ(k@JFqAytW4MHzH9Km13(S9kjs`RCOG+!NKPq5 z2ny^eB=r{{`{PO_2ldB%Nyh+{-$7vNFYT)UiI`z$dW$+LjAeUYRLi5TCg?T+Qu}KZM)h# zgZ77KbcR+i@4i#s)d>ww)P%}uSdoD5uurH`d`&n`NiU%B=Z{+00S-J?ss z?AjTg?IeG+}oTqYIs{3g`Nv(o|v=HvAA+S_x3k%fm(I zIAE$Q2nEh9lF7|VIZ53aDd7U^4=F;Tv zFu_c(8T6+zN_;hg+1MO}KHSiwu5mtmX38&N$-}F56ryJDX^1gqJVM)wJ$E5Ah3;v` zys3e(Cd&R)b>ZK%Fu5?3lsOOzvP1|7DrL!N?`!MB`&16qRxNy$PRg7MFBa>rv`_fp zy}88fNeL?y2rUR7sDDjQS#w`z&vm@qqT2%7t+Z50254z2e|#R!#w@ri{gG|LxvEEy zLHRjV8s2wGkE3HNo+BsYu5Fn3gJj9OV4uOJMT-!&bsbh^h2SRG$k|Ri9lol|UZ2@R z_F@2R7+so^Bk-`C|5r2NdHWwvy)!mbryN|q>hZ5@!5xQr#-84`$~g0_@o^aKoNxB*6R0Cgls%;bPoFNN~_GbuD^R3Ycf;c z^QQ4`&OBMA+gS9MZ;o)tj?nRsC7`O*qGo}e4iQn_Tb-l^UFoukQCE?vgg!VRvL-Ys zOu`Am1TVe8FtNm7c}F z+SxYbOPi?sS@^uK08x=HeJ{sbf477Sa$QG7y27oPRvG!QJ`BCS6SdqQMeU#Dasqm; zh^idQpBCJ2r!IRB+;gqIb%@$NZyR-Y2uG+~B6y3k2M>hjo1ofe4Cabz&G<~~IIfhYeLWH@(YrQ+_@f4gW84RaS=;0cVI zpdJW7Kht32_*oa%!yTwz_cJjL6m&2{V#Zy&Qo6_j18tF*aY6g@unVl)f*0BKWA`f)f8~mv);Z-E^;=D;qC8q5z%lWXQPs zGg%45FSsbA`cRoh{@#2v_$O6Da@67pMNveXQx4|MGzrdOsD#GYG!MA|mG~2q)6P7m zE>35bAbehK`TFe#VpeU)>%4{gbk1W$3oX``?lTAVG;R6QMOq3(beXA)Nl16u^4qe! zZj&RR$2|5U1E`d+DVF@_youoPb;O)u-96B$0*OLZF>F6^X?}i5u_i&NCaGF)+WHA9 z(2dd=4l^qZBrWh?R-qa!# zwx^#ImeG?^KO%+PKx(ioZU*0Ij3vGdO+k9Y&o&{EgD#E3bh{0A2MQT~^W@c*Cx|c& z(~5Ee>KBGUx@_g#lQx`#m3g$X}#Vjd0 z?mp4Lp(5T3$*a2Ws zp>HcT5dTGpd3E0NZlxMnK$4K=gko5$NGR|!E`C3mu(`4vIFLcRAYrG+Nvi+L+EjQC zlSu*~LGyh~Ft#;j&EP&3>3Hi`Xo~T>YRJ%RV&s_9BacSbCVHt2XMZIUG1F}1Nrs6V z?as!hugCyChc1x@dLN*lCzF?c0^8#{nt6=WxcwVYa9w^Z*akh_KH1x*v^|0XP@fJG z_B0))QoLt`$!MuCGBk0}yG>XCaFS)-Dh5z4f9T$QSm_@;?o=QAU{v*;eWz0=qSG8Nwq*20)6E~?Bo!tvg<+8QaCZ{ABMC_3f>5#SW{lM&)J zH~9tV#UhiYZuFFnfl=c-HRv9b1~2UlC^VS|(UqFV9LWUMkc8x#n( ztm?jG@6BTf^MOF?0l}qlVp0lYQ4{sN=$YsZ&=v5#!A(&FC)_u_H_G)5z1-xVhtWj< z(z6Z}+;Cwnt19_F8acaz5OC*pyOcP=Y2p8;H<^zDZdeI_zUBQCM|giWl)!xcVNFV8 zJ6PpHPoenxwFA1$vRN`3iwNiOc}PdT?nrq{A}S#u_|PceY=y*msl?rysa0&_K@$tV zuM>e$e8DJIn(sbz1DhQk7xkdb;19-a`#c1=?a(jZ4f0q-lHOF)9$bG+oY5wb-dSLn z#<{JDqz_#??ZBOB*Lj)x7to*Hnq!QNoW8SHpGMioHsw*lxrH z^Lr;cABxu@&2>s$!CT10f*#gg(VP&Q*t4d}`!{E|IfizxymBJxCE;A3c>CWq#`_5= z`q4|9R(P|yim@heNbHyAC3mFYR;HgQF_a;jpSud%elIQdTA7zyZNZnPOC!W+=ETyf z!x=i{P_?dCnv}#jw0)C%hA}0EILVzu&8JrDPY~+TgY>tt;D0`m$<;**aqAa~WVr$h zOMskl6Ahwplz$6sKM*KLy##vZYPyWKcUjbwoBB0suev6gy zpqK7wC21H@n_@cLb$GucA8=T#gdV1S3>30BFzrUF6z5*n_HFYCa_1B4ErLqMz)4%- z@-htoJ}Pib%FZr3qm%3dQD)5Vzg=}>lpN|qJ&sF`!EG~ zgVnS9_dOHUX}O;qWlPILO+Eq?A&r!@gl6|>5+Od(!}B8U_sA(HN(;J z6pN!pgOlwLBh7JE(IxQ+@Sp!k``d%8D_9+g^MSuq2g)hVc}?5_TK8A`4~ccSSi$#= zvHAhNg{pEaxC0W@vpdS*)1c%;2-b?h3z8@4`qmg^izdQ<9fQjHTN~(iP(HtGR%o6wTB<`N59@C!D~Tk z(~**g!}{mt+pytWobX2VB7rZKbAmW^o|O7@(2u$)V|Xf;ncHqIB?<-{g0|l7)Axi* z^b#X-JjpvLx;_E#G#?oU?-}-={=MV(Az`upD}C2|W?Ah+y{xwpQRXK7L59Z$E<-Mr zUIKzx%W6thZuCSQDC6*j{%sURMlZBYvhyh6B;lRx9vro+PAS8J6)T~I7&Q*TYdRF= zMsk=GURzFJcRLoMwJvgS$oYaUzy2=^m}RVN<4_R$$DsiN&B19A7%EE;s8mQ{Bbp!K z$bsts9a@Md>(7^eJxp1v$5l$8F^Mh_ia`#c&cpOJWm(;7<;zr4R8?f7Lt!|MCYEit zHTC|ckveXcr9Au+;@DqsmM1WeI3Zn%iW0+#V7n2ai+C;|?sLu*OK z`PE=UHoCXEkCBy$wvq?KU7TYVzI`iNu3{QJhzN%aQ+3R;zkBouon#4?P3E62kcL4U z2`T9?S=@<|Bh?n08DcjJwOv$c&eWl{@g;|Lb=XAxN?CB!=N^E}_kNNi+33Id+4}|j zxfo#hDUF9Q;BW(jA?mLiX)sf+_STz_b`6qA#6oBDjm3t(vKjw$1bBymj!o?MYXAa@ z`6D6LgKhQq`^E5Esb&FCZ0%la+S`Sy7u0Q8fQ2Ipyt**=tg@)J$&{;K1BIh{E{ z_Eff=Hp*D^paG%>8){<^V)x;qKL+mjey}k8BFg(W8oxJ=zwgTg9!%Nu7zF&to#9Kg zm*u-aKTh33NVW(A0Lnf2s+z9eXWD&u=00dK}EW$}opY96p z<92X6;Ho|A}R0e-_Z84pZZKgz$Ms*E8y;8>1=EF)>qJJ z;@5@<^16QURj1-6x5X=&ETjmFKf&bQjl{-tYy)$V`<{yC28L`pjEhm*PE5H*b|M-t zJGxQkZG=P^jVtX6$SZa%beU3`l2d=J`{(`uyD(0=%IdQuBw)-l+n7&8zFXAYm=Wa)Dtu(0t`9B6AEzfeQUU;tNXKZcnW8@ z5lO_XQQE+#xGhPdZ0RMarQ=@+k0RCVMhQ+hg@mS7j>bXO_qhP-FIe~Ebj_6Dj^Okh zi^X3czMh{It6orrB-*?&yR`(?ndFaC*k1D~m4y>eRb0=fg=5gvZuDziG|?R~4NQ$} zl9zN`!kjs@NMC$zvl87spd+M`!iPqa&kOZFqYd_hwS-L@f~3vzKfGg{uYE5IT+!cG z^JscTx~S5?i;r7wtV8ed3`K?|dgqZ~3RD`~ErfVf2eE#h6{K+NXR# zKUSbL$;rZbqqCEVT12ckv%Ai1f`NNJ zuCy15!#0MoY^g@v6S$3n#OJGW1cQCpdmOi_%@*Rc?8CZVU*^mdG(kny)2f6o;(eZu z&1|n2nyvY&Gc<5WBomDA;reT~3;7;eu(=P50;C7?oOROu`3Y#sRKN6Yw`Ewg%k|R2 zEi^^i1tyEbpsi#ZGqLp=(;jGc1q^#Y5vF>f;-X_oy&tx-1c%Yw1km6rEfqBpZZAT! zkLy_3Ae6eXg}$fuh4bPS?5W0-Cf(rJBQHkhK6X?4ss}z<1Q5gep$=3VgLnR%vbzJ~ z$<|*xTSyR1 zSHc7v1ow0~+_hE?U8;klfGjAaKy&^fup1&5y0i&e<}x%rpiZISziI-uu;!#J(;TDHh z=usv!st3!+6)ji2n99|LDCLHlW*R-;x2oZA<=Y{@*hRoO5oJJ zj&y`qr}_d>&@tb(&oIU%VHB4<8CE)z&*&vj1_S}V=f6ekXycwF`cktqI~be=(yvPy zGgY*{y4*)X3Xrlv+^8LTajj*Icp9qtg{&90@MqXv9dTvY)n6#>5`kTY`k z{l27}6}q^9-ZRnSSlC`7$*kUku~_D-J)6WZVM=UoIp<9aYqaWZp6z{H>m(hgHU=jk z-k|~!-!xWJU2`m#gYt2qFzTd!FvFK`hd_MTPmKV!x9c$yAUIHPjJ3o{TxC`M9FN(Ed!j z1NjfRacet23uFYP;02UOG>pN5i07Iy$Eo2Y_^&H{=mDZBF*1gsk)vQ^g@(|z3QAg6 zOl8t%gJr>WTox)a5`l_`#%0Ed>Cg8RmvO!)=NlLtnoCi@PM{}6{YhWv%*=mg?jPP5 zoh-tu0SCQ-Ac4FBw9Cw6xSu^f7a6toIMABaqRk#tk(A!n`yPEBcRuPm@wfqw6YTa( zUY$W|qPU7YM?ZI|tS_!|xdnYsaAe&!^ZANvb#;6$`=oS?;GPi00q!7dM;?)meUFYK zMj{cP`d(oTO&|gB8()(6H_9*6S&~%?v@52!VYN&mAv;#X&Co+Nt|;@QCp>Dfo%-&a zGKJ2PG+r%$riW{Ds9xw)q$eo9;n-YZnKox}J@A7#J(m6XXJRk~O};B2yqtB@dzx1+ zq#JAp0{k|XoSaqThk?{0g4_OFCgkF96C)t_H-iXfMtn3sku^ULPJpFNnkf0<{ObgG z*mdhsNQ0*# zO0OkSZ1mFTXR%{8wBPQB;FdS0iUUKu7Vpb)aYbZsStY4D8UDsY1JFIFO#}M1`!+SH z3mPX!=Sl};8M+>Wz*My4E+-NKZBZ$s+}JfmWC>N)7sUe>$sLRsmdXr#3_st)$Ziw? zeRiyH@XNhkNWPb6Z6RBGN&Vcp7D6mIw`(3T?X8BJz`KD^y7&9ppZ^Tl1pmRLy8NMY zO%vJC+r>q-nqg8VsJj%5Bp90xqY-QP6Bk)QC^-$}T^wvQe^&-fJ@3rXYA{56`n?8!&dUJ^V+y zEScJlSN#SrwLP!VU=EX%B3yiL@rveR1tPBOySu6ara+HnqGzH#qN|KB(PLw1hrr4E z9)m>Y?z3h(g|=^UJxwF|T%4h?!(?biLf}XzpZF&s=qCj_g0ynT^z&s?#sT1_yT|3} zN@9hdVPO`--oGQF{h0^pdzhDb-j0q4RvChMQTB#Zevt;sS76N#C!cCu9}|F2E3G0_E3WGS`?vORg1{A6hzz2DW1g_>iTYv|u`u>)gc zvK|MwoVK7>3JNT8TCfN(1Vf3Ic7y@WNomv*JnoR^6pQzv zycmCN8Tu5s$4^z-xd^7yu2#R=tksLY8S;xO<>Xc>X}fJnI}a6?Jt78YSZ`Q@);iEYzYk#L*tZKU7!;(LR|_VU&5-v>hk z2+TXtypnhlH!poQ_k4EG&8316`)H(4Pu;riXsQGBe+CPe+t}iUy))W}pi~SCEMQ5d z^+kXF5mD2(cN5A&CYseuK|DRpfhn&t%!y&kbbEU%;$^W9NMd~L z;8fC=3M^a*V#Ba6eZJk`3tSB|Aw9g8sy(E%B2u%{q5B)GGH1 zp*G_eVn8_&jAF}x^g_1Dsg5Pir<9^9QV3U{`yJ@@EWq9)xR2%CN!~4I|F~BdbaL7i zquuP=Sfq!-l#vrP>|0^%{X3&^f;6I&6>S=;+u3;jF7zH4khT-y6pct&MJ0${(uGTLnm zm2T`L!R(lvP1ru=h!nc3snaO|&|w8c9XN{s-d`#q(*Rp>y6n5yt(*2GumZ24vTLa0 zC%-}UkjZ%6S+W1i0w%Pys6_4SY|k*alVe(Z_fBARP(bB2CV{1yg)X#El4PAc|#1$bNR57qCQWXDQ zTvaReE-p})9$+aLOBE6iX{U7B&GrOOp;5ZE`dmvIXr)$JO6n(|EroqSlOmcdq(Xif z8;YeA_l&S%i-5lS4FfAg2f;EA>^K4b?nq+3g-XEIFzVC*Pdc3FxSNl9_yRsEUnrdv zoFOZ`^C4>sX?Hxuu#h;u+d>#>5Pzo%OVt19DG8+e5AGU6{+1Sk6>yx$%S?*Q)Y7$O zbH^5RvvDykbT@n)<@n1`5dLiow9TDD5jYUm~E%a z-3GdE^<~^&U3>s4tNl{bgAA6Tf-APi?Q1G+mEdWI04CVBU8ydD{$tql|D2cS>WG?; zx@rAzY`Y0o$0gT=JZow%pgfa^)Yrx`{n(wB60gFPHjs-l7xgVBTZwW;L28?uUVQqt z@_K1Y^`FwuNN_UYw=xKWhLLw2xS3mX7=4ZDf2vXwK5L>wc*qn@B&IQnL!(cmbkEYo zyoWR(t~FG7h#r}+@`)&C|7IFxk$(*)MJGKk z@6^n#X>(<195gY?mdaPL^n91hy72o^_tRLlO>ux%Z$YxS&N!J|^>p8yH_DcXw;6Pj)nlw9WfM8tHrYw@B-Aoo zKx2Ex(}O+jpg1`obhk~?N2p~>`C(Xk6H1^r1O?f0ijrPAlKp~dd`QJ`(QoC)Tbs4g zm24>nmWGoaiYTJcwNs@NP1snyl6W&V?QaBr{Br7@$p4E<^^k7L(%*Y8hMCt* zn#{i*p^fy-o|;C|MfqDufauj9C#;vSX@;jUic0?NfHkD6K?n^*dZ9A7im7k2o= zyNW=aP#J3=zfz^Rt{q0G9|Gw$32g{Ju4KC9wJ_f%tF|K}qLROi!7l9gU~Ddl14${} zor`U5=#+{^Z~iqRRiob=FNZ3v^}SEOt)ql6Heul6g(O&z1@UAK#!0?Iqf2>k@BQ#- zYkCpOq{iGB?dVrJ)6EI24)3nZK4+xJD6p*Ptj#9CVy2-`Hz-jxxoN@Ojb zSitxLG&oaH`fYs;QxXT^-?>YYxg%0!W8F-YPTw%0y?{bk*JyV{V zV0kaFh>P?e;D6CNI2t#0dJ0Ou*{x>Z|?`2%YccQ`5IAtz+@ z+NCG?#=5B>EwB4lboA~8Hy{nLVBv>xM7Tb~6*{7G?^Cwz-gaW|IFdb8#S?j5w z8vA-4eL=mNAYr9Pv^6Y;fNJLnVy}Q6K0onOaOhBUD*RZAB&ZPh6;&hX)wcY~FlDv; z-s2SJ(Ecs?0=S3k=103rq=k0dQ!IT4L~d88O{;HUFBA>h!`$ndfmConM#u~hPQv3dWG zy}yi#>J9&g(LtmIR6szwq@+QFAylMGx`zGfcIpoHjNKmxXRK38b}U+|dm5z?59 z(+BYzx9Z0`bGN4{HtccjrRNW5w*-!HwQTfL^qq6fi4n6&`J_3@Gzalj(U#=+1n@(& zJJ$V3KMDCSN(vHYVjOC5?c|c7C|NoCMC0#v;$}jyuioO$FA&GVPldcTc;I<4FGXQO zmR*XquA7*z)=~as`RM9?OR%iSpo$B>AtnYx^Mp)g2paa0ZzA;_+kJ=o4lFyIYri!F z|E8+#F^5wPq1-<|bb2J$Y$K*xSktY7n`wX_I+{Xug2&6tDZ1#HJRhxMT$8(bcfH^Re#G7q$}nFKbvJ3fyeE zq=sx{D8ZjpDzr6GoEghho?@(%?}F8`>Xb&sc?*j6qNF_R>lFI^PE zaHJIeJ}nRG|LsVGWZLj%9RK5v#IKu>7iP;@skdoHOk>pyhi_*qEclCSKw=k!XO z;uD4`HBomJCor#2c$MwIJ=&f9U#{AP*&+vwBbw3+m>dS{~rz z-V&>raS@8uJU){9uKR#{IP4XFD46P| z?^(RYAXTKb;G56m$rivHaLNphlQjRx7BHFkZ%%9){MVO7)LedFl##@8Hwcw!5;o`8>|L$_sOim&8)Y$((>i8n=P z??;dD;p)UO#K?TVYD^R7Y1Wv0OcYd%!Z*i?>gpchEwGUd=<<<}$quxzWTBCcT<*qK z4j@clC@wDK(fe>J6Ig>|#0j()67PItULK9Kmt^fxKC!!xJot%ZypPhwO;e38row2 zt*9`Sr1VI7pQ|x%;m(z*H&pUnjhW^N`LEQ95unCd%a9WJwz!jYrKDb*XTE;`#{w?( zcV_w>qI(=)dkJ6gIt=P8VOcA54MZCD7F`L24>=8vqpQJ*cx1}S1DW|R?LMriS;=3B zvP?m01;-w37V7|I16H&WJpM(!9)h&INEi>?Fo?|F@Tndv{%T$GkNRL~+M+3vBQ>v2 zj$Mj2HK#}|)6glW=wrLe=2PMMSNW_K(51>x25m2)bT#F#Usf{|E;Uo9zsp^0pf1lK zG$%8d%k$Mr&L@je-gY=Js9Y}e)`5sOc3v$Zl*p#BPwxIQgFGLpR!fQ|?DcQQ zC-Pi*#;_$zP8Pp^io$olhYFJ!Hat|5{WF&x z5tw(rl(q~TZ5wgDn`YiW-m`o5~nKUrg(m3P-SnrzYqxzOMZv9 zELD>*Kp1Od_rxW=^|AdEH$o4HyVk{f#p9^gto_=`YPDxeQx9n}bwwVz^*s&{jip|TEBUa#ux?#=Rks<~GcJC9@1E8NB74Hf1(SE^3v~?L`^nbc z9$t2$v)|!uS=_T0$WvW(Mg&mgAuVBmXfzS!;HZps4OOEv%ewMq&+M`jj^r)!j|-B} z8>(>N@s=2qdO<#7H@A5%G@zE_I#R10N*}Qq^~F|2@Nvg?ero)2zsbH%2lT2hiYfDS zc|pKsbu`{r>k#I`;kqtX-S~Td<_WpN1Xp#m{^QMhSIXK3h>G@`+i%H~on0 z;Q3IWo2!*?r9Yr1YroF??Tq0S&=?N#L#JeK;fX{wHUXhcrE#a(6UnWyjZyi(`)>qf z&8kcW%Sx1vP}r6#yPWC<=O}EROwvh~mz)PQ-}%p`&x$efKEA_c9`%>YQt9^#q+6fM zFy4<~g&Dq|$RZi@WQ;;KBBPW43%_UmvcRZ2z6ZK*^{-gn*5-D+Wt}1sJ_W2g{4iM z)Sg|XWKAAfylC<`GiqkyyaLUb2_L~i10A8YdkE6Oo+S?a5Cs))TsMm{!*3r<|5y*( zOSU#~BcA$MIAiA0GJ=Gi2cu`hVRXWqTfg^@^lTp_l8}`yVp8}Z!8_Tck`&9f9>ONw z{g(QjlR2ve4xAJDItSr5!kKVQJvBVaai4_LVg>yqmtsrJw8TY%pUF1|1;#erU-Zt~ zo5T|B>ff;E)EhMAl%f5 zp!4~rRCh1;4av(dFGqUFWJATwyWJ1psC0V4oNwP%7`%8T`1}01-N(l|JAdb&XLI2^ z79U_iRI(>Cmu0LY2#imQeJO6m_?L8Ly}xOG{QX2NO_Zms-_F6z0H}U&A-%Is#LmOm z|7zJm&~>0|u*CgmrF~k!VE$0nexp^vtcyTz!W*_lI^XwJLn*Ld7JcTh9W8>wYz*W7 zYrgszHE1R_6e25kd!}MAmywgE!iM@aV6vmcn2)BI&&4XN7bPgLN2l znK(NrkKuj1Q~ZM!R;xwzvO$59)TAs_Ja>iJDH*buTSqrUpZOaq0wOIvysAd!;Wlls}W5U^lt;pg&OFI zImAyBOOxKx$DLtUkki=DoOV7WLcBh{Ts=bp*pko+_fR~Z$fwBChb9-MpRTq19kx3 z&!?}+z2*#VyNz|uYSKm_Je!coCoB-Y zC4Kc}PWH(}$RR1@6H2VhnMCLpZ9Uccqah5+-^brtO4t$FPCUYA$+#>@ zuRS=MHfK^ ztCRj-uF}%wkgZ>zQvg)TVll0N{q_w z4Z=nnt>~0D{_GkhD|CQu+L=7QQnS4i`Bv-r`vQWt+Q%t!7kHg}HBtv|J-oTKH7y*~ z^8Uake*ERXHK*3OjRApf!NPk_R^sBQj8%kWjp!Z&jUp+2#pMb}rtmKm{w=SC&UB2T z1qKs;dlItVZk{KVJaxZJ)xaIg!g{UL_ojPs;?g15ckAHFOwa5ru=Bo)TH_@@m6dZ{ zAFkeVF-{YWKwkt92Y6zIXK0N9GaVfhT;q!#j2Tbr7QnlaIG*Rr7kHm$P#3`+6-Z zy*fXqX{@$x=jb9a-Y7$Uswv^?gZ+9`wm_eXyS=Wf_6eievvf1^d!{%taWpgUn5zc{6;&) zVA1E`mvmz7gZ8D7MyK%j)s`t?DN_!g+1ixqM>}0vH~bPF^{tfwXZUkcu+xc%H`<}6 z)wAvf6H2R2KuZjXv%?Y-Ak!e9V|ej8jOVW7H+*#AG!LuL#qy=!Uk2sudlG(o^^&)& z<<^TKqBx}qz~E}`Bvo?hAlH)N6HW3TLAHAtLg(mvv=6wJJi25^rByULI9u0!0ub!d z$2no6Nf$hDLDj=c)HR8?P*w2lje6jw)>@YT&PN=gp#4bG*4yh{*x6EKVbuO5QHjyZ zHV$rXe_zrcUv06dXJlipiRCX+m)@Y2OU0ZmmfXjG-8W)D9zj}+FC>T`dr5hUZu_ZB z-fK4GsyD8;+@`sa_CLj9l=Zz0zDk{G>z&7v2fy0kAf&&KFZ-_Z8=GB@BIbtqmtqj> zBbt2%oZM$|zot7rjyxoS{?Z_eJ{X(MwUCu%(tntUL{OCP>|DWwhW}!Wb8#By(@%+} z5t*4h4GnWO+2>OE`%>vs&o9h&Xzc5qXx?eRv>1fC2p2QZ)Aqf058SgfLH>P*cR-`W1|lM;7EZkFvEw#d8m zd?n%J^;yr@tv}2<|5m1>Cv_`}it@x3l_V=eFtd$S|Gj?VA-;_G78PoO;6)g7%1Uqs z-X1OF$l5nRE*eht9E9AMS5u@Mog^TSgM{O_t#A&rkN^$ zomaU|2ujnTTT3)F)Z`G4l-5qx{q{WD)0uaswkfAx3$iM3EuIbZLMSM-l7=xn zC*|6i5|d{}vAF9!pEqV>FjgWwQ3##1E-K=71mqEO`Q+=^cHvQ&eU#W>(@hw`X%4xd>ZPHwoa}uM4hdXuH zh(2Z`noQK4GJL#f+0sL+sG43sA=}nDIePzk_ot+@k7IwbJ^cOt+g-J$;FndXUl1JP ztp3Dg%0ZJVPowC-yl0|tiBfO8^96*Dakk~pB%@+hCyLb%K(*cT<8a5R);)C2F(Pl4 zI+|*`I(J#_h)TK`X}#~ut!(|*z|g>irlLfUIW;xapitD1VI37IirhP}0Q>!ZJd--Y z>nQk)A-JQGew(tCGr_q`IcUXO)b%x)dnQq=cFZbzv=e|f8H<@`BjR*OA z*|=LJY`ySJyR@lluC+SA47=yVebus#H6n-@(^B7oO(kpFWL^EEEagqcXYaej^|VKv zQJNX*R~7`UB`+Xr28!QzqO@BY1a$|Lib(qpEXg(d^T~qgq6aE!@Ua?yZk4yFkvuRC zJf3h1x;WZUhCWXfcrOdF%NwCAT0#Plj#T}Q$&hk9Z zqNdY#O@W!t{Z*fr7>gTN@uzDMTbA&TLz1<^rWeSutu3vxgl@U#LC~ZCY&4;l1;PD5ZcuOTF=BVrVoTsEH-Az+5yG>|p6mIIIUu#YY zDPL7s^go9dV{G1z!lkHOrZaF#;7$RKu9ps?H1OTXXtc=0evc~qDd~wzZ+mTK>=S#q z9sRk@=PVm8X|UkVkDRXh4Q*H1Uk^j7=vtnJ$Oz;5Ocd*|XDpC>+8W92%Mf!X2Ni~G z8FZ&@Tk(Bp@GTk0YmZ|%vw=Jkkv7tL)}-M5FX@l#;(AuRWnR{EBMUi`jTZzglhcnR zwTlJyV7Y>B*NUy%wrg(9RA~5L)ojFA;4T-7#jx7cHw8}3O&m8h zApF#aIA&r_iOFkEya0I{SL1)?8KeyRFZzfCC2<03+A;xH>PjG0$p(1FJ&cGb2Zndxj&HKA}h^qQh z@tRJ12Lm^i*S9~gKngfagdNn>)JFA<37DzzaSzNo$xPbDzcs$bg^vVe&wfE=vI+Cj zNa%FbCgW|atXzuIi)Ve*pdyv(ZA>2zR_S~f+EaGiVt;M72IqJb4JgLM)>&_r>847y zzdt~^nA+-n|1)&)A}uwOj;h3JqSw7f=QeA8FmnfvfN@y_)Jw4!s=AD<4!l*(juE@z zo4EO1B?{vi>@N~!ZY~{KhneC9XJemo12*hgV{FE7u@;-8_#xcaI`L?_wcIW+j8e?7 z440hp;9fl=B%qe0HKD5T69)($oGxNNxqb*w=G&bpNtTjE{v3Ap`1PcV$}F!y=G%d^ zaeJLI#0{E!jILveoOPUUE$Y7>Tgr6s5)-Vp%ld%k7&CQm`CDDx@PXXmN_6f^xHtUT zL&qTbYN4gXO1t9Y;9zS@+PKKrK;*MYC*H%tMOA$}0Nl-lfiInZ2W@LV*KMiNMq4#Y z@9WRa8fpj?%a_B3zw@TX>S|l1JE4LI+>GG$Njl>XcGG_Itz`;S`wzwC{mT2FT!8yw z@oWk~aKX{QtdFWA28{Cv(gE#~C#ofh!s?|TFMjqF(cVoeeHAT=)NgQ_zdAoO_1nnr z48!9Fffwh({AvedN_k3VYRhzQ%6TgK;omaA%x!vWN*>g9o|5Cg^grl23l=y0a@^8! zlnt2v0PWmdzZ~~v@n5l$T~VzodV@6aw1*cX)-11vdA-_Vg*R>2NjZ8IK@~fTR z%g%3(ZZ68=;GL=rz06Fvt$0*tYc$yFI3$JG)J@jO7XAUh2^C2tJgn)Bc^=)EcjYpHK;~pQo*~LgN>b-52?jM* zZ8sNN*B8g>{N`_#4Z1YV>CABQQlh@Ps=$UcIYfwa9d$jlcS$JekYMmE<{rhyfts3! zq(U7hkft1n(l-hJ{l(Y@P05KdO4T?_3Cdh)aFsCk>~GL>Yu>AbAPlPN#pq@&>3v`I zrPOe^?#76*ldQX~es>~1*+BAM{)6BgysG-NiD(*3m1$=1H|x$lyYwEQ8*ei)k@)!l zsHS)*CPOGs-k>*a%l6qf5R6vBfjphY`g|@8uJr+PADJ;MZk_xS%iK#t#W z_Z{+%5%k}QH=&>hcL)AIJmA=4b6l}5BUQ*chCv#FWgFuyXo!dGx54qD$@N^wjFSAL ztw4PcY=pHD`@f#~O8CF5`UY{cB?7cUO3ZaRK9E^dj2Ls&Bt^0Bk^wK~xCK52F5`%F-jw)zPOiU!Ot4pnA zxXZ=Ra#AMqnq2%98ZI@gv-|U%sahk3mz%N9ajtQn;r6Vr)%Q@`d*|vf`?j^QF~#(c z0vLcF6|kG;fZFxRGQ@r#gS5>*#ur*QmKuTE zEBmXnefCU`_>ADKvW6Ad>2T1A5KdkgsnnU-I1uTvt0f+y(dbbN)fE2F2I0{W05jy( z|0__X3DiI~yK(h-uLwVlRsyxJ=R~@>xsiV_zOw{?DgU#(`1kK$py68U#b!yBe$*Z9 z+#Iggb7Q3dcC4ffMe?R@`IdbX73<~Zv1=F~5; z)%o&xt8f^O&{q?6n0{w$TpFUy=4|L2iS*xXbK|gH`4cC7wMe=}d-X=nPwZ;1J3IL1 z91szLHn;sBVI+pNHgUFT@j+;d%ad*N)i(-m{ElhK`wP&9pld&|K{$Mjl8A`NHsiY- z0Bl(bDJUo&)33N-Jf}?hjGo%|RhMo7VseSPZKv(r7cGahH9I2eFIropliYzWf1E1F zTIFQkBnQTG^ApUBX~6#P-;}1MXP=`ySYY4K9fN?(#3`_fdts;|-cFXAY%w@e;^5#U zB_$C~^F(1e`ML!ts{wALm~V1?oScG!lQA&h91V0P_eP4M0^IxPydO{tegdd+N99J# z{&?Wi>o+VlKxI=(GRxb#9+QLX-9?;Q!B zy#+vu-UKnhEm&&gWL1Zl#uVewjCi@gbHMU zn)pnWo5V^SDEMyinlx*x`6{z)XL!xp#`+Kv5(XWYn3}kB;(TEUIxA33M+TmnM0Ao$ z?1e~wd#qr9#}H6S+v&Uin-V>>i@G^!P|FnGhTY!WNm1PLOE+=<6U?`7gaqA(Ys1n@ zUs-U8pIWA^zK@lB=D7d}o?eWCCRVSLP*5j=yQ@4IL zKmKk5>&)YX?5np}wr`N-Zj6&Kj_t9UVJW!L(~Ygx!yy{57Y5)6_ZmaMUZ-`IuiTKr z_Gx*`&M2D*W0K^>dM;oC^4x-G)YF7FzRQp-QVNa^r8~F#0!AhY3LQ8Hw9?oYodg8N z?rTGtf*-!QArY|4`P~#OqW1r>vX8o}x~~a3irzrX#dx{#+VtOJmti~G){?jdSi_+_ zDmPKW%$_yGG%pkaRRqJ3U4X3QCUNU|VWtJn3d%}K#?k;gof|D03A@4#2`yLnLceFlYrYt8Hp5=J|%=uvm)}3Mh`3m$u2A=Bf|A&P4 z&F;Qmas0nOpx_>eaV`eneK)_=P(~4Ou@X7pE}v!(&~Q^2=uS`5ip6L zCuhsC@QoCK_ekJ@0iZT_XZ-HYEPgPU{F^EyA4`7?=r@G-X4^UVu;SZq3#~#mI;ns& z^DlH>fGt%^75vZ}MSW51d$^kEHo*D$^JljtheWUj;QQ(BRtSfaqgLDA*jXc`ea zZNL%H`z&Bd52|InbUj$-u(9hl-PHa?d**~FY%F}T1n6gWFqMTTNQLypg_zu>G6MT^QimfTu9%04Jw%qz#o^p<3pOiit^P#^!9r ziw-959AFLu|IJ~u6OZBV@bK-$Rz*!s%`wz=tiS|tXn^0atAMnvEEzesiCFaDi`CeU zJu5mII0syhRN%$B@YlV9{E)=>vOX3_LbPC|rYzs4=QR{!K1!{(K{_Y&?<&yn**Ypiy!xDo{v4YJnJlcyK@`e3Ca5)NOc$6RAUp7l z(Wbt~o2{p(=kU^!^v!;A-PUCLURR_%ZBWXr`^t^A zC(pkr z0?U23#-HVsaTl7Kn+0j=F@#uslSXgrV10P59M77)sJI(2zKkBupLc{eet*nYA zRd;+Zw!N*aELBq7Ow5yc(?y-z?R)2AB`&58hiMA$?#QUx^B&BxS#I34SrIEK6*yZq z-{PIjwE)PfPD-sM<$79Lnx3iu_6d0B>iqU*si-gu0t=E^4z#o1Qc{Nd9b?$maz|{k zrP_{9-bzThuLiDV-j2baL2ib?yPMJWQ{`>vP@@mab+F6sD;y$A_*n35pLCY5uBktq zr=efnFdGJB8>O#Y%5OPzq4{jP2y#G5`M_b#0ho@~N9{wCY z>JOmq>l6#C-)p{%UzSOE&jhz*yW z(FZWRp|+js$K&Irijv}D8i&^P93uHxacu~SyCMTKr49H7_3Qmu0K~;eZ7PWI0p;0B zl{ByWZ!=g%jsYYDg%RDp!BRq5vDK6sSKC z-Tj3$VGMvy{dK$Tmsvf$oP_Ncv-Wq6T=?n6L`k8gK!l;HRL{SkGI^}N@UpS12hXin zavZL9yzxq&BylB}^~Wwo38_i%Ouq*}`BP&yqZO{AqoJ(p;3?z7mdl$1>lqHT!C38B zOQKFqwcz`z<~sYSy7TIs=t1{)2J1a0 z%gY?u$fpuJ#X2QKv!W`$p>E2ZKm>cWSyF)v+zku_LZ$F6A;}Eh`#C`53wVumnAz(f z2!!>qE*o;Bdq zE*Wlp=BG^gpy>*d)z+@Vqoab{ao^3@3{JyHr7_d{_X#@IG6GMaJVq<^HCB?Aev(G^ z8SPc7@i{*-l-94ebrL)-B+v2KH==pC+3xMd24Cee1l;BUuwxs;(#Oh*3p>~>iI+g< z%=`koBI+H(*nH0S6>hXol%*AvIuBO^{Q%%n$7geUG>fOTevd8axX(g?P zz;s{_D)%D|@$$QzP6z$bMqxHF^?`|)`-N>GG5}uIY{tH^EDCFC?Ia-vGA|pguJ8GP zmY2XiYBw4fg0EbYO{E0l6s|C9%f}>N=w4j3U(&B5%ZwBi6_a^0j}zr6P2J*l-nPj+ z_I_X0NS?Z7;i-A4`uV8Du%4)v6`6f~^wIxDSSChAI5~)$i;FdKlc`bDH7TkYh^9rF ztO0uqzzmeTQnhB$T?PP;^eO8aUXXPMV6R3?^eRraXX&OZvgguUu}Pkb@%tdKurDZ& z*Y^wkcMMv*$|RuU7rU+Yy{GEp8YI~Y&WuYS&c3z;ie9)-s6)e5- z9iTQ0AjvO{T=F~pz}Vgf?1%-*+0{=OUn4#xy$g>&+N#J}-umI5-(P|EYU2BEn|>-0^B7}6yB{D><^a>DZgxlyg#JJY9duXOC{0;q4ZHbO#`aDl z-sTMO=e-R;Q^1-4=LYPGE^xToz*%E(0UqQP0O-x_Fpfo@V@;9?XAv+FN>omJuM(b_ z2-r_{Cqqu`0|5eL1RV4ahFaskho<~tPAj=T4>4R zULnka#q2aSs(3^jRNaLn)Y~>z&1Jsq@UQ(R$xdh8o?3pVn{4QgXBB0|#fHreGj&tO zbaNaVa+*=vc*p$essl}rPd{=m3l|m?6qJ{%ji$0%PmJ!qPSmn&=l2N606?25K}NSVS6w`wFWpH0H>YYQ(#y{dFWb@jn#BFdos-^p$& zvUhP}2gC(Uq4(UVUie9*sIQ|ii=S?8Q6=mASpvhkAe|k%rI@y#QY@%7mV)BA@wPp+ z^TQYKZemfjq=w%*2^DjxVH|gqucXQQ=D!e%$AzK2=Q5TTRel53a&CqE7l>-&0;m`G z{O<@r|5u!7wsTb;ZqE8xS4Sra2q-|HAF_%slfH7j_FQwW*20>G$&YChN#7d(H<5oi ztQl#LocVsLR(q2lszL`q&JsXqSl5>gjtJfVhEzHO38KK8-;~4B(nidiyz?&B*p-W4 z-!db(xNk3}mo;tbOFspE{=j*6FTgz&j@nZd*v_5<3B1kH=7a|1XTr+4qCAN2gC=Kg z=KiUI|He)0J>WY?i~BmeF*H2Os~wUwk29;n`Z&IN{>XlNHst+(gS^r+Wr(HkAs%`1 z>4qF*uXj~!JP<9w|1W<%Evmu}HB{r7#AjX1thG-tqjn0m?;dW`1_Bn~a~YrQ?=A9u z#}K3&`k<8K_CHFTl-(8(w!pD`)FuB*DT6LYgDL*c@d}jxD;@uD$QAtmZ*j+(|2u^K z|2xcL9Mp#fE@f2&KLHG_YPgiZJ);LQSPCrn%@`$L8t7#UE5KOI0G$wLUlWfm`=IZ& zH<8Z6M?v-}^az-+rU~PTpLN1oCLKNu_&u50@l-Wz+OZ|t1FqC7AtE!ztn z={_m8e5(!?Qj2ayqwN(yE$vR*1+9L#k{PJ-JH!X z`d5x2WEsXp{qG=J+XZH0h#4A&QcxbrJ^t@@&r2Ztdd{^7Bhk}lYrvCD*OYdABO(Zl z-z)g(A#S=cIqej#8mKZmv-8O;(B3*O&(iG0oCSY~_G@5m3j7z(u8y`|Gz>Vp>nxM# z$Su4Q1}(XmcEEJ3cBLc`!jPgSXU6^p&LFhMLEC_6Co77lA?+y<<=|0mvme$f?lfozw(OKBus&Ey!~OKxT_P|REA6WG@t zvnC8(wr;;c73OD$m09YWStmx>hasVf3%uTz*@~?8Fo@CC7t=I#fOyXvmXq$!zm<5l zG4-TDs4`&xTgQ!tZQ-W9_=f1yXx0_e@o&Gr3aZZQmRM5SG@v}XQYmNK6%YwiXNo3wTA3=2yQkxS=d)!YaOs?x8JhXnMPhL(IMZj#GZeby%*;zL z{cY18%~EGVc!GQF7fJc_Lcrsc2Zs>{m>|^Jm$>pk{Pk9hw)jo6qwSCRqbWtgxi2)a ziv0k?9=BX=dH6%8jf~hcKn_zOWV(V^&!`D(9mpPmY2>fzJdTnqOEZKz4So_kGqI)F zFj4^5BwxUp{UjY4E_yb#7b7}_8nOGw>JPqH!vb}X_|&K=K|KdxYDZ;do|`bWskPp2 z@3xbvy|oD4pajPYEwA+^&iN!_GN}11oSoBJk#*MQ$LB`?C4V3kyg6LVs#t=Ttb^qx z!TAUlZhrPJwv&jO*H*g6T)ltuaX=xe>H!qq;Wy%$*;^S*9j=){nX3~=&z9d2c-K~5 zi)7hM6N3HI*Ai@5!=HTSGT=;6cKjB#mM3kb5#wMjLA!)b6Pfhol z`>8}It~c5A%rBIQG3UgBtxxQTO)v=o=#|eIcC5#(b<0>y*3E#eRT6k^Dr#Mlgh1bda1GOv z*{?rsJ{z!PU-!Fw=Ap-9ZAVflEAm_`Mkswq^|5Yld{yvTkf&HO~|&)H00C{ z4r}P3vTSM|DTV6($gL;L8}5J{Th-RLYH0cu%BAX}I#kDwbE?rN&y))sPdU$HtqekY zFl%$KK)OR%H!JLqrBXq!^w;}EA4A>sXXp=T#vJXt9ECkDqTgP) z!D-W$waxjKm_g{h(xCh7fi$6@jHp}UY`HdV+P}5PakHU$pIEKkVn4pL1Hr1B`Zm1E zR>I1n%ZgN`xKJ!c;pfiVsK!7`gVRc-_4*zU@Cz{YON(X}f8s(1huMaBo}gG8>)&Rl z<4tO!Ry!XRpkN$mKKVA$zXM?UaCbeBQ?>4_jVY|o>kB&@E8I{V`P7wLY&%a+QCH4v z@;SWhVLB9D@U}v23xnXl5L1PHSbGC^y!7m|KV|utK(E?z-;rIOeS*vTtDD42wI-yc zNqBoSNhie9P&(L~^cDyI#N#B}Kya|R9|5l3K}H%idaW^Nbgq3jR+-s@IJM1>Gq_^9 z2@1fW?AaQiA4%@9?c!hT$s#Ok(2%m&=rlPODU$h^=`y)4g)_;FYpkB zckf@zh8@eQ>4^P;`AY;R7JI9K;&VO(9_+QK$Ym7*#`h&Xh~)y3no9=idXsEj_DQGW zL*2b)*XO&h%G&cz_nPfFovr!gwtRnH9|zM&r|fLp|G@^B1gMor<3fzAqm#9B)56M) z)A395HSJSSt+Z=mFbjP9)}c^lt)niTuq|fbX2h|Zn-&ZgQ{b=FcH1k{LroC8T*Ng4 z-!|8o$OPjnup0E2_YSCc_<6JVV&?sW3WAI&GKOYh8f`sJvyn>r&<)*?Ydp{Q?dj;S zMMJSFBb<*7U{<&=nJ#NitF1(IhY66Q63PrYmX^l&k(v6)+mImzj2foxaY*P}G9#YO zDUy-C7nUOWapx6|mPML%E5+&(5zpE0$rrXARMR$}uvC!*4Qw5KxYgp)xAw#?9@o=x zQz)a!JtjAHBA9Of`O{wqF)~KYl|jph@pL%x_iiya3HD@eachY*xCuyyj<}rFFod+d z+Ct{%Cnx?J3&FWGnYy)k#F2Elh#-WHWTsUV!EhqR2>j`*R^j_S79g8zOz-Oki_sS^ z%giLih-#&fvp*mY7z6Lzg4Hj-nzv2$Q$PGfmR3k~&Gww#QsUd+ciSzQ)vCoi-ShH) zw59lS-aQ=rx4#IRCUmCks!G!-*}mv^bN2nCW~ETx`JwxaCv>=3#oiC+qqmUakDYv* zT{~)9hh{^=)^@zN0=V{fhK#7W;_zGFa{U{g@prjw3}qNil2S6m5l_Y&*cxoV$uk4l z&_%6Ckonz@Ir!GB<{s|?mYumyz_GJ0&x+l}zk zG+O@BD}&BfN(6p;L8!jdaN+jf?fj?h&EX?G2B24IZj|?hRh;po7Daq^%q>2gW(#8Y z1}Z>X)a)J?6JyF$$4>P6z$5!tI|SH(*SyojtVNMXD5}MPdT_^aSM)q zMDxM$wui&U`HN$j=6u6T8Tsqu(BquPa@EC-=b5c-oQlt-+3ARcZ<>*@iXqV!Q~1 zm>)eySd69=*V$QmPBy(Hxkb7~p`!~4f6MFFZQXp!;NW4Eg9S&bP zF*Y7@;W@ojftXat)cka>&)POXRh{4{%yBE|%gANTKnm{77xtS;K=E@_ zqohWjkggnqgUB2;1972pt*G0dY;L&i$N6;ms|TgKk}p~EgYQ3mr3^rCoEjrCK?cr8at(-@2nftLiZEL(tTC9-?X`nx?zIvf{ zB(s^j)5%#nXZ`pW#t(1Wvu^50US?||Wp1YKBighg((_=#n|^$IZ<{y4Qg}a#xGr-* z0>rqzuB#KYFP{@p-c_ISXa7MPdRL%_SUU7-7bomTgjEF#J zyB?&E`}!B*t4L;Gkt*y$?CTe)Q`}ghcF!{L?S>6Yhg)@9 zU!_+F>Y)3Ro@PQR5}8~j)UGFLDSk++mh-7|J%(ei3-)xsBUg7+b^fOJkQaM-{XNvD z>#tTnNNd2QGwJC)Wn)i+*oA`iP8(Z=IP2UB_+2-gLJlT1FnCGb2Oe!1p1<) zx@}Anp}jg-Hs(Nx#Ptm^U%CWOZ7r8qK-{((-)j`lOALi62P~`o4n^ zBhBP0f2;58$+GsXQHeDk±s+C3+>aPufV?$MwNStTlhQw*QOw^j#Bf#AN`5A=p% z`8?%R>a;$aQ00_`U#3sels0%izbyRp@!8|nH$}Y(yx>wl)A-jyGaB$czG*R?(Wb+H z1r(Tv4n9@ud^;^~j!7sSHmz}uXq4C9K5!O}XUcd$8{HWA z^9T|e^zQgVj}ycb4MdRlcfVF42B#_lxd?g+L{H=C%U6hx>YknoIJMiVdeu6m2-8H_ zmw8im63P95%r5*4!`bXGajv+cX*E*b7@AX+-lB7KNco2LjQ?1jX9~swGnjga+BbZ3 z^c%~#k4rPvB3d!&AaeLUAOI_5^0(7up2^qQ?ego+>8&~P$iC&F$A>|@C!-Z(_Om>< zn@3w7A<$J>RBnUaM`^D9ZOQxp#Vm=7s|tlXTjjy_qFDrzD^n4P;c*xRbd3CLlAEQL z?2L*1_w<2g)t!2xQ%taY|6)4{ccV;Tbo-WNQcF<(RMrPWQsEP;1`=NFsF9tx0=TYpdsAECkl{&2ocAGqy$#aYtOog6UYgzYJSC6YL9l+n12UyYTg+ zxldSdzN06*b2ZL|bEb$rWQ6=Dc~Xe3oErH+(ht;r^3=B^wD) z$1c9(X~Va-$D1H8)1N-tcxEQpll~V%N-y8rGCy)&;vO?q;NwT+q1IXD?_t~c0t%qx zoDcrF2nwIX*YBP#8!_2>R-pPT$!`Fq!O#JLchmeeFD7KkZ6gTC8k zv*Xd2DT>2fP-g%#aOKYaW6{mEarmxzIe1Sat=o!|I(+SQ_IH%-4dgr}$fXF0oRmJK(9gR<3e zsZ-L1kM%Z8zq9b+7s0Qu_j&dI4Q+hkt;`G78@0!@WzWVnjiQC6h7tb_?;K_Qdv7n7 zwc8(7hBp)Te_8+8)!$Ka&%OCgMgd>0-tJ}FdVlT=Q=tR zUhWC+=k#Z=14oDxO?Tz|n9bar^QyGc>`$gT!x9Og8@rfTljkWjh|9dv+KNROV6x#Qk*S(i1xK3uKn3tDnm{r-UW|$d86Y literal 0 HcmV?d00001 diff --git a/pkg-py/docs/images/viz-scatter.png b/pkg-py/docs/images/viz-scatter.png new file mode 100644 index 0000000000000000000000000000000000000000..db25bfe2a5274d16c95b4f608ebacffeaad4eb44 GIT binary patch literal 51671 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 literal 0 HcmV?d00001 diff --git a/pkg-py/docs/images/viz-show-query.png b/pkg-py/docs/images/viz-show-query.png new file mode 100644 index 0000000000000000000000000000000000000000..fc9ae6384a2cebb3dcbf71e5592e81e55181879b GIT binary patch literal 77475 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 SXUP&c0w*h_Bv~bH67*kz)b#@Z literal 0 HcmV?d00001 diff --git a/pkg-py/docs/index.qmd b/pkg-py/docs/index.qmd index 88b0da5c5..8d119ae3b 100644 --- a/pkg-py/docs/index.qmd +++ b/pkg-py/docs/index.qmd @@ -75,6 +75,11 @@ querychat can also handle more general questions about the data that require cal ![](/images/quickstart-summary.png){fig-alt="Screenshot of the querychat's app with a summary statistic inlined in the chat." class="lightbox shadow rounded mb-3"} +querychat can also create visualizations, powered by [ggsql](https://ggsql.org/) and [Altair](https://altair-viz.github.io/). +With the [visualization tool](visualize.qmd) enabled, ask for a chart and it appears inline in the conversation: + +![](/images/viz-bar-chart.png){fig-alt="Screenshot of querychat with an inline bar chart showing survival rate by passenger class." class="lightbox shadow rounded mb-3"} + ## Web frameworks While the examples above use [Shiny](https://shiny.posit.co/py/), querychat also supports [Streamlit](https://streamlit.io/), [Gradio](https://gradio.app/), and [Dash](https://dash.plotly.com/). Each framework has its own `QueryChat` class under the relevant sub-module, but the methods and properties are mostly consistent across all of them. diff --git a/pkg-py/docs/tools.qmd b/pkg-py/docs/tools.qmd index e438e1bde..0889c07dd 100644 --- a/pkg-py/docs/tools.qmd +++ b/pkg-py/docs/tools.qmd @@ -6,7 +6,7 @@ querychat combines [tool calling](https://posit-dev.github.io/chatlas/get-starte One important thing to understand generally about querychat's tools is they are Python functions, and that execution happens on _your machine_, not on the LLM provider's side. In other words, the SQL queries generated by the LLM are executed locally in the Python process running the app. -querychat provides the LLM access two tool groups: +querychat provides the LLM access to three tool groups: 1. **Data updating** - Filter and sort data (without sending results to the LLM). 2. **Data analysis** - Calculate summaries and return results for interpretation by the LLM. @@ -52,6 +52,40 @@ app = qc.app() ![](/images/quickstart-summary.png){fig-alt="Screenshot of the querychat's app with a summary statistic inlined in the chat." class="lightbox shadow rounded mb-3"} +## Data visualization + +When a user asks for a chart or visualization, the LLM generates a [ggsql](https://ggsql.org/) query — standard SQL extended with a `VISUALISE` clause — and requests a call to the `visualize_query` tool. +This tool: + +1. Executes the SQL portion of the query +2. Renders the `VISUALISE` clause as an Altair chart +3. Displays the chart inline in the chat + +Unlike the data updating tools, visualization queries don't affect the dashboard filter. +They query the full dataset independently, and each call replaces the previous visualization. + +The inline chart includes controls for fullscreen viewing, saving as PNG/SVG, and a "Show Query" toggle that reveals the underlying ggsql code. + +To use the visualization tool, first install the `viz` extras: + +```bash +pip install "querychat[viz]" +``` + +Then include `"visualize_query"` in the `tools` parameter (it is not enabled by default): + +```{.python filename="viz-app.py"} +from querychat import QueryChat +from querychat.data import titanic + +qc = QueryChat(titanic(), "titanic", tools=("query", "update", "visualize_query")) +app = qc.app() +``` + +![](/images/viz-scatter.png){fig-alt="Screenshot of querychat with an inline scatter plot." class="lightbox shadow rounded mb-3"} + +See [Visualizations](visualize.qmd) for more details. + ## View the source If you'd like to better understand how the tools work and how the LLM is prompted to use them, check out the following resources: @@ -65,3 +99,4 @@ If you'd like to better understand how the tools work and how the LLM is prompte - [`prompts/tool-update-dashboard.md`](https://github.com/posit-dev/querychat/blob/main/pkg-py/src/querychat/prompts/tool-update-dashboard.md) - [`prompts/tool-reset-dashboard.md`](https://github.com/posit-dev/querychat/blob/main/pkg-py/src/querychat/prompts/tool-reset-dashboard.md) - [`prompts/tool-query.md`](https://github.com/posit-dev/querychat/blob/main/pkg-py/src/querychat/prompts/tool-query.md) +- [`prompts/tool-visualize-query.md`](https://github.com/posit-dev/querychat/blob/main/pkg-py/src/querychat/prompts/tool-visualize-query.md) diff --git a/pkg-py/docs/visualize.qmd b/pkg-py/docs/visualize.qmd new file mode 100644 index 000000000..8074869b2 --- /dev/null +++ b/pkg-py/docs/visualize.qmd @@ -0,0 +1,104 @@ +--- +title: Visualizations +lightbox: true +--- + +querychat can create charts inline in the chat. +When you ask a question that benefits from a visualization, the LLM writes a query using [ggsql](https://ggsql.org/) — a SQL-like visualization grammar — and renders an [Altair](https://altair-viz.github.io/) chart directly in the conversation. + +## Getting started + +Visualization requires two steps: + +1. **Install the `viz` extras:** + + ```bash + pip install "querychat[viz]" + ``` + +2. **Include `"visualize_query"` in the `tools` parameter:** + + ```{.python filename="app.py"} + from querychat import QueryChat + from querychat.data import titanic + + qc = QueryChat(titanic(), "titanic", tools=("query", "update", "visualize_query")) + app = qc.app() + ``` + +Ask something like "Show me survival rate by passenger class as a bar chart" and querychat will generate and display the chart inline: + +![](/images/viz-bar-chart.png){fig-alt="Bar chart showing survival rate by passenger class." class="lightbox shadow rounded mb-3"} + +## Choosing tools + +The `tools` parameter controls which capabilities the LLM has access to. +By default, only `"query"` and `"update"` are enabled — visualization must be opted into explicitly. + +To enable only query and visualization (no dashboard filtering): + +```{.python} +qc = QueryChat(titanic(), "titanic", tools=("query", "visualize_query")) +``` + +See [Tools](tools.qmd) for a full reference on available tools and what each one does. + +## Custom apps + +The example below shows a minimal custom Shiny app using only the `"query"` and `"visualize_query"` tools. +It omits `"update"` to focus entirely on data analysis and visualization rather than dashboard filtering: + +```{.python filename="app.py"} +{{< include /../examples/10-viz-app.py >}} +``` + +## What you can ask for + +querychat can generate a wide range of chart types. +Some example prompts: + +- "Show me a bar chart of survival rate by passenger class" +- "Scatter plot of age vs fare, colored by survival" +- "Line chart of average fare over time" +- "Histogram of passenger ages" +- "Facet survival rate by class and sex" + +The LLM chooses an appropriate chart type based on your question, but you can always be specific. +If you ask for a bar chart, you'll get a bar chart. + +![](/images/viz-scatter.png){fig-alt="Scatter plot of age vs fare colored by survival status." class="lightbox shadow rounded mb-3"} + +::: {.callout-tip} +If you don't like the chart, ask the LLM to adjust it — for example, "make the dots bigger" or "use a log scale on the y-axis". +::: + +## Chart controls + +Each chart has controls in its footer: + +**Fullscreen** — Click the expand icon to view the chart in fullscreen mode. + +![](/images/viz-fullscreen.png){fig-alt="A chart displayed in fullscreen mode." class="lightbox shadow rounded mb-3"} + +**Save** — Download the chart as a PNG or SVG file. + +**Show Query** — Expand the footer to see the ggsql query used to generate the chart. + +![](/images/viz-show-query.png){fig-alt="A chart with the Show Query footer expanded, showing the ggsql query." class="lightbox shadow rounded mb-3"} + +## How it works + +1. **The LLM generates a ggsql query** — a SQL-like grammar that describes both data transformation and visual encoding in a single statement. +2. **The SQL is executed** — querychat runs the data portion of the query against your data source locally. +3. **The VISUALISE clause is rendered** — the result is passed to Altair, which produces a Vega-Lite chart specification. +4. **The chart appears inline** — the chart is streamed back into the conversation as an interactive widget. + +Note that visualization queries are independent of any active dashboard filter set by the `update` tool. +They always run against the full dataset. + +Learn more about the ggsql grammar at [ggsql.org](https://ggsql.org/). + +## See also + +- [Tools](tools.qmd) — Understand what querychat can do under the hood +- [Provide context](context.qmd) — Help the LLM understand your data better diff --git a/pkg-py/examples/10-viz-app.py b/pkg-py/examples/10-viz-app.py index ee38c8c02..5a3af3356 100644 --- a/pkg-py/examples/10-viz-app.py +++ b/pkg-py/examples/10-viz-app.py @@ -10,13 +10,17 @@ tools=("query", "visualize_query"), ) -app_ui = ui.page_fillable( - qc.ui(), -) - - -def server(input, output, session): - qc.server() +#def app_ui(request): +# return ui.page_fillable( +# qc.ui(), +# ) +# +# +#def server(input, output, session): +# qc.server(enable_bookmarking=True) +# +# +#app = App(app_ui, server, bookmark_store="url") -app = App(app_ui, server) +app = qc.app() \ No newline at end of file diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index ed3555ae7..ca490686a 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -301,7 +301,7 @@ def app_server(input: Inputs, output: Outputs, session: Session): self.id, data_source=data_source, greeting=self.greeting, - client=self._client, + client=self.client, enable_bookmarking=enable_bookmarking, tools=self.tools, ) @@ -607,6 +607,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"), data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -623,6 +624,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 = DEFAULT_TOOLS, data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -639,6 +641,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 = DEFAULT_TOOLS, data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -655,6 +658,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 = DEFAULT_TOOLS, data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -671,6 +675,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 = DEFAULT_TOOLS, data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -686,6 +691,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 = DEFAULT_TOOLS, data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -705,6 +711,7 @@ def __init__( table_name, greeting=greeting, client=client, + tools=tools, data_description=data_description, categorical_threshold=categorical_threshold, extra_instructions=extra_instructions, @@ -732,7 +739,7 @@ def __init__( self.id, data_source=self._data_source, greeting=self.greeting, - client=self._client, + client=self.client, enable_bookmarking=enable, tools=self.tools, ) @@ -875,4 +882,3 @@ def title(self, value: Optional[str] = None) -> str | None | bool: return self._vals.title() else: return self._vals.title.set(value) - diff --git a/pkg-py/src/querychat/_shiny_module.py b/pkg-py/src/querychat/_shiny_module.py index 17bf75cea..7ef210e4c 100644 --- a/pkg-py/src/querychat/_shiny_module.py +++ b/pkg-py/src/querychat/_shiny_module.py @@ -1,10 +1,9 @@ from __future__ import annotations -import copy import warnings from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Generic, Union +from typing import TYPE_CHECKING, Generic, TypedDict, Union import chatlas import shinychat @@ -13,13 +12,9 @@ from shiny import module, reactive, ui from ._querychat_core import GREETING_PROMPT +from ._viz_altair_widget import AltairWidget +from ._viz_ggsql import execute_ggsql 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 @@ -31,16 +26,36 @@ from ._datasource import DataSource from ._querychat_base import TOOL_GROUPS from ._viz_tools import VisualizeQueryData - from .tools import UpdateDashboardData + from .types import UpdateDashboardData ReactiveString = reactive.Value[str] """A reactive string value.""" ReactiveStringOrNone = reactive.Value[Union[str, None]] """A reactive string (or None) value.""" + +class VizWidgetEntry(TypedDict): + """A bookmarked visualization widget: enough state to re-register on restore.""" + + widget_id: str + ggsql: str + + CHAT_ID = "chat" +class _DeferredStubChatClient: + """Placeholder chat client for deferred stub sessions.""" + + def __getattr__(self, _name: str): + raise RuntimeError( + "Chat client is unavailable during stub session before data_source is set." + ) + + +ServerClient = chatlas.Chat | _DeferredStubChatClient + + @module.ui def mod_ui(*, preload_viz: bool = False, **kwargs): css_path = Path(__file__).parent / "static" / "css" / "styles.css" @@ -49,18 +64,14 @@ def mod_ui(*, preload_viz: bool = False, **kwargs): tag = shinychat.chat_ui(CHAT_ID, **kwargs) tag.add_class("querychat") - children: list[ui.TagChild] = [ + return ui.TagList( 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) + preload_viz_deps_ui() if preload_viz else None, + ) @dataclass @@ -89,16 +100,17 @@ class ServerValues(Generic[IntoFrameT]): `.title()`, or set it with `.title.set("...")`. Returns `None` if no title has been set. client - 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. + Session chat client value. + For real sessions this is a `chatlas.Chat` created by the client + factory. For deferred stub sessions (where `data_source` is not set + yet), this is a placeholder client that raises when accessed. """ df: Callable[[], IntoFrameT] sql: ReactiveStringOrNone title: ReactiveStringOrNone - client: chatlas.Chat + client: ServerClient @module.server @@ -109,7 +121,7 @@ def mod_server( *, data_source: DataSource[IntoFrameT] | None, greeting: str | None, - client: chatlas.Chat | Callable, + client: Callable[..., chatlas.Chat], enable_bookmarking: bool, tools: tuple[TOOL_GROUPS, ...] | None = None, ) -> ServerValues[IntoFrameT]: @@ -118,9 +130,29 @@ def mod_server( 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) + if not callable(client): + raise TypeError("mod_server() requires a callable client factory.") + + def update_dashboard(data: UpdateDashboardData): + sql.set(data["query"]) + title.set(data["title"]) + + def reset_dashboard(): + sql.set(None) + title.set(None) + + viz_widgets: list[VizWidgetEntry] = [] + + def on_visualize(data: VisualizeQueryData): + viz_widgets.append({"widget_id": data["widget_id"], "ggsql": data["ggsql"]}) + + def build_chat_client() -> chatlas.Chat: + return client( + update_dashboard=update_dashboard, + reset_dashboard=reset_dashboard, + visualize_query=on_visualize, + tools=tools, + ) # Short-circuit for stub sessions (e.g. 1st run of an Express app) # data_source may be None during stub session for deferred pattern @@ -129,11 +161,15 @@ def mod_server( def _stub_df(): raise RuntimeError("RuntimeError: No current reactive context") + stub_client = ( + _DeferredStubChatClient() if data_source is None else build_chat_client() + ) + return ServerValues( df=_stub_df, sql=sql, title=title, - client=client if isinstance(client, chatlas.Chat) else client(), + client=stub_client, ) # Real session requires data_source @@ -143,36 +179,8 @@ def _stub_df(): "Set it via the data_source property before users connect." ) - def update_dashboard(data: UpdateDashboardData): - sql.set(data["query"]) - title.set(data["title"]) - - 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, - visualize_query=update_query_viz, - ) - else: - # Legacy pattern: client is Chat instance - chat = copy.deepcopy(client) - - chat.register_tool(tool_update_dashboard(data_source, update_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)) + # Build the session-specific chat client through QueryChat.client(...). + chat = build_chat_client() if has_viz_tool(tools): preload_viz_deps_server() @@ -181,20 +189,8 @@ def update_query_viz(data: VisualizeQueryData): @reactive.calc def filtered_df(): query = sql.get() - 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 + df = data_source.get_data() if not query else data_source.execute_query(query) + return df # Chat UI logic chat_ui = shinychat.Chat(CHAT_ID) @@ -251,8 +247,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() + if viz_widgets: + vals["querychat_viz_widgets"] = viz_widgets @session.bookmark.on_restore def _on_restore(x: RestoreState) -> None: @@ -263,18 +259,44 @@ def _on_restore(x: RestoreState) -> None: title.set(vals["querychat_title"]) if "querychat_has_greeted" in vals: has_greeted.set(vals["querychat_has_greeted"]) - 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, - ) + if "querychat_viz_widgets" in vals: + restored = restore_viz_widgets( + data_source, vals["querychat_viz_widgets"] + ) + viz_widgets[:] = restored + + return ServerValues(df=filtered_df, sql=sql, title=title, client=chat) class GreetWarning(Warning): """Warning raised when no greeting is provided to QueryChat.""" + + +def restore_viz_widgets( + data_source: DataSource[IntoFrameT], + saved_widgets: list[VizWidgetEntry], +) -> list[VizWidgetEntry]: + """Re-execute ggsql queries, register widgets, and return restored entries.""" + from ggsql import validate + from shinywidgets import register_widget + + restored: list[VizWidgetEntry] = [] + + for entry in saved_widgets: + widget_id = entry["widget_id"] + ggsql_str = entry["ggsql"] + try: + validated = validate(ggsql_str) + spec = execute_ggsql(data_source, validated) + altair_widget = AltairWidget.from_ggsql(spec, widget_id=widget_id) + register_widget(widget_id, altair_widget.widget) + restored.append(entry) + except Exception: + # If a query fails on restore (e.g. data changed), skip it. + # The placeholder will remain empty but the rest of the chat restores. + warnings.warn( + f"Failed to restore visualization widget '{widget_id}' on bookmark restore.", + stacklevel=2, + ) + + return restored diff --git a/pkg-py/src/querychat/_system_prompt.py b/pkg-py/src/querychat/_system_prompt.py index b742ce4d4..a18630153 100644 --- a/pkg-py/src/querychat/_system_prompt.py +++ b/pkg-py/src/querychat/_system_prompt.py @@ -8,7 +8,7 @@ from ._viz_utils import has_viz_tool -SCHEMA_TAG_RE = re.compile(r"\{\{[{#^/]?\s*schema\b") +_SCHEMA_TAG_RE = re.compile(r"\{\{[{#^/]?\s*schema\b") if TYPE_CHECKING: from ._datasource import DataSource @@ -52,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 ) @@ -89,4 +89,10 @@ def render(self, tools: tuple[TOOL_GROUPS, ...] | None) -> str: "include_query_guidelines": len(tools or ()) > 0, } - return chevron.render(self.template, context) + prompts_dir = str(Path(__file__).parent / "prompts") + return chevron.render( + self.template, + context, + partials_path=prompts_dir, + partials_ext="md", + ) diff --git a/pkg-py/src/querychat/_utils.py b/pkg-py/src/querychat/_utils.py index aec6aecef..082860347 100644 --- a/pkg-py/src/querychat/_utils.py +++ b/pkg-py/src/querychat/_utils.py @@ -304,3 +304,14 @@ def to_polars(data: IntoFrame) -> pl.DataFrame: if isinstance(nw_df, nw.LazyFrame): nw_df = nw_df.collect() return nw_df.to_polars() + + +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) diff --git a/pkg-py/src/querychat/_viz_altair_widget.py b/pkg-py/src/querychat/_viz_altair_widget.py index eec22884f..14f40202c 100644 --- a/pkg-py/src/querychat/_viz_altair_widget.py +++ b/pkg-py/src/querychat/_viz_altair_widget.py @@ -2,7 +2,6 @@ from __future__ import annotations -import functools from typing import TYPE_CHECKING, Any, cast from uuid import uuid4 @@ -14,18 +13,6 @@ 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. @@ -42,24 +29,32 @@ class AltairWidget: widget: alt.JupyterChart widget_id: str - def __init__(self, chart: alt.TopLevelMixin) -> None: + def __init__( + self, + chart: alt.TopLevelMixin, + *, + widget_id: str | None = None, + ) -> None: import altair as alt - is_compound = isinstance(chart, get_compound_chart_types()) + is_compound = isinstance( + chart, + (alt.FacetChart, alt.ConcatChart, alt.HConcatChart, alt.VConcatChart), + ) # 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 = fit_chart_to_container( 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]}" + self.widget_id = widget_id or f"querychat_viz_{uuid4().hex[:8]}" # Reactively update compound cell sizes when the container resizes. # Also part of the compound sizing workaround (issue #238). @@ -67,11 +62,13 @@ def __init__(self, chart: alt.TopLevelMixin) -> None: self._setup_reactive_sizing(self.widget, self.widget_id) @classmethod - def from_ggsql(cls, spec: ggsql.Spec) -> AltairWidget: + def from_ggsql( + cls, spec: ggsql.Spec, *, widget_id: str | None = None + ) -> AltairWidget: from ggsql import VegaLiteWriter writer = VegaLiteWriter() - return cls(writer.render_chart(spec)) + return cls(writer.render_chart(spec), widget_id=widget_id) @staticmethod def _setup_reactive_sizing(widget: alt.JupyterChart, widget_id: str) -> None: @@ -89,7 +86,7 @@ def _sizing_effect(): if chart is None: return chart = cast("alt.Chart", chart) - chart2 = inject_compound_sizes(chart, int(width), int(height)) + chart2 = fit_chart_to_container(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. @@ -111,23 +108,20 @@ def _sizing_effect(): DEFAULT_COMPOUND_HEIGHT = 450 LEGEND_CHANNELS = frozenset( - {"color", "colour", "fill", "stroke", "shape", "size", "opacity"} + {"color", "fill", "stroke", "shape", "size", "opacity"} ) LEGEND_WIDTH = 120 # approximate space for a right-side legend -def inject_compound_sizes( +def fit_chart_to_container( chart: alt.TopLevelMixin, container_width: int, container_height: int, ) -> alt.TopLevelMixin: """ - Set cell ``width``/``height`` on a compound spec via in-place mutation. + Return a copy of ``chart`` with cell ``width``/``height`` set. - 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. + The original chart is never mutated. For faceted charts, divides the container width by the number of columns. For hconcat/concat, divides by the number of sub-specs. @@ -136,8 +130,12 @@ def inject_compound_sizes( Subtracts padding estimates so the rendered cells fill the container, including space for legends when present. """ + import copy + import altair as alt + chart = copy.copy(chart) + # 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 diff --git a/pkg-py/src/querychat/_viz_ggsql.py b/pkg-py/src/querychat/_viz_ggsql.py index bf119b4b1..cc3e8d8b0 100644 --- a/pkg-py/src/querychat/_viz_ggsql.py +++ b/pkg-py/src/querychat/_viz_ggsql.py @@ -13,20 +13,19 @@ from ._datasource import DataSource -def execute_ggsql(data_source: DataSource, query: str) -> ggsql.Spec: +def execute_ggsql(data_source: DataSource, validated: ggsql.Validated) -> ggsql.Spec: """ - Execute a full ggsql query against a DataSource, returning a Spec. + Execute a pre-validated 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. + Executes the SQL portion 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). + validated + A pre-validated ggsql query (from ``ggsql.validate()``). Returns ------- @@ -34,19 +33,19 @@ def execute_ggsql(data_source: DataSource, query: str) -> ggsql.Spec: The writer-independent plot specification. """ - import ggsql as _ggsql + from ggsql import DuckDBReader - validated = _ggsql.validate(query) pl_df = to_polars(data_source.execute_query(validated.sql())) - reader = _ggsql.DuckDBReader("duckdb://memory") + reader = 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) + name = table[1:-1] if table.startswith('"') and table.endswith('"') else table + reader.register(name, pl_df) return reader.execute(visual) else: # SELECT ... VISUALISE — no FROM in VISUALISE clause, so register diff --git a/pkg-py/src/querychat/_viz_tools.py b/pkg-py/src/querychat/_viz_tools.py index a21d49658..608add8d0 100644 --- a/pkg-py/src/querychat/_viz_tools.py +++ b/pkg-py/src/querychat/_viz_tools.py @@ -2,6 +2,9 @@ from __future__ import annotations +import base64 +import copy +import io from typing import TYPE_CHECKING, Any, TypedDict from uuid import uuid4 @@ -13,10 +16,12 @@ from .__version import __version__ from ._icons import bs_icon +from ._utils import read_prompt_template if TYPE_CHECKING: from collections.abc import Callable + import altair as alt from ipywidgets.widgets.widget import Widget from ._datasource import DataSource @@ -35,12 +40,15 @@ class VisualizeQueryData(TypedDict): ggsql The full ggsql query string (SQL + VISUALISE). title - A descriptive title for the visualization, or None if not provided. + A descriptive title for the visualization. + widget_id + The unique widget ID used to register the visualization with shinywidgets. """ ggsql: str - title: str | None + title: str + widget_id: str def tool_visualize_query( @@ -84,19 +92,31 @@ def __init__( widget_id: str, widget: Widget, ggsql_str: str, - title: str | None, + title: str, row_count: int, col_count: int, + column_names: list[str], + png_bytes: bytes | None = None, **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." + cols_str = ", ".join(column_names) + title_display = f" with title '{title}'" if title else "" + text = f"Chart displayed{title_display}. Data: {row_count} rows, {col_count} columns: {cols_str}." + + if png_bytes is not None: + from chatlas._content import ContentImageInline + + png_b64 = base64.b64encode(png_bytes).decode("ascii") + value: Any = [ + text, + ContentImageInline(image_content_type="image/png", data=png_b64), + ] + else: + value = text footer = build_viz_footer(ggsql_str, title, widget_id) @@ -116,7 +136,7 @@ def __init__( ), } - super().__init__(value=markdown, extra=extra, **kwargs) + super().__init__(value=value, model_format="as_is", extra=extra, **kwargs) # --------------------------------------------------------------------------- @@ -127,22 +147,22 @@ def __init__( def visualize_query_impl( data_source: DataSource, update_fn: Callable[[VisualizeQueryData], None], -) -> Callable[[str, str | None], ContentToolResult]: +) -> Callable[[str, str], ContentToolResult]: """Create the visualize_query implementation function.""" - import ggsql as ggsql_pkg + from ggsql import validate from ._viz_altair_widget import AltairWidget from ._viz_ggsql import execute_ggsql def visualize_query( ggsql: str, - title: str | None = None, + title: str, ) -> ContentToolResult: """Execute a ggsql query and render the visualization.""" markdown = f"```sql\n{ggsql}\n```" try: - validated = ggsql_pkg.validate(ggsql) + validated = validate(ggsql) if not validated.has_visual(): # When VISUALISE contains SQL expressions (e.g., CAST()), # ggsql silently treats the entire query as plain SQL: @@ -150,7 +170,9 @@ def visualize_query( # 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() + has_keyword = ( + "VISUALISE" in ggsql.upper() or "VISUALIZE" in ggsql.upper() + ) if has_keyword: raise ValueError( "VISUALISE clause was not recognized. " @@ -164,14 +186,28 @@ def visualize_query( "Use querychat_query for queries without visualization." ) - spec = execute_ggsql(data_source, ggsql) + spec = execute_ggsql(data_source, validated) altair_widget = AltairWidget.from_ggsql(spec) metadata = spec.metadata() row_count = metadata["rows"] - col_count = len(metadata["columns"]) - update_fn({"ggsql": ggsql, "title": title}) + # Render a static PNG thumbnail for LLM feedback. + # render_chart needs a fresh Altair chart (AltairWidget mutates its copy). + from ggsql import VegaLiteWriter + + raw_chart = VegaLiteWriter().render_chart(spec) + column_names = _extract_column_names(raw_chart) + col_count = len(column_names) + + try: + png_bytes = render_chart_to_png(raw_chart) + except Exception: + png_bytes = None + + update_fn( + {"ggsql": ggsql, "title": title, "widget_id": altair_widget.widget_id} + ) return VisualizeQueryResult( widget_id=altair_widget.widget_id, @@ -180,6 +216,8 @@ def visualize_query( title=title, row_count=row_count, col_count=col_count, + column_names=column_names, + png_bytes=png_bytes, ) except Exception as e: @@ -190,15 +228,44 @@ def visualize_query( return visualize_query -def read_prompt_template(filename: str, **kwargs: object) -> str: - """Read and interpolate a prompt template file.""" - from pathlib import Path +def _extract_column_names(chart: alt.TopLevelMixin) -> list[str]: + """Extract user-facing column names from a VegaLite chart's encoding titles.""" + chart_dict = chart.to_dict() + layers = chart_dict.get("layer", [chart_dict]) + seen: list[str] = [] + for layer in layers: + enc = layer.get("encoding", {}) + for ch_val in enc.values(): + if isinstance(ch_val, dict): + title = ch_val.get("title") + if title and title not in seen: + seen.append(title) + return seen - import chevron - template_path = Path(__file__).parent / "prompts" / filename - template = template_path.read_text() - return chevron.render(template, kwargs) +PNG_WIDTH = 500 +PNG_HEIGHT = 300 + + +def render_chart_to_png(chart: alt.TopLevelMixin) -> bytes: + """Render an Altair chart to PNG bytes at a fixed size for LLM feedback.""" + import altair as alt + + from ._viz_altair_widget import fit_chart_to_container + + chart = copy.deepcopy(chart) + is_compound = isinstance( + chart, + (alt.FacetChart, alt.ConcatChart, alt.HConcatChart, alt.VConcatChart), + ) + if is_compound: + chart = fit_chart_to_container(chart, PNG_WIDTH, PNG_HEIGHT) + else: + chart = chart.properties(width=PNG_WIDTH, height=PNG_HEIGHT) + + buf = io.BytesIO() + chart.save(buf, format="png", scale_factor=1) + return buf.getvalue() def viz_dep() -> HTMLDependency: @@ -217,7 +284,7 @@ def viz_dep() -> HTMLDependency: def build_viz_footer( ggsql_str: str, - title: str | None, + title: str, widget_id: str, ) -> TagList: """Build footer HTML for visualization tool results.""" @@ -277,7 +344,7 @@ def build_viz_footer( { "class": "querychat-save-png-btn", "data-widget-id": widget_id, - "data-title": title or "chart", + "data-title": title, }, "Save as PNG", ), @@ -285,7 +352,7 @@ def build_viz_footer( { "class": "querychat-save-svg-btn", "data-widget-id": widget_id, - "data-title": title or "chart", + "data-title": title, }, "Save as SVG", ), diff --git a/pkg-py/src/querychat/_viz_utils.py b/pkg-py/src/querychat/_viz_utils.py index cf7be61e7..eafe8631e 100644 --- a/pkg-py/src/querychat/_viz_utils.py +++ b/pkg-py/src/querychat/_viz_utils.py @@ -8,23 +8,14 @@ def has_viz_tool(tools: tuple[str, ...] | None) -> bool: 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 + """Check whether visualization dependencies (ggsql, altair, shinywidgets, vl-convert-python) are installed.""" + import importlib.util + return all( + importlib.util.find_spec(pkg) is not None + for pkg in ("ggsql", "altair", "shinywidgets", "vl_convert") + ) PRELOAD_WIDGET_ID = "__querychat_preload_viz__" @@ -34,6 +25,7 @@ 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;", @@ -44,11 +36,13 @@ def preload_viz_deps_ui(): 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/ggsql-syntax.md b/pkg-py/src/querychat/prompts/ggsql-syntax.md new file mode 100644 index 000000000..99cc45173 --- /dev/null +++ b/pkg-py/src/querychat/prompts/ggsql-syntax.md @@ -0,0 +1,506 @@ +## 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. diff --git a/pkg-py/src/querychat/prompts/prompt.md b/pkg-py/src/querychat/prompts/prompt.md index f15d6edb0..3d3004901 100644 --- a/pkg-py/src/querychat/prompts/prompt.md +++ b/pkg-py/src/querychat/prompts/prompt.md @@ -204,7 +204,78 @@ You might want to explore the advanced features {{#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. +You can create visualizations using the `visualize_query` tool, which uses ggsql — a SQL extension for declarative data visualization. + +### Visualization best practices + +The database schema in this prompt includes column names, types, and summary statistics. If that context isn't sufficient for a confident visualization — e.g., you're unsure about value distributions, need to check for NULLs, or want to gauge row counts before choosing a chart type — use the `query` tool (if available) to inspect the data before visualizing. Pass `collapsed=True` for these preparatory queries so the results don't clutter the conversation. + +Follow the principles below to produce clear, interpretable charts. + +#### Axis labels must be readable + +When the x-axis contains categorical labels (names, categories, long strings), prefer flipping axes with `PROJECT y, x TO cartesian` so labels read naturally left-to-right. Short numeric or date labels on the x-axis are fine horizontal — this applies specifically to text categories. + +#### Always include axis labels with units + +Charts should be interpretable without reading the surrounding prose. Always include axis labels that describe what is shown, including units when applicable (e.g., `LABEL y => 'Revenue ($M)'`, not just `LABEL y => 'Revenue'`). + +#### Maximize data-ink ratio + +Every visual element should serve a purpose: + +- Don't map columns to aesthetics (color, size, shape) unless the distinction is meaningful to the user's question. A single-series bar chart doesn't need color. +- When using color for categories, keep to 7 or fewer distinct values. Beyond that, consider filtering to the most important categories or using facets instead. +- Avoid dual-encoding the same variable (e.g., mapping the same column to both x-position and color) unless it genuinely aids interpretation. + +#### Avoid overplotting + +When a dataset has many rows, plotting one mark per row creates clutter that obscures patterns. Before generating a query, consider the row count and data characteristics visible in the schema. + +**For large datasets (hundreds+ rows):** + +- **Aggregate first**: Use `GROUP BY` with `COUNT`, `AVG`, `SUM`, or other aggregates to reduce to meaningful summaries before visualizing. +- **Choose chart types that summarize naturally**: histograms for distributions, boxplots for group comparisons, line charts for trends over time. + +**For two numeric variables with many rows:** + +Bin in SQL and use `DRAW rect` to create a heatmap: + +```sql +WITH binned AS ( + SELECT ROUND(x_col / 5) * 5 AS x_bin, + ROUND(y_col / 5) * 5 AS y_bin, + COUNT(*) AS n + FROM large_table + GROUP BY x_bin, y_bin +) +SELECT * FROM binned +VISUALISE x_bin AS x, y_bin AS y, n AS fill +DRAW rect +SCALE fill TO viridis +``` + +**If individual points matter** (e.g., outlier detection): use `SETTING opacity` to reveal density through overlap. + +#### Choose chart types based on the data relationship + +Match the chart type to what the user is trying to understand: + +- **Comparison across categories**: bar chart (`DRAW bar`, with `PROJECT y, x TO cartesian` for long labels). Order bars by value, not alphabetically. +- **Trend over time**: line chart (`DRAW line`). Use `SCALE x VIA date` for date columns. +- **Distribution of a single variable**: histogram (`DRAW histogram`) or density (`DRAW density`). +- **Relationship between two numeric variables**: scatter plot (`DRAW point`), but prefer aggregation or heatmap if the dataset is large. +- **Part-of-whole**: stacked bar chart (map subcategory to `fill`). Avoid pie charts — position along a common scale is easier to decode than angle. + +### Graceful recovery + +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 + +The syntax reference below covers all available clauses, geom types, scales, and examples. + +{{> ggsql-syntax}} {{/has_tool_visualize_query}} ## Important Guidelines @@ -212,6 +283,7 @@ You can create visualizations using the `visualize_query` tool, which uses ggsql - **Ask for clarification** if any request is unclear or ambiguous - **Be concise** due to the constrained interface - **Only answer data questions using your tools** - never use prior knowledge or assumptions about the data, even if the dataset seems familiar +- **Be skeptical of your own interpretations** - when describing chart results or data patterns, encourage the user to verify findings rather than presenting analytical conclusions as fact - **Use Markdown tables** for any tabular or structured data in your responses {{#extra_instructions}} diff --git a/pkg-py/src/querychat/prompts/tool-query.md b/pkg-py/src/querychat/prompts/tool-query.md index ef07bde3a..2acc7e3f1 100644 --- a/pkg-py/src/querychat/prompts/tool-query.md +++ b/pkg-py/src/querychat/prompts/tool-query.md @@ -26,6 +26,8 @@ Parameters ---------- query : A valid {{db_type}} SQL SELECT statement. Must follow the database schema provided in the system prompt. Use clear column aliases (e.g., 'AVG(price) AS avg_price') and include SQL comments for complex logic. Subqueries and CTEs are encouraged for readability. +collapsed : + Optional. Set to true for exploratory or preparatory queries (e.g., inspecting data before visualization, checking row counts, previewing column values) whose results aren't the primary answer. When true, the result card starts collapsed so it doesn't clutter the conversation. _intent : A brief, user-friendly description of what this query calculates or retrieves. diff --git a/pkg-py/src/querychat/prompts/tool-visualize-query.md b/pkg-py/src/querychat/prompts/tool-visualize-query.md index 4c7295c90..f8e177510 100644 --- a/pkg-py/src/querychat/prompts/tool-visualize-query.md +++ b/pkg-py/src/querychat/prompts/tool-visualize-query.md @@ -1,540 +1,13 @@ -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. +Render a ggsql query inline in the chat. See the "Visualization with ggsql" section of the system prompt for usage guidance, best practices, and the ggsql syntax reference. 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. + 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. + If successful, a static image of the rendered plot. If not, an error message. diff --git a/pkg-py/src/querychat/static/css/viz.css b/pkg-py/src/querychat/static/css/viz.css index fa1faf50d..1b5812bc1 100644 --- a/pkg-py/src/querychat/static/css/viz.css +++ b/pkg-py/src/querychat/static/css/viz.css @@ -125,7 +125,7 @@ /* shinychat sets max-height:500px on all cards, which is too small for viz+editor */ -.card:has(.querychat-viz-container) { +.shiny-tool-card:has(.querychat-viz-container) { max-height: 700px; overflow: hidden; } diff --git a/pkg-py/src/querychat/tools.py b/pkg-py/src/querychat/tools.py index f8c3c2842..5336df5a2 100644 --- a/pkg-py/src/querychat/tools.py +++ b/pkg-py/src/querychat/tools.py @@ -1,14 +1,17 @@ from __future__ import annotations -from pathlib import Path from typing import TYPE_CHECKING, Any, Protocol, TypedDict, runtime_checkable -import chevron from chatlas import ContentToolResult, Tool from shinychat.types import ToolResultDisplay from ._icons import bs_icon -from ._utils import as_narwhals, df_to_html, querychat_tool_starts_open +from ._utils import ( + as_narwhals, + df_to_html, + querychat_tool_starts_open, + read_prompt_template, +) from ._viz_tools import tool_visualize_query __all__ = [ @@ -77,13 +80,6 @@ def log_update(data: UpdateDashboardData): title: str -def _read_prompt_template(filename: str, **kwargs) -> str: - """Read and interpolate a prompt template file.""" - template_path = Path(__file__).parent / "prompts" / filename - template = template_path.read_text() - return chevron.render(template, kwargs) - - def _update_dashboard_impl( data_source: DataSource, update_fn: Callable[[UpdateDashboardData], None], @@ -154,7 +150,7 @@ def tool_update_dashboard( """ impl = _update_dashboard_impl(data_source, update_fn) - description = _read_prompt_template( + description = read_prompt_template( "tool-update-dashboard.md", db_type=data_source.get_db_type(), ) @@ -220,7 +216,7 @@ def tool_reset_dashboard( """ impl = _reset_dashboard_impl(reset_fn) - description = _read_prompt_template("tool-reset-dashboard.md") + description = read_prompt_template("tool-reset-dashboard.md") impl.__doc__ = description return Tool.from_func( @@ -230,10 +226,14 @@ def tool_reset_dashboard( ) -def _query_impl(data_source: DataSource) -> Callable[[str, str], ContentToolResult]: +def _query_impl(data_source: DataSource) -> Callable[..., ContentToolResult]: """Create the implementation function for querying data.""" - def query(query: str, _intent: str = "") -> ContentToolResult: + def query( + query: str, + collapsed: bool | None = None, # noqa: FBT001 (LLM tool parameter) + _intent: str = "", + ) -> ContentToolResult: error = None markdown = f"```sql\n{query}\n```" value = None @@ -258,7 +258,9 @@ def query(query: str, _intent: str = "") -> ContentToolResult: "display": ToolResultDisplay( markdown=markdown, show_request=False, - open=querychat_tool_starts_open("query"), + open=(not collapsed) + if collapsed is not None + else querychat_tool_starts_open("query"), icon=bs_icon("table"), ), }, @@ -284,7 +286,7 @@ def tool_query(data_source: DataSource) -> Tool: """ impl = _query_impl(data_source) - description = _read_prompt_template( + description = read_prompt_template( "tool-query.md", db_type=data_source.get_db_type() ) impl.__doc__ = description diff --git a/pkg-py/tests/playwright/apps/viz_bookmark_app.py b/pkg-py/tests/playwright/apps/viz_bookmark_app.py new file mode 100644 index 000000000..6552bfa8a --- /dev/null +++ b/pkg-py/tests/playwright/apps/viz_bookmark_app.py @@ -0,0 +1,25 @@ +"""Test app for viz bookmark restore: uses server-side bookmarking to avoid URL length limits.""" + +from querychat import QueryChat +from querychat.data import titanic + +from shiny import App, ui + +qc = QueryChat( + titanic(), + "titanic", + tools=("query", "visualize_query"), +) + + +def app_ui(request): + return ui.page_fillable( + qc.ui(), + ) + + +def server(input, output, session): + qc.server(enable_bookmarking=True) + + +app = App(app_ui, server, bookmark_store="server") diff --git a/pkg-py/tests/playwright/test_10_viz_inline.py b/pkg-py/tests/playwright/test_10_viz_inline.py index e21e35f17..857e860cb 100644 --- a/pkg-py/tests/playwright/test_10_viz_inline.py +++ b/pkg-py/tests/playwright/test_10_viz_inline.py @@ -40,12 +40,12 @@ def test_viz_tool_renders_inline_chart(self) -> None: 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]") + tool_card = self.page.locator(".shiny-tool-result:has(.tool-fullscreen-toggle)") 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) + # The card should contain the viz container (Altair chart via shinywidgets) + viz_container = tool_card.locator(".querychat-viz-container") + expect(viz_container).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.""" @@ -55,7 +55,7 @@ def test_fullscreen_button_visible_on_viz_card(self) -> None: self.chat.send_user_input(method="click") # Wait for viz tool result - tool_card = self.page.locator("shiny-tool-result[full-screen]") + tool_card = self.page.locator(".shiny-tool-result:has(.tool-fullscreen-toggle)") expect(tool_card).to_be_visible(timeout=90000) # Fullscreen toggle should be visible @@ -70,7 +70,7 @@ def test_fullscreen_toggle_expands_card(self) -> None: self.chat.send_user_input(method="click") # Wait for viz tool result - tool_result = self.page.locator("shiny-tool-result[full-screen]") + tool_result = self.page.locator(".shiny-tool-result:has(.tool-fullscreen-toggle)") expect(tool_result).to_be_visible(timeout=90000) # Click fullscreen toggle @@ -89,7 +89,7 @@ def test_escape_closes_fullscreen(self) -> None: self.chat.send_user_input(method="click") # Wait for viz tool result - tool_result = self.page.locator("shiny-tool-result[full-screen]") + tool_result = self.page.locator(".shiny-tool-result:has(.tool-fullscreen-toggle)") expect(tool_result).to_be_visible(timeout=90000) # Enter fullscreen @@ -111,9 +111,9 @@ def test_non_viz_tool_results_have_no_fullscreen(self) -> None: self.chat.send_user_input(method="click") # Wait for a tool result (any) - tool_result = self.page.locator("shiny-tool-result").first + 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]") + # Non-viz tool results should NOT have fullscreen toggle + fs_results = self.page.locator(".shiny-tool-result:has(.tool-fullscreen-toggle)") 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 index 09691e198..6812a2f88 100644 --- a/pkg-py/tests/playwright/test_11_viz_footer.py +++ b/pkg-py/tests/playwright/test_11_viz_footer.py @@ -35,7 +35,7 @@ def _send_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( + page.locator(".shiny-tool-result:has(.tool-fullscreen-toggle)").wait_for( state="visible", timeout=TOOL_RESULT_TIMEOUT ) # Wait for the footer buttons to appear inside the card @@ -159,7 +159,7 @@ class TestVizFooterScreenshots: 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 = page.locator(".shiny-tool-result:has(.tool-fullscreen-toggle)") card.screenshot(path="test-results/viz-footer-default.png") def test_footer_query_expanded(self, page: Page) -> None: @@ -168,7 +168,7 @@ def test_footer_query_expanded(self, page: Page) -> None: btn.click() page.wait_for_timeout(300) # wait for CSS transition - card = page.locator("shiny-tool-result[full-screen]") + card = page.locator(".shiny-tool-result:has(.tool-fullscreen-toggle)") card.screenshot(path="test-results/viz-footer-query-expanded.png") def test_footer_save_menu_open(self, page: Page) -> None: @@ -176,5 +176,5 @@ def test_footer_save_menu_open(self, page: Page) -> None: btn = page.locator(".querychat-save-btn") btn.click() - card = page.locator("shiny-tool-result[full-screen]") + card = page.locator(".shiny-tool-result:has(.tool-fullscreen-toggle)") card.screenshot(path="test-results/viz-footer-save-menu-open.png") diff --git a/pkg-py/tests/playwright/test_12_viz_bookmark.py b/pkg-py/tests/playwright/test_12_viz_bookmark.py new file mode 100644 index 000000000..7683b4355 --- /dev/null +++ b/pkg-py/tests/playwright/test_12_viz_bookmark.py @@ -0,0 +1,136 @@ +""" +Playwright tests for visualization bookmark restore behavior. + +These tests verify that when a user creates a visualization and then +restores from a bookmark URL, the chart widget is properly re-rendered +(not just the empty HTML shell). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +from playwright.sync_api import expect + +if TYPE_CHECKING: + from collections.abc import Generator + + from playwright.sync_api import BrowserContext, Page + from shinychat.playwright import ChatController as ChatControllerType + +import sys + +# conftest.py is not importable directly; add the test directory to sys.path +sys.path.insert(0, str(Path(__file__).parent)) +from conftest import ( + _create_chat_controller, + _find_free_port, + _start_server_with_retry, + _start_shiny_app_threaded, + _stop_shiny_server, +) + +VIZ_PROMPT = "Use the visualize tool to create a scatter plot of age vs fare" +TOOL_RESULT_TIMEOUT = 90_000 +APPS_DIR = Path(__file__).parent / "apps" + + +@pytest.fixture(scope="module") +def app_viz_bookmark() -> Generator[str, None, None]: + """Start the viz bookmark test app with server-side bookmarking.""" + app_path = str(APPS_DIR / "viz_bookmark_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_viz_bookmark(page: Page) -> ChatControllerType: + return _create_chat_controller(page, "titanic") + + +class TestVizBookmarkRestore: + """Tests for visualization restoration from bookmark URLs.""" + + @pytest.fixture(autouse=True) + def setup( + self, page: Page, app_viz_bookmark: str, chat_viz_bookmark: ChatControllerType + ) -> None: + """Navigate to the viz app and create a viz before each test.""" + self.app_url = app_viz_bookmark + self.page = page + self.chat = chat_viz_bookmark + + page.goto(app_viz_bookmark) + page.wait_for_selector("shiny-chat-container", timeout=30_000) + + # Wait for the greeting bookmark URL to be set first + # (bookmark_on="response" auto-bookmarks after greeting) + page.wait_for_function( + "() => window.location.search.includes('_state_id_=')", + timeout=30_000, + ) + self.greeting_url = page.url + + # Create a visualization + chat_viz_bookmark.set_user_input(VIZ_PROMPT) + chat_viz_bookmark.send_user_input(method="click") + + # Wait for the viz tool result to fully render + page.locator(".shiny-tool-result:has(.tool-fullscreen-toggle)").wait_for( + state="visible", timeout=TOOL_RESULT_TIMEOUT + ) + page.locator(".querychat-footer-buttons").wait_for( + state="visible", timeout=10_000 + ) + + def _wait_for_viz_bookmark_url(self) -> str: + """Wait for the URL to update from the greeting bookmark to the viz bookmark.""" + greeting_search = self.greeting_url.split("?", 1)[1] if "?" in self.greeting_url else "" + self.page.wait_for_function( + "(greetingSearch) => window.location.search.includes('_state_id_=') && window.location.search !== '?' + greetingSearch", + arg=greeting_search, + timeout=30_000, + ) + return self.page.url + + def test_bookmark_url_updates_after_viz(self) -> None: + """BOOKMARK-VIZ-URL: URL should update with new state ID after viz is created.""" + url = self._wait_for_viz_bookmark_url() + assert url != self.greeting_url, "URL should have changed after viz bookmarking" + + def test_viz_widget_renders_on_bookmark_restore(self, context: BrowserContext) -> None: + """BOOKMARK-VIZ-RESTORE: Restored bookmark should re-render the chart widget, not just the HTML shell.""" + bookmark_url = self._wait_for_viz_bookmark_url() + + # Open the bookmark URL in a new page (new session) + new_page = context.new_page() + new_page.goto(bookmark_url) + new_page.wait_for_selector("shiny-chat-container", timeout=30_000) + + # The viz container HTML should be restored (shinychat restores message HTML) + viz_container = new_page.locator(".querychat-viz-container") + expect(viz_container).to_be_visible(timeout=30_000) + + # The critical check: the widget should actually render a chart, + # not just be an empty output_widget div. A rendered Vega-Lite chart + # will have a canvas or SVG inside a .vega-embed container. + chart_element = viz_container.locator("canvas, svg, .vega-embed") + expect(chart_element.first).to_be_visible(timeout=30_000) + + new_page.close() diff --git a/pkg-py/tests/test_ggsql.py b/pkg-py/tests/test_ggsql.py index b1cb9af1b..22a37e698 100644 --- a/pkg-py/tests/test_ggsql.py +++ b/pkg-py/tests/test_ggsql.py @@ -109,7 +109,8 @@ class TestExecuteGgsql: 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") + query = "SELECT * FROM test_data VISUALISE x, y DRAW point" + spec = execute_ggsql(ds, ggsql.validate(query)) altair_widget = AltairWidget.from_ggsql(spec) result = altair_widget.widget.chart.to_dict() assert "$schema" in result @@ -120,23 +121,24 @@ def test_with_filtered_query(self): 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" - ) + query = "SELECT * FROM test_data WHERE x > 2 VISUALISE x, y DRAW point" + spec = execute_ggsql(ds, ggsql.validate(query)) 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") + query = "SELECT * FROM test_data VISUALISE x, y DRAW point" + spec = execute_ggsql(ds, ggsql.validate(query)) 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") + query = "VISUALISE x, y FROM test_data DRAW point" + spec = execute_ggsql(ds, ggsql.validate(query)) assert spec.metadata()["rows"] == 3 assert "VISUALISE" in spec.visual() @@ -146,7 +148,8 @@ def test_with_pandas_dataframe(self): 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") + query = "SELECT * FROM test_data VISUALISE x, y DRAW point" + spec = execute_ggsql(ds, ggsql.validate(query)) altair_widget = AltairWidget.from_ggsql(spec) result = altair_widget.widget.chart.to_dict() assert "$schema" in result diff --git a/pkg-py/tests/test_shiny_viz_regressions.py b/pkg-py/tests/test_shiny_viz_regressions.py new file mode 100644 index 000000000..fc1c4298b --- /dev/null +++ b/pkg-py/tests/test_shiny_viz_regressions.py @@ -0,0 +1,395 @@ +"""Regression tests for Shiny ggsql tool wiring and bookmark restore.""" + +import inspect +import os +from types import SimpleNamespace +from unittest.mock import patch + +import chatlas +import pytest +from querychat import QueryChat +from querychat._shiny import QueryChatExpress +from querychat._shiny_module import mod_server +from querychat.data import tips + +from shiny import reactive + + +@pytest.fixture(autouse=True) +def set_dummy_api_key(): + old_api_key = os.environ.get("OPENAI_API_KEY") + os.environ["OPENAI_API_KEY"] = "sk-dummy-api-key-for-testing" + yield + if old_api_key is not None: + os.environ["OPENAI_API_KEY"] = old_api_key + else: + del os.environ["OPENAI_API_KEY"] + + +@pytest.fixture +def sample_df(): + return tips() + + +def _identity(fn): + return fn + + +def _event(*_args, **_kwargs): + def wrapper(fn): + return fn + + return wrapper + + +def _raw_mod_server(): + return inspect.getclosurevars(mod_server).nonlocals["fn"] + + +class DummyBookmark: + def on_bookmark(self, fn): + self.bookmark_fn = fn + return fn + + def on_restore(self, fn): + self.restore_fn = fn + return fn + + +class DummySession: + def __init__(self): + self.bookmark = DummyBookmark() + + def is_stub_session(self): + return False + + +class DummyStubSession(DummySession): + def is_stub_session(self): + return True + + +class DummyChatUi: + def __init__(self, *_args, **_kwargs): + pass + + def on_user_submit(self, fn): + return fn + + async def append_message_stream(self, _stream): + return None + + async def append_message(self, _message): + return None + + def enable_bookmarking(self, _chat): + return None + + +class DummyProvider(chatlas.Provider): + def __init__(self, *, name, model): + super().__init__(name=name, model=model) + + def list_models(self): + return [] + + def chat_perform(self, *, stream, turns, tools, data_model, kwargs): + return () if stream else SimpleNamespace() + + async def chat_perform_async( + self, *, stream, turns, tools, data_model, kwargs + ): + return () if stream else SimpleNamespace() + + def stream_text(self, chunk): + return None + + def stream_merge_chunks(self, completion, chunk): + return completion or {} + + def stream_turn(self, completion, has_data_model): + return SimpleNamespace() + + def value_turn(self, completion, has_data_model): + return SimpleNamespace() + + def value_tokens(self, completion): + return (0, 0, 0) + + def token_count(self, *args, tools, data_model): + return 0 + + async def token_count_async(self, *args, tools, data_model): + return 0 + + def translate_model_params(self, params): + return params + + def supported_model_params(self): + return set() + + +def test_app_passes_callable_client_to_mod_server(sample_df): + qc = QueryChat(sample_df, "tips", tools=("query", "visualize_query")) + app = qc.app() + captured = {} + + def fake_mod_server(*args, **kwargs): + captured.update(kwargs) + vals = SimpleNamespace() + vals.title = lambda: None + vals.sql = lambda: None + vals.df = list + vals.title.set = lambda _value: None + vals.sql.set = lambda _value: None + return vals + + with ( + patch("querychat._shiny.mod_server", fake_mod_server), + patch("querychat._shiny.render.text", _identity), + patch("querychat._shiny.render.ui", _identity), + patch("querychat._shiny.render.data_frame", _identity), + patch("querychat._shiny.reactive.effect", _identity), + patch("querychat._shiny.reactive.event", _event), + patch("querychat._shiny.req", lambda value: value), + patch("querychat._shiny.output_markdown_stream", lambda *a, **k: None), + ): + app.server( + SimpleNamespace(reset_query=lambda: None), + SimpleNamespace(), + SimpleNamespace(), + ) + + assert callable(captured["client"]) + assert not isinstance(captured["client"], chatlas.Chat) + + +def test_express_passes_callable_client_to_mod_server(sample_df, monkeypatch): + captured = {} + + class CurrentSession: + pass + + monkeypatch.setattr("querychat._shiny.get_current_session", lambda: CurrentSession()) + monkeypatch.setattr( + "querychat._shiny.mod_server", + lambda *args, **kwargs: captured.update(kwargs) or SimpleNamespace(), + ) + + QueryChatExpress( + sample_df, + "tips", + tools=("query", "visualize_query"), + enable_bookmarking=False, + ) + + assert callable(captured["client"]) + assert not isinstance(captured["client"], chatlas.Chat) + + +def test_server_passes_callable_client_to_mod_server(sample_df, monkeypatch): + qc = QueryChat(sample_df, "tips", tools=("query", "visualize_query")) + captured = {} + + class CurrentSession: + pass + + monkeypatch.setattr("querychat._shiny.get_current_session", lambda: CurrentSession()) + monkeypatch.setattr( + "querychat._shiny.mod_server", + lambda *args, **kwargs: captured.update(kwargs) or SimpleNamespace(), + ) + + qc.server(enable_bookmarking=False) + + assert callable(captured["client"]) + assert not isinstance(captured["client"], chatlas.Chat) + + +def test_mod_server_rejects_raw_chat_instance(sample_df): + qc = QueryChat(sample_df, "tips", tools=("query", "visualize_query")) + raw_chat = chatlas.Chat(provider=DummyProvider(name="dummy", model="dummy")) + + with ( + patch("querychat._shiny_module.preload_viz_deps_server", lambda: None), + patch("querychat._shiny_module.shinychat.Chat", DummyChatUi), + pytest.raises(TypeError, match="callable"), + ): + _raw_mod_server()( + SimpleNamespace(chat_update=lambda: None), + SimpleNamespace(), + DummySession(), + data_source=qc.data_source, + greeting=qc.greeting, + client=raw_chat, + enable_bookmarking=False, + tools=qc.tools, + ) + + +def test_mod_server_stub_session_deferred_client_factory_does_not_raise(): + qc = QueryChat(None, "users") + + vals = _raw_mod_server()( + SimpleNamespace(chat_update=lambda: None), + SimpleNamespace(), + DummyStubSession(), + data_source=None, + greeting=qc.greeting, + client=qc.client, + enable_bookmarking=False, + tools=qc.tools, + ) + + with pytest.raises(RuntimeError, match="unavailable during stub session"): + _ = vals.client.stream_async + + +def test_stub_session_with_data_source_uses_real_factory_kwarg_shape(sample_df): + qc = QueryChat(sample_df, "tips", tools=("query", "visualize_query")) + calls = [] + + def client_factory(**kwargs): + calls.append(kwargs) + return qc.client(**kwargs) + + with ( + patch("querychat._shiny_module.preload_viz_deps_server", lambda: None), + patch("querychat._shiny_module.shinychat.Chat", DummyChatUi), + ): + _raw_mod_server()( + SimpleNamespace(chat_update=lambda: None), + SimpleNamespace(), + DummySession(), + data_source=qc.data_source, + greeting=qc.greeting, + client=client_factory, + enable_bookmarking=False, + tools=qc.tools, + ) + _raw_mod_server()( + SimpleNamespace(chat_update=lambda: None), + SimpleNamespace(), + DummyStubSession(), + data_source=qc.data_source, + greeting=qc.greeting, + client=client_factory, + enable_bookmarking=False, + tools=qc.tools, + ) + + real_call, stub_call = calls + expected_keys = {"update_dashboard", "reset_dashboard", "visualize_query", "tools"} + + assert set(real_call) == expected_keys + assert set(stub_call) == expected_keys + assert real_call["tools"] == ("query", "visualize_query") + assert stub_call["tools"] == ("query", "visualize_query") + assert callable(real_call["update_dashboard"]) + assert callable(real_call["reset_dashboard"]) + assert callable(real_call["visualize_query"]) + assert callable(stub_call["update_dashboard"]) + assert callable(stub_call["reset_dashboard"]) + assert callable(stub_call["visualize_query"]) + + +def test_callable_mod_server_passes_visualize_callback_and_tools(sample_df): + qc = QueryChat(sample_df, "tips", tools=("query", "visualize_query")) + captured = {} + + def client_factory(**kwargs): + captured.update(kwargs) + return qc.client(**kwargs) + + with ( + patch("querychat._shiny_module.preload_viz_deps_server", lambda: None), + patch("querychat._shiny_module.shinychat.Chat", DummyChatUi), + ): + _raw_mod_server()( + SimpleNamespace(chat_update=lambda: None), + SimpleNamespace(), + DummySession(), + data_source=qc.data_source, + greeting=qc.greeting, + client=client_factory, + enable_bookmarking=False, + tools=qc.tools, + ) + + assert captured["tools"] == ("query", "visualize_query") + assert callable(captured["visualize_query"]) + assert callable(captured["update_dashboard"]) + assert callable(captured["reset_dashboard"]) + + +def test_callable_mod_server_honors_query_and_viz_only_tools(sample_df): + qc = QueryChat(sample_df, "tips", tools=("query", "visualize_query")) + + with ( + patch("querychat._shiny_module.preload_viz_deps_server", lambda: None), + patch("querychat._shiny_module.shinychat.Chat", DummyChatUi), + ): + vals = _raw_mod_server()( + SimpleNamespace(chat_update=lambda: None), + SimpleNamespace(), + DummySession(), + data_source=qc.data_source, + greeting=qc.greeting, + client=qc.client, + enable_bookmarking=False, + tools=qc.tools, + ) + + assert sorted(vals.client._tools.keys()) == [ + "querychat_query", + "querychat_visualize_query", + ] + + +def test_restored_viz_widgets_survive_second_bookmark_cycle(sample_df): + qc = QueryChat(sample_df, "tips", tools=("query", "visualize_query")) + callbacks = {} + session = DummySession() + + def client_factory(**kwargs): + callbacks.update(kwargs) + return qc.client(**kwargs) + + with ( + patch("querychat._shiny_module.preload_viz_deps_server", lambda: None), + patch("querychat._shiny_module.shinychat.Chat", DummyChatUi), + patch( + "querychat._shiny_module.restore_viz_widgets", + lambda _data_source, saved_widgets: list(saved_widgets), + ), + ): + _raw_mod_server()( + SimpleNamespace(chat_update=lambda: None), + SimpleNamespace(), + session, + data_source=qc.data_source, + greeting=qc.greeting, + client=client_factory, + enable_bookmarking=True, + tools=qc.tools, + ) + saved = [ + { + "widget_id": "querychat_viz_1", + "ggsql": "SELECT 1 VISUALISE 1 AS x DRAW point", + } + ] + callbacks["visualize_query"](saved[0]) + + first_bookmark = SimpleNamespace(values={}) + with reactive.isolate(): + session.bookmark.bookmark_fn(first_bookmark) + assert first_bookmark.values["querychat_viz_widgets"] == saved + + with reactive.isolate(): + session.bookmark.restore_fn(SimpleNamespace(values=first_bookmark.values)) + + second_bookmark = SimpleNamespace(values={}) + with reactive.isolate(): + session.bookmark.bookmark_fn(second_bookmark) + assert second_bookmark.values["querychat_viz_widgets"] == saved diff --git a/pkg-py/tests/test_tools.py b/pkg-py/tests/test_tools.py index 94d8e3c64..3267b6570 100644 --- a/pkg-py/tests/test_tools.py +++ b/pkg-py/tests/test_tools.py @@ -2,7 +2,52 @@ import warnings +import narwhals.stable.v1 as nw +import pandas as pd +import pytest +from querychat._datasource import DataFrameSource from querychat._utils import querychat_tool_starts_open +from querychat.tools import _query_impl + + +@pytest.fixture +def data_source(): + df = nw.from_native(pd.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]})) + return DataFrameSource(df, "test_table") + + +class TestQueryCollapsedParameter: + """Tests for the query tool's collapsed parameter.""" + + def test_collapsed_true_sets_open_false(self, data_source, monkeypatch): + monkeypatch.delenv("QUERYCHAT_TOOL_DETAILS", raising=False) + query_fn = _query_impl(data_source) + result = query_fn("SELECT * FROM test_table", collapsed=True) + assert result.extra["display"].open is False + + def test_collapsed_false_sets_open_true(self, data_source, monkeypatch): + monkeypatch.delenv("QUERYCHAT_TOOL_DETAILS", raising=False) + query_fn = _query_impl(data_source) + result = query_fn("SELECT * FROM test_table", collapsed=False) + assert result.extra["display"].open is True + + def test_collapsed_none_falls_back_to_default(self, data_source, monkeypatch): + monkeypatch.delenv("QUERYCHAT_TOOL_DETAILS", raising=False) + query_fn = _query_impl(data_source) + result = query_fn("SELECT * FROM test_table") + assert result.extra["display"].open is True # default for query + + def test_collapsed_overrides_env_expanded(self, data_source, monkeypatch): + monkeypatch.setenv("QUERYCHAT_TOOL_DETAILS", "expanded") + query_fn = _query_impl(data_source) + result = query_fn("SELECT * FROM test_table", collapsed=True) + assert result.extra["display"].open is False + + def test_collapsed_overrides_env_collapsed(self, data_source, monkeypatch): + monkeypatch.setenv("QUERYCHAT_TOOL_DETAILS", "collapsed") + query_fn = _query_impl(data_source) + result = query_fn("SELECT * FROM test_table", collapsed=False) + assert result.extra["display"].open is True def test_querychat_tool_starts_open_default_behavior(monkeypatch): diff --git a/pkg-py/tests/test_viz_footer.py b/pkg-py/tests/test_viz_footer.py index 1911c9a38..fde5ec510 100644 --- a/pkg-py/tests/test_viz_footer.py +++ b/pkg-py/tests/test_viz_footer.py @@ -77,6 +77,20 @@ def _patch_deps(monkeypatch): lambda _ggsql, _title, _wid: TagList(FOOTER_SENTINEL), ) + import ggsql + from querychat import _viz_tools + + mock_raw_chart = MagicMock() + mock_vl_writer = MagicMock() + mock_vl_writer.render_chart.return_value = mock_raw_chart + monkeypatch.setattr(ggsql, "VegaLiteWriter", lambda: mock_vl_writer) + monkeypatch.setattr( + _viz_tools, "_extract_column_names", lambda _chart: ["x", "y"] + ) + monkeypatch.setattr( + _viz_tools, "render_chart_to_png", lambda _chart: b"\x89PNG\r\n\x1a\n" + ) + def _make_viz_result(data_source): """Create a VisualizeQueryResult for testing.""" diff --git a/pkg-py/tests/test_viz_tools.py b/pkg-py/tests/test_viz_tools.py index 711d4830d..fd11e8d1c 100644 --- a/pkg-py/tests/test_viz_tools.py +++ b/pkg-py/tests/test_viz_tools.py @@ -1,6 +1,6 @@ """Tests for visualization tool functions.""" -import builtins +import importlib.util import narwhals.stable.v1 as nw import polars as pl @@ -13,14 +13,14 @@ class TestVizDependencyCheck: def test_missing_ggsql_raises_helpful_error(self, monkeypatch): """Requesting viz tools without ggsql installed should fail early.""" - real_import = builtins.__import__ + real_find_spec = importlib.util.find_spec - def mock_import(name, *args, **kwargs): + def mock_find_spec(name, *args, **kwargs): if name == "ggsql": - raise ImportError("No module named 'ggsql'") - return real_import(name, *args, **kwargs) + return None + return real_find_spec(name, *args, **kwargs) - monkeypatch.setattr(builtins, "__import__", mock_import) + monkeypatch.setattr(importlib.util, "find_spec", mock_find_spec) from querychat._querychat_base import normalize_tools @@ -37,18 +37,13 @@ def test_no_error_without_viz_tools(self): 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) + monkeypatch.setattr( + importlib.util, "find_spec", lambda name, *a, **kw: None + ) from querychat._querychat_base import normalize_tools - # Should not raise even though ggsql is missing + # Should not raise even though find_spec returns None for everything result = normalize_tools(("visualize_query",), default=None, check_deps=False) assert result == ("visualize_query",) @@ -129,3 +124,122 @@ def update_fn(data: VisualizeQueryData): assert result.error is not None assert "VISUALISE" in str(result.error) + + +class TestVisualizeQueryResultContent: + @pytest.mark.ggsql + def test_result_value_contains_image(self, data_source, monkeypatch): + from unittest.mock import MagicMock + + from chatlas._content import ContentImageInline + from ipywidgets.widgets.widget import Widget + + monkeypatch.setattr("shinywidgets.register_widget", lambda _id, _w: None) + monkeypatch.setattr( + "shinywidgets.output_widget", lambda _id, **_kw: MagicMock() + ) + monkeypatch.setattr(Widget, "_widget_construction_callback", lambda _w: None) + + callback_data = {} + + def update_fn(data: VisualizeQueryData): + callback_data.update(data) + + tool = tool_visualize_query(data_source, update_fn) + result = tool.func( + ggsql="SELECT x, y FROM test_data VISUALISE x, y DRAW point", + title="Test Chart", + ) + + assert isinstance(result, VisualizeQueryResult) + # value should be a list with [str, ContentImageInline] + assert isinstance(result.value, list) + assert len(result.value) == 2 + assert isinstance(result.value[0], str) + assert isinstance(result.value[1], ContentImageInline) + assert result.value[1].image_content_type == "image/png" + # model_format must be "as_is" so chatlas passes the list as + # multimodal content (not stringified) to the LLM provider + assert result.model_format == "as_is" + + @pytest.mark.ggsql + def test_result_text_includes_column_names(self, data_source, monkeypatch): + from unittest.mock import MagicMock + + from ipywidgets.widgets.widget import Widget + + monkeypatch.setattr("shinywidgets.register_widget", lambda _id, _w: None) + monkeypatch.setattr( + "shinywidgets.output_widget", lambda _id, **_kw: MagicMock() + ) + monkeypatch.setattr(Widget, "_widget_construction_callback", lambda _w: None) + + tool = tool_visualize_query(data_source, lambda _: None) + result = tool.func( + ggsql="SELECT x, y FROM test_data VISUALISE x, y DRAW point", + title="Test Chart", + ) + + text_part = result.value[0] + assert "x" in text_part + assert "y" in text_part + assert "Test Chart" in text_part + + @pytest.mark.ggsql + def test_png_failure_falls_back_to_text_only(self, data_source, monkeypatch): + from unittest.mock import MagicMock + + from ipywidgets.widgets.widget import Widget + + monkeypatch.setattr("shinywidgets.register_widget", lambda _id, _w: None) + monkeypatch.setattr( + "shinywidgets.output_widget", lambda _id, **_kw: MagicMock() + ) + monkeypatch.setattr(Widget, "_widget_construction_callback", lambda _w: None) + + def explode(_chart): + raise RuntimeError("vl-convert failed") + + monkeypatch.setattr("querychat._viz_tools.render_chart_to_png", explode) + + tool = tool_visualize_query(data_source, lambda _: None) + result = tool.func( + ggsql="SELECT x, y FROM test_data VISUALISE x, y DRAW point", + title="Test Chart", + ) + + # Should still succeed, just text-only + assert isinstance(result, VisualizeQueryResult) + assert isinstance(result.value, str) + assert result.error is None + + +class TestRenderChartToPng: + @pytest.mark.ggsql + def test_simple_chart_returns_bytes(self): + import altair as alt + + chart = alt.Chart({"values": [{"x": 1, "y": 2}]}).mark_point().encode( + x="x:Q", y="y:Q" + ) + from querychat._viz_tools import render_chart_to_png + + result = render_chart_to_png(chart) + assert isinstance(result, bytes) + assert result[:8] == b"\x89PNG\r\n\x1a\n" # PNG magic bytes + + @pytest.mark.ggsql + def test_compound_chart_returns_bytes(self): + import altair as alt + + chart = ( + alt.Chart({"values": [{"x": 1, "y": 2, "c": "A"}]}) + .mark_point() + .encode(x="x:Q", y="y:Q") + .facet("c:N") + ) + from querychat._viz_tools import render_chart_to_png + + result = render_chart_to_png(chart) + assert isinstance(result, bytes) + assert result[:8] == b"\x89PNG\r\n\x1a\n" diff --git a/pkg-r/inst/prompts/prompt.md b/pkg-r/inst/prompts/prompt.md index 455f60a63..8c6ff97bc 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, answering questions, and exploring data visually. +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 have access to a {{db_type}} SQL database with the following schema: @@ -117,24 +117,12 @@ 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 @@ -165,15 +153,6 @@ 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 9dcc28b9c..20e1dbb53 100644 --- a/pkg-r/inst/prompts/tool-query.md +++ b/pkg-r/inst/prompts/tool-query.md @@ -17,7 +17,6 @@ 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 64e5cedfe..b35d9e652 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ maintainers = [ dependencies = [ "duckdb", "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", + "shinychat @ git+https://github.com/posit-dev/shinychat.git@fix/simplify-message-storage-and-streaming-deps", "htmltools", "chatlas>=0.13.2", "narwhals", @@ -49,7 +49,7 @@ 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"] +viz = ["ggsql>=0.1.8", "altair>=5.0", "shinywidgets>=0.3.0", "vl-convert-python>=1.3.0"] [project.urls] Homepage = "https://github.com/posit-dev/querychat" # TODO update when we have docs @@ -57,6 +57,12 @@ Repository = "https://github.com/posit-dev/querychat" Issues = "https://github.com/posit-dev/querychat/issues" Source = "https://github.com/posit-dev/querychat/tree/main/pkg-py" +[tool.uv] +required-environments = [ + "sys_platform == 'linux' and platform_machine == 'x86_64'", + "sys_platform == 'darwin'", +] + [tool.hatch.metadata] allow-direct-references = true @@ -78,7 +84,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", "ggsql>=0.1.8", "altair>=5.0", "shinywidgets>=0.3.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", "vl-convert-python>=1.3.0"] docs = ["quartodoc>=0.11.1", "griffe<2", "nbformat", "nbclient", "ipykernel"] examples = [ "openai", @@ -216,13 +222,14 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" # disable S101 (flagging asserts) for tests [tool.ruff.lint.per-file-ignores] -"pkg-py/tests/*.py" = ["S101", "PLR2004"] # Allow assert and magic numbers in tests +"pkg-py/tests/*.py" = ["S101", "PLR2004", "ARG", "PLW0108"] # Allow assert, magic numbers, unused args, and unnecessary lambdas in tests "pkg-py/tests/playwright/*.py" = ["S101", "PLR2004", "S310", "S603", "S607", "PERF203"] # Test fixtures launch subprocesses "pkg-py/examples/tests/*.py" = ["S101", "PLR2004"] # Allow assert and magic numbers in tests "pkg-py/src/querychat/_dash.py" = ["E402"] # Backwards-compat aliases at end of file "pkg-py/src/querychat/_gradio.py" = ["E402"] # Backwards-compat aliases at end of file "pkg-py/src/querychat/_streamlit.py" = ["E402"] # Backwards-compat aliases at end of file "pkg-py/src/querychat/types/__init__.py" = ["A005"] # Deliberately shadows stdlib types module +"pkg-py/docs/_screenshots/*.py" = ["S310", "PLR2004", "PERF203"] # Dev utility scripts [tool.ruff.format] quote-style = "double" From afebc155b4669cfd444b7c045a624135de8f1d74 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 8 Apr 2026 17:33:31 -0500 Subject: [PATCH 04/31] refactor: simplify viz preload and trim brittle tests --- pkg-py/src/querychat/_viz_utils.py | 26 +++- pkg-py/src/querychat/static/js/viz-preload.js | 50 ++++++++ pkg-py/tests/playwright/test_11_viz_footer.py | 26 ---- pkg-py/tests/test_shiny_viz_regressions.py | 83 ++++++------ pkg-py/tests/test_viz_footer.py | 118 ++---------------- 5 files changed, 119 insertions(+), 184 deletions(-) create mode 100644 pkg-py/src/querychat/static/js/viz-preload.js diff --git a/pkg-py/src/querychat/_viz_utils.py b/pkg-py/src/querychat/_viz_utils.py index eafe8631e..0f5525cdb 100644 --- a/pkg-py/src/querychat/_viz_utils.py +++ b/pkg-py/src/querychat/_viz_utils.py @@ -2,6 +2,11 @@ from __future__ import annotations +from htmltools import HTMLDependency, tags +from shinywidgets import output_widget + +from .__version import __version__ + def has_viz_tool(tools: tuple[str, ...] | None) -> bool: """Check if visualize_query is among the configured tools.""" @@ -23,13 +28,26 @@ def has_viz_deps() -> bool: 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), + viz_preload_dep(), + class_="querychat-viz-preload", + hidden="", + aria_hidden="true", style="position:absolute; left:-9999px; width:1px; height:1px;", - **{"aria-hidden": "true"}, + ) + + +def viz_preload_dep() -> HTMLDependency: + """HTMLDependency for viz preload-specific JS.""" + return HTMLDependency( + "querychat-viz-preload", + __version__, + source={ + "package": "querychat", + "subdir": "static", + }, + script=[{"src": "js/viz-preload.js"}], ) diff --git a/pkg-py/src/querychat/static/js/viz-preload.js b/pkg-py/src/querychat/static/js/viz-preload.js new file mode 100644 index 000000000..088433af2 --- /dev/null +++ b/pkg-py/src/querychat/static/js/viz-preload.js @@ -0,0 +1,50 @@ +(function () { + if (!window.Shiny) return; + + var preloadObserver = null; + + function stopVizPreloadObserver() { + if (!preloadObserver) return; + preloadObserver.disconnect(); + preloadObserver = null; + } + + function handleVizPreload(root) { + if (!root || !root.isConnected) return; + + if (window.__querychatVizPreloaded) { + root.remove(); + stopVizPreloadObserver(); + return; + } + + window.__querychatVizPreloaded = true; + root.removeAttribute("hidden"); + stopVizPreloadObserver(); + } + + function processVizPreloads(node) { + if (!(node instanceof Element)) return; + + if (node.matches(".querychat-viz-preload")) { + handleVizPreload(node); + } + + node.querySelectorAll(".querychat-viz-preload").forEach(handleVizPreload); + } + + processVizPreloads(document.documentElement); + + if (!window.__querychatVizPreloaded) { + preloadObserver = new MutationObserver(function (mutations) { + mutations.forEach(function (mutation) { + mutation.addedNodes.forEach(processVizPreloads); + }); + }); + + preloadObserver.observe(document.documentElement, { + childList: true, + subtree: true, + }); + } +})(); diff --git a/pkg-py/tests/playwright/test_11_viz_footer.py b/pkg-py/tests/playwright/test_11_viz_footer.py index 6812a2f88..2cd586952 100644 --- a/pkg-py/tests/playwright/test_11_viz_footer.py +++ b/pkg-py/tests/playwright/test_11_viz_footer.py @@ -152,29 +152,3 @@ def test_toggle_save_menu(self, page: Page) -> None: 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:has(.tool-fullscreen-toggle)") - 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:has(.tool-fullscreen-toggle)") - 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:has(.tool-fullscreen-toggle)") - card.screenshot(path="test-results/viz-footer-save-menu-open.png") diff --git a/pkg-py/tests/test_shiny_viz_regressions.py b/pkg-py/tests/test_shiny_viz_regressions.py index fc1c4298b..6a946162a 100644 --- a/pkg-py/tests/test_shiny_viz_regressions.py +++ b/pkg-py/tests/test_shiny_viz_regressions.py @@ -245,12 +245,12 @@ def test_mod_server_stub_session_deferred_client_factory_does_not_raise(): _ = vals.client.stream_async -def test_stub_session_with_data_source_uses_real_factory_kwarg_shape(sample_df): +def test_callable_mod_server_passes_visualize_callback_and_tools(sample_df): qc = QueryChat(sample_df, "tips", tools=("query", "visualize_query")) - calls = [] + captured = {} def client_factory(**kwargs): - calls.append(kwargs) + captured.update(kwargs) return qc.client(**kwargs) with ( @@ -267,72 +267,64 @@ def client_factory(**kwargs): enable_bookmarking=False, tools=qc.tools, ) - _raw_mod_server()( - SimpleNamespace(chat_update=lambda: None), - SimpleNamespace(), - DummyStubSession(), - data_source=qc.data_source, - greeting=qc.greeting, - client=client_factory, - enable_bookmarking=False, - tools=qc.tools, - ) - real_call, stub_call = calls - expected_keys = {"update_dashboard", "reset_dashboard", "visualize_query", "tools"} - - assert set(real_call) == expected_keys - assert set(stub_call) == expected_keys - assert real_call["tools"] == ("query", "visualize_query") - assert stub_call["tools"] == ("query", "visualize_query") - assert callable(real_call["update_dashboard"]) - assert callable(real_call["reset_dashboard"]) - assert callable(real_call["visualize_query"]) - assert callable(stub_call["update_dashboard"]) - assert callable(stub_call["reset_dashboard"]) - assert callable(stub_call["visualize_query"]) + assert captured["tools"] == ("query", "visualize_query") + assert callable(captured["visualize_query"]) + assert callable(captured["update_dashboard"]) + assert callable(captured["reset_dashboard"]) -def test_callable_mod_server_passes_visualize_callback_and_tools(sample_df): +def test_mod_server_preloads_viz_for_each_real_session_instance(sample_df): qc = QueryChat(sample_df, "tips", tools=("query", "visualize_query")) - captured = {} - - def client_factory(**kwargs): - captured.update(kwargs) - return qc.client(**kwargs) + session = DummySession() + preload_calls = [] with ( - patch("querychat._shiny_module.preload_viz_deps_server", lambda: None), + patch( + "querychat._shiny_module.preload_viz_deps_server", + lambda: preload_calls.append("called"), + ), patch("querychat._shiny_module.shinychat.Chat", DummyChatUi), ): _raw_mod_server()( SimpleNamespace(chat_update=lambda: None), SimpleNamespace(), - DummySession(), + session, data_source=qc.data_source, greeting=qc.greeting, - client=client_factory, + client=qc.client, + enable_bookmarking=False, + tools=qc.tools, + ) + _raw_mod_server()( + SimpleNamespace(chat_update=lambda: None), + SimpleNamespace(), + session, + data_source=qc.data_source, + greeting=qc.greeting, + client=qc.client, enable_bookmarking=False, tools=qc.tools, ) - assert captured["tools"] == ("query", "visualize_query") - assert callable(captured["visualize_query"]) - assert callable(captured["update_dashboard"]) - assert callable(captured["reset_dashboard"]) + assert preload_calls == ["called", "called"] -def test_callable_mod_server_honors_query_and_viz_only_tools(sample_df): +def test_mod_server_stub_session_does_not_preload_viz(sample_df): qc = QueryChat(sample_df, "tips", tools=("query", "visualize_query")) + preload_calls = [] with ( - patch("querychat._shiny_module.preload_viz_deps_server", lambda: None), + patch( + "querychat._shiny_module.preload_viz_deps_server", + lambda: preload_calls.append("called"), + ), patch("querychat._shiny_module.shinychat.Chat", DummyChatUi), ): - vals = _raw_mod_server()( + _raw_mod_server()( SimpleNamespace(chat_update=lambda: None), SimpleNamespace(), - DummySession(), + DummyStubSession(), data_source=qc.data_source, greeting=qc.greeting, client=qc.client, @@ -340,10 +332,7 @@ def test_callable_mod_server_honors_query_and_viz_only_tools(sample_df): tools=qc.tools, ) - assert sorted(vals.client._tools.keys()) == [ - "querychat_query", - "querychat_visualize_query", - ] + assert preload_calls == [] def test_restored_viz_widgets_survive_second_bookmark_cycle(sample_df): diff --git a/pkg-py/tests/test_viz_footer.py b/pkg-py/tests/test_viz_footer.py index fde5ec510..4c80bfc4f 100644 --- a/pkg-py/tests/test_viz_footer.py +++ b/pkg-py/tests/test_viz_footer.py @@ -13,22 +13,6 @@ 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 @@ -72,10 +56,6 @@ def _patch_deps(monkeypatch): "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), - ) import ggsql from querychat import _viz_tools @@ -92,71 +72,6 @@ def _patch_deps(monkeypatch): ) -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.""" @@ -181,28 +96,17 @@ def test_cls_parameter_injects_class(self): assert "querychat-icon" in html -class TestVizJsUseMutationObserver: - """Verify viz.js uses MutationObserver instead of setInterval for vega export.""" +class TestVizPreloadMarkup: + def test_preload_markup_has_no_inline_script(self): + from querychat._viz_utils import PRELOAD_WIDGET_ID, preload_viz_deps_ui - 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" + rendered = TagList(preload_viz_deps_ui()).render() + preload_dep = next( + dep for dep in rendered["dependencies"] if dep.name == "querychat-viz-preload" ) - 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." - ) + assert PRELOAD_WIDGET_ID in rendered["html"] + assert "querychat-viz-preload" in rendered["html"] + assert "hidden" in rendered["html"] + assert " Date: Wed, 8 Apr 2026 17:54:05 -0500 Subject: [PATCH 05/31] =?UTF-8?q?fix(prompts):=20polish=20ggsql-syntax.md?= =?UTF-8?q?=20=E2=80=94=20LABEL=20wording,=20PLACE/DRAW=20distinction,=20t?= =?UTF-8?q?rim=20redundant=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 fix(prompts): polish tool descriptions — collapsed default, error-mode reinforcement, tighter ggsql param Co-Authored-By: Claude Sonnet 4.6 fix(prompts): tool name consistency, conditional fallback, standalone routing section Co-Authored-By: Claude Sonnet 4.6 style: fix docstring formatting in new test class Co-Authored-By: Claude Opus 4.6 fix(ggsql): guard unsupported layer sources --- pkg-py/src/querychat/_viz_ggsql.py | 63 +++++++-- pkg-py/src/querychat/prompts/ggsql-syntax.md | 57 +++++--- pkg-py/src/querychat/prompts/prompt.md | 13 +- pkg-py/src/querychat/prompts/tool-query.md | 2 +- .../querychat/prompts/tool-visualize-query.md | 4 +- pkg-py/tests/test_ggsql.py | 127 ++++++++++++++++-- pkg-py/tests/test_system_prompt.py | 54 ++++++++ 7 files changed, 268 insertions(+), 52 deletions(-) diff --git a/pkg-py/src/querychat/_viz_ggsql.py b/pkg-py/src/querychat/_viz_ggsql.py index cc3e8d8b0..db2e5fadd 100644 --- a/pkg-py/src/querychat/_viz_ggsql.py +++ b/pkg-py/src/querychat/_viz_ggsql.py @@ -35,11 +35,22 @@ def execute_ggsql(data_source: DataSource, validated: ggsql.Validated) -> ggsql. """ from ggsql import DuckDBReader + visual = validated.visual() + if has_layer_level_source(visual): + # Short term, querychat only supports visual layers that can be replayed + # from one final SQL result. Long term, the cleaner fix is likely to use + # ggsql's native remote-reader execution path (for example via ODBC-backed + # Readers) instead of reconstructing multi-relation scope here. + raise ValueError( + "Layer-specific sources are not currently supported in querychat visual " + "queries. Rewrite the query so that all layers come from the final SQL " + "result." + ) + pl_df = to_polars(data_source.execute_query(validated.sql())) reader = DuckDBReader("duckdb://memory") - visual = validated.visual() - table = extract_visualise_table(visual) + table = validated_source_table(validated) if table is not None: # VISUALISE [mappings] FROM
— register data under the @@ -54,20 +65,52 @@ def execute_ggsql(data_source: DataSource, validated: ggsql.Validated) -> ggsql. return reader.execute(f"SELECT * FROM _data {visual}") +def validated_source_table(validated: ggsql.Validated) -> str | None: + """ + Return the top-level ``VISUALISE ... FROM
`` source table. + + Prefer ggsql's structured ``Validated.source_table()`` API when available. + Fall back to parsing the visual clause while querychat still supports older + ggsql Python bindings that do not expose that method yet. + """ + source_table = getattr(validated, "source_table", None) + if callable(source_table): + return source_table() + return extract_visualise_table(validated.visual()) + + def extract_visualise_table(visual: str) -> str | None: """ 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. + This is a compatibility fallback for older ggsql Python bindings that do + not yet expose ``Validated.source_table()``. """ - # 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 + + +def has_layer_level_source(visual: str) -> bool: + """ + Return ``True`` when a DRAW clause defines its own ``FROM ``. + + Querychat currently replays the VISUALISE portion against a single local + relation, so layer-specific sources cannot be preserved reliably. + """ + clauses = re.split( + r"(?=\b(?:DRAW|SCALE|PROJECT|FACET|PLACE|LABEL|THEME)\b)", + visual, + flags=re.IGNORECASE, + ) + for clause in clauses: + if not re.match(r"^\s*DRAW\b", clause, re.IGNORECASE): + continue + if re.search( + r'\bMAPPING\b[\s\S]*?\bFROM\s+("[^"]+?"|\S+)', + clause, + re.IGNORECASE, + ): + return True + return False diff --git a/pkg-py/src/querychat/prompts/ggsql-syntax.md b/pkg-py/src/querychat/prompts/ggsql-syntax.md index 99cc45173..4e26f90f0 100644 --- a/pkg-py/src/querychat/prompts/ggsql-syntax.md +++ b/pkg-py/src/querychat/prompts/ggsql-syntax.md @@ -86,16 +86,6 @@ DRAW geom_type | 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 @@ -134,7 +124,7 @@ PLACE text SETTING x => 10, y => 50, label => 'Threshold' 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. +`PLACE` supports any geom type but is most useful with `rule`, `linear`, `text`, `segment`, and `rect`. Use `PLACE` for fixed annotation values known at query time; use `DRAW` with `MAPPING` when values come from data columns. Unlike `DRAW`, `PLACE` has no `MAPPING`, `FILTER`, `PARTITION BY`, or `ORDER BY` sub-clauses. ### Statistical Layers and REMAPPING @@ -321,7 +311,7 @@ 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. +Use LABEL for axis labels only. Do NOT use `LABEL title => ...` — the tool's `title` parameter handles chart titles. ```sql LABEL x => 'X Axis Label', y => 'Y Axis Label' @@ -491,16 +481,41 @@ PROJECT TO polar SETTING inner => 0.5 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: +3. **In querychat, all layers must come from the final SQL result**: Do not use layer-specific `FROM source` inside `DRAW ... MAPPING ...` clauses. If you need raw data and a summary in one chart, put both into one final relation and distinguish layers with a column such as `layer_type`: + ```sql + WITH raw AS ( + SELECT + date, + amount, + region, + 'raw' AS layer_type + FROM sales + ), + summary AS ( + SELECT + date, + AVG(amount) AS amount, + region, + 'summary' AS layer_type + FROM sales + GROUP BY date, region + ), + combined AS ( + SELECT * FROM raw + UNION ALL + SELECT * FROM summary + ) + SELECT * FROM combined + VISUALISE date AS x, amount AS y + DRAW point MAPPING region AS color FILTER layer_type = 'raw' + DRAW line MAPPING region AS color FILTER layer_type = 'summary' + ``` +4. **String values use single quotes**: In SETTING, LABEL, and RENAMING clauses, always use single quotes for string values. Double quotes cause parse errors. +5. **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`. +6. **Charts vs Tables**: For visualizations use VISUALISE with DRAW. For tabular data use plain SQL without VISUALISE. +7. **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. +8. **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. diff --git a/pkg-py/src/querychat/prompts/prompt.md b/pkg-py/src/querychat/prompts/prompt.md index 3d3004901..4a08d949f 100644 --- a/pkg-py/src/querychat/prompts/prompt.md +++ b/pkg-py/src/querychat/prompts/prompt.md @@ -117,10 +117,15 @@ Response: "The average revenue is $X." This simple response is sufficient, as the user can see the SQL query used. + +{{/has_tool_query}} +{{#has_tool_query}} {{#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}} +### 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}} @@ -208,7 +213,7 @@ You can create visualizations using the `visualize_query` tool, which uses ggsql ### Visualization best practices -The database schema in this prompt includes column names, types, and summary statistics. If that context isn't sufficient for a confident visualization — e.g., you're unsure about value distributions, need to check for NULLs, or want to gauge row counts before choosing a chart type — use the `query` tool (if available) to inspect the data before visualizing. Pass `collapsed=True` for these preparatory queries so the results don't clutter the conversation. +The database schema in this prompt includes column names, types, and summary statistics. If that context isn't sufficient for a confident visualization — e.g., you're unsure about value distributions, need to check for NULLs, or want to gauge row counts before choosing a chart type — use the `querychat_query` tool (if available) to inspect the data before visualizing. Pass `collapsed=True` for these preparatory queries so the results don't clutter the conversation. Follow the principles below to produce clear, interpretable charts. @@ -269,7 +274,7 @@ Match the chart type to what the user is trying to understand: ### Graceful recovery -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. +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.{{#has_tool_query}} If the error persists, fall back to `querychat_query` for a tabular answer.{{/has_tool_query}} ### ggsql syntax reference diff --git a/pkg-py/src/querychat/prompts/tool-query.md b/pkg-py/src/querychat/prompts/tool-query.md index 2acc7e3f1..164c95b55 100644 --- a/pkg-py/src/querychat/prompts/tool-query.md +++ b/pkg-py/src/querychat/prompts/tool-query.md @@ -27,7 +27,7 @@ Parameters query : A valid {{db_type}} SQL SELECT statement. Must follow the database schema provided in the system prompt. Use clear column aliases (e.g., 'AVG(price) AS avg_price') and include SQL comments for complex logic. Subqueries and CTEs are encouraged for readability. collapsed : - Optional. Set to true for exploratory or preparatory queries (e.g., inspecting data before visualization, checking row counts, previewing column values) whose results aren't the primary answer. When true, the result card starts collapsed so it doesn't clutter the conversation. + Optional (default: false). Set to true for exploratory or preparatory queries (e.g., inspecting data before visualization, checking row counts, previewing column values) whose results aren't the primary answer. When true, the result card starts collapsed so it doesn't clutter the conversation. _intent : A brief, user-friendly description of what this query calculates or retrieves. diff --git a/pkg-py/src/querychat/prompts/tool-visualize-query.md b/pkg-py/src/querychat/prompts/tool-visualize-query.md index f8e177510..ee671a9dd 100644 --- a/pkg-py/src/querychat/prompts/tool-visualize-query.md +++ b/pkg-py/src/querychat/prompts/tool-visualize-query.md @@ -1,9 +1,9 @@ -Render a ggsql query inline in the chat. See the "Visualization with ggsql" section of the system prompt for usage guidance, best practices, and the ggsql syntax reference. +Render a ggsql query inline in the chat. All data transformations must happen in the SELECT clause — VISUALISE and MAPPING accept column names only, not SQL expressions or functions. 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. + A full ggsql query. Must include a VISUALISE clause and at least one DRAW clause. The SELECT portion uses {{db_type}} SQL; VISUALISE and MAPPING accept column names only, not expressions. Do NOT include `LABEL title => ...` in the query — use the `title` parameter instead. title : A brief, user-friendly title for this visualization. This is displayed as the card header above the chart. diff --git a/pkg-py/tests/test_ggsql.py b/pkg-py/tests/test_ggsql.py index 22a37e698..3ed629b27 100644 --- a/pkg-py/tests/test_ggsql.py +++ b/pkg-py/tests/test_ggsql.py @@ -6,11 +6,16 @@ import pytest from querychat._datasource import DataFrameSource from querychat._viz_altair_widget import AltairWidget -from querychat._viz_ggsql import execute_ggsql, extract_visualise_table +from querychat._viz_ggsql import ( + execute_ggsql, + extract_visualise_table, + has_layer_level_source, + validated_source_table, +) class TestExtractVisualiseTable: - """Tests for extract_visualise_table() regex parsing.""" + """Tests for extract_visualise_table() compatibility parsing.""" def test_bare_identifier(self): assert extract_visualise_table("VISUALISE x, y FROM mytable DRAW point") == "mytable" @@ -28,21 +33,38 @@ 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" +class TestValidatedSourceTable: + def test_uses_structured_method_when_available(self): + class FakeValidated: + def source_table(self): + return "sales" - def test_case_insensitive_from(self): - assert extract_visualise_table("VISUALISE x from mytable DRAW point") == "mytable" + def visual(self): + return "VISUALISE x FROM ignored DRAW point" - 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 + assert validated_source_table(FakeValidated()) == "sales" + + def test_falls_back_to_visual_parse_for_older_bindings(self): + class FakeValidated: + def visual(self): + return "VISUALISE x FROM sales DRAW point" + + assert validated_source_table(FakeValidated()) == "sales" + + +class TestHasLayerLevelSource: + def test_detects_draw_level_from(self): + visual = "VISUALISE x, y DRAW bar MAPPING z AS fill FROM summary" + assert has_layer_level_source(visual) + + def test_ignores_visualise_from(self): + visual = "VISUALISE x, y FROM sales DRAW point MAPPING z AS color" + assert not has_layer_level_source(visual) + + def test_ignores_scale_from(self): + visual = "VISUALISE x, y DRAW point MAPPING z AS color SCALE x FROM [0, 10]" + assert not has_layer_level_source(visual) class TestGgsqlValidate: @@ -153,3 +175,80 @@ def test_with_pandas_dataframe(self): altair_widget = AltairWidget.from_ggsql(spec) result = altair_widget.widget.chart.to_dict() assert "$schema" in result + + @pytest.mark.ggsql + def test_rejects_layer_level_from_sources_with_clear_error(self): + nw_df = nw.from_native( + pl.DataFrame( + { + "date": ["2024-01", "2024-01", "2024-02", "2024-02"], + "region": ["north", "south", "north", "south"], + "amount": [10, 20, 30, 40], + } + ) + ) + ds = DataFrameSource(nw_df, "sales") + query = """ + WITH summary AS ( + SELECT region, SUM(amount) 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 + """ + + with pytest.raises( + ValueError, + match="Layer-specific sources are not currently supported", + ): + execute_ggsql(ds, ggsql.validate(query)) + + @pytest.mark.ggsql + def test_supports_single_relation_raw_plus_summary_overlay(self): + nw_df = nw.from_native( + pl.DataFrame( + { + "x": [1, 1, 2, 2], + "y": [10, 20, 30, 40], + "category": ["a", "b", "a", "b"], + } + ) + ) + ds = DataFrameSource(nw_df, "sales") + query = """ + WITH raw AS ( + SELECT + x, + y, + category, + 'raw' AS layer_type + FROM sales + ), + summary AS ( + SELECT + x, + AVG(y) AS y, + category, + 'summary' AS layer_type + FROM sales + GROUP BY x, category + ), + combined AS ( + SELECT * FROM raw + UNION ALL + SELECT * FROM summary + ) + SELECT * + FROM combined + VISUALISE x AS x, y AS y + DRAW point MAPPING category AS color FILTER layer_type = 'raw' + DRAW line MAPPING category AS color FILTER layer_type = 'summary' + """ + + spec = execute_ggsql(ds, ggsql.validate(query)) + assert spec.metadata()["rows"] == 4 + assert "VISUALISE" in spec.visual() diff --git a/pkg-py/tests/test_system_prompt.py b/pkg-py/tests/test_system_prompt.py index 64b64c9b7..153a1bce2 100644 --- a/pkg-py/tests/test_system_prompt.py +++ b/pkg-py/tests/test_system_prompt.py @@ -298,3 +298,57 @@ def test_schema_computed_for_conditional_section(self, sample_data_source): ) assert prompt.schema != "" + + +class TestVizPromptConditionals: + """Tests for visualization-related conditional rendering in the real prompt.""" + + def test_graceful_recovery_fallback_excluded_without_query_tool( + self, sample_data_source + ): + """ + When only visualize_query is enabled (no query tool), the fallback + to querychat_query should not appear in the rendered prompt. + """ + from pathlib import Path + + template_path = ( + Path(__file__).parent.parent + / "src" + / "querychat" + / "prompts" + / "prompt.md" + ) + prompt = QueryChatSystemPrompt( + prompt_template=template_path, + data_source=sample_data_source, + ) + + rendered = prompt.render(tools=("update", "visualize_query")) + + assert "fall back to" not in rendered + + def test_graceful_recovery_fallback_included_with_query_tool( + self, sample_data_source + ): + """ + When both query and visualize_query are enabled, the fallback + to querychat_query should appear. + """ + from pathlib import Path + + template_path = ( + Path(__file__).parent.parent + / "src" + / "querychat" + / "prompts" + / "prompt.md" + ) + prompt = QueryChatSystemPrompt( + prompt_template=template_path, + data_source=sample_data_source, + ) + + rendered = prompt.render(tools=("update", "query", "visualize_query")) + + assert "fall back to" in rendered From 29e731b2b16f8d7d3a8b896c65660d75f4151723 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 8 Apr 2026 19:22:23 -0500 Subject: [PATCH 06/31] refactor(viz): simplify chart feedback path --- pkg-py/src/querychat/_viz_ggsql.py | 20 ++----------- pkg-py/src/querychat/_viz_tools.py | 45 ++++-------------------------- pkg-py/tests/test_ggsql.py | 22 +-------------- pkg-py/tests/test_viz_tools.py | 10 +++---- 4 files changed, 14 insertions(+), 83 deletions(-) diff --git a/pkg-py/src/querychat/_viz_ggsql.py b/pkg-py/src/querychat/_viz_ggsql.py index db2e5fadd..076b4f3b3 100644 --- a/pkg-py/src/querychat/_viz_ggsql.py +++ b/pkg-py/src/querychat/_viz_ggsql.py @@ -50,7 +50,7 @@ def execute_ggsql(data_source: DataSource, validated: ggsql.Validated) -> ggsql. pl_df = to_polars(data_source.execute_query(validated.sql())) reader = DuckDBReader("duckdb://memory") - table = validated_source_table(validated) + table = extract_visualise_table(visual) if table is not None: # VISUALISE [mappings] FROM
— register data under the @@ -65,26 +65,12 @@ def execute_ggsql(data_source: DataSource, validated: ggsql.Validated) -> ggsql. return reader.execute(f"SELECT * FROM _data {visual}") -def validated_source_table(validated: ggsql.Validated) -> str | None: - """ - Return the top-level ``VISUALISE ... FROM
`` source table. - - Prefer ggsql's structured ``Validated.source_table()`` API when available. - Fall back to parsing the visual clause while querychat still supports older - ggsql Python bindings that do not expose that method yet. - """ - source_table = getattr(validated, "source_table", None) - if callable(source_table): - return source_table() - return extract_visualise_table(validated.visual()) - - def extract_visualise_table(visual: str) -> str | None: """ Extract the table name from ``VISUALISE ... FROM
`` if present. - This is a compatibility fallback for older ggsql Python bindings that do - not yet expose ``Validated.source_table()``. + This reimplements a small part of ggsql's parsing because the current + Python bindings do not expose the top-level VISUALISE source directly. """ draw_pos = re.search(r"\bDRAW\b", visual, re.IGNORECASE) vis_clause = visual[: draw_pos.start()] if draw_pos else visual diff --git a/pkg-py/src/querychat/_viz_tools.py b/pkg-py/src/querychat/_viz_tools.py index 608add8d0..860f05876 100644 --- a/pkg-py/src/querychat/_viz_tools.py +++ b/pkg-py/src/querychat/_viz_tools.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Any, TypedDict from uuid import uuid4 -from chatlas import ContentToolResult, Tool +from chatlas import ContentToolResult, Tool, content_image_url from htmltools import HTMLDependency, TagList, tags from shinychat.types import ToolResultDisplay @@ -93,9 +93,6 @@ def __init__( widget: Widget, ggsql_str: str, title: str, - row_count: int, - col_count: int, - column_names: list[str], png_bytes: bytes | None = None, **kwargs: Any, ): @@ -103,17 +100,14 @@ def __init__( register_widget(widget_id, widget) - cols_str = ", ".join(column_names) title_display = f" with title '{title}'" if title else "" - text = f"Chart displayed{title_display}. Data: {row_count} rows, {col_count} columns: {cols_str}." + text = f"Chart displayed{title_display}." if png_bytes is not None: - from chatlas._content import ContentImageInline - png_b64 = base64.b64encode(png_bytes).decode("ascii") - value: Any = [ + value = [ text, - ContentImageInline(image_content_type="image/png", data=png_b64), + content_image_url(f"data:image/png;base64,{png_b64}"), ] else: value = text @@ -149,7 +143,7 @@ def visualize_query_impl( update_fn: Callable[[VisualizeQueryData], None], ) -> Callable[[str, str], ContentToolResult]: """Create the visualize_query implementation function.""" - from ggsql import validate + from ggsql import VegaLiteWriter, validate from ._viz_altair_widget import AltairWidget from ._viz_ggsql import execute_ggsql @@ -187,18 +181,9 @@ def visualize_query( ) spec = execute_ggsql(data_source, validated) - altair_widget = AltairWidget.from_ggsql(spec) - - metadata = spec.metadata() - row_count = metadata["rows"] - - # Render a static PNG thumbnail for LLM feedback. - # render_chart needs a fresh Altair chart (AltairWidget mutates its copy). - from ggsql import VegaLiteWriter raw_chart = VegaLiteWriter().render_chart(spec) - column_names = _extract_column_names(raw_chart) - col_count = len(column_names) + altair_widget = AltairWidget(copy.deepcopy(raw_chart)) try: png_bytes = render_chart_to_png(raw_chart) @@ -214,9 +199,6 @@ def visualize_query( widget=altair_widget.widget, ggsql_str=ggsql, title=title, - row_count=row_count, - col_count=col_count, - column_names=column_names, png_bytes=png_bytes, ) @@ -228,21 +210,6 @@ def visualize_query( return visualize_query -def _extract_column_names(chart: alt.TopLevelMixin) -> list[str]: - """Extract user-facing column names from a VegaLite chart's encoding titles.""" - chart_dict = chart.to_dict() - layers = chart_dict.get("layer", [chart_dict]) - seen: list[str] = [] - for layer in layers: - enc = layer.get("encoding", {}) - for ch_val in enc.values(): - if isinstance(ch_val, dict): - title = ch_val.get("title") - if title and title not in seen: - seen.append(title) - return seen - - PNG_WIDTH = 500 PNG_HEIGHT = 300 diff --git a/pkg-py/tests/test_ggsql.py b/pkg-py/tests/test_ggsql.py index 3ed629b27..d49a821e7 100644 --- a/pkg-py/tests/test_ggsql.py +++ b/pkg-py/tests/test_ggsql.py @@ -10,12 +10,11 @@ execute_ggsql, extract_visualise_table, has_layer_level_source, - validated_source_table, ) class TestExtractVisualiseTable: - """Tests for extract_visualise_table() compatibility parsing.""" + """Tests for extract_visualise_table() parsing.""" def test_bare_identifier(self): assert extract_visualise_table("VISUALISE x, y FROM mytable DRAW point") == "mytable" @@ -34,25 +33,6 @@ def test_ignores_draw_level_from(self): assert extract_visualise_table(visual) is None -class TestValidatedSourceTable: - def test_uses_structured_method_when_available(self): - class FakeValidated: - def source_table(self): - return "sales" - - def visual(self): - return "VISUALISE x FROM ignored DRAW point" - - assert validated_source_table(FakeValidated()) == "sales" - - def test_falls_back_to_visual_parse_for_older_bindings(self): - class FakeValidated: - def visual(self): - return "VISUALISE x FROM sales DRAW point" - - assert validated_source_table(FakeValidated()) == "sales" - - class TestHasLayerLevelSource: def test_detects_draw_level_from(self): visual = "VISUALISE x, y DRAW bar MAPPING z AS fill FROM summary" diff --git a/pkg-py/tests/test_viz_tools.py b/pkg-py/tests/test_viz_tools.py index fd11e8d1c..b5eeffd6e 100644 --- a/pkg-py/tests/test_viz_tools.py +++ b/pkg-py/tests/test_viz_tools.py @@ -131,7 +131,6 @@ class TestVisualizeQueryResultContent: def test_result_value_contains_image(self, data_source, monkeypatch): from unittest.mock import MagicMock - from chatlas._content import ContentImageInline from ipywidgets.widgets.widget import Widget monkeypatch.setattr("shinywidgets.register_widget", lambda _id, _w: None) @@ -152,18 +151,18 @@ def update_fn(data: VisualizeQueryData): ) assert isinstance(result, VisualizeQueryResult) - # value should be a list with [str, ContentImageInline] + # value should be a list with [str, image content] assert isinstance(result.value, list) assert len(result.value) == 2 assert isinstance(result.value[0], str) - assert isinstance(result.value[1], ContentImageInline) + assert getattr(result.value[1], "content_type", None) == "image_inline" assert result.value[1].image_content_type == "image/png" # model_format must be "as_is" so chatlas passes the list as # multimodal content (not stringified) to the LLM provider assert result.model_format == "as_is" @pytest.mark.ggsql - def test_result_text_includes_column_names(self, data_source, monkeypatch): + def test_result_text_is_minimal(self, data_source, monkeypatch): from unittest.mock import MagicMock from ipywidgets.widgets.widget import Widget @@ -181,8 +180,7 @@ def test_result_text_includes_column_names(self, data_source, monkeypatch): ) text_part = result.value[0] - assert "x" in text_part - assert "y" in text_part + assert text_part == "Chart displayed with title 'Test Chart'." assert "Test Chart" in text_part @pytest.mark.ggsql From ca6b75df214a85e4544da8277c8ab521ed687e87 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 8 Apr 2026 19:56:24 -0500 Subject: [PATCH 07/31] refactor(imports): hoist stdlib/hard-dep imports, keep only optional deps lazy Move pathlib, copy, importlib.util, and chevron to top-level imports. Move internal module imports (_viz_altair_widget, _viz_ggsql) to top level in _viz_tools.py. Keep ggsql, altair, and shinywidgets as runtime imports since they are optional (viz extra). Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/_utils.py | 6 ++---- pkg-py/src/querychat/_viz_altair_widget.py | 3 +-- pkg-py/src/querychat/_viz_tools.py | 7 ++----- pkg-py/src/querychat/_viz_utils.py | 7 ++++--- 4 files changed, 9 insertions(+), 14 deletions(-) diff --git a/pkg-py/src/querychat/_utils.py b/pkg-py/src/querychat/_utils.py index 082860347..cb6fc379b 100644 --- a/pkg-py/src/querychat/_utils.py +++ b/pkg-py/src/querychat/_utils.py @@ -4,8 +4,10 @@ import re import warnings from contextlib import contextmanager +from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, Optional, overload +import chevron import narwhals.stable.v1 as nw from great_tables import GT @@ -308,10 +310,6 @@ def to_polars(data: IntoFrame) -> pl.DataFrame: 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) diff --git a/pkg-py/src/querychat/_viz_altair_widget.py b/pkg-py/src/querychat/_viz_altair_widget.py index 14f40202c..0d6f7f69a 100644 --- a/pkg-py/src/querychat/_viz_altair_widget.py +++ b/pkg-py/src/querychat/_viz_altair_widget.py @@ -2,6 +2,7 @@ from __future__ import annotations +import copy from typing import TYPE_CHECKING, Any, cast from uuid import uuid4 @@ -130,8 +131,6 @@ def fit_chart_to_container( Subtracts padding estimates so the rendered cells fill the container, including space for legends when present. """ - import copy - import altair as alt chart = copy.copy(chart) diff --git a/pkg-py/src/querychat/_viz_tools.py b/pkg-py/src/querychat/_viz_tools.py index 860f05876..b691c9b30 100644 --- a/pkg-py/src/querychat/_viz_tools.py +++ b/pkg-py/src/querychat/_viz_tools.py @@ -17,6 +17,8 @@ from .__version import __version__ from ._icons import bs_icon from ._utils import read_prompt_template +from ._viz_altair_widget import AltairWidget, fit_chart_to_container +from ._viz_ggsql import execute_ggsql if TYPE_CHECKING: from collections.abc import Callable @@ -145,9 +147,6 @@ def visualize_query_impl( """Create the visualize_query implementation function.""" from ggsql import VegaLiteWriter, validate - from ._viz_altair_widget import AltairWidget - from ._viz_ggsql import execute_ggsql - def visualize_query( ggsql: str, title: str, @@ -218,8 +217,6 @@ def render_chart_to_png(chart: alt.TopLevelMixin) -> bytes: """Render an Altair chart to PNG bytes at a fixed size for LLM feedback.""" import altair as alt - from ._viz_altair_widget import fit_chart_to_container - chart = copy.deepcopy(chart) is_compound = isinstance( chart, diff --git a/pkg-py/src/querychat/_viz_utils.py b/pkg-py/src/querychat/_viz_utils.py index 0f5525cdb..eb9e0897d 100644 --- a/pkg-py/src/querychat/_viz_utils.py +++ b/pkg-py/src/querychat/_viz_utils.py @@ -2,8 +2,9 @@ from __future__ import annotations +import importlib.util + from htmltools import HTMLDependency, tags -from shinywidgets import output_widget from .__version import __version__ @@ -15,8 +16,6 @@ def has_viz_tool(tools: tuple[str, ...] | None) -> bool: def has_viz_deps() -> bool: """Check whether visualization dependencies (ggsql, altair, shinywidgets, vl-convert-python) are installed.""" - import importlib.util - return all( importlib.util.find_spec(pkg) is not None for pkg in ("ggsql", "altair", "shinywidgets", "vl_convert") @@ -28,6 +27,8 @@ def has_viz_deps() -> bool: def preload_viz_deps_ui(): """Return a hidden widget output that triggers eager JS dependency loading.""" + from shinywidgets import output_widget + return tags.div( output_widget(PRELOAD_WIDGET_ID), viz_preload_dep(), From fa6f9667c2c48dd3660525d3d4f002a13403dc26 Mon Sep 17 00:00:00 2001 From: Carson Date: Wed, 8 Apr 2026 19:49:05 -0500 Subject: [PATCH 08/31] refactor(prompts): restructure system and tool prompts for unified capabilities - Move visualization into "Your Capabilities" as a peer of filtering/querying - Add "Choosing Between Query and Visualization" decision preamble - Clean up conditional branching (drop viz-only fallback, simplify nesting) - Integrate viz suggestions into existing suggestion examples - Replace "(if available)" hedging with Mustache conditionals - Trim tool-query.md and tool-update-dashboard.md: remove routing guidance and cross-tool references now covered by the main prompt - Add structural verification tests for prompt conditionals Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/prompts/prompt.md | 180 +++++++++--------- pkg-py/src/querychat/prompts/tool-query.md | 16 +- .../prompts/tool-update-dashboard.md | 8 +- pkg-py/tests/test_system_prompt.py | 52 +++++ 4 files changed, 142 insertions(+), 114 deletions(-) diff --git a/pkg-py/src/querychat/prompts/prompt.md b/pkg-py/src/querychat/prompts/prompt.md index 4a08d949f..611f94e23 100644 --- a/pkg-py/src/querychat/prompts/prompt.md +++ b/pkg-py/src/querychat/prompts/prompt.md @@ -117,27 +117,102 @@ Response: "The average revenue is $X." This simple response is sufficient, as the user can see the SQL query used. - {{/has_tool_query}} +{{#has_tool_visualize_query}} +### Visualizing Data + +You can create visualizations using the `querychat_visualize_query` tool, which uses ggsql — a SQL extension for declarative data visualization. Write a ggsql query (SQL with a VISUALISE clause), and the tool executes the SQL, renders the VISUALISE clause as an Altair chart, and displays it inline in the chat. + +#### Visualization best practices + +The database schema in this prompt includes column names, types, and summary statistics. {{#has_tool_query}}If that context isn't sufficient for a confident visualization — e.g., you're unsure about value distributions, need to check for NULLs, or want to gauge row counts before choosing a chart type — use the `querychat_query` tool to inspect the data before visualizing. Pass `collapsed=True` for these preparatory queries so the results don't clutter the conversation.{{/has_tool_query}} + +Follow the principles below to produce clear, interpretable charts. + +#### Axis labels must be readable + +When the x-axis contains categorical labels (names, categories, long strings), prefer flipping axes with `PROJECT y, x TO cartesian` so labels read naturally left-to-right. Short numeric or date labels on the x-axis are fine horizontal — this applies specifically to text categories. + +#### Always include axis labels with units + +Charts should be interpretable without reading the surrounding prose. Always include axis labels that describe what is shown, including units when applicable (e.g., `LABEL y => 'Revenue ($M)'`, not just `LABEL y => 'Revenue'`). + +#### Maximize data-ink ratio + +Every visual element should serve a purpose: + +- Don't map columns to aesthetics (color, size, shape) unless the distinction is meaningful to the user's question. A single-series bar chart doesn't need color. +- When using color for categories, keep to 7 or fewer distinct values. Beyond that, consider filtering to the most important categories or using facets instead. +- Avoid dual-encoding the same variable (e.g., mapping the same column to both x-position and color) unless it genuinely aids interpretation. + +#### Avoid overplotting + +When a dataset has many rows, plotting one mark per row creates clutter that obscures patterns. Before generating a query, consider the row count and data characteristics visible in the schema. + +**For large datasets (hundreds+ rows):** + +- **Aggregate first**: Use `GROUP BY` with `COUNT`, `AVG`, `SUM`, or other aggregates to reduce to meaningful summaries before visualizing. +- **Choose chart types that summarize naturally**: histograms for distributions, boxplots for group comparisons, line charts for trends over time. + +**For two numeric variables with many rows:** + +Bin in SQL and use `DRAW rect` to create a heatmap: + +```sql +WITH binned AS ( + SELECT ROUND(x_col / 5) * 5 AS x_bin, + ROUND(y_col / 5) * 5 AS y_bin, + COUNT(*) AS n + FROM large_table + GROUP BY x_bin, y_bin +) +SELECT * FROM binned +VISUALISE x_bin AS x, y_bin AS y, n AS fill +DRAW rect +SCALE fill TO viridis +``` + +**If individual points matter** (e.g., outlier detection): use `SETTING opacity` to reveal density through overlap. + +#### Choose chart types based on the data relationship + +Match the chart type to what the user is trying to understand: + +- **Comparison across categories**: bar chart (`DRAW bar`, with `PROJECT y, x TO cartesian` for long labels). Order bars by value, not alphabetically. +- **Trend over time**: line chart (`DRAW line`). Use `SCALE x VIA date` for date columns. +- **Distribution of a single variable**: histogram (`DRAW histogram`) or density (`DRAW density`). +- **Relationship between two numeric variables**: scatter plot (`DRAW point`), but prefer aggregation or heatmap if the dataset is large. +- **Part-of-whole**: stacked bar chart (map subcategory to `fill`). Avoid pie charts — position along a common scale is easier to decode than angle. + +#### Graceful recovery + +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.{{#has_tool_query}} If the error persists, fall back to `querychat_query` for a tabular answer.{{/has_tool_query}} + +#### ggsql syntax reference + +The syntax reference below covers all available clauses, geom types, scales, and examples. + +{{> ggsql-syntax}} +{{/has_tool_visualize_query}} {{#has_tool_query}} {{#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. +Use `querychat_query` for questions with single-value answers (averages, counts, totals, specific lookups). Use `querychat_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 +### Visualization Requests -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. +You cannot create charts or visualizations. If users ask for a plot, chart, or visual representation of the data, explain that visualization is not currently enabled.{{#has_tool_query}} Offer to answer their question with a tabular query instead.{{/has_tool_query}} Suggest that the developer can enable visualization by installing `querychat[viz]` and adding `"visualize_query"` to the `tools` parameter. {{/has_tool_visualize_query}} -{{#has_tool_visualize_query}} +{{^has_tool_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. +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_query}} @@ -163,22 +238,20 @@ You might want to explore the advanced features **Nested lists:** ```md +{{#has_tool_query}} * Analyze the data * What's the average …? * How many …? -* Filter and sort - * Show records from the year … - * Sort the ____ by ____ … -``` +{{/has_tool_query}} {{#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}} +* Filter and sort + * Show records from the year … + * Sort the ____ by ____ … +``` #### When to Include Suggestions @@ -206,83 +279,6 @@ 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. - -### Visualization best practices - -The database schema in this prompt includes column names, types, and summary statistics. If that context isn't sufficient for a confident visualization — e.g., you're unsure about value distributions, need to check for NULLs, or want to gauge row counts before choosing a chart type — use the `querychat_query` tool (if available) to inspect the data before visualizing. Pass `collapsed=True` for these preparatory queries so the results don't clutter the conversation. - -Follow the principles below to produce clear, interpretable charts. - -#### Axis labels must be readable - -When the x-axis contains categorical labels (names, categories, long strings), prefer flipping axes with `PROJECT y, x TO cartesian` so labels read naturally left-to-right. Short numeric or date labels on the x-axis are fine horizontal — this applies specifically to text categories. - -#### Always include axis labels with units - -Charts should be interpretable without reading the surrounding prose. Always include axis labels that describe what is shown, including units when applicable (e.g., `LABEL y => 'Revenue ($M)'`, not just `LABEL y => 'Revenue'`). - -#### Maximize data-ink ratio - -Every visual element should serve a purpose: - -- Don't map columns to aesthetics (color, size, shape) unless the distinction is meaningful to the user's question. A single-series bar chart doesn't need color. -- When using color for categories, keep to 7 or fewer distinct values. Beyond that, consider filtering to the most important categories or using facets instead. -- Avoid dual-encoding the same variable (e.g., mapping the same column to both x-position and color) unless it genuinely aids interpretation. - -#### Avoid overplotting - -When a dataset has many rows, plotting one mark per row creates clutter that obscures patterns. Before generating a query, consider the row count and data characteristics visible in the schema. - -**For large datasets (hundreds+ rows):** - -- **Aggregate first**: Use `GROUP BY` with `COUNT`, `AVG`, `SUM`, or other aggregates to reduce to meaningful summaries before visualizing. -- **Choose chart types that summarize naturally**: histograms for distributions, boxplots for group comparisons, line charts for trends over time. - -**For two numeric variables with many rows:** - -Bin in SQL and use `DRAW rect` to create a heatmap: - -```sql -WITH binned AS ( - SELECT ROUND(x_col / 5) * 5 AS x_bin, - ROUND(y_col / 5) * 5 AS y_bin, - COUNT(*) AS n - FROM large_table - GROUP BY x_bin, y_bin -) -SELECT * FROM binned -VISUALISE x_bin AS x, y_bin AS y, n AS fill -DRAW rect -SCALE fill TO viridis -``` - -**If individual points matter** (e.g., outlier detection): use `SETTING opacity` to reveal density through overlap. - -#### Choose chart types based on the data relationship - -Match the chart type to what the user is trying to understand: - -- **Comparison across categories**: bar chart (`DRAW bar`, with `PROJECT y, x TO cartesian` for long labels). Order bars by value, not alphabetically. -- **Trend over time**: line chart (`DRAW line`). Use `SCALE x VIA date` for date columns. -- **Distribution of a single variable**: histogram (`DRAW histogram`) or density (`DRAW density`). -- **Relationship between two numeric variables**: scatter plot (`DRAW point`), but prefer aggregation or heatmap if the dataset is large. -- **Part-of-whole**: stacked bar chart (map subcategory to `fill`). Avoid pie charts — position along a common scale is easier to decode than angle. - -### Graceful recovery - -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.{{#has_tool_query}} If the error persists, fall back to `querychat_query` for a tabular answer.{{/has_tool_query}} - -### ggsql syntax reference - -The syntax reference below covers all available clauses, geom types, scales, and examples. - -{{> ggsql-syntax}} -{{/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 164c95b55..312b85fcc 100644 --- a/pkg-py/src/querychat/prompts/tool-query.md +++ b/pkg-py/src/querychat/prompts/tool-query.md @@ -1,21 +1,7 @@ -Execute a SQL query and return the results - -This tool executes a {{db_type}} SQL SELECT query against the database and returns the raw result data for analysis. - -**When to use:** Call this tool whenever the user asks a question that requires data analysis, aggregation, or calculations. Use this for questions like: -- "What is the average...?" -- "How many records...?" -- "Which item has the highest/lowest...?" -- "What's the total sum of...?" -- "What percentage of ...?" - -Always use SQL for counting, averaging, summing, and other calculations—NEVER attempt manual calculations on your own. Use this tool repeatedly if needed to avoid any kind of manual calculation. - -**When not to use:** Do NOT use this tool for filtering or sorting the dashboard display. If the user wants to "Show me..." or "Filter to..." certain records in the dashboard, use the `querychat_update_dashboard` tool instead. +Execute a {{db_type}} SQL SELECT query and return the results for analysis. **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-update-dashboard.md b/pkg-py/src/querychat/prompts/tool-update-dashboard.md index dae9861c0..0b98d219b 100644 --- a/pkg-py/src/querychat/prompts/tool-update-dashboard.md +++ b/pkg-py/src/querychat/prompts/tool-update-dashboard.md @@ -1,10 +1,4 @@ -Filter and sort the dashboard data - -This tool executes a {{db_type}} SQL SELECT query to filter or sort the data used in the dashboard. - -**When to use:** Call this tool whenever the user requests filtering, sorting, or data manipulation on the dashboard with questions like "Show me..." or "Which records have...". This tool is appropriate for any request that involves showing a subset of the data or reordering it. - -**When not to use:** Do NOT use this tool for general questions about the data that can be answered with a single value or summary statistic. For those questions, use the `querychat_query` tool instead. +Filter and sort the dashboard data by executing a {{db_type}} SQL SELECT query. **Important constraints:** diff --git a/pkg-py/tests/test_system_prompt.py b/pkg-py/tests/test_system_prompt.py index 153a1bce2..68bf6c1c6 100644 --- a/pkg-py/tests/test_system_prompt.py +++ b/pkg-py/tests/test_system_prompt.py @@ -352,3 +352,55 @@ def test_graceful_recovery_fallback_included_with_query_tool( rendered = prompt.render(tools=("update", "query", "visualize_query")) assert "fall back to" in rendered + + def test_viz_only_has_no_cannot_query_message(self, sample_data_source): + """ + When only visualize_query is enabled (no query tool), the rendered prompt + should NOT contain "cannot query or analyze" and SHOULD contain + "Visualizing Data". + """ + from pathlib import Path + + template_path = ( + Path(__file__).parent.parent + / "src" + / "querychat" + / "prompts" + / "prompt.md" + ) + prompt = QueryChatSystemPrompt( + prompt_template=template_path, + data_source=sample_data_source, + ) + + rendered = prompt.render(tools=("visualize_query",)) + + assert "cannot query or analyze" not in rendered + assert "Visualizing Data" in rendered + + def test_choosing_section_only_with_both_tools(self, sample_data_source): + """ + The "Choosing Between Query and Visualization" section should only appear + when both query and visualize_query are enabled. + """ + from pathlib import Path + + template_path = ( + Path(__file__).parent.parent + / "src" + / "querychat" + / "prompts" + / "prompt.md" + ) + prompt = QueryChatSystemPrompt( + prompt_template=template_path, + data_source=sample_data_source, + ) + + rendered_both = prompt.render(tools=("query", "visualize_query")) + rendered_query_only = prompt.render(tools=("query",)) + rendered_viz_only = prompt.render(tools=("visualize_query",)) + + assert "Choosing Between Query and Visualization" in rendered_both + assert "Choosing Between Query and Visualization" not in rendered_query_only + assert "Choosing Between Query and Visualization" not in rendered_viz_only From 7cd9f9e63133a031c632e601b44167eff26de068 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 9 Apr 2026 09:34:31 -0500 Subject: [PATCH 09/31] fix(prompts): favor viz over redundant tables, collapse preparatory queries Guide the LLM to prefer visualization for comparisons/distributions/trends and always collapse query results when a chart covers the same data. Also remove stale _extract_column_names monkeypatch that was failing CI. Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/prompts/prompt.md | 6 ++++-- pkg-py/tests/test_viz_footer.py | 3 --- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/pkg-py/src/querychat/prompts/prompt.md b/pkg-py/src/querychat/prompts/prompt.md index 611f94e23..652ea9482 100644 --- a/pkg-py/src/querychat/prompts/prompt.md +++ b/pkg-py/src/querychat/prompts/prompt.md @@ -125,7 +125,7 @@ You can create visualizations using the `querychat_visualize_query` tool, which #### Visualization best practices -The database schema in this prompt includes column names, types, and summary statistics. {{#has_tool_query}}If that context isn't sufficient for a confident visualization — e.g., you're unsure about value distributions, need to check for NULLs, or want to gauge row counts before choosing a chart type — use the `querychat_query` tool to inspect the data before visualizing. Pass `collapsed=True` for these preparatory queries so the results don't clutter the conversation.{{/has_tool_query}} +The database schema in this prompt includes column names, types, and summary statistics. {{#has_tool_query}}If that context isn't sufficient for a confident visualization — e.g., you're unsure about value distributions, need to check for NULLs, or want to gauge row counts before choosing a chart type — use the `querychat_query` tool to inspect the data before visualizing. Always pass `collapsed=True` for these preparatory queries so the chart remains the focal point of the response.{{/has_tool_query}} Follow the principles below to produce clear, interpretable charts. @@ -198,7 +198,9 @@ The syntax reference below covers all available clauses, geom types, scales, and {{#has_tool_visualize_query}} ### Choosing Between Query and Visualization -Use `querychat_query` for questions with single-value answers (averages, counts, totals, specific lookups). Use `querychat_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. +Use `querychat_query` for single-value answers (averages, counts, totals, specific lookups) or when the user needs to see exact values. Use `querychat_visualize_query` when comparisons, distributions, or trends are involved — even for small result sets, a chart is often clearer than a short table. + +**Avoid redundant expanded results.** If you run a preparatory query before visualizing, or if both a table and chart would show the same data, always pass `collapsed=True` on the query so the user sees the chart prominently, not a duplicate table above it. The user can still expand the table if they want the exact values. {{/has_tool_visualize_query}} {{/has_tool_query}} diff --git a/pkg-py/tests/test_viz_footer.py b/pkg-py/tests/test_viz_footer.py index 4c80bfc4f..7051fec43 100644 --- a/pkg-py/tests/test_viz_footer.py +++ b/pkg-py/tests/test_viz_footer.py @@ -64,9 +64,6 @@ def _patch_deps(monkeypatch): mock_vl_writer = MagicMock() mock_vl_writer.render_chart.return_value = mock_raw_chart monkeypatch.setattr(ggsql, "VegaLiteWriter", lambda: mock_vl_writer) - monkeypatch.setattr( - _viz_tools, "_extract_column_names", lambda _chart: ["x", "y"] - ) monkeypatch.setattr( _viz_tools, "render_chart_to_png", lambda _chart: b"\x89PNG\r\n\x1a\n" ) From 8c4a78154e29557bc877d0991872bc1e2b12dfc1 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 9 Apr 2026 09:39:24 -0500 Subject: [PATCH 10/31] docs: add viz tool and collapsed query param to changelog Co-Authored-By: Claude Opus 4.6 --- pkg-py/CHANGELOG.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index c3346cfde..c962153af 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -7,14 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Improvements +### New features -* When a custom `prompt_template` is provided that doesn't contain Mustache references to `{{schema}}`, the expensive `get_schema()` call is now skipped entirely. This allows users with large databases to avoid slow startup by providing their own prompt that includes schema information inline (or omits it). (#208) +* Added a `"visualize_query"` tool that lets the LLM create inline Altair charts from natural language requests using [ggsql](https://github.com/posit-dev/ggsql) — a SQL extension for declarative data visualization. Include it via `tools=("query", "visualize_query")` (or alongside `"update"`). Charts render inline in the chat with fullscreen support, a "Show Query" toggle, and Save as PNG/SVG. Install the optional dependencies with `pip install querychat[viz]`. (#219) -### New features +* The `querychat_query` tool now accepts an optional `collapsed` parameter. When `collapsed=True`, the result card starts collapsed so preparatory or exploratory queries don't clutter the conversation. The LLM is guided to use this automatically when running queries before a visualization. * Added support for Snowflake Semantic Views. When connected to Snowflake (via SQLAlchemy or Ibis), querychat automatically discovers available Semantic Views and includes their definitions in the system prompt. This helps the LLM generate correct queries using the `SEMANTIC_VIEW()` table function with certified business metrics and dimensions. (#200) +### Improvements + +* When a custom `prompt_template` is provided that doesn't contain Mustache references to `{{schema}}`, the expensive `get_schema()` call is now skipped entirely. This allows users with large databases to avoid slow startup by providing their own prompt that includes schema information inline (or omits it). (#208) + ## [0.5.1] - 2026-01-23 ### New features From 127a0669bd5f4636187f6476a81d5561efe27d73 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 9 Apr 2026 09:48:36 -0500 Subject: [PATCH 11/31] fix: address Copilot PR review feedback - Include vl-convert-python in viz dependency error message - Use querychat_tool_starts_open() for viz tool open state - Fix docs: viz tool is opt-in, not default - Fix docs: each viz call produces a new message, not a replacement Co-Authored-By: Claude Opus 4.6 --- pkg-py/docs/build.qmd | 4 ++-- pkg-py/docs/tools.qmd | 2 +- pkg-py/src/querychat/_querychat_base.py | 4 ++-- pkg-py/src/querychat/_viz_tools.py | 4 ++-- pyproject.toml | 3 +++ 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/pkg-py/docs/build.qmd b/pkg-py/docs/build.qmd index 88e8cc130..29cf82066 100644 --- a/pkg-py/docs/build.qmd +++ b/pkg-py/docs/build.qmd @@ -34,8 +34,8 @@ qc = QueryChat(titanic(), "titanic") ::: {.callout-tip} ### Visualization support -By default, querychat includes a visualization tool that lets the LLM create inline charts. -You can control which tools are available with the `tools` parameter. +querychat supports an optional visualization tool that lets the LLM create inline charts. +Enable it by including `"visualize_query"` in the `tools` parameter. See [Visualizations](visualize.qmd) for details. ::: diff --git a/pkg-py/docs/tools.qmd b/pkg-py/docs/tools.qmd index 0889c07dd..09b46be9d 100644 --- a/pkg-py/docs/tools.qmd +++ b/pkg-py/docs/tools.qmd @@ -62,7 +62,7 @@ This tool: 3. Displays the chart inline in the chat Unlike the data updating tools, visualization queries don't affect the dashboard filter. -They query the full dataset independently, and each call replaces the previous visualization. +They query the full dataset independently, and each call produces a new inline chart message in the chat. The inline chart includes controls for fullscreen viewing, saving as PNG/SVG, and a "Show Query" toggle that reveals the underlying ggsql code. diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index 280d645d1..40b0084bd 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -307,7 +307,7 @@ def normalize_tools( 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]" + "Visualization tools require ggsql, altair, shinywidgets, and " + "vl-convert-python. Install them with: pip install querychat[viz]" ) return result diff --git a/pkg-py/src/querychat/_viz_tools.py b/pkg-py/src/querychat/_viz_tools.py index b691c9b30..b9c7759f8 100644 --- a/pkg-py/src/querychat/_viz_tools.py +++ b/pkg-py/src/querychat/_viz_tools.py @@ -16,7 +16,7 @@ from .__version import __version__ from ._icons import bs_icon -from ._utils import read_prompt_template +from ._utils import querychat_tool_starts_open, read_prompt_template from ._viz_altair_widget import AltairWidget, fit_chart_to_container from ._viz_ggsql import execute_ggsql @@ -125,7 +125,7 @@ def __init__( html=widget_html, title=title or "Query Visualization", show_request=False, - open=True, + open=querychat_tool_starts_open("visualize_query"), full_screen=True, icon=bs_icon("graph-up"), footer=footer, diff --git a/pyproject.toml b/pyproject.toml index b35d9e652..f31278dcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,6 +58,9 @@ Issues = "https://github.com/posit-dev/querychat/issues" Source = "https://github.com/posit-dev/querychat/tree/main/pkg-py" [tool.uv] +# Restrict lock-file resolution to platforms we actually target in CI. +# Without this, uv may resolve dependency versions whose wheels aren't +# available on all platforms (e.g. non-x86_64 Linux), causing CI failures. required-environments = [ "sys_platform == 'linux' and platform_machine == 'x86_64'", "sys_platform == 'darwin'", From 014f2796431186f691623bd896955084870da7a9 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 13:43:51 -0500 Subject: [PATCH 12/31] chore: update github remotes --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index f31278dcf..3cbdb8e0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,8 +21,8 @@ maintainers = [ ] dependencies = [ "duckdb", - "shiny @ git+https://github.com/posit-dev/py-shiny.git@feat/ggsql-language", - "shinychat @ git+https://github.com/posit-dev/shinychat.git@fix/simplify-message-storage-and-streaming-deps", + "shiny @ git+https://github.com/posit-dev/py-shiny.git", + "shinychat @ git+https://github.com/posit-dev/shinychat.git", "htmltools", "chatlas>=0.13.2", "narwhals", From a8aeae4241882fde8db6306ea353b4e6734f5e4a Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 17:08:49 -0500 Subject: [PATCH 13/31] fix: add stream_content stub to DummyProvider for chatlas 0.16.0 compat chatlas 0.16.0 added stream_content as a new abstract method on Provider, causing DummyProvider instantiation to fail in CI. Co-Authored-By: Claude Opus 4.6 --- pkg-py/tests/test_shiny_viz_regressions.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg-py/tests/test_shiny_viz_regressions.py b/pkg-py/tests/test_shiny_viz_regressions.py index 6a946162a..b9d51772c 100644 --- a/pkg-py/tests/test_shiny_viz_regressions.py +++ b/pkg-py/tests/test_shiny_viz_regressions.py @@ -101,6 +101,9 @@ async def chat_perform_async( ): return () if stream else SimpleNamespace() + def stream_content(self, chunk): + return None + def stream_text(self, chunk): return None From b2533638974726db0b7596aee858a51d23bee56b Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 17:19:43 -0500 Subject: [PATCH 14/31] fix(viz): use deepcopy in fit_chart_to_container to avoid mutating input shallow copy + nested attribute mutation was modifying the original chart's sub-specs through shared references, violating the function's documented contract. Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/_viz_altair_widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg-py/src/querychat/_viz_altair_widget.py b/pkg-py/src/querychat/_viz_altair_widget.py index 0d6f7f69a..00d40347d 100644 --- a/pkg-py/src/querychat/_viz_altair_widget.py +++ b/pkg-py/src/querychat/_viz_altair_widget.py @@ -133,7 +133,7 @@ def fit_chart_to_container( """ import altair as alt - chart = copy.copy(chart) + chart = copy.deepcopy(chart) # Approximate padding; will be replaced when ggsql handles compound sizing # natively (https://github.com/posit-dev/ggsql/issues/238). From f51c7185e4dc58cea071eb551a520d95bf61cd7c Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 17:27:37 -0500 Subject: [PATCH 15/31] fix(viz): use as_narwhals in to_polars to fix ibis source compatibility narwhals.stable.v1.from_native wraps ibis Tables as DataFrame (not LazyFrame), so the isinstance(nw_df, nw.LazyFrame) check was False and collect() was never called, causing 'IbisLazyFrame has no attribute to_polars'. Delegate to as_narwhals() which already handles ibis correctly. Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/_utils.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/pkg-py/src/querychat/_utils.py b/pkg-py/src/querychat/_utils.py index cb6fc379b..9127b98a8 100644 --- a/pkg-py/src/querychat/_utils.py +++ b/pkg-py/src/querychat/_utils.py @@ -302,10 +302,7 @@ def df_to_html(df, maxrows: int = 5) -> str: 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() + return as_narwhals(data).to_polars() def read_prompt_template(filename: str, **kwargs: object) -> str: From 2e04dd21534628fa682003cfe3bfadbac4ac4a75 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 17:44:25 -0500 Subject: [PATCH 16/31] fix(prompts): improve column casing guidance for Snowflake uppercase identifiers Snowflake uppercases unquoted identifiers, causing case mismatches when ggsql validates VISUALISE column references against DuckDB results. Replace the generic casing note with explicit wrong/correct examples instructing the LLM to alias uppercase columns to lowercase in SELECT. Borrowed from ggsqlbot's proven prompt pattern. Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/prompts/ggsql-syntax.md | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pkg-py/src/querychat/prompts/ggsql-syntax.md b/pkg-py/src/querychat/prompts/ggsql-syntax.md index 4e26f90f0..3f0d52b36 100644 --- a/pkg-py/src/querychat/prompts/ggsql-syntax.md +++ b/pkg-py/src/querychat/prompts/ggsql-syntax.md @@ -511,7 +511,19 @@ PROJECT TO polar SETTING inner => 0.5 DRAW line MAPPING region AS color FILTER layer_type = 'summary' ``` 4. **String values use single quotes**: In SETTING, LABEL, and RENAMING clauses, always use single quotes for string values. Double quotes cause parse errors. -5. **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. **Column casing in VISUALISE**: DuckDB lowercases unquoted column names in query results, and VISUALISE validates column references **case-sensitively**. If your source table has uppercase column names (e.g., from Snowflake), you **must** alias them to lowercase in the SELECT clause: + ```sql + -- WRONG: VISUALISE references uppercase name, but DuckDB lowercases it in results + SELECT ROOM_TYPE, COUNT(*) AS listings FROM airbnb + VISUALISE ROOM_TYPE AS x, listings AS y + DRAW bar + + -- CORRECT: Alias to lowercase, then reference the alias + SELECT ROOM_TYPE AS room_type, COUNT(*) AS listings FROM airbnb + VISUALISE room_type AS x, listings AS y + DRAW bar + ``` + As a general rule, always use lowercase column names and aliases in both SELECT and VISUALISE clauses. 6. **Charts vs Tables**: For visualizations use VISUALISE with DRAW. For tabular data use plain SQL without VISUALISE. 7. **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. 8. **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: From 372b8e6fd7f5a82c52e08f82f4a33ebbcffd9c88 Mon Sep 17 00:00:00 2001 From: Carson Date: Thu, 16 Apr 2026 17:56:55 -0500 Subject: [PATCH 17/31] fix(viz): lowercase DataFrame columns before DuckDB registration Snowflake uppercases all unquoted identifiers, so columns come back as AVG_INTEREST_RATE even when the LLM writes AS avg_interest_rate. Since ggsql validates VISUALISE column references case-sensitively and DuckDB is case-insensitive, lowercasing the DataFrame columns before registering ensures the LLM's lowercase references always match. Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/_viz_ggsql.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pkg-py/src/querychat/_viz_ggsql.py b/pkg-py/src/querychat/_viz_ggsql.py index 076b4f3b3..b1a950363 100644 --- a/pkg-py/src/querychat/_viz_ggsql.py +++ b/pkg-py/src/querychat/_viz_ggsql.py @@ -48,6 +48,10 @@ def execute_ggsql(data_source: DataSource, validated: ggsql.Validated) -> ggsql. ) pl_df = to_polars(data_source.execute_query(validated.sql())) + # Snowflake (and some other backends) uppercase unquoted identifiers, + # but the LLM writes lowercase aliases in the VISUALISE clause. + # DuckDB is case-insensitive, so lowercasing here lets both match. + pl_df.columns = [c.lower() for c in pl_df.columns] reader = DuckDBReader("duckdb://memory") table = extract_visualise_table(visual) From abdaa9a108cc8f33e5eb12102f54f04a0f8ab13c Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 17 Apr 2026 22:26:37 -0500 Subject: [PATCH 18/31] feat: add truncate_error for capping tool error messages Adds truncate_error() in _utils to cap error strings sent to the LLM, stripping schema dumps and applying a hard character limit. Wired into all tool error catch blocks in tools.py and _viz_tools.py. Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/_querychat_core.py | 2 + pkg-py/src/querychat/_utils.py | 42 ++++++++++++++++++++ pkg-py/src/querychat/_viz_tools.py | 6 +-- pkg-py/src/querychat/tools.py | 9 +++-- pkg-py/tests/test_truncate_error.py | 52 +++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 7 deletions(-) create mode 100644 pkg-py/tests/test_truncate_error.py diff --git a/pkg-py/src/querychat/_querychat_core.py b/pkg-py/src/querychat/_querychat_core.py index af0685e01..1dd132631 100644 --- a/pkg-py/src/querychat/_querychat_core.py +++ b/pkg-py/src/querychat/_querychat_core.py @@ -165,6 +165,8 @@ def format_tool_result(result: ContentToolResult) -> str: return str(result) + + def format_query_error(e: Exception) -> str: """Format a query error with helpful guidance.""" error_msg = str(e).lower() diff --git a/pkg-py/src/querychat/_utils.py b/pkg-py/src/querychat/_utils.py index 9127b98a8..6d4c803d8 100644 --- a/pkg-py/src/querychat/_utils.py +++ b/pkg-py/src/querychat/_utils.py @@ -20,6 +20,48 @@ from narwhals.stable.v1.typing import IntoFrame +_SCHEMA_DUMP_PATTERN = re.compile( + r"^\s*[\{\[]|'additionalProperties'|\"additionalProperties\"", +) + + +def truncate_error(error_msg: str, max_chars: int = 500) -> str: + if len(error_msg) <= max_chars: + return error_msg + + lines = error_msg.split("\n") + meaningful: list[str] = [] + truncated_by_schema = False + for line in lines: + if not line.strip(): + truncated_by_schema = True + break + if _SCHEMA_DUMP_PATTERN.search(line): + truncated_by_schema = True + break + meaningful.append(line) + + if truncated_by_schema and meaningful: + prefix = "\n".join(meaningful) + if len(prefix) > max_chars: + cut = prefix[:max_chars] + last_space = cut.rfind(" ") + if last_space > max_chars // 2: + cut = cut[:last_space] + prefix = cut + return prefix.rstrip() + "\n\n(error truncated)" + + # No schema markers found (or nothing before them) — apply hard cap if needed + if len(error_msg) <= max_chars: + return error_msg + + truncated = error_msg[:max_chars] + last_space = truncated.rfind(" ") + if last_space > max_chars // 2: + truncated = truncated[:last_space] + return truncated.rstrip() + "\n\n(error truncated)" + + class MISSING_TYPE: # noqa: N801 """ A singleton representing a missing value. diff --git a/pkg-py/src/querychat/_viz_tools.py b/pkg-py/src/querychat/_viz_tools.py index b9c7759f8..aed06b6f5 100644 --- a/pkg-py/src/querychat/_viz_tools.py +++ b/pkg-py/src/querychat/_viz_tools.py @@ -16,7 +16,7 @@ from .__version import __version__ from ._icons import bs_icon -from ._utils import querychat_tool_starts_open, read_prompt_template +from ._utils import querychat_tool_starts_open, read_prompt_template, truncate_error from ._viz_altair_widget import AltairWidget, fit_chart_to_container from ._viz_ggsql import execute_ggsql @@ -202,9 +202,9 @@ def visualize_query( ) except Exception as e: - error_msg = str(e) + error_msg = truncate_error(str(e)) markdown += f"\n\n> Error: {error_msg}" - return ContentToolResult(value=markdown, error=e) + return ContentToolResult(value=markdown, error=Exception(error_msg)) return visualize_query diff --git a/pkg-py/src/querychat/tools.py b/pkg-py/src/querychat/tools.py index 5336df5a2..27e42909f 100644 --- a/pkg-py/src/querychat/tools.py +++ b/pkg-py/src/querychat/tools.py @@ -11,6 +11,7 @@ df_to_html, querychat_tool_starts_open, read_prompt_template, + truncate_error, ) from ._viz_tools import tool_visualize_query @@ -107,9 +108,9 @@ def update_dashboard(query: str, title: str) -> ContentToolResult: update_fn({"query": query, "title": title}) except Exception as e: - error = str(e) + error = truncate_error(str(e)) markdown += f"\n\n> Error: {error}" - return ContentToolResult(value=markdown, error=e) + return ContentToolResult(value=markdown, error=Exception(error)) # Return ContentToolResult with display metadata return ContentToolResult( @@ -247,9 +248,9 @@ def query( markdown += "\n\n" + str(tbl_html) except Exception as e: - error = str(e) + error = truncate_error(str(e)) markdown += f"\n\n> Error: {error}" - return ContentToolResult(value=markdown, error=e) + return ContentToolResult(value=markdown, error=Exception(error)) # Return ContentToolResult with display metadata return ContentToolResult( diff --git a/pkg-py/tests/test_truncate_error.py b/pkg-py/tests/test_truncate_error.py new file mode 100644 index 000000000..57b2db169 --- /dev/null +++ b/pkg-py/tests/test_truncate_error.py @@ -0,0 +1,52 @@ +"""Tests for truncate_error.""" + +from querychat._utils import truncate_error + + +class TestTruncateError: + def test_short_message_unchanged(self): + msg = "Column 'foo' not found" + assert truncate_error(msg) == msg + + def test_empty_string(self): + assert truncate_error("") == "" + + def test_short_message_with_blank_line_unchanged(self): + msg = "line1\n\nline2" + assert truncate_error(msg) == msg + + def test_truncates_at_blank_line(self): + msg = "Something went wrong\n\n" + "x" * 500 + result = truncate_error(msg) + assert result == "Something went wrong\n\n(error truncated)" + + def test_truncates_at_schema_dump_line(self): + msg = "Bad property\nFailed validating 'additionalProperties' in schema[0]:\n" + "x" * 500 + result = truncate_error(msg) + assert "Bad property" in result + assert "(error truncated)" in result + assert "{'additionalProperties'" not in result + + def test_hard_cap_on_long_single_line(self): + msg = "x " * 300 # 600 chars, single line, no schema markers + result = truncate_error(msg, max_chars=500) + assert len(result) <= 500 + len("\n\n(error truncated)") + assert result.endswith("\n\n(error truncated)") + + def test_hard_cap_cuts_on_word_boundary(self): + msg = "word " * 200 + result = truncate_error(msg, max_chars=100) + assert not result.split("\n\n(error truncated)")[0].endswith(" w") + + def test_preserves_first_line_of_altair_error(self): + first_line = "Additional properties are not allowed ('offset' was unexpected)" + schema_dump = "\n\nFailed validating 'additionalProperties' in schema[0]['properties']['encoding']:\n {'additionalProperties': False,\n 'properties': {'angle': " + "x" * 10000 + msg = first_line + schema_dump + result = truncate_error(msg) + assert result.startswith(first_line) + assert len(result) < 600 + + def test_custom_max_chars(self): + msg = "a" * 200 + result = truncate_error(msg, max_chars=100) + assert len(result) <= 100 + len("\n\n(error truncated)") From 878f42d2c58b301aa766213ce85fd73df62ed72d Mon Sep 17 00:00:00 2001 From: Carson Date: Fri, 17 Apr 2026 22:26:48 -0500 Subject: [PATCH 19/31] fix(prompts): update ggsql syntax guide and bump to v0.2.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates syntax guide for ggsql v0.2.4 breaking changes: rect→tile, linear merged into rule, array syntax to parentheses, updated text aesthetics. Fixes pre-existing errors (invalid position fill, nonexistent density stacking). Bumps ggsql dependency to >=0.2.4. Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/prompts/ggsql-syntax.md | 68 +++++++++----------- pkg-py/src/querychat/prompts/prompt.md | 4 +- pyproject.toml | 4 +- 3 files changed, 33 insertions(+), 43 deletions(-) diff --git a/pkg-py/src/querychat/prompts/ggsql-syntax.md b/pkg-py/src/querychat/prompts/ggsql-syntax.md index 3f0d52b36..9a6f0c016 100644 --- a/pkg-py/src/querychat/prompts/ggsql-syntax.md +++ b/pkg-py/src/querychat/prompts/ggsql-syntax.md @@ -15,10 +15,9 @@ DRAW geom_type [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]] +[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 @@ -63,17 +62,17 @@ DRAW geom_type | Category | Types | |----------|-------| -| Basic | `point`, `line`, `path`, `bar`, `area`, `rect`, `polygon`, `ribbon` | +| Basic | `point`, `line`, `path`, `bar`, `area`, `tile`, `polygon`, `ribbon` | | Statistical | `histogram`, `density`, `smooth`, `boxplot`, `violin` | -| Annotation | `text`, `segment`, `arrow`, `rule`, `linear`, `errorbar` | +| Annotation | `text`, `label`, `segment`, `arrow`, `rule`, `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. +- `tile` 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]`). +- `text` (or `label`) renders text labels. Map `label` for the text content. Settings: `format` (template string for label formatting), `offset` (pixel offset as `(x, y)`). Labels containing `\n` are automatically split into multiple lines. - `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. +- `rule` draws full-span reference lines. Map a value to `y` for a horizontal line or `x` for a vertical line. Optionally map `slope` to create diagonal reference lines: `y = a + slope * x` (when `y` is mapped) or `x = a + slope * y` (when `x` is mapped). +- `line` and `path` support continuously varying `linewidth`, `stroke`, and `opacity` aesthetics within groups. **Aesthetics (MAPPING):** @@ -84,7 +83,7 @@ DRAW geom_type | 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) | +| Rule | `slope` (for diagonal `rule` lines) | **PARTITION BY** groups data without visual encoding (useful for separate lines per group without color): @@ -115,16 +114,16 @@ PLACE rule SETTING y => 100 PLACE rule SETTING x => '2024-06-01' -- Multiple reference lines (array values) -PLACE rule SETTING y => [50, 75, 100] +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 +-- Diagonal reference line (y = -1 + 0.4 * x) +PLACE rule SETTING slope => 0.4, y => -1 ``` -`PLACE` supports any geom type but is most useful with `rule`, `linear`, `text`, `segment`, and `rect`. Use `PLACE` for fixed annotation values known at query time; use `DRAW` with `MAPPING` when values come from data columns. Unlike `DRAW`, `PLACE` has no `MAPPING`, `FILTER`, `PARTITION BY`, or `ORDER BY` sub-clauses. +`PLACE` supports any geom type but is most useful with `rule`, `text`, `segment`, and `tile`. Use `PLACE` for fixed annotation values known at query time; use `DRAW` with `MAPPING` when values come from data columns. Unlike `DRAW`, `PLACE` has no `MAPPING`, `FILTER`, `PARTITION BY`, or `ORDER BY` sub-clauses. Array values in PLACE SETTING are recycled into multiple rows only for supported aesthetics; geom parameters (like `offset` on `text`) are passed through as-is. ### Statistical Layers and REMAPPING @@ -143,9 +142,9 @@ Some layers compute statistics. Use REMAPPING to access computed values: `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. +`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'`). Use `REMAPPING intensity AS y` to show unnormalized density that reflects group size differences. Use `SETTING position => 'stack'` for stacked densities. -`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. +`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. Additional settings: `side` (`'both'` default, `'left'`/`'bottom'`, `'right'`/`'top'` — for half-violin/ridgeline plots), `width` (any value > 0; values > 1 enable ridgeline-style overlapping). **Examples:** @@ -193,9 +192,9 @@ SCALE [TYPE] aesthetic [FROM range] [TO output] [VIA transform] [SETTING prop => **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 +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: @@ -204,8 +203,8 @@ SCALE color TO sequential -- default continuous palette (derived from na 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 +SCALE color TO ('red', 'blue') -- explicit color array +SCALE size TO (1, 10) -- numeric output range ``` **VIA** — transformation: @@ -298,7 +297,7 @@ Creates small multiples (subplots by category). 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 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) ``` @@ -311,20 +310,11 @@ SCALE panel RENAMING 'N' => 'North', 'S' => 'South' ### LABEL Clause -Use LABEL for axis labels only. Do NOT use `LABEL title => ...` — the tool's `title` parameter handles chart titles. +Use LABEL for axis labels only. Do NOT use `LABEL title => ...` — the tool's `title` parameter handles chart titles. Set a label to `null` to suppress it. ```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' +LABEL x => null -- suppress x-axis label ``` ## Complete Examples @@ -336,7 +326,6 @@ 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):** @@ -374,11 +363,11 @@ VISUALISE FROM measurements DRAW density MAPPING value AS x, category AS color SETTING opacity => 0.7 ``` -**Heatmap with rect:** +**Heatmap with tile:** ```sql SELECT day, month, temperature FROM weather VISUALISE day AS x, month AS y, temperature AS color -DRAW rect +DRAW tile ``` **Threshold reference lines (using PLACE):** @@ -435,7 +424,7 @@ LABEL y => 'Temperature' 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' +DRAW text MAPPING n AS label SETTING offset => (0, -11), fill => 'white' ``` **Donut chart:** @@ -526,8 +515,9 @@ PROJECT TO polar SETTING inner => 0.5 As a general rule, always use lowercase column names and aliases in both SELECT and VISUALISE clauses. 6. **Charts vs Tables**: For visualizations use VISUALISE with DRAW. For tabular data use plain SQL without VISUALISE. 7. **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. -8. **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: +8. **Bar position adjustments**: Bars stack automatically when `fill` is mapped. Use `SETTING position => 'dodge'` for side-by-side bars, or `position => 'stack', total => 1` for proportional (100%) 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 + 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 + DRAW bar MAPPING category AS x, subcategory AS fill SETTING position => 'stack', total => 1 -- proportional ``` diff --git a/pkg-py/src/querychat/prompts/prompt.md b/pkg-py/src/querychat/prompts/prompt.md index 652ea9482..9123bbe40 100644 --- a/pkg-py/src/querychat/prompts/prompt.md +++ b/pkg-py/src/querychat/prompts/prompt.md @@ -156,7 +156,7 @@ When a dataset has many rows, plotting one mark per row creates clutter that obs **For two numeric variables with many rows:** -Bin in SQL and use `DRAW rect` to create a heatmap: +Bin in SQL and use `DRAW tile` to create a heatmap: ```sql WITH binned AS ( @@ -168,7 +168,7 @@ WITH binned AS ( ) SELECT * FROM binned VISUALISE x_bin AS x, y_bin AS y, n AS fill -DRAW rect +DRAW tile SCALE fill TO viridis ``` diff --git a/pyproject.toml b/pyproject.toml index b2bfcd343..c04ea5366 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ 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", "vl-convert-python>=1.3.0"] +viz = ["ggsql>=0.2.4", "altair>=6.0", "shinywidgets>=0.8.0", "vl-convert-python>=1.9.0"] [project.urls] Homepage = "https://github.com/posit-dev/querychat" # TODO update when we have docs @@ -87,7 +87,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", "ggsql>=0.1.8", "altair>=5.0", "shinywidgets>=0.3.0", "vl-convert-python>=1.3.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.2.4", "altair>=6.0", "shinywidgets>=0.8.0", "vl-convert-python>=1.9.0"] docs = ["quartodoc>=0.11.1", "griffe<2", "nbformat", "nbclient", "ipykernel"] examples = [ "openai", From b1ad65c6cbabe7e2f7bcd7f2534281d73b2e44f6 Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 20 Apr 2026 10:35:11 -0500 Subject: [PATCH 20/31] refactor: rename visualize_query tool to visualize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR feedback that "visualize_query" sounds like it might provide a visual representation of a query rather than visualizing data. Renames across source, prompts, docs, examples, and tests: - Tool name: querychat_visualize_query → querychat_visualize - Functions: tool_visualize_query → tool_visualize - Types: VisualizeQueryData → VisualizeData, VisualizeQueryResult → VisualizeResult - Template var: has_tool_visualize_query → has_tool_visualize - Prompt file: tool-visualize-query.md → tool-visualize.md - User-facing string: "visualize_query" → "visualize" Co-Authored-By: Claude Opus 4.6 --- pkg-py/CHANGELOG.md | 2 +- pkg-py/docs/_quarto.yml | 2 +- pkg-py/docs/build.qmd | 2 +- pkg-py/docs/tools.qmd | 6 +-- pkg-py/docs/visualize.qmd | 8 ++-- pkg-py/examples/10-viz-app.py | 25 ++++++----- pkg-py/src/querychat/_querychat_base.py | 24 +++++------ pkg-py/src/querychat/_shiny.py | 4 +- pkg-py/src/querychat/_shiny_module.py | 6 +-- pkg-py/src/querychat/_system_prompt.py | 2 +- pkg-py/src/querychat/_utils.py | 4 +- pkg-py/src/querychat/_viz_tools.py | 34 +++++++-------- pkg-py/src/querychat/_viz_utils.py | 4 +- pkg-py/src/querychat/prompts/prompt.md | 26 ++++++------ ...l-visualize-query.md => tool-visualize.md} | 0 pkg-py/src/querychat/tools.py | 4 +- pkg-py/src/querychat/types/__init__.py | 6 +-- .../tests/playwright/apps/viz_bookmark_app.py | 2 +- pkg-py/tests/playwright/test_10_viz_inline.py | 2 +- pkg-py/tests/test_shiny_viz_regressions.py | 22 +++++----- pkg-py/tests/test_system_prompt.py | 18 ++++---- pkg-py/tests/test_tools.py | 8 ++-- pkg-py/tests/test_viz_tools.py | 42 +++++++++---------- 23 files changed, 126 insertions(+), 127 deletions(-) rename pkg-py/src/querychat/prompts/{tool-visualize-query.md => tool-visualize.md} (100%) diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index 20785d727..ff0e3f084 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### New features -* Added a `"visualize_query"` tool that lets the LLM create inline Altair charts from natural language requests using [ggsql](https://github.com/posit-dev/ggsql) — a SQL extension for declarative data visualization. Include it via `tools=("query", "visualize_query")` (or alongside `"update"`). Charts render inline in the chat with fullscreen support, a "Show Query" toggle, and Save as PNG/SVG. Install the optional dependencies with `pip install querychat[viz]`. (#219) +* Added a `"visualize"` tool that lets the LLM create inline Altair charts from natural language requests using [ggsql](https://github.com/posit-dev/ggsql) — a SQL extension for declarative data visualization. Include it via `tools=("query", "visualize")` (or alongside `"update"`). Charts render inline in the chat with fullscreen support, a "Show Query" toggle, and Save as PNG/SVG. Install the optional dependencies with `pip install querychat[viz]`. (#219) * The `querychat_query` tool now accepts an optional `collapsed` parameter. When `collapsed=True`, the result card starts collapsed so preparatory or exploratory queries don't clutter the conversation. The LLM is guided to use this automatically when running queries before a visualization. diff --git a/pkg-py/docs/_quarto.yml b/pkg-py/docs/_quarto.yml index 7574fb787..927859ba4 100644 --- a/pkg-py/docs/_quarto.yml +++ b/pkg-py/docs/_quarto.yml @@ -115,7 +115,7 @@ quartodoc: signature_name: short - name: tools.tool_reset_dashboard signature_name: short - - name: tools.tool_visualize_query + - name: tools.tool_visualize signature_name: short filters: diff --git a/pkg-py/docs/build.qmd b/pkg-py/docs/build.qmd index f4ff68abd..460d36cc7 100644 --- a/pkg-py/docs/build.qmd +++ b/pkg-py/docs/build.qmd @@ -35,7 +35,7 @@ qc = QueryChat(titanic(), "titanic") ### Visualization support querychat supports an optional visualization tool that lets the LLM create inline charts. -Enable it by including `"visualize_query"` in the `tools` parameter. +Enable it by including `"visualize"` in the `tools` parameter. See [Visualizations](visualize.qmd) for details. ::: diff --git a/pkg-py/docs/tools.qmd b/pkg-py/docs/tools.qmd index 09b46be9d..855fc24b7 100644 --- a/pkg-py/docs/tools.qmd +++ b/pkg-py/docs/tools.qmd @@ -54,7 +54,7 @@ app = qc.app() ## Data visualization -When a user asks for a chart or visualization, the LLM generates a [ggsql](https://ggsql.org/) query — standard SQL extended with a `VISUALISE` clause — and requests a call to the `visualize_query` tool. +When a user asks for a chart or visualization, the LLM generates a [ggsql](https://ggsql.org/) query — standard SQL extended with a `VISUALISE` clause — and requests a call to the `visualize` tool. This tool: 1. Executes the SQL portion of the query @@ -72,13 +72,13 @@ To use the visualization tool, first install the `viz` extras: pip install "querychat[viz]" ``` -Then include `"visualize_query"` in the `tools` parameter (it is not enabled by default): +Then include `"visualize"` in the `tools` parameter (it is not enabled by default): ```{.python filename="viz-app.py"} from querychat import QueryChat from querychat.data import titanic -qc = QueryChat(titanic(), "titanic", tools=("query", "update", "visualize_query")) +qc = QueryChat(titanic(), "titanic", tools=("query", "update", "visualize")) app = qc.app() ``` diff --git a/pkg-py/docs/visualize.qmd b/pkg-py/docs/visualize.qmd index 8074869b2..e8d720b68 100644 --- a/pkg-py/docs/visualize.qmd +++ b/pkg-py/docs/visualize.qmd @@ -16,13 +16,13 @@ Visualization requires two steps: pip install "querychat[viz]" ``` -2. **Include `"visualize_query"` in the `tools` parameter:** +2. **Include `"visualize"` in the `tools` parameter:** ```{.python filename="app.py"} from querychat import QueryChat from querychat.data import titanic - qc = QueryChat(titanic(), "titanic", tools=("query", "update", "visualize_query")) + qc = QueryChat(titanic(), "titanic", tools=("query", "update", "visualize")) app = qc.app() ``` @@ -38,14 +38,14 @@ By default, only `"query"` and `"update"` are enabled — visualization must be To enable only query and visualization (no dashboard filtering): ```{.python} -qc = QueryChat(titanic(), "titanic", tools=("query", "visualize_query")) +qc = QueryChat(titanic(), "titanic", tools=("query", "visualize")) ``` See [Tools](tools.qmd) for a full reference on available tools and what each one does. ## Custom apps -The example below shows a minimal custom Shiny app using only the `"query"` and `"visualize_query"` tools. +The example below shows a minimal custom Shiny app using only the `"query"` and `"visualize"` tools. It omits `"update"` to focus entirely on data analysis and visualization rather than dashboard filtering: ```{.python filename="app.py"} diff --git a/pkg-py/examples/10-viz-app.py b/pkg-py/examples/10-viz-app.py index 5a3af3356..4f16393d8 100644 --- a/pkg-py/examples/10-viz-app.py +++ b/pkg-py/examples/10-viz-app.py @@ -7,20 +7,19 @@ qc = QueryChat( titanic(), "titanic", - tools=("query", "visualize_query"), + tools=("query", "visualize"), ) -#def app_ui(request): -# return ui.page_fillable( -# qc.ui(), -# ) -# -# -#def server(input, output, session): -# qc.server(enable_bookmarking=True) -# -# -#app = App(app_ui, server, bookmark_store="url") +# Minimal chat app with visualization support +def app_ui(request): + return ui.page_fillable( + qc.ui(), + ) -app = qc.app() \ No newline at end of file + +def server(input, output, session): + qc.server(enable_bookmarking=True) + + +app = App(app_ui, server, bookmark_store="url") \ No newline at end of file diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index 25e841971..ffa325aed 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -29,7 +29,7 @@ tool_query, tool_reset_dashboard, tool_update_dashboard, - tool_visualize_query, + tool_visualize, ) if TYPE_CHECKING: @@ -37,9 +37,9 @@ from narwhals.stable.v1.typing import IntoFrame - from ._viz_tools import VisualizeQueryData + from ._viz_tools import VisualizeData -TOOL_GROUPS = Literal["update", "query", "visualize_query"] +TOOL_GROUPS = Literal["update", "query", "visualize"] DEFAULT_TOOLS: tuple[TOOL_GROUPS, ...] = ("update", "query") class QueryChatBase(Generic[IntoFrameT]): @@ -132,7 +132,7 @@ def _create_session_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, + visualize: Callable[[VisualizeData], None] | None = None, ) -> chatlas.Chat: """Create a fresh, fully-configured Chat.""" spec = self._client_spec if isinstance(client_spec, MISSING_TYPE) else client_spec @@ -157,9 +157,9 @@ def _create_session_client( if "query" in resolved_tools: chat.register_tool(tool_query(data_source)) - if "visualize_query" in resolved_tools: - query_viz_fn = visualize_query or (lambda _: None) - chat.register_tool(tool_visualize_query(data_source, query_viz_fn)) + if "visualize" in resolved_tools: + viz_fn = visualize or (lambda _: None) + chat.register_tool(tool_visualize(data_source, viz_fn)) return chat @@ -169,7 +169,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, + visualize: Callable[[VisualizeData], None] | None = None, ) -> chatlas.Chat: """ Create a chat client with registered tools. @@ -177,14 +177,14 @@ def client( Parameters ---------- tools - Which tools to include: `"update"`, `"query"`, `"visualize_query"`, + Which tools to include: `"update"`, `"query"`, `"visualize"`, 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. + visualize + Callback when visualize tool succeeds. Returns ------- @@ -197,7 +197,7 @@ def client( tools=tools, update_dashboard=update_dashboard, reset_dashboard=reset_dashboard, - visualize_query=visualize_query, + visualize=visualize, ) def generate_greeting(self, *, echo: Literal["none", "output"] = "none") -> str: diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index d49aa11a2..f915e79f4 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -98,10 +98,10 @@ 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", "visualize_query")` + - A tuple of tools: `("update", "query", "visualize")` - `None` or `()` to disable all tools - Default is `("update", "query")`. The visualization tool (`"visualize_query"`) + Default is `("update", "query")`. The visualization tool (`"visualize"`) can be opted into by including it in the tuple. Set to `"update"` to prevent the LLM from accessing data values, only diff --git a/pkg-py/src/querychat/_shiny_module.py b/pkg-py/src/querychat/_shiny_module.py index 7ef210e4c..7b568afa9 100644 --- a/pkg-py/src/querychat/_shiny_module.py +++ b/pkg-py/src/querychat/_shiny_module.py @@ -25,7 +25,7 @@ from ._datasource import DataSource from ._querychat_base import TOOL_GROUPS - from ._viz_tools import VisualizeQueryData + from ._viz_tools import VisualizeData from .types import UpdateDashboardData ReactiveString = reactive.Value[str] @@ -143,14 +143,14 @@ def reset_dashboard(): viz_widgets: list[VizWidgetEntry] = [] - def on_visualize(data: VisualizeQueryData): + def on_visualize(data: VisualizeData): viz_widgets.append({"widget_id": data["widget_id"], "ggsql": data["ggsql"]}) def build_chat_client() -> chatlas.Chat: return client( update_dashboard=update_dashboard, reset_dashboard=reset_dashboard, - visualize_query=on_visualize, + visualize=on_visualize, tools=tools, ) diff --git a/pkg-py/src/querychat/_system_prompt.py b/pkg-py/src/querychat/_system_prompt.py index a18630153..0a57a70ba 100644 --- a/pkg-py/src/querychat/_system_prompt.py +++ b/pkg-py/src/querychat/_system_prompt.py @@ -85,7 +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), + "has_tool_visualize": 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 6d4c803d8..1c8f9f31b 100644 --- a/pkg-py/src/querychat/_utils.py +++ b/pkg-py/src/querychat/_utils.py @@ -219,7 +219,7 @@ def get_tool_details_setting() -> Optional[Literal["expanded", "collapsed", "def def querychat_tool_starts_open( action: Literal[ - "update", "query", "reset", "visualize_query" + "update", "query", "reset", "visualize" ], ) -> bool: """ @@ -228,7 +228,7 @@ def querychat_tool_starts_open( Parameters ---------- action : str - The action type ('update', 'query', 'reset', or 'visualize_query') + The action type ('update', 'query', 'reset', or 'visualize') Returns ------- diff --git a/pkg-py/src/querychat/_viz_tools.py b/pkg-py/src/querychat/_viz_tools.py index aed06b6f5..d273cf354 100644 --- a/pkg-py/src/querychat/_viz_tools.py +++ b/pkg-py/src/querychat/_viz_tools.py @@ -29,12 +29,12 @@ from ._datasource import DataSource -class VisualizeQueryData(TypedDict): +class VisualizeData(TypedDict): """ - Data passed to visualize_query callback. + Data passed to visualize callback. This TypedDict defines the structure of data passed to the - `tool_visualize_query` callback function when the LLM creates an + `tool_visualize` callback function when the LLM creates an exploratory visualization from a ggsql query. Attributes @@ -53,9 +53,9 @@ class VisualizeQueryData(TypedDict): widget_id: str -def tool_visualize_query( +def tool_visualize( data_source: DataSource, - update_fn: Callable[[VisualizeQueryData], None], + update_fn: Callable[[VisualizeData], None], ) -> Tool: """ Create a tool that executes a ggsql query and renders the visualization. @@ -65,7 +65,7 @@ def tool_visualize_query( data_source The data source to query against update_fn - Callback function to call with VisualizeQueryData when visualization succeeds + Callback function to call with VisualizeData when visualization succeeds Returns ------- @@ -73,20 +73,20 @@ def tool_visualize_query( A tool that can be registered with chatlas """ - impl = visualize_query_impl(data_source, update_fn) + impl = visualize_impl(data_source, update_fn) impl.__doc__ = read_prompt_template( - "tool-visualize-query.md", + "tool-visualize.md", db_type=data_source.get_db_type(), ) return Tool.from_func( impl, - name="querychat_visualize_query", + name="querychat_visualize", annotations={"title": "Query Visualization"}, ) -class VisualizeQueryResult(ContentToolResult): +class VisualizeResult(ContentToolResult): """Tool result that registers an ipywidget and embeds it inline via shinywidgets.""" def __init__( @@ -125,7 +125,7 @@ def __init__( html=widget_html, title=title or "Query Visualization", show_request=False, - open=querychat_tool_starts_open("visualize_query"), + open=querychat_tool_starts_open("visualize"), full_screen=True, icon=bs_icon("graph-up"), footer=footer, @@ -140,14 +140,14 @@ def __init__( # --------------------------------------------------------------------------- -def visualize_query_impl( +def visualize_impl( data_source: DataSource, - update_fn: Callable[[VisualizeQueryData], None], + update_fn: Callable[[VisualizeData], None], ) -> Callable[[str, str], ContentToolResult]: - """Create the visualize_query implementation function.""" + """Create the visualize implementation function.""" from ggsql import VegaLiteWriter, validate - def visualize_query( + def visualize( ggsql: str, title: str, ) -> ContentToolResult: @@ -193,7 +193,7 @@ def visualize_query( {"ggsql": ggsql, "title": title, "widget_id": altair_widget.widget_id} ) - return VisualizeQueryResult( + return VisualizeResult( widget_id=altair_widget.widget_id, widget=altair_widget.widget, ggsql_str=ggsql, @@ -206,7 +206,7 @@ def visualize_query( markdown += f"\n\n> Error: {error_msg}" return ContentToolResult(value=markdown, error=Exception(error_msg)) - return visualize_query + return visualize PNG_WIDTH = 500 diff --git a/pkg-py/src/querychat/_viz_utils.py b/pkg-py/src/querychat/_viz_utils.py index eb9e0897d..57aae7a50 100644 --- a/pkg-py/src/querychat/_viz_utils.py +++ b/pkg-py/src/querychat/_viz_utils.py @@ -10,8 +10,8 @@ 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 + """Check if visualize is among the configured tools.""" + return tools is not None and "visualize" in tools def has_viz_deps() -> bool: diff --git a/pkg-py/src/querychat/prompts/prompt.md b/pkg-py/src/querychat/prompts/prompt.md index 9123bbe40..f810553c5 100644 --- a/pkg-py/src/querychat/prompts/prompt.md +++ b/pkg-py/src/querychat/prompts/prompt.md @@ -118,10 +118,10 @@ Response: "The average revenue is $X." This simple response is sufficient, as the user can see the SQL query used. {{/has_tool_query}} -{{#has_tool_visualize_query}} +{{#has_tool_visualize}} ### Visualizing Data -You can create visualizations using the `querychat_visualize_query` tool, which uses ggsql — a SQL extension for declarative data visualization. Write a ggsql query (SQL with a VISUALISE clause), and the tool executes the SQL, renders the VISUALISE clause as an Altair chart, and displays it inline in the chat. +You can create visualizations using the `querychat_visualize` tool, which uses ggsql — a SQL extension for declarative data visualization. Write a ggsql query (SQL with a VISUALISE clause), and the tool executes the SQL, renders the VISUALISE clause as an Altair chart, and displays it inline in the chat. #### Visualization best practices @@ -193,30 +193,30 @@ If a visualization fails, read the error message carefully and retry with a corr The syntax reference below covers all available clauses, geom types, scales, and examples. {{> ggsql-syntax}} -{{/has_tool_visualize_query}} +{{/has_tool_visualize}} {{#has_tool_query}} -{{#has_tool_visualize_query}} +{{#has_tool_visualize}} ### Choosing Between Query and Visualization -Use `querychat_query` for single-value answers (averages, counts, totals, specific lookups) or when the user needs to see exact values. Use `querychat_visualize_query` when comparisons, distributions, or trends are involved — even for small result sets, a chart is often clearer than a short table. +Use `querychat_query` for single-value answers (averages, counts, totals, specific lookups) or when the user needs to see exact values. Use `querychat_visualize` when comparisons, distributions, or trends are involved — even for small result sets, a chart is often clearer than a short table. **Avoid redundant expanded results.** If you run a preparatory query before visualizing, or if both a table and chart would show the same data, always pass `collapsed=True` on the query so the user sees the chart prominently, not a duplicate table above it. The user can still expand the table if they want the exact values. -{{/has_tool_visualize_query}} +{{/has_tool_visualize}} {{/has_tool_query}} -{{^has_tool_visualize_query}} +{{^has_tool_visualize}} ### Visualization Requests -You cannot create charts or visualizations. If users ask for a plot, chart, or visual representation of the data, explain that visualization is not currently enabled.{{#has_tool_query}} Offer to answer their question with a tabular query instead.{{/has_tool_query}} Suggest that the developer can enable visualization by installing `querychat[viz]` and adding `"visualize_query"` to the `tools` parameter. +You cannot create charts or visualizations. If users ask for a plot, chart, or visual representation of the data, explain that visualization is not currently enabled.{{#has_tool_query}} Offer to answer their question with a tabular query instead.{{/has_tool_query}} Suggest that the developer can enable visualization by installing `querychat[viz]` and adding `"visualize"` to the `tools` parameter. -{{/has_tool_visualize_query}} +{{/has_tool_visualize}} {{^has_tool_query}} -{{^has_tool_visualize_query}} +{{^has_tool_visualize}} ### 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}} {{/has_tool_query}} ### Providing Suggestions for Next Steps @@ -245,11 +245,11 @@ You might want to explore the advanced features * What's the average …? * How many …? {{/has_tool_query}} -{{#has_tool_visualize_query}} +{{#has_tool_visualize}} * Visualize the data * Show a bar chart of … * Plot the trend of … over time -{{/has_tool_visualize_query}} +{{/has_tool_visualize}} * Filter and sort * Show records from the year … * Sort the ____ by ____ … diff --git a/pkg-py/src/querychat/prompts/tool-visualize-query.md b/pkg-py/src/querychat/prompts/tool-visualize.md similarity index 100% rename from pkg-py/src/querychat/prompts/tool-visualize-query.md rename to pkg-py/src/querychat/prompts/tool-visualize.md diff --git a/pkg-py/src/querychat/tools.py b/pkg-py/src/querychat/tools.py index 27e42909f..48a17b5cc 100644 --- a/pkg-py/src/querychat/tools.py +++ b/pkg-py/src/querychat/tools.py @@ -13,13 +13,13 @@ read_prompt_template, truncate_error, ) -from ._viz_tools import tool_visualize_query +from ._viz_tools import tool_visualize __all__ = [ "tool_query", "tool_reset_dashboard", "tool_update_dashboard", - "tool_visualize_query", + "tool_visualize", ] if TYPE_CHECKING: diff --git a/pkg-py/src/querychat/types/__init__.py b/pkg-py/src/querychat/types/__init__.py index 87b284325..88b598326 100644 --- a/pkg-py/src/querychat/types/__init__.py +++ b/pkg-py/src/querychat/types/__init__.py @@ -9,7 +9,7 @@ from .._querychat_core import AppStateDict from .._shiny_module import ServerValues from .._utils import UnsafeQueryError -from .._viz_tools import VisualizeQueryData, VisualizeQueryResult +from .._viz_tools import VisualizeData, VisualizeResult from ..tools import UpdateDashboardData __all__ = ( @@ -23,6 +23,6 @@ "ServerValues", "UnsafeQueryError", "UpdateDashboardData", - "VisualizeQueryData", - "VisualizeQueryResult", + "VisualizeData", + "VisualizeResult", ) diff --git a/pkg-py/tests/playwright/apps/viz_bookmark_app.py b/pkg-py/tests/playwright/apps/viz_bookmark_app.py index 6552bfa8a..17c678797 100644 --- a/pkg-py/tests/playwright/apps/viz_bookmark_app.py +++ b/pkg-py/tests/playwright/apps/viz_bookmark_app.py @@ -8,7 +8,7 @@ qc = QueryChat( titanic(), "titanic", - tools=("query", "visualize_query"), + tools=("query", "visualize"), ) diff --git a/pkg-py/tests/playwright/test_10_viz_inline.py b/pkg-py/tests/playwright/test_10_viz_inline.py index 857e860cb..6bc746668 100644 --- a/pkg-py/tests/playwright/test_10_viz_inline.py +++ b/pkg-py/tests/playwright/test_10_viz_inline.py @@ -2,7 +2,7 @@ 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 +1. The visualize 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) """ diff --git a/pkg-py/tests/test_shiny_viz_regressions.py b/pkg-py/tests/test_shiny_viz_regressions.py index b9d51772c..ab3e2babe 100644 --- a/pkg-py/tests/test_shiny_viz_regressions.py +++ b/pkg-py/tests/test_shiny_viz_regressions.py @@ -133,7 +133,7 @@ def supported_model_params(self): def test_app_passes_callable_client_to_mod_server(sample_df): - qc = QueryChat(sample_df, "tips", tools=("query", "visualize_query")) + qc = QueryChat(sample_df, "tips", tools=("query", "visualize")) app = qc.app() captured = {} @@ -182,7 +182,7 @@ class CurrentSession: QueryChatExpress( sample_df, "tips", - tools=("query", "visualize_query"), + tools=("query", "visualize"), enable_bookmarking=False, ) @@ -191,7 +191,7 @@ class CurrentSession: def test_server_passes_callable_client_to_mod_server(sample_df, monkeypatch): - qc = QueryChat(sample_df, "tips", tools=("query", "visualize_query")) + qc = QueryChat(sample_df, "tips", tools=("query", "visualize")) captured = {} class CurrentSession: @@ -210,7 +210,7 @@ class CurrentSession: def test_mod_server_rejects_raw_chat_instance(sample_df): - qc = QueryChat(sample_df, "tips", tools=("query", "visualize_query")) + qc = QueryChat(sample_df, "tips", tools=("query", "visualize")) raw_chat = chatlas.Chat(provider=DummyProvider(name="dummy", model="dummy")) with ( @@ -249,7 +249,7 @@ def test_mod_server_stub_session_deferred_client_factory_does_not_raise(): def test_callable_mod_server_passes_visualize_callback_and_tools(sample_df): - qc = QueryChat(sample_df, "tips", tools=("query", "visualize_query")) + qc = QueryChat(sample_df, "tips", tools=("query", "visualize")) captured = {} def client_factory(**kwargs): @@ -271,14 +271,14 @@ def client_factory(**kwargs): tools=qc.tools, ) - assert captured["tools"] == ("query", "visualize_query") - assert callable(captured["visualize_query"]) + assert captured["tools"] == ("query", "visualize") + assert callable(captured["visualize"]) assert callable(captured["update_dashboard"]) assert callable(captured["reset_dashboard"]) def test_mod_server_preloads_viz_for_each_real_session_instance(sample_df): - qc = QueryChat(sample_df, "tips", tools=("query", "visualize_query")) + qc = QueryChat(sample_df, "tips", tools=("query", "visualize")) session = DummySession() preload_calls = [] @@ -314,7 +314,7 @@ def test_mod_server_preloads_viz_for_each_real_session_instance(sample_df): def test_mod_server_stub_session_does_not_preload_viz(sample_df): - qc = QueryChat(sample_df, "tips", tools=("query", "visualize_query")) + qc = QueryChat(sample_df, "tips", tools=("query", "visualize")) preload_calls = [] with ( @@ -339,7 +339,7 @@ def test_mod_server_stub_session_does_not_preload_viz(sample_df): def test_restored_viz_widgets_survive_second_bookmark_cycle(sample_df): - qc = QueryChat(sample_df, "tips", tools=("query", "visualize_query")) + qc = QueryChat(sample_df, "tips", tools=("query", "visualize")) callbacks = {} session = DummySession() @@ -371,7 +371,7 @@ def client_factory(**kwargs): "ggsql": "SELECT 1 VISUALISE 1 AS x DRAW point", } ] - callbacks["visualize_query"](saved[0]) + callbacks["visualize"](saved[0]) first_bookmark = SimpleNamespace(values={}) with reactive.isolate(): diff --git a/pkg-py/tests/test_system_prompt.py b/pkg-py/tests/test_system_prompt.py index 68bf6c1c6..8ef56d7b1 100644 --- a/pkg-py/tests/test_system_prompt.py +++ b/pkg-py/tests/test_system_prompt.py @@ -307,7 +307,7 @@ def test_graceful_recovery_fallback_excluded_without_query_tool( self, sample_data_source ): """ - When only visualize_query is enabled (no query tool), the fallback + When only visualize is enabled (no query tool), the fallback to querychat_query should not appear in the rendered prompt. """ from pathlib import Path @@ -324,7 +324,7 @@ def test_graceful_recovery_fallback_excluded_without_query_tool( data_source=sample_data_source, ) - rendered = prompt.render(tools=("update", "visualize_query")) + rendered = prompt.render(tools=("update", "visualize")) assert "fall back to" not in rendered @@ -332,7 +332,7 @@ def test_graceful_recovery_fallback_included_with_query_tool( self, sample_data_source ): """ - When both query and visualize_query are enabled, the fallback + When both query and visualize are enabled, the fallback to querychat_query should appear. """ from pathlib import Path @@ -349,13 +349,13 @@ def test_graceful_recovery_fallback_included_with_query_tool( data_source=sample_data_source, ) - rendered = prompt.render(tools=("update", "query", "visualize_query")) + rendered = prompt.render(tools=("update", "query", "visualize")) assert "fall back to" in rendered def test_viz_only_has_no_cannot_query_message(self, sample_data_source): """ - When only visualize_query is enabled (no query tool), the rendered prompt + When only visualize is enabled (no query tool), the rendered prompt should NOT contain "cannot query or analyze" and SHOULD contain "Visualizing Data". """ @@ -373,7 +373,7 @@ def test_viz_only_has_no_cannot_query_message(self, sample_data_source): data_source=sample_data_source, ) - rendered = prompt.render(tools=("visualize_query",)) + rendered = prompt.render(tools=("visualize",)) assert "cannot query or analyze" not in rendered assert "Visualizing Data" in rendered @@ -381,7 +381,7 @@ def test_viz_only_has_no_cannot_query_message(self, sample_data_source): def test_choosing_section_only_with_both_tools(self, sample_data_source): """ The "Choosing Between Query and Visualization" section should only appear - when both query and visualize_query are enabled. + when both query and visualize are enabled. """ from pathlib import Path @@ -397,9 +397,9 @@ def test_choosing_section_only_with_both_tools(self, sample_data_source): data_source=sample_data_source, ) - rendered_both = prompt.render(tools=("query", "visualize_query")) + rendered_both = prompt.render(tools=("query", "visualize")) rendered_query_only = prompt.render(tools=("query",)) - rendered_viz_only = prompt.render(tools=("visualize_query",)) + rendered_viz_only = prompt.render(tools=("visualize",)) assert "Choosing Between Query and Visualization" in rendered_both assert "Choosing Between Query and Visualization" not in rendered_query_only diff --git a/pkg-py/tests/test_tools.py b/pkg-py/tests/test_tools.py index 3267b6570..887cef548 100644 --- a/pkg-py/tests/test_tools.py +++ b/pkg-py/tests/test_tools.py @@ -57,7 +57,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 + assert querychat_tool_starts_open("visualize") is True def test_querychat_tool_starts_open_expanded(monkeypatch): @@ -67,7 +67,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 + assert querychat_tool_starts_open("visualize") is True def test_querychat_tool_starts_open_collapsed(monkeypatch): @@ -77,7 +77,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 + assert querychat_tool_starts_open("visualize") is False def test_querychat_tool_starts_open_default_setting(monkeypatch): @@ -87,7 +87,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 + assert querychat_tool_starts_open("visualize") is True def test_querychat_tool_starts_open_case_insensitive(monkeypatch): diff --git a/pkg-py/tests/test_viz_tools.py b/pkg-py/tests/test_viz_tools.py index b5eeffd6e..65d977dc8 100644 --- a/pkg-py/tests/test_viz_tools.py +++ b/pkg-py/tests/test_viz_tools.py @@ -6,8 +6,8 @@ import polars as pl import pytest from querychat._datasource import DataFrameSource -from querychat.tools import tool_visualize_query -from querychat.types import VisualizeQueryData, VisualizeQueryResult +from querychat.tools import tool_visualize +from querychat.types import VisualizeData, VisualizeResult class TestVizDependencyCheck: @@ -25,7 +25,7 @@ def mock_find_spec(name, *args, **kwargs): from querychat._querychat_base import normalize_tools with pytest.raises(ImportError, match="pip install querychat\\[viz\\]"): - normalize_tools(("visualize_query",), default=None) + normalize_tools(("visualize",), default=None) def test_no_error_without_viz_tools(self): """Non-viz tool configs should not check for ggsql.""" @@ -44,8 +44,8 @@ def test_check_deps_false_skips_check(self, monkeypatch): from querychat._querychat_base import normalize_tools # Should not raise even though find_spec returns None for everything - result = normalize_tools(("visualize_query",), default=None, check_deps=False) - assert result == ("visualize_query",) + result = normalize_tools(("visualize",), default=None, check_deps=False) + assert result == ("visualize",) @pytest.fixture @@ -65,21 +65,21 @@ def data_source(sample_df): return DataFrameSource(nw_df, "test_data") -class TestToolVisualizeQuery: +class TestToolVisualize: def test_creates_tool(self, data_source): callback_data = {} - def update_fn(data: VisualizeQueryData): + def update_fn(data: VisualizeData): callback_data.update(data) - tool = tool_visualize_query(data_source, update_fn) - assert tool.name == "querychat_visualize_query" + tool = tool_visualize(data_source, update_fn) + assert tool.name == "querychat_visualize" @pytest.mark.ggsql def test_tool_executes_sql_and_renders(self, data_source, monkeypatch): callback_data = {} - def update_fn(data: VisualizeQueryData): + def update_fn(data: VisualizeData): callback_data.update(data) from unittest.mock import MagicMock @@ -93,7 +93,7 @@ def update_fn(data: VisualizeQueryData): # 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) + tool = tool_visualize(data_source, update_fn) impl = tool.func result = impl( @@ -105,7 +105,7 @@ def update_fn(data: VisualizeQueryData): assert "title" in callback_data assert callback_data["title"] == "Filtered Scatter" - assert isinstance(result, VisualizeQueryResult) + assert isinstance(result, VisualizeResult) display = result.extra["display"] assert display.full_screen is True assert display.open is True @@ -114,10 +114,10 @@ def update_fn(data: VisualizeQueryData): def test_tool_handles_query_without_visualise(self, data_source): callback_data = {} - def update_fn(data: VisualizeQueryData): + def update_fn(data: VisualizeData): callback_data.update(data) - tool = tool_visualize_query(data_source, update_fn) + tool = tool_visualize(data_source, update_fn) impl = tool.func result = impl(ggsql="SELECT x, y FROM test_data", title="No Viz") @@ -126,7 +126,7 @@ def update_fn(data: VisualizeQueryData): assert "VISUALISE" in str(result.error) -class TestVisualizeQueryResultContent: +class TestVisualizeResultContent: @pytest.mark.ggsql def test_result_value_contains_image(self, data_source, monkeypatch): from unittest.mock import MagicMock @@ -141,16 +141,16 @@ def test_result_value_contains_image(self, data_source, monkeypatch): callback_data = {} - def update_fn(data: VisualizeQueryData): + def update_fn(data: VisualizeData): callback_data.update(data) - tool = tool_visualize_query(data_source, update_fn) + tool = tool_visualize(data_source, update_fn) result = tool.func( ggsql="SELECT x, y FROM test_data VISUALISE x, y DRAW point", title="Test Chart", ) - assert isinstance(result, VisualizeQueryResult) + assert isinstance(result, VisualizeResult) # value should be a list with [str, image content] assert isinstance(result.value, list) assert len(result.value) == 2 @@ -173,7 +173,7 @@ def test_result_text_is_minimal(self, data_source, monkeypatch): ) monkeypatch.setattr(Widget, "_widget_construction_callback", lambda _w: None) - tool = tool_visualize_query(data_source, lambda _: None) + tool = tool_visualize(data_source, lambda _: None) result = tool.func( ggsql="SELECT x, y FROM test_data VISUALISE x, y DRAW point", title="Test Chart", @@ -200,14 +200,14 @@ def explode(_chart): monkeypatch.setattr("querychat._viz_tools.render_chart_to_png", explode) - tool = tool_visualize_query(data_source, lambda _: None) + tool = tool_visualize(data_source, lambda _: None) result = tool.func( ggsql="SELECT x, y FROM test_data VISUALISE x, y DRAW point", title="Test Chart", ) # Should still succeed, just text-only - assert isinstance(result, VisualizeQueryResult) + assert isinstance(result, VisualizeResult) assert isinstance(result.value, str) assert result.error is None From 20fa032f49d9991adf3b23b5436eeafc420cafc7 Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 20 Apr 2026 10:49:22 -0500 Subject: [PATCH 21/31] Update example to use shiny express --- pkg-py/examples/10-viz-app.py | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/pkg-py/examples/10-viz-app.py b/pkg-py/examples/10-viz-app.py index 4f16393d8..fe9ef6dc8 100644 --- a/pkg-py/examples/10-viz-app.py +++ b/pkg-py/examples/10-viz-app.py @@ -1,25 +1,17 @@ -from querychat import QueryChat +from querychat.express import QueryChat from querychat.data import titanic -from shiny import App, ui +from shiny.express import ui, app_opts # Omits "update" tool — this demo focuses on query + visualization only qc = QueryChat( titanic(), "titanic", - tools=("query", "visualize"), + tools=("query", "visualize") ) +qc.ui() -# Minimal chat app with visualization support -def app_ui(request): - return ui.page_fillable( - qc.ui(), - ) +ui.page_opts(fillable=True, title="QueryChat Visualization Demo") - -def server(input, output, session): - qc.server(enable_bookmarking=True) - - -app = App(app_ui, server, bookmark_store="url") \ No newline at end of file +app_opts(bookmark_store="url") From b6ba1a8241b2f3105b2a09413b8ae7804ed02aae Mon Sep 17 00:00:00 2001 From: Carson Sievert Date: Mon, 20 Apr 2026 10:54:01 -0500 Subject: [PATCH 22/31] Update pkg-py/src/querychat/static/js/viz-preload.js Co-authored-by: Garrick Aden-Buie --- pkg-py/src/querychat/static/js/viz-preload.js | 82 ++++++++++--------- 1 file changed, 42 insertions(+), 40 deletions(-) diff --git a/pkg-py/src/querychat/static/js/viz-preload.js b/pkg-py/src/querychat/static/js/viz-preload.js index 088433af2..c6ef5bf2e 100644 --- a/pkg-py/src/querychat/static/js/viz-preload.js +++ b/pkg-py/src/querychat/static/js/viz-preload.js @@ -1,50 +1,52 @@ -(function () { - if (!window.Shiny) return; +(() => { + // In Shiny apps, reveal the first `.querychat-viz-preload` element that appears + // and then stop watching the DOM. This is a one-time, page-level initialization: + // if a preload element already exists at startup, reveal it immediately; otherwise + // observe DOM mutations until one is added, then reveal it and disconnect. - var preloadObserver = null; + if (!window.Shiny || window.__querychatVizPreloaded) return; - function stopVizPreloadObserver() { - if (!preloadObserver) return; - preloadObserver.disconnect(); - preloadObserver = null; - } + let preloadObserver; - function handleVizPreload(root) { - if (!root || !root.isConnected) return; + const stopVizPreloadObserver = () => { + preloadObserver?.disconnect(); + preloadObserver = undefined; + }; - if (window.__querychatVizPreloaded) { - root.remove(); - stopVizPreloadObserver(); - return; - } + const findVizPreload = (node) => { + if (!(node instanceof Element)) return null; + return node.matches(".querychat-viz-preload") + ? node + : node.querySelector(".querychat-viz-preload"); + }; + + const revealVizPreload = (root) => { + if (!root?.isConnected || window.__querychatVizPreloaded) return false; window.__querychatVizPreloaded = true; - root.removeAttribute("hidden"); + root.hidden = false; stopVizPreloadObserver(); - } - - function processVizPreloads(node) { - if (!(node instanceof Element)) return; - - if (node.matches(".querychat-viz-preload")) { - handleVizPreload(node); + return true; + }; + + const processVizPreloads = (node) => { + const preloadRoot = findVizPreload(node); + if (!preloadRoot) return false; + return revealVizPreload(preloadRoot); + }; + + if (processVizPreloads(document.documentElement)) return; + + preloadObserver = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (processVizPreloads(node)) return; + } } + }); - node.querySelectorAll(".querychat-viz-preload").forEach(handleVizPreload); - } - - processVizPreloads(document.documentElement); - - if (!window.__querychatVizPreloaded) { - preloadObserver = new MutationObserver(function (mutations) { - mutations.forEach(function (mutation) { - mutation.addedNodes.forEach(processVizPreloads); - }); - }); - - preloadObserver.observe(document.documentElement, { - childList: true, - subtree: true, - }); - } + preloadObserver.observe(document.documentElement, { + childList: true, + subtree: true, + }); })(); From e5f35be7493a60b0973e209baa694de6d5ce0ea1 Mon Sep 17 00:00:00 2001 From: Carson Sievert Date: Mon, 20 Apr 2026 11:09:07 -0500 Subject: [PATCH 23/31] Wrap ggsql syntax reference in XML tag Co-authored-by: Garrick Aden-Buie --- pkg-py/src/querychat/prompts/prompt.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg-py/src/querychat/prompts/prompt.md b/pkg-py/src/querychat/prompts/prompt.md index f810553c5..5331a3d05 100644 --- a/pkg-py/src/querychat/prompts/prompt.md +++ b/pkg-py/src/querychat/prompts/prompt.md @@ -190,9 +190,9 @@ If a visualization fails, read the error message carefully and retry with a corr #### ggsql syntax reference -The syntax reference below covers all available clauses, geom types, scales, and examples. - + {{> ggsql-syntax}} + {{/has_tool_visualize}} {{#has_tool_query}} {{#has_tool_visualize}} From 0bec1433fb83732ebdd546eef96a3d65ab21a173 Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 20 Apr 2026 11:22:12 -0500 Subject: [PATCH 24/31] fix(prompts): gate visual exploration mention on visualize tool presence Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/prompts/prompt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg-py/src/querychat/prompts/prompt.md b/pkg-py/src/querychat/prompts/prompt.md index 5331a3d05..994d4eec1 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, answering questions, and exploring data visually. +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.{{#has_tool_visualize}} You can also help them explore data visually.{{/has_tool_visualize}} You have access to a {{db_type}} SQL database with the following schema: From eebd82a43a7b9007196e86a9644f3181cf169a47 Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 20 Apr 2026 11:26:20 -0500 Subject: [PATCH 25/31] fix(prompts): add trailing comma note to ggsql syntax important notes LLM was generating trailing commas in LABEL clauses causing parse errors. Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/prompts/ggsql-syntax.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg-py/src/querychat/prompts/ggsql-syntax.md b/pkg-py/src/querychat/prompts/ggsql-syntax.md index 9a6f0c016..8a1fd38d3 100644 --- a/pkg-py/src/querychat/prompts/ggsql-syntax.md +++ b/pkg-py/src/querychat/prompts/ggsql-syntax.md @@ -515,7 +515,15 @@ PROJECT TO polar SETTING inner => 0.5 As a general rule, always use lowercase column names and aliases in both SELECT and VISUALISE clauses. 6. **Charts vs Tables**: For visualizations use VISUALISE with DRAW. For tabular data use plain SQL without VISUALISE. 7. **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. -8. **Bar position adjustments**: Bars stack automatically when `fill` is mapped. Use `SETTING position => 'dodge'` for side-by-side bars, or `position => 'stack', total => 1` for proportional (100%) stacking: +8. **No trailing commas**: SETTING, LABEL, MAPPING, and RENAMING clauses must not end with a trailing comma. A comma after the last item causes a parse error. + ```sql + -- WRONG: trailing comma after the last label + LABEL x => 'Gender', y => 'Count', + + -- CORRECT + LABEL x => 'Gender', y => 'Count' + ``` +9. **Bar position adjustments**: Bars stack automatically when `fill` is mapped. Use `SETTING position => 'dodge'` for side-by-side bars, or `position => 'stack', total => 1` for proportional (100%) 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 From 231e3efab7e1fa06924c0558a343ebee63490413 Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 20 Apr 2026 11:34:18 -0500 Subject: [PATCH 26/31] fix(prompts): restore when-to-use/when-not-to-use in tool descriptions Restores the title + description + usage guidance convention in tool-query.md and tool-update-dashboard.md, keeping new additions like the collapsed parameter. Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/prompts/tool-query.md | 15 ++++++++++++++- .../querychat/prompts/tool-update-dashboard.md | 8 +++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/pkg-py/src/querychat/prompts/tool-query.md b/pkg-py/src/querychat/prompts/tool-query.md index 312b85fcc..246cc90ee 100644 --- a/pkg-py/src/querychat/prompts/tool-query.md +++ b/pkg-py/src/querychat/prompts/tool-query.md @@ -1,4 +1,17 @@ -Execute a {{db_type}} SQL SELECT query and return the results for analysis. +Execute a SQL query and return the results + +This tool executes a {{db_type}} SQL SELECT query against the database and returns the raw result data for analysis. + +**When to use:** Call this tool whenever the user asks a question that requires data analysis, aggregation, or calculations. Use this for questions like: +- "What is the average...?" +- "How many records...?" +- "Which item has the highest/lowest...?" +- "What's the total sum of...?" +- "What percentage of ...?" + +Always use SQL for counting, averaging, summing, and other calculations—NEVER attempt manual calculations on your own. Use this tool repeatedly if needed to avoid any kind of manual calculation. + +**When not to use:** Do NOT use this tool for filtering or sorting the dashboard display. If the user wants to "Show me..." or "Filter to..." certain records in the dashboard, use the `querychat_update_dashboard` tool instead. **Important guidelines:** diff --git a/pkg-py/src/querychat/prompts/tool-update-dashboard.md b/pkg-py/src/querychat/prompts/tool-update-dashboard.md index 0b98d219b..dae9861c0 100644 --- a/pkg-py/src/querychat/prompts/tool-update-dashboard.md +++ b/pkg-py/src/querychat/prompts/tool-update-dashboard.md @@ -1,4 +1,10 @@ -Filter and sort the dashboard data by executing a {{db_type}} SQL SELECT query. +Filter and sort the dashboard data + +This tool executes a {{db_type}} SQL SELECT query to filter or sort the data used in the dashboard. + +**When to use:** Call this tool whenever the user requests filtering, sorting, or data manipulation on the dashboard with questions like "Show me..." or "Which records have...". This tool is appropriate for any request that involves showing a subset of the data or reordering it. + +**When not to use:** Do NOT use this tool for general questions about the data that can be answered with a single value or summary statistic. For those questions, use the `querychat_query` tool instead. **Important constraints:** From d4b9d205cb5e91fa44ea984d246754906efd070f Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 20 Apr 2026 11:48:47 -0500 Subject: [PATCH 27/31] refactor(prompts): move viz tool-use instructions into tool description Adds title/description/when-to-use/constraints to tool-visualize.md following the same convention as the other tool prompts. Removes duplicated routing and error recovery guidance from the system prompt. Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/prompts/prompt.md | 12 +----------- pkg-py/src/querychat/prompts/tool-visualize.md | 12 +++++++++++- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg-py/src/querychat/prompts/prompt.md b/pkg-py/src/querychat/prompts/prompt.md index 994d4eec1..bd4cca92c 100644 --- a/pkg-py/src/querychat/prompts/prompt.md +++ b/pkg-py/src/querychat/prompts/prompt.md @@ -184,26 +184,16 @@ Match the chart type to what the user is trying to understand: - **Relationship between two numeric variables**: scatter plot (`DRAW point`), but prefer aggregation or heatmap if the dataset is large. - **Part-of-whole**: stacked bar chart (map subcategory to `fill`). Avoid pie charts — position along a common scale is easier to decode than angle. -#### Graceful recovery - -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.{{#has_tool_query}} If the error persists, fall back to `querychat_query` for a tabular answer.{{/has_tool_query}} - #### ggsql syntax reference {{> ggsql-syntax}} -{{/has_tool_visualize}} {{#has_tool_query}} -{{#has_tool_visualize}} -### Choosing Between Query and Visualization - -Use `querychat_query` for single-value answers (averages, counts, totals, specific lookups) or when the user needs to see exact values. Use `querychat_visualize` when comparisons, distributions, or trends are involved — even for small result sets, a chart is often clearer than a short table. **Avoid redundant expanded results.** If you run a preparatory query before visualizing, or if both a table and chart would show the same data, always pass `collapsed=True` on the query so the user sees the chart prominently, not a duplicate table above it. The user can still expand the table if they want the exact values. - -{{/has_tool_visualize}} {{/has_tool_query}} +{{/has_tool_visualize}} {{^has_tool_visualize}} ### Visualization Requests diff --git a/pkg-py/src/querychat/prompts/tool-visualize.md b/pkg-py/src/querychat/prompts/tool-visualize.md index ee671a9dd..89e46d20b 100644 --- a/pkg-py/src/querychat/prompts/tool-visualize.md +++ b/pkg-py/src/querychat/prompts/tool-visualize.md @@ -1,4 +1,14 @@ -Render a ggsql query inline in the chat. All data transformations must happen in the SELECT clause — VISUALISE and MAPPING accept column names only, not SQL expressions or functions. +Create a data visualization + +Render a ggsql query (SQL with a VISUALISE clause) as an Altair chart displayed inline in the chat. + +**When to use:** Call this tool when the user's question involves comparisons, distributions, or trends — even for small result sets, a chart is often clearer than a table.{{#has_tool_query}} For single-value answers (averages, counts, totals, specific lookups) or when the user needs exact values, use `querychat_query` instead.{{/has_tool_query}} + +**Key constraints:** + +- All data transformations must happen in the SELECT clause — VISUALISE and MAPPING accept column names only, not SQL expressions or functions +- Do NOT include `LABEL title => ...` in the query — use the `title` parameter instead +- 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.{{#has_tool_query}} If the error persists, fall back to `querychat_query` for a tabular answer.{{/has_tool_query}} Parameters ---------- From 5fd68d24c5d6bec70f8f0043be51511ece3060f1 Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 20 Apr 2026 11:51:24 -0500 Subject: [PATCH 28/31] fix(tests): update prompt tests for moved viz instructions Tests now check for "Avoid redundant expanded results" instead of removed headings/content that moved to tool-visualize.md. Co-Authored-By: Claude Opus 4.6 --- pkg-py/tests/test_system_prompt.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pkg-py/tests/test_system_prompt.py b/pkg-py/tests/test_system_prompt.py index 8ef56d7b1..976362045 100644 --- a/pkg-py/tests/test_system_prompt.py +++ b/pkg-py/tests/test_system_prompt.py @@ -328,12 +328,12 @@ def test_graceful_recovery_fallback_excluded_without_query_tool( assert "fall back to" not in rendered - def test_graceful_recovery_fallback_included_with_query_tool( + def test_collapsed_guidance_included_with_both_tools( self, sample_data_source ): """ - When both query and visualize are enabled, the fallback - to querychat_query should appear. + When both query and visualize are enabled, the collapsed query + guidance should appear in the system prompt. """ from pathlib import Path @@ -351,7 +351,7 @@ def test_graceful_recovery_fallback_included_with_query_tool( rendered = prompt.render(tools=("update", "query", "visualize")) - assert "fall back to" in rendered + assert "Avoid redundant expanded results" in rendered def test_viz_only_has_no_cannot_query_message(self, sample_data_source): """ @@ -378,9 +378,9 @@ def test_viz_only_has_no_cannot_query_message(self, sample_data_source): assert "cannot query or analyze" not in rendered assert "Visualizing Data" in rendered - def test_choosing_section_only_with_both_tools(self, sample_data_source): + def test_collapsed_guidance_only_with_both_tools(self, sample_data_source): """ - The "Choosing Between Query and Visualization" section should only appear + The "Avoid redundant expanded results" guidance should only appear when both query and visualize are enabled. """ from pathlib import Path @@ -401,6 +401,6 @@ def test_choosing_section_only_with_both_tools(self, sample_data_source): rendered_query_only = prompt.render(tools=("query",)) rendered_viz_only = prompt.render(tools=("visualize",)) - assert "Choosing Between Query and Visualization" in rendered_both - assert "Choosing Between Query and Visualization" not in rendered_query_only - assert "Choosing Between Query and Visualization" not in rendered_viz_only + assert "Avoid redundant expanded results" in rendered_both + assert "Avoid redundant expanded results" not in rendered_query_only + assert "Avoid redundant expanded results" not in rendered_viz_only From 2fc4303a3ddcf9957aa66be9065620c90f87f6bb Mon Sep 17 00:00:00 2001 From: Carson Date: Mon, 20 Apr 2026 12:00:02 -0500 Subject: [PATCH 29/31] fix(prompts): add missing ggsql syntax from skill reference Adds null mapping, rect/errorbar geoms, subtitle/caption labels, lollipop/ridgeline examples, scale oob setting, and facet panel filtering. Co-Authored-By: Claude Opus 4.6 --- pkg-py/src/querychat/prompts/ggsql-syntax.md | 34 ++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/pkg-py/src/querychat/prompts/ggsql-syntax.md b/pkg-py/src/querychat/prompts/ggsql-syntax.md index 8a1fd38d3..e98b90e56 100644 --- a/pkg-py/src/querychat/prompts/ggsql-syntax.md +++ b/pkg-py/src/querychat/prompts/ggsql-syntax.md @@ -43,6 +43,7 @@ DRAW bar MAPPING region AS x, total AS y | 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) | +| Null | `null AS color` | Suppress an inherited global mapping for this layer | ### DRAW Clause (Layers) @@ -64,7 +65,7 @@ DRAW geom_type |----------|-------| | Basic | `point`, `line`, `path`, `bar`, `area`, `tile`, `polygon`, `ribbon` | | Statistical | `histogram`, `density`, `smooth`, `boxplot`, `violin` | -| Annotation | `text`, `label`, `segment`, `arrow`, `rule`, `errorbar` | +| Annotation | `text`, `label`, `segment`, `arrow`, `rule`, `rect`, `errorbar` | - `path` is like `line` but preserves data order instead of sorting by x. - `tile` 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. @@ -72,6 +73,8 @@ DRAW geom_type - `text` (or `label`) renders text labels. Map `label` for the text content. Settings: `format` (template string for label formatting), `offset` (pixel offset as `(x, y)`). Labels containing `\n` are automatically split into multiple lines. - `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. Optionally map `slope` to create diagonal reference lines: `y = a + slope * x` (when `y` is mapped) or `x = a + slope * y` (when `x` is mapped). +- `rect` draws rectangles. Pick 2 per axis from center (`x`/`y`), min (`xmin`/`ymin`), max (`xmax`/`ymax`), `width`, `height`. Or just map center (defaults to width/height of 1). +- `errorbar` displays interval marks. Requires `x`, `ymin`, `ymax`. Settings: `width` (hinge width in points, default 10; `null` to hide hinges). - `line` and `path` support continuously varying `linewidth`, `stroke`, and `opacity` aesthetics within groups. **Aesthetics (MAPPING):** @@ -230,8 +233,11 @@ 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 +SCALE y FROM (0, 100) SETTING oob => 'squish' -- squish out-of-bounds values to range boundary ``` +`oob` (out-of-bounds) controls data outside the scale range: `'keep'` (default for x/y), `'censor'` (remove, default for other aesthetics), `'squish'` (clamp to boundary). + **RENAMING** — custom axis/legend labels: ```sql SCALE DISCRETE x RENAMING 'A' => 'Alpha', 'B' => 'Beta' @@ -308,13 +314,22 @@ FACET region SCALE panel RENAMING 'N' => 'North', 'S' => 'South' ``` +Filter to specific panels via SCALE FROM: +```sql +FACET island +SCALE panel FROM ('Biscoe', 'Dream') +``` + ### LABEL Clause -Use LABEL for axis labels only. Do NOT use `LABEL title => ...` — the tool's `title` parameter handles chart titles. Set a label to `null` to suppress it. +Use LABEL for axis labels, subtitles, and captions. Do NOT use `LABEL title => ...` — the tool's `title` parameter handles chart titles. Set a label to `null` to suppress it. + +Available labels: any aesthetic name (`x`, `y`, `fill`, `color`, etc.), `subtitle`, `caption`. ```sql LABEL x => 'X Axis Label', y => 'Y Axis Label' LABEL x => null -- suppress x-axis label +LABEL subtitle => 'Q4 2024 data', caption => 'Source: internal database' ``` ## Complete Examples @@ -427,6 +442,21 @@ DRAW bar DRAW text MAPPING n AS label SETTING offset => (0, -11), fill => 'white' ``` +**Lollipop chart:** +```sql +SELECT ROUND(bill_dep) AS bill_dep, COUNT(*) AS n FROM penguins GROUP BY 1 +VISUALISE bill_dep AS x, n AS y +DRAW segment MAPPING 0 AS yend +DRAW point +``` + +**Ridgeline / joy plot:** +```sql +VISUALISE temp AS x, month AS y FROM weather +DRAW violin SETTING width => 4, side => 'top' +SCALE ORDINAL y +``` + **Donut chart:** ```sql VISUALISE FROM products From a5b23722450ce32fd417b0ffd0f5b757707359ec Mon Sep 17 00:00:00 2001 From: Carson Sievert Date: Mon, 20 Apr 2026 12:13:33 -0500 Subject: [PATCH 30/31] Fix pkg-py/docs/tools.qmd link Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg-py/docs/tools.qmd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg-py/docs/tools.qmd b/pkg-py/docs/tools.qmd index 855fc24b7..44301f1d4 100644 --- a/pkg-py/docs/tools.qmd +++ b/pkg-py/docs/tools.qmd @@ -99,4 +99,4 @@ If you'd like to better understand how the tools work and how the LLM is prompte - [`prompts/tool-update-dashboard.md`](https://github.com/posit-dev/querychat/blob/main/pkg-py/src/querychat/prompts/tool-update-dashboard.md) - [`prompts/tool-reset-dashboard.md`](https://github.com/posit-dev/querychat/blob/main/pkg-py/src/querychat/prompts/tool-reset-dashboard.md) - [`prompts/tool-query.md`](https://github.com/posit-dev/querychat/blob/main/pkg-py/src/querychat/prompts/tool-query.md) -- [`prompts/tool-visualize-query.md`](https://github.com/posit-dev/querychat/blob/main/pkg-py/src/querychat/prompts/tool-visualize-query.md) +- [`prompts/tool-visualize.md`](https://github.com/posit-dev/querychat/blob/main/pkg-py/src/querychat/prompts/tool-visualize.md) From 0f2314df26fbdf0c4a6e54e7c65645d1c018ba3c Mon Sep 17 00:00:00 2001 From: Carson Sievert Date: Mon, 20 Apr 2026 12:13:53 -0500 Subject: [PATCH 31/31] Fix typo Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg-py/src/querychat/prompts/prompt.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg-py/src/querychat/prompts/prompt.md b/pkg-py/src/querychat/prompts/prompt.md index bd4cca92c..2876bcf00 100644 --- a/pkg-py/src/querychat/prompts/prompt.md +++ b/pkg-py/src/querychat/prompts/prompt.md @@ -186,7 +186,7 @@ Match the chart type to what the user is trying to understand: #### ggsql syntax reference - + {{> ggsql-syntax}} {{#has_tool_query}}