diff --git a/pkg-py/CHANGELOG.md b/pkg-py/CHANGELOG.md index d373812e..c64b7e67 100644 --- a/pkg-py/CHANGELOG.md +++ b/pkg-py/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Improvements + +* The `tools` parameter now uses `"filter"` as the preferred name (instead of `"update"`) for the dashboard-filtering tool group. The default is now `("filter", "query")`. The legacy name `"update"` is still accepted everywhere. (#222) + ### New features * 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) diff --git a/pkg-py/src/querychat/_dash.py b/pkg-py/src/querychat/_dash.py index a4202690..c8c187e5 100644 --- a/pkg-py/src/querychat/_dash.py +++ b/pkg-py/src/querychat/_dash.py @@ -97,7 +97,7 @@ def __init__( *, greeting: Optional[str | PathType] = None, client: Optional[str | chatlas.Chat] = None, - tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("update", "query"), + tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("filter", "query"), data_description: Optional[str | PathType] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | PathType] = None, @@ -113,7 +113,7 @@ def __init__( *, greeting: Optional[str | PathType] = None, client: Optional[str | chatlas.Chat] = None, - tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("update", "query"), + tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("filter", "query"), data_description: Optional[str | PathType] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | PathType] = None, @@ -129,7 +129,7 @@ def __init__( *, greeting: Optional[str | PathType] = None, client: Optional[str | chatlas.Chat] = None, - tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("update", "query"), + tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("filter", "query"), data_description: Optional[str | PathType] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | PathType] = None, @@ -145,7 +145,7 @@ def __init__( *, greeting: Optional[str | PathType] = None, client: Optional[str | chatlas.Chat] = None, - tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("update", "query"), + tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("filter", "query"), data_description: Optional[str | PathType] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | PathType] = None, @@ -161,7 +161,7 @@ def __init__( *, greeting: Optional[str | PathType] = None, client: Optional[str | chatlas.Chat] = None, - tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("update", "query"), + tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("filter", "query"), data_description: Optional[str | PathType] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | PathType] = None, @@ -176,7 +176,7 @@ def __init__( *, greeting: Optional[str | PathType] = None, client: Optional[str | chatlas.Chat] = None, - tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("update", "query"), + tools: TOOL_GROUPS | tuple[TOOL_GROUPS, ...] | None = ("filter", "query"), data_description: Optional[str | PathType] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | PathType] = None, diff --git a/pkg-py/src/querychat/_gradio.py b/pkg-py/src/querychat/_gradio.py index c0f3518d..cc006708 100644 --- a/pkg-py/src/querychat/_gradio.py +++ b/pkg-py/src/querychat/_gradio.py @@ -91,7 +91,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 = ("filter", "query"), data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -106,7 +106,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 = ("filter", "query"), data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -121,7 +121,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 = ("filter", "query"), data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -136,7 +136,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 = ("filter", "query"), data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -151,7 +151,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 = ("filter", "query"), data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -165,7 +165,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 = ("filter", "query"), data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, diff --git a/pkg-py/src/querychat/_querychat_base.py b/pkg-py/src/querychat/_querychat_base.py index f30634e7..90ba4bc0 100644 --- a/pkg-py/src/querychat/_querychat_base.py +++ b/pkg-py/src/querychat/_querychat_base.py @@ -39,8 +39,8 @@ from ._viz_tools import VisualizeData -TOOL_GROUPS = Literal["update", "query", "visualize"] -DEFAULT_TOOLS: tuple[TOOL_GROUPS, ...] = ("update", "query") +TOOL_GROUPS = Literal["filter", "update", "query", "visualize"] +DEFAULT_TOOLS: tuple[TOOL_GROUPS, ...] = ("filter", "query") class QueryChatBase(Generic[IntoFrameT]): """ @@ -177,8 +177,9 @@ def client( Parameters ---------- tools - Which tools to include: `"update"`, `"query"`, `"visualize"`, - or a combination. + Which tools to include: `"filter"`, `"query"`, `"visualize"`, + or a combination. The legacy name `"update"` is still accepted + as an alias for `"filter"`. update_dashboard Callback when update_dashboard tool succeeds. reset_dashboard @@ -306,25 +307,25 @@ def create_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, + default: tuple[TOOL_GROUPS, ...] | set[str] | None, *, check_deps: bool = True, -) -> tuple[TOOL_GROUPS, ...] | None: +) -> set[str] | None: if tools is None or tools == (): - result = None + resolved = None elif isinstance(tools, MISSING_TYPE): - result = default + resolved = set(default) if default is not None else None elif isinstance(tools, str): - result = (tools,) - elif isinstance(tools, tuple): - result = tools + resolved = {tools} else: - result = tuple(tools) + resolved = set(tools) + if resolved is not None: + resolved = {"update" if t == "filter" else t for t in resolved} if not check_deps: - return result - if has_viz_tool(result) and not has_viz_deps(): + return resolved + if has_viz_tool(resolved) and not has_viz_deps(): raise ImportError( "Visualization tools require ggsql, altair, shinywidgets, and " "vl-convert-python. Install them with: pip install querychat[viz]" ) - return result + return resolved diff --git a/pkg-py/src/querychat/_shiny.py b/pkg-py/src/querychat/_shiny.py index f915e79f..9f05132d 100644 --- a/pkg-py/src/querychat/_shiny.py +++ b/pkg-py/src/querychat/_shiny.py @@ -62,7 +62,7 @@ class QueryChat(QueryChatBase[IntoFrameT]): **Privacy-focused mode:** Only allow dashboard filtering, ensuring the LLM can't see any raw data. ```python - qc = QueryChat(df, "my_data", tools="update") + qc = QueryChat(df, "my_data", tools="filter") qc.app() ``` @@ -97,15 +97,18 @@ class QueryChat(QueryChatBase[IntoFrameT]): defaults to `"openai"`. 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")` + - A single tool string: `"filter"` or `"query"` + - A tuple of tools: `("filter", "query", "visualize")` - `None` or `()` to disable all tools - Default is `("update", "query")`. The visualization tool (`"visualize"`) + Default is `("filter", "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 - allowing dashboard filtering without answering questions. + Pass only `"filter"` to restrict the LLM to dashboard filtering, + omitting both the `"query"` and `"visualize"` tools so the LLM + cannot access or display any raw data values. + + The legacy name `"update"` is still accepted as an alias for `"filter"`. The tools can be overridden per-client by passing a different `tools` parameter to the `.client()` method. @@ -142,7 +145,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 = ("filter", "query"), data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -620,7 +623,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 = ("filter", "query"), data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, diff --git a/pkg-py/src/querychat/_shiny_module.py b/pkg-py/src/querychat/_shiny_module.py index 7b568afa..f877395b 100644 --- a/pkg-py/src/querychat/_shiny_module.py +++ b/pkg-py/src/querychat/_shiny_module.py @@ -24,7 +24,6 @@ from shiny import Inputs, Outputs, Session from ._datasource import DataSource - from ._querychat_base import TOOL_GROUPS from ._viz_tools import VisualizeData from .types import UpdateDashboardData @@ -123,7 +122,7 @@ def mod_server( greeting: str | None, client: Callable[..., chatlas.Chat], enable_bookmarking: bool, - tools: tuple[TOOL_GROUPS, ...] | None = None, + tools: set[str] | None = None, ) -> ServerValues[IntoFrameT]: # Reactive values to store state sql = ReactiveStringOrNone(None) diff --git a/pkg-py/src/querychat/_streamlit.py b/pkg-py/src/querychat/_streamlit.py index 484b8f4a..b68a6eff 100644 --- a/pkg-py/src/querychat/_streamlit.py +++ b/pkg-py/src/querychat/_streamlit.py @@ -66,7 +66,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 = ("filter", "query"), data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -81,7 +81,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 = ("filter", "query"), data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -96,7 +96,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 = ("filter", "query"), data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -111,7 +111,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 = ("filter", "query"), data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -126,7 +126,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 = ("filter", "query"), data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, @@ -140,7 +140,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 = ("filter", "query"), data_description: Optional[str | Path] = None, categorical_threshold: int = 20, extra_instructions: Optional[str | Path] = None, diff --git a/pkg-py/src/querychat/_system_prompt.py b/pkg-py/src/querychat/_system_prompt.py index 0a57a70b..f690a069 100644 --- a/pkg-py/src/querychat/_system_prompt.py +++ b/pkg-py/src/querychat/_system_prompt.py @@ -12,7 +12,6 @@ if TYPE_CHECKING: from ._datasource import DataSource - from ._querychat_base import TOOL_GROUPS class QueryChatSystemPrompt: @@ -62,12 +61,12 @@ def __init__( self.categorical_threshold = categorical_threshold self.data_source = data_source - def render(self, tools: tuple[TOOL_GROUPS, ...] | None) -> str: + def render(self, tools: set[str] | None) -> str: """ Render system prompt with tool configuration. Args: - tools: Normalized tuple of tool groups to enable (already normalized by caller) + tools: Normalized set of tool groups to enable (already normalized by caller) Returns: Fully rendered system prompt string diff --git a/pkg-py/src/querychat/_viz_utils.py b/pkg-py/src/querychat/_viz_utils.py index 57aae7a5..1702b5e2 100644 --- a/pkg-py/src/querychat/_viz_utils.py +++ b/pkg-py/src/querychat/_viz_utils.py @@ -9,7 +9,7 @@ from .__version import __version__ -def has_viz_tool(tools: tuple[str, ...] | None) -> bool: +def has_viz_tool(tools: set[str] | None) -> bool: """Check if visualize is among the configured tools.""" return tools is not None and "visualize" in tools diff --git a/pkg-py/tests/playwright/test_10_viz_inline.py b/pkg-py/tests/playwright/test_10_viz_inline.py index b6689b94..3b4892e3 100644 --- a/pkg-py/tests/playwright/test_10_viz_inline.py +++ b/pkg-py/tests/playwright/test_10_viz_inline.py @@ -158,17 +158,3 @@ def test_fullscreen_footer_does_not_stretch(self) -> None: assert footer_box is not None assert footer_box["height"] < 100 - 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 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/test_base.py b/pkg-py/tests/test_base.py index 1aa75c56..cffb932f 100644 --- a/pkg-py/tests/test_base.py +++ b/pkg-py/tests/test_base.py @@ -141,27 +141,39 @@ def test_with_empty_tuple_returns_none(self): def test_with_missing_returns_default(self): result = normalize_tools(MISSING, default=("update", "query")) - assert result == ("update", "query") + assert result == {"update", "query"} def test_with_string_returns_tuple(self): result = normalize_tools("update", default=None) - assert result == ("update",) + assert result == {"update"} def test_with_tuple_returns_same(self): result = normalize_tools(("update", "query"), default=None) - assert result == ("update", "query") + assert result == {"update", "query"} def test_with_list_returns_tuple(self): tools_list: Any = ["update", "query"] result = normalize_tools(tools_list, default=None) - assert result == ("update", "query") + assert result == {"update", "query"} + + def test_filter_normalized_to_update(self): + result = normalize_tools("filter", default=None) + assert result == {"update"} + + def test_filter_query_normalized(self): + result = normalize_tools(("filter", "query"), default=None) + assert result == {"update", "query"} + + def test_filter_and_update_deduplicated(self): + result = normalize_tools(("filter", "update", "query"), default=None) + assert result == {"update", "query"} class TestQueryChatBase: def test_init_with_dataframe(self, sample_df): qc = QueryChatBase(sample_df, "test_table") assert isinstance(qc.data_source, DataFrameSource) - assert qc.tools == ("update", "query") + assert qc.tools == {"update", "query"} def test_init_with_custom_greeting(self, sample_df): qc = QueryChatBase(sample_df, "test_table", greeting="Hello!") @@ -173,7 +185,7 @@ def test_init_with_tools_none(self, sample_df): def test_init_with_single_tool(self, sample_df): qc = QueryChatBase(sample_df, "test_table", tools="query") - assert qc.tools == ("query",) + assert qc.tools == {"query"} def test_invalid_table_name_raises(self, sample_df): with pytest.raises(ValueError, match="Table name must begin with a letter"): diff --git a/pkg-py/tests/test_client_console.py b/pkg-py/tests/test_client_console.py index 5db3e6e6..f9d32e5b 100644 --- a/pkg-py/tests/test_client_console.py +++ b/pkg-py/tests/test_client_console.py @@ -273,7 +273,7 @@ def test_default_tools_maintain_current_behavior(self, sample_df): # Without tools parameter, should include both tools (like before) qc = QueryChat(sample_df, "test_table", greeting="Hello!") - assert qc.tools == ("update", "query") + assert qc.tools == {"update", "query"} prompt = qc.system_prompt assert "Filtering and Sorting Data" in prompt @@ -292,4 +292,4 @@ def test_existing_initialization_still_works(self, sample_df): assert qc is not None assert qc.id == "querychat_test_table" - assert qc.tools == ("update", "query") + assert qc.tools == {"update", "query"} diff --git a/pkg-py/tests/test_frameworks.py b/pkg-py/tests/test_frameworks.py index f206b12f..52e41ea4 100644 --- a/pkg-py/tests/test_frameworks.py +++ b/pkg-py/tests/test_frameworks.py @@ -133,7 +133,7 @@ def test_custom_tools(self, sample_df): from querychat.dash import QueryChat qc = QueryChat(sample_df, "tips", tools="query") - assert qc.tools == ("query",) + assert qc.tools == {"query"} qc_none = QueryChat(sample_df, "tips", tools=None) assert qc_none.tools is None @@ -175,7 +175,7 @@ def test_custom_tools(self, sample_df): from querychat.streamlit import QueryChat qc = QueryChat(sample_df, "tips", tools="query") - assert qc.tools == ("query",) + assert qc.tools == {"query"} qc_none = QueryChat(sample_df, "tips", tools=None) assert qc_none.tools is None diff --git a/pkg-py/tests/test_shiny_viz_regressions.py b/pkg-py/tests/test_shiny_viz_regressions.py index ab3e2bab..c0aa9a27 100644 --- a/pkg-py/tests/test_shiny_viz_regressions.py +++ b/pkg-py/tests/test_shiny_viz_regressions.py @@ -271,7 +271,7 @@ def client_factory(**kwargs): tools=qc.tools, ) - assert captured["tools"] == ("query", "visualize") + assert captured["tools"] == {"query", "visualize"} assert callable(captured["visualize"]) assert callable(captured["update_dashboard"]) assert callable(captured["reset_dashboard"]) diff --git a/pkg-py/tests/test_viz_tools.py b/pkg-py/tests/test_viz_tools.py index 0e8a9e7a..5d9c2db3 100644 --- a/pkg-py/tests/test_viz_tools.py +++ b/pkg-py/tests/test_viz_tools.py @@ -48,7 +48,7 @@ def test_check_deps_false_skips_check(self, monkeypatch): # Should not raise even though find_spec returns None for everything result = normalize_tools(("visualize",), default=None, check_deps=False) - assert result == ("visualize",) + assert result == {"visualize"} def test_ggsql_syntax_reference_uses_range_not_errorbar(): diff --git a/pkg-r/DESCRIPTION b/pkg-r/DESCRIPTION index 1b9b3c91..7b37530d 100644 --- a/pkg-r/DESCRIPTION +++ b/pkg-r/DESCRIPTION @@ -51,8 +51,8 @@ Suggests: withr VignetteBuilder: knitr +Config/roxygen2/version: 8.0.0 Config/testthat/edition: 3 Config/testthat/parallel: true Encoding: UTF-8 Roxygen: list(markdown = TRUE) -Config/roxygen2/version: 8.0.0 diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md index 639010ca..ae09694c 100644 --- a/pkg-r/NEWS.md +++ b/pkg-r/NEWS.md @@ -1,5 +1,7 @@ # querychat (development version) +* The `tools` parameter now uses `"filter"` as the preferred name (instead of `"update"`) for the dashboard-filtering tool group. The default is now `c("filter", "query")`. The legacy name `"update"` is still accepted everywhere. (#222) + * `QueryChat$server()` now accepts a `client` parameter for session-scoped chat client overrides. This enables Posit Connect managed OAuth workflows where API credentials are only available inside the Shiny server function. The client spec is stored lazily at construction time and resolved only when needed, so `QueryChat$new(NULL, "table")` no longer requires an API key. (#205) * 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) diff --git a/pkg-r/R/QueryChat.R b/pkg-r/R/QueryChat.R index a471c4dd..1d1b4f91 100644 --- a/pkg-r/R/QueryChat.R +++ b/pkg-r/R/QueryChat.R @@ -178,7 +178,7 @@ QueryChat <- R6::R6Class( #' @field id ID for the QueryChat instance. id = NULL, #' @field tools The allowed tools for the chat client. - tools = c("update", "query"), + tools = c("filter", "query"), #' @description #' Create a new QueryChat object. @@ -208,11 +208,12 @@ QueryChat <- R6::R6Class( #' `QUERYCHAT_CLIENT` environment variable, or defaults to #' [ellmer::chat_openai()] #' @param tools Which querychat tools to include in the chat client, by - #' default. `"update"` includes the tools for updating and resetting the + #' default. `"filter"` includes the tools for filtering and resetting the #' dashboard and `"query"` includes the tool for executing SQL queries. - #' Use `tools = "update"` when you only want the dashboard updating tools, + #' Use `tools = "filter"` when you only want the dashboard filtering tools, #' or when you want to disable the querying tool entirely to prevent the - #' LLM from seeing any of the data in your dataset. + #' LLM from seeing any of the data in your dataset. The legacy name + #' `"update"` is still accepted as an alias for `"filter"`. #' @param data_description Optional description of the data in plain text or #' Markdown. Can be a string or a file path. This provides context to the #' LLM about what the data represents. @@ -237,7 +238,7 @@ QueryChat <- R6::R6Class( id = NULL, greeting = NULL, client = NULL, - tools = c("update", "query"), + tools = c("filter", "query"), data_description = NULL, categorical_threshold = 20, extra_instructions = NULL, @@ -249,7 +250,8 @@ QueryChat <- R6::R6Class( # Validate arguments check_string(id, allow_null = TRUE) check_string(greeting, allow_null = TRUE) - arg_match(tools) + arg_match(tools, values = c("filter", "update", "query"), multiple = TRUE) + tools <- normalize_tools(tools) check_string(data_description, allow_null = TRUE) check_number_whole(categorical_threshold, min = 1) check_string(extra_instructions, allow_null = TRUE) @@ -311,9 +313,10 @@ QueryChat <- R6::R6Class( #' data source. #' #' @param tools Which querychat tools to include in the chat client. - #' `"update"` includes the tools for updating and resetting the dashboard + #' `"filter"` includes the tools for filtering and resetting the dashboard #' and `"query"` includes the tool for executing SQL queries. By default, #' when `tools = NA`, the values provided at initialization are used. + #' The legacy name `"update"` is still accepted as an alias for `"filter"`. #' @param update_dashboard Optional function to call with the `query` and #' `title` generated by the LLM for the `update_dashboard` tool. #' @param reset_dashboard Optional function to call when the @@ -328,9 +331,10 @@ QueryChat <- R6::R6Class( if (!is_na(tools) && !is.null(tools)) { tools <- arg_match( tools, - values = c("update", "query"), + values = c("filter", "update", "query"), multiple = TRUE ) + tools <- normalize_tools(tools) } private$create_session_client( @@ -831,11 +835,12 @@ QueryChat <- R6::R6Class( #' `QUERYCHAT_CLIENT` environment variable, or defaults to #' [ellmer::chat_openai()] #' @param tools Which querychat tools to include in the chat client, by -#' default. `"update"` includes the tools for updating and resetting the +#' default. `"filter"` includes the tools for filtering and resetting the #' dashboard and `"query"` includes the tool for executing SQL queries. -#' Use `tools = "update"` when you only want the dashboard updating tools, +#' Use `tools = "filter"` when you only want the dashboard filtering tools, #' or when you want to disable the querying tool entirely to prevent the -#' LLM from seeing any of the data in your dataset. +#' LLM from seeing any of the data in your dataset. The legacy name +#' `"update"` is still accepted as an alias for `"filter"`. #' @param data_description Optional description of the data in plain text or #' Markdown. Can be a string or a file path. This provides context to the #' LLM about what the data represents. @@ -865,7 +870,7 @@ querychat <- function( id = NULL, greeting = NULL, client = NULL, - tools = c("update", "query"), + tools = c("filter", "query"), data_description = NULL, categorical_threshold = 20, extra_instructions = NULL, @@ -908,7 +913,7 @@ querychat_app <- function( id = NULL, greeting = NULL, client = NULL, - tools = c("update", "query"), + tools = c("filter", "query"), data_description = NULL, categorical_threshold = 20, extra_instructions = NULL, @@ -951,6 +956,14 @@ querychat_app <- function( qc$app(bookmark_store = bookmark_store) } +normalize_tools <- function(tools) { + if (is.null(tools)) { + return(NULL) + } + tools[tools == "filter"] <- "update" + unique(tools) +} + normalize_data_source <- function(data_source, table_name) { if (is_data_source(data_source)) { return(data_source) diff --git a/pkg-r/man/QueryChat.Rd b/pkg-r/man/QueryChat.Rd index f6f61132..7e58d1bf 100644 --- a/pkg-r/man/QueryChat.Rd +++ b/pkg-r/man/QueryChat.Rd @@ -144,7 +144,7 @@ the value is normalized and the system prompt is rebuilt.} id = NULL, greeting = NULL, client = NULL, - tools = c("update", "query"), + tools = c("filter", "query"), data_description = NULL, categorical_threshold = 20, extra_instructions = NULL, @@ -183,11 +183,12 @@ create a greeting to save and reuse.} \code{\link[ellmer:chat_openai]{ellmer::chat_openai()}} }} \item{\code{tools}}{Which querychat tools to include in the chat client, by -default. \code{"update"} includes the tools for updating and resetting the +default. \code{"filter"} includes the tools for filtering and resetting the dashboard and \code{"query"} includes the tool for executing SQL queries. -Use \code{tools = "update"} when you only want the dashboard updating tools, +Use \code{tools = "filter"} when you only want the dashboard filtering tools, or when you want to disable the querying tool entirely to prevent the -LLM from seeing any of the data in your dataset.} +LLM from seeing any of the data in your dataset. The legacy name +\code{"update"} is still accepted as an alias for \code{"filter"}.} \item{\code{data_description}}{Optional description of the data in plain text or Markdown. Can be a string or a file path. This provides context to the LLM about what the data represents.} @@ -232,9 +233,10 @@ data source. \if{html}{\out{
}} \describe{ \item{\code{tools}}{Which querychat tools to include in the chat client. -\code{"update"} includes the tools for updating and resetting the dashboard +\code{"filter"} includes the tools for filtering and resetting the dashboard and \code{"query"} includes the tool for executing SQL queries. By default, -when \code{tools = NA}, the values provided at initialization are used.} +when \code{tools = NA}, the values provided at initialization are used. +The legacy name \code{"update"} is still accepted as an alias for \code{"filter"}.} \item{\code{update_dashboard}}{Optional function to call with the \code{query} and \code{title} generated by the LLM for the \code{update_dashboard} tool.} \item{\code{reset_dashboard}}{Optional function to call when the diff --git a/pkg-r/man/querychat-convenience.Rd b/pkg-r/man/querychat-convenience.Rd index c76876bd..9a0a63dd 100644 --- a/pkg-r/man/querychat-convenience.Rd +++ b/pkg-r/man/querychat-convenience.Rd @@ -12,7 +12,7 @@ querychat( id = NULL, greeting = NULL, client = NULL, - tools = c("update", "query"), + tools = c("filter", "query"), data_description = NULL, categorical_threshold = 20, extra_instructions = NULL, @@ -27,7 +27,7 @@ querychat_app( id = NULL, greeting = NULL, client = NULL, - tools = c("update", "query"), + tools = c("filter", "query"), data_description = NULL, categorical_threshold = 20, extra_instructions = NULL, @@ -68,11 +68,12 @@ a greeting to save and reuse.} }} \item{tools}{Which querychat tools to include in the chat client, by -default. \code{"update"} includes the tools for updating and resetting the +default. \code{"filter"} includes the tools for filtering and resetting the dashboard and \code{"query"} includes the tool for executing SQL queries. -Use \code{tools = "update"} when you only want the dashboard updating tools, +Use \code{tools = "filter"} when you only want the dashboard filtering tools, or when you want to disable the querying tool entirely to prevent the -LLM from seeing any of the data in your dataset.} +LLM from seeing any of the data in your dataset. The legacy name +\code{"update"} is still accepted as an alias for \code{"filter"}.} \item{data_description}{Optional description of the data in plain text or Markdown. Can be a string or a file path. This provides context to the diff --git a/pkg-r/tests/testthat/test-QueryChat.R b/pkg-r/tests/testthat/test-QueryChat.R index 71d1e6aa..e1866c5b 100644 --- a/pkg-r/tests/testthat/test-QueryChat.R +++ b/pkg-r/tests/testthat/test-QueryChat.R @@ -356,6 +356,48 @@ describe("QueryChat$client()", { expect_false("querychat_query" %in% tool_names) }) + it("treats 'filter' as an alias for 'update'", { + qc <- QueryChat$new( + new_test_df(), + "test_df" + ) + withr::defer(qc$cleanup()) + + client <- qc$client(tools = "filter") + + tool_names <- sapply(client$get_tools(), function(t) t@name) + expect_contains(tool_names, "querychat_update_dashboard") + expect_contains(tool_names, "querychat_reset_dashboard") + expect_false("querychat_query" %in% tool_names) + }) + + it("normalizes c('filter', 'query') to update + query tools", { + qc <- QueryChat$new( + new_test_df(), + "test_df", + tools = c("filter", "query") + ) + withr::defer(qc$cleanup()) + + client <- qc$client(tools = NA) + + tool_names <- sapply(client$get_tools(), function(t) t@name) + expect_contains(tool_names, "querychat_update_dashboard") + expect_contains(tool_names, "querychat_reset_dashboard") + expect_contains(tool_names, "querychat_query") + }) + + it("deduplicates when both 'filter' and 'update' are provided", { + qc <- QueryChat$new( + new_test_df(), + "test_df", + tools = c("filter", "update", "query") + ) + withr::defer(qc$cleanup()) + + expect_equal(qc$tools, c("update", "query")) + }) + it("registers only query tool when tools = 'query'", { qc <- QueryChat$new( new_test_df(),