diff --git a/pkg-py/src/querychat/_querychat.py b/pkg-py/src/querychat/_querychat.py index 52136587..b5655a9f 100644 --- a/pkg-py/src/querychat/_querychat.py +++ b/pkg-py/src/querychat/_querychat.py @@ -137,7 +137,7 @@ def __init__( ) # Fork and empty chat now so the per-session forks are fast - client = normalize_client(client) + client = as_querychat_client(client) self._client = copy.deepcopy(client) self._client.set_turns([]) self._client.system_prompt = prompt @@ -636,7 +636,7 @@ def normalize_data_source( return DataFrameSource(data_source, table_name) -def normalize_client(client: str | chatlas.Chat | None) -> chatlas.Chat: +def as_querychat_client(client: str | chatlas.Chat | None) -> chatlas.Chat: if client is None: client = os.getenv("QUERYCHAT_CLIENT", None) diff --git a/pkg-r/DESCRIPTION b/pkg-r/DESCRIPTION index f11321b5..e8bd4104 100644 --- a/pkg-r/DESCRIPTION +++ b/pkg-r/DESCRIPTION @@ -1,6 +1,6 @@ Package: querychat Title: Filter and Query Data Frames in 'shiny' Using an LLM Chat Interface -Version: 0.0.1.9000 +Version: 0.1.0.9000 Authors@R: c( person("Joe", "Cheng", , "joe@posit.co", role = c("aut", "cre")), person("Posit Software, PBC", role = c("cph", "fnd")) @@ -23,17 +23,16 @@ Imports: lifecycle, promises, purrr, + R6, rlang, shiny, shinychat (>= 0.2.0.9000), utils, - whisker, - xtable + whisker Suggests: bsicons, DT, palmerpenguins, - R6, RSQLite, shinytest2, testthat (>= 3.0.0), diff --git a/pkg-r/NAMESPACE b/pkg-r/NAMESPACE index 6db28d67..fa321d5b 100644 --- a/pkg-r/NAMESPACE +++ b/pkg-r/NAMESPACE @@ -1,5 +1,7 @@ # Generated by roxygen2: do not edit by hand +S3method(as_querychat_data_source,DBIConnection) +S3method(as_querychat_data_source,data.frame) S3method(cleanup_source,dbi_source) S3method(create_system_prompt,querychat_data_source) S3method(execute_query,dbi_source) @@ -7,14 +9,15 @@ S3method(get_db_type,data_frame_source) S3method(get_db_type,dbi_source) S3method(get_db_type,default) S3method(get_schema,dbi_source) -S3method(querychat_data_source,DBIConnection) -S3method(querychat_data_source,data.frame) S3method(test_query,dbi_source) +export(QueryChat) +export(as_querychat_data_source) export(cleanup_source) export(create_system_prompt) export(execute_query) export(get_db_type) export(get_schema) +export(querychat) export(querychat_app) export(querychat_data_source) export(querychat_greeting) @@ -23,4 +26,7 @@ export(querychat_server) export(querychat_sidebar) export(querychat_ui) export(test_query) +importFrom(R6,R6Class) +importFrom(bslib,sidebar) importFrom(lifecycle,deprecated) +importFrom(rlang,"%||%") diff --git a/pkg-r/NEWS.md b/pkg-r/NEWS.md index 74aced17..5a91e2ef 100644 --- a/pkg-r/NEWS.md +++ b/pkg-r/NEWS.md @@ -1,5 +1,8 @@ # querychat (development version) +* Nearly the entire functional API (i.e., `querychat_init()`, `querychat_sidebar()`, `querychat_server()`, etc) has been hard deprecated in favor of a simpler OOP-based API. Namely, the new `QueryChat$new()` class is now the main entry point (instead of `querychat_init()`) and has methods to replace old functions (e.g., `$sidebar()`, `$server()`, etc). (#109) + * In addition, `querychat_data_source()` was renamed to `as_querychat_data_source()`, and remains exported for a developer extension point, but users no longer have to explicitly create a data source. (#109) + * Added `prompt_template` support for `querychat_system_prompt()`. (Thank you, @oacar! #37, #45) * `querychat_init()` now accepts a `client`, replacing the previous `create_chat_func` argument. (#60) diff --git a/pkg-r/R/QueryChat.R b/pkg-r/R/QueryChat.R new file mode 100644 index 00000000..3c214b2d --- /dev/null +++ b/pkg-r/R/QueryChat.R @@ -0,0 +1,681 @@ +#' QueryChat: Interactive Data Querying with Natural Language +#' +#' @description +#' `QueryChat` is an R6 class built on Shiny, shinychat, and ellmer to enable +#' interactive querying of data using natural language. It leverages large +#' language models (LLMs) to translate user questions into SQL queries, execute +#' them against a data source (data frame or database), and various ways of +#' accessing/displaying the results. +#' +#' +#' @details +#' The `QueryChat` class takes your data (a data frame or database connection) +#' as input and provides methods to: +#' - Generate a chat UI for natural language queries (e.g., `$app()`, `$sidebar()`) +#' - Initialize server logic that returns session-specific reactive values (via `$server()`) +#' - Access reactive data, SQL queries, and titles through the returned server values +#' +#' @section Usage: +#' ```r +#' library(querychat) +#' +#' # Create a QueryChat object +#' qc <- QueryChat$new(mtcars, "mtcars") +#' +#' # Quick start: run a complete app +#' qc$app() +#' +#' # Or build a custom Shiny app +#' ui <- page_sidebar( +#' qc$sidebar(), +#' verbatimTextOutput("sql"), +#' dataTableOutput("data") +#' ) +#' +#' server <- function(input, output, session) { +#' qc_vals <- qc$server() +#' +#' output$sql <- renderText(qc_vals$sql()) +#' output$data <- renderDataTable(qc_vals$df()) +#' } +#' +#' shinyApp(ui, server) +#' ``` +#' +#' @export +#' @examples +#' \dontrun{ +#' # Basic usage with a data frame +#' qc <- QueryChat$new(mtcars, "mtcars") +#' app <- qc$app() +#' +#' # With a custom greeting +#' greeting <- "Welcome! Ask me about the mtcars dataset." +#' qc <- QueryChat$new(mtcars, "mtcars", greeting = greeting) +#' +#' # With a specific LLM provider +#' qc <- QueryChat$new(mtcars, "mtcars", client = "anthropic/claude-sonnet-4-5") +#' +#' # Generate a greeting for reuse +#' qc <- QueryChat$new(mtcars, "mtcars") +#' greeting <- qc$generate_greeting(echo = "text") +#' # Save greeting for next time +#' writeLines(greeting, "mtcars_greeting.md") +#' } +QueryChat <- R6::R6Class( + "QueryChat", + private = list( + server_values = NULL, + .data_source = NULL, + .client = NULL + ), + public = list( + #' @field greeting The greeting message displayed to users. + greeting = NULL, + #' @field id The module ID for namespacing. + id = NULL, + + #' @description + #' Create a new QueryChat object. + #' + #' @param data_source Either a data.frame or a database connection (e.g., DBI + #' connection). + #' @param table_name A string specifying the table name to use in SQL queries. + #' If `data_source` is a data.frame, this is the name to refer to it by in + #' queries (typically the variable name). If `data_source` is a database + #' connection, this is the name of the table in the database. + #' @param ... Additional arguments (currently unused). + #' @param id Optional module ID for the QueryChat instance. If not provided, + #' will be auto-generated from `table_name`. The ID is used to namespace + #' the Shiny module. + #' @param greeting Optional initial message to display to users. Can be a + #' character string (in Markdown format) or a file path. If not provided, + #' a greeting will be generated at the start of each conversation using the + #' LLM, which adds latency and cost. Use `$generate_greeting()` to create + #' a greeting to save and reuse. + #' @param client Optional chat client. Can be: + #' - An [ellmer::Chat] object + #' - A string to pass to [ellmer::chat()] (e.g., `"openai/gpt-4o"`) + #' - `NULL` (default): Uses the `querychat.client` option, the + #' `QUERYCHAT_CLIENT` environment variable, or defaults to + #' [ellmer::chat_openai()] + #' @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. + #' @param categorical_threshold For text columns, the maximum number of unique + #' values to consider as a categorical variable. Default is 20. + #' @param extra_instructions Optional additional instructions for the chat + #' model in plain text or Markdown. Can be a string or a file path. + #' @param prompt_template Optional path to or string of a custom prompt + #' template file. If not provided, the default querychat template will be + #' used. See the package prompts directory for the default template format. + #' @param cleanup Whether or not to automatically run `$cleanup()` when the + #' Shiny session/app stops. By default, cleanup only occurs if `QueryChat` + #' gets created within a Shiny session. Set to `TRUE` to always clean up, + #' or `FALSE` to never clean up automatically. + #' + #' @return A new `QueryChat` object. + #' + #' @examples + #' \dontrun{ + #' # Basic usage + #' qc <- QueryChat$new(mtcars, "mtcars") + #' + #' # With options + #' qc <- QueryChat$new( + #' mtcars, + #' "mtcars", + #' greeting = "Welcome to the mtcars explorer!", + #' client = "openai/gpt-4o", + #' data_description = "Motor Trend car road tests dataset" + #' ) + #' + #' # With database + #' library(DBI) + #' conn <- dbConnect(RSQLite::SQLite(), ":memory:") + #' dbWriteTable(conn, "mtcars", mtcars) + #' qc <- QueryChat$new(conn, "mtcars") + #' } + initialize = function( + data_source, + table_name, + ..., + id = NULL, + greeting = NULL, + client = NULL, + data_description = NULL, + categorical_threshold = 20, + extra_instructions = NULL, + prompt_template = NULL, + cleanup = NA + ) { + rlang::check_dots_empty() + + private$.data_source <- normalize_data_source(data_source, table_name) + + # Validate table name + if (!grepl("^[a-zA-Z][a-zA-Z0-9_]*$", table_name)) { + rlang::abort( + "Table name must begin with a letter and contain only letters, numbers, and underscores" + ) + } + + self$id <- id %||% table_name + + if (!is.null(greeting) && file.exists(greeting)) { + greeting <- paste(readLines(greeting), collapse = "\n") + } + self$greeting <- greeting + + if (is.null(greeting)) { + rlang::warn(c( + "No greeting provided; the LLM will be invoked at conversation start to generate one.", + "*" = "For faster startup, lower cost, and determinism, please save a greeting and pass it to QueryChat$new().", + "i" = "You can generate a greeting with $generate_greeting()." + )) + } + + prompt <- create_system_prompt( + private$.data_source, + data_description = data_description, + categorical_threshold = categorical_threshold, + extra_instructions = extra_instructions, + prompt_template = prompt_template + ) + + # Fork and empty chat now so the per-session forks are fast + client <- as_querychat_client(client) + private$.client <- client$clone() + private$.client$set_turns(list()) + private$.client$set_system_prompt(prompt) + + # By default, only close automatically if a Shiny session is active + if (is.na(cleanup)) { + cleanup <- !is.null(shiny::getDefaultReactiveDomain()) + } + + if (cleanup) { + shiny::onStop(function() { + message("Closing data source...") + self$cleanup() + }) + } + }, + + #' @description + #' Create and run a Shiny gadget for chatting with data + #' + #' Runs a Shiny gadget (designed for interactive use) that provides + #' a complete interface for chatting with your data using natural language. + #' If you're looking to deploy this app or run it through some other means, + #' see `$app_obj()`. + #' + #' @param ... Arguments passed to `$app_obj()`. + #' @param bookmark_store The bookmarking storage method. Passed to + #' [shiny::enableBookmarking()]. If `"url"` or `"server"`, the chat state + #' (including current query) will be bookmarked. Default is `"url"`. + #' + #' @return Invisibly returns a list of session-specific values: + #' - `df`: The final filtered data frame + #' - `sql`: The final SQL query string + #' - `title`: The final title + #' - `client`: The session-specific chat client instance + #' + #' @examples + #' \dontrun{ + #' library(querychat) + #' + #' qc <- QueryChat$new(mtcars, "mtcars") + #' qc$app() + #' } + #' + app = function(..., bookmark_store = "url") { + app <- self$app_obj(..., bookmark_store = bookmark_store) + vals <- tryCatch(shiny::runGadget(app), interrupt = function(cnd) NULL) + invisible(vals) + }, + + #' @description + #' A streamlined Shiny app for chatting with data + #' + #' Creates a Shiny app designed for chatting with data, with: + #' - A sidebar containing the chat interface + #' - A card displaying the current SQL query + #' - A card displaying the filtered data table + #' - A reset button to clear the query + #' + #' @param ... Additional arguments (currently unused). + #' @param bookmark_store The bookmarking storage method. Passed to + #' [shiny::enableBookmarking()]. If `"url"` or `"server"`, the chat state + #' (including current query) will be bookmarked. Default is `"url"`. + #' + #' @return A Shiny app object that can be run with `shiny::runApp()`. + #' + #' @examples + #' \dontrun{ + #' library(querychat) + #' + #' qc <- QueryChat$new(mtcars, "mtcars") + #' app <- qc$app_obj() + #' shiny::runApp(app) + #' } + #' + app_obj = function(..., bookmark_store = "url") { + rlang::check_installed("DT") + rlang::check_installed("bsicons") + rlang::check_dots_empty() + + table_name <- private$.data_source$table_name + + ui <- function(req) { + bslib::page_sidebar( + title = shiny::HTML(sprintf( + "querychat with %s", + table_name + )), + class = "bslib-page-dashboard", + sidebar = self$sidebar(), + shiny::useBusyIndicators(pulse = TRUE, spinners = FALSE), + bslib::card( + fill = FALSE, + style = bslib::css(max_height = "33%"), + bslib::card_header( + shiny::div( + class = "hstack", + shiny::div( + bsicons::bs_icon("terminal-fill"), + shiny::textOutput("query_title", inline = TRUE) + ), + shiny::div( + class = "ms-auto", + shiny::uiOutput("ui_reset", inline = TRUE) + ) + ) + ), + shiny::uiOutput("sql_output") + ), + bslib::card( + full_screen = TRUE, + bslib::card_header(bsicons::bs_icon("table"), "Data"), + DT::DTOutput("dt") + ), + shiny::actionButton( + "close_btn", + label = "", + class = "btn-close", + style = "position: fixed; top: 6px; right: 6px;" + ) + ) + } + + server <- function(input, output, session) { + qc_vals <- self$server() + + output$query_title <- shiny::renderText({ + if (shiny::isTruthy(qc_vals$title())) { + qc_vals$title() + } else { + "SQL Query" + } + }) + + output$ui_reset <- shiny::renderUI({ + shiny::req(qc_vals$sql()) + + shiny::actionButton( + "reset_query", + label = "Reset Query", + class = "btn btn-outline-danger btn-sm lh-1" + ) + }) + + shiny::observeEvent(input$reset_query, label = "on_reset_query", { + qc_vals$sql("") + qc_vals$title(NULL) + }) + + output$dt <- DT::renderDT({ + DT::datatable( + qc_vals$df(), + fillContainer = TRUE, + options = list(pageLength = 25, scrollX = TRUE) + ) + }) + + output$sql_output <- shiny::renderUI({ + sql <- if (shiny::isTruthy(qc_vals$sql())) { + qc_vals$sql() + } else { + paste("SELECT * FROM", table_name) + } + + sql_code <- paste(c("```sql", sql, "```"), collapse = "\n") + + shinychat::output_markdown_stream( + "sql_code", + content = sql_code, + auto_scroll = FALSE, + width = "100%" + ) + }) + + shiny::observeEvent(input$close_btn, label = "on_close_btn", { + shiny::stopApp(list( + df = qc_vals$df(), + sql = qc_vals$sql(), + title = qc_vals$title(), + client = qc_vals$client + )) + }) + } + + shiny::shinyApp(ui, server, enableBookmarking = bookmark_store) + }, + + #' @description + #' Create a sidebar containing the querychat UI. + #' + #' This method generates a [bslib::sidebar()] component containing the chat + #' interface, suitable for use with [bslib::page_sidebar()] or similar layouts. + #' + #' @param width Width of the sidebar in pixels. Default is 400. + #' @param height Height of the sidebar. Default is "100%". + #' @param ... Additional arguments passed to [bslib::sidebar()]. + #' + #' @return A [bslib::sidebar()] UI component. + #' + #' @examples + #' \dontrun{ + #' qc <- QueryChat$new(mtcars, "mtcars") + #' + #' ui <- page_sidebar( + #' qc$sidebar(), + #' # Main content here + #' ) + #' } + sidebar = function(width = 400, height = "100%", ...) { + bslib::sidebar( + width = width, + height = height, + class = "querychat-sidebar", + ..., + self$ui() + ) + }, + + #' @description + #' Create the UI for the querychat chat interface. + #' + #' This method generates the chat UI component. Typically you'll use + #' `$sidebar()` instead, which wraps this in a sidebar layout. + #' + #' @param ... Additional arguments passed to [shinychat::chat_ui()]. + #' + #' @return A UI component containing the chat interface. + #' + #' @examples + #' \dontrun{ + #' qc <- QueryChat$new(mtcars, "mtcars") + #' + #' ui <- fluidPage( + #' qc$ui() + #' ) + #' } + ui = function(...) { + mod_ui(self$id, ...) + }, + + #' @description + #' Initialize the querychat server logic. + #' + #' This method must be called within a Shiny server function. It sets up + #' the reactive logic for the chat interface and returns session-specific + #' reactive values. + #' + #' @param session The Shiny session object. + #' + #' @return A list containing session-specific reactive values and the chat + #' client with the following elements: + #' - `df`: Reactive expression returning the current filtered data frame + #' - `sql`: Reactive value for the current SQL query string + #' - `title`: Reactive value for the current title + #' - `client`: The session-specific chat client instance + #' + #' @examples + #' \dontrun{ + #' qc <- QueryChat$new(mtcars, "mtcars") + #' + #' server <- function(input, output, session) { + #' qc_vals <- qc$server() + #' + #' output$data <- renderDataTable(qc_vals$df()) + #' output$query <- renderText(qc_vals$sql()) + #' output$title <- renderText(qc_vals$title() %||% "No Query") + #' } + #' } + server = function(session = shiny::getDefaultReactiveDomain()) { + if (is.null(session)) { + rlang::abort( + "$server() must be called within a Shiny server function." + ) + } + + mod_server( + self$id, + data_source = private$.data_source, + greeting = self$greeting, + client = private$.client + ) + }, + + #' @description + #' Generate a welcome greeting for the chat. + #' + #' By default, `QueryChat$new()` generates a greeting at the start of every + #' new conversation, which is convenient for getting started and development, + #' but also might add unnecessary latency and cost. Use this method to + #' generate a greeting once and save it for reuse. + #' + #' @param echo Whether to print the greeting to the console. Options are + #' `"none"` (default, no output) or `"output"` (print to console). + #' + #' @return The greeting string in Markdown format. + #' + #' @examples + #' \dontrun{ + #' # Create QueryChat object + #' qc <- QueryChat$new(mtcars, "mtcars") + #' + #' # Generate a greeting and save it + #' greeting <- qc$generate_greeting() + #' writeLines(greeting, "mtcars_greeting.md") + #' + #' # Later, use the saved greeting + #' qc2 <- QueryChat$new(mtcars, "mtcars", greeting = "mtcars_greeting.md") + #' } + generate_greeting = function(echo = c("none", "output")) { + echo <- match.arg(echo) + + chat <- private$.client$clone() + chat$set_turns(list()) + + prompt <- "Please give me a friendly greeting. Include a few sample prompts in a two-level bulleted list." + as.character(chat$chat(prompt, echo = echo)) + }, + + #' @description + #' Clean up resources associated with the data source. + #' + #' This method releases any resources (e.g., database connections) + #' associated with the data source. Call this when you are done using + #' the QueryChat object to avoid resource leaks. + #' + #' Note: If `auto_cleanup` was set to `TRUE` in the constructor, + #' this will be called automatically when the Shiny app stops. + #' + #' @return Invisibly returns `NULL`. Resources are cleaned up internally. + cleanup = function() { + cleanup_source(private$.data_source) + } + ), + active = list( + #' @field system_prompt Get the system prompt. + system_prompt = function() { + private$.client$get_system_prompt() + }, + + #' @field data_source Get the current data source. + data_source = function() { + private$.data_source + } + ) +) + + +#' QueryChat convenience functions +#' +#' Convenience functions for wrapping [QueryChat] creation (i.e., `querychat()`) +#' and app launching (i.e., `querychat_app()`). +#' +#' @param data_source Either a data.frame or a database connection (e.g., DBI +#' connection). +#' @param table_name A string specifying the table name to use in SQL queries. +#' If `data_source` is a data.frame, this is the name to refer to it by in +#' queries (typically the variable name). If `data_source` is a database +#' connection, this is the name of the table in the database. +#' @param ... Additional arguments (currently unused). +#' @param id Optional module ID for the QueryChat instance. If not provided, +#' will be auto-generated from `table_name`. The ID is used to namespace +#' the Shiny module. +#' @param greeting Optional initial message to display to users. Can be a +#' character string (in Markdown format) or a file path. If not provided, +#' a greeting will be generated at the start of each conversation using the +#' LLM, which adds latency and cost. Use `$generate_greeting()` to create +#' a greeting to save and reuse. +#' @param client Optional chat client. Can be: +#' - An [ellmer::Chat] object +#' - A string to pass to [ellmer::chat()] (e.g., `"openai/gpt-4o"`) +#' - `NULL` (default): Uses the `querychat.client` option, the +#' `QUERYCHAT_CLIENT` environment variable, or defaults to +#' [ellmer::chat_openai()] +#' @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. +#' @param categorical_threshold For text columns, the maximum number of unique +#' values to consider as a categorical variable. Default is 20. +#' @param extra_instructions Optional additional instructions for the chat +#' model in plain text or Markdown. Can be a string or a file path. +#' @param prompt_template Optional path to or string of a custom prompt +#' template file. If not provided, the default querychat template will be +#' used. See the package prompts directory for the default template format. +#' @param cleanup Whether or not to automatically run `$cleanup()` when the +#' Shiny session/app stops. By default, cleanup only occurs if `QueryChat` +#' gets created within a Shiny session. Set to `TRUE` to always clean up, +#' or `FALSE` to never clean up automatically. +#' +#' @return A `QueryChat` object. See [QueryChat] for available methods. +#' +#' @rdname querychat-convenience +#' +#' @export +#' @examples +#' \dontrun{ +#' # Quick start - chat with mtcars dataset in one line +#' querychat_app(mtcars, "mtcars") +#' +#' # Add options +#' querychat_app( +#' mtcars, +#' "mtcars", +#' greeting = "Welcome to the mtcars explorer!", +#' client = "openai/gpt-4o" +#' ) +#' +#' # Chat with a database table +#' library(DBI) +#' conn <- dbConnect(RSQLite::SQLite(), ":memory:") +#' dbWriteTable(conn, "mtcars", mtcars) +#' querychat_app(conn, "mtcars") +#' +#' # Create QueryChat class object +#' qc <- querychat(mtcars, "mtcars") +#' +#' # Run the app later +#' qc$app() +#' +#' } +querychat <- function( + data_source, + table_name, + ..., + id = NULL, + greeting = NULL, + client = NULL, + data_description = NULL, + categorical_threshold = 20, + extra_instructions = NULL, + prompt_template = NULL, + cleanup = NA +) { + QueryChat$new( + data_source = data_source, + table_name = table_name, + ..., + id = id, + greeting = greeting, + client = client, + data_description = data_description, + categorical_threshold = categorical_threshold, + extra_instructions = extra_instructions, + prompt_template = prompt_template, + cleanup = cleanup + ) +} + + +#' @rdname querychat-convenience +#' @param bookmark_store The bookmarking storage method. Passed to +#' [shiny::enableBookmarking()]. If `"url"` or `"server"`, the chat state +#' (including current query) will be bookmarked. Default is `"url"`. +#' @return Invisibly returns the chat object after the app stops. +#' +#' @export +querychat_app <- function( + data_source, + table_name, + ..., + id = NULL, + greeting = NULL, + client = NULL, + data_description = NULL, + categorical_threshold = 20, + extra_instructions = NULL, + prompt_template = NULL, + cleanup = TRUE, + bookmark_store = "url" +) { + qc <- QueryChat$new( + data_source = data_source, + table_name = table_name, + ..., + id = id, + greeting = greeting, + client = client, + data_description = data_description, + categorical_threshold = categorical_threshold, + extra_instructions = extra_instructions, + prompt_template = prompt_template, + cleanup = cleanup + ) + + qc$app(bookmark_store = bookmark_store) +} + + +normalize_data_source <- function(data_source, table_name) { + if (is_data_source(data_source)) { + data_source + } else { + as_querychat_data_source(data_source, table_name) + } +} diff --git a/pkg-r/R/data_source.R b/pkg-r/R/data_source.R index d7beb36b..73684c55 100644 --- a/pkg-r/R/data_source.R +++ b/pkg-r/R/data_source.R @@ -1,28 +1,22 @@ #' Create a data source for querychat #' -#' Generic function to create a data source for querychat. This function -#' dispatches to appropriate methods based on input. +#' An entrypoint for developers to create custom data sources for use with +#' querychat. Most users shouldn't use this function directly; instead, they +#' should pass their data to `QueryChat$new()`. #' #' @param x A data frame or DBI connection #' @param table_name The name to use for the table in the data source. Can be: #' - A character string (e.g., "table_name") #' - Or, for tables contained within catalogs or schemas, a [DBI::Id()] object (e.g., `DBI::Id(schema = "schema_name", table = "table_name")`) -#' @param categorical_threshold For text columns, the maximum number of unique values to consider as a categorical variable -#' @param ... Additional arguments passed to specific methods #' @return A querychat_data_source object +#' @keywords internal #' @export -querychat_data_source <- function(x, ...) { - UseMethod("querychat_data_source") +as_querychat_data_source <- function(x, table_name = NULL, ...) { + UseMethod("as_querychat_data_source") } #' @export -#' @rdname querychat_data_source -querychat_data_source.data.frame <- function( - x, - table_name = NULL, - categorical_threshold = 20, - ... -) { +as_querychat_data_source.data.frame <- function(x, table_name = NULL, ...) { if (is.null(table_name)) { # Infer table name from dataframe name, if not already added table_name <- deparse(substitute(x)) @@ -47,23 +41,13 @@ querychat_data_source.data.frame <- function( duckdb::duckdb_register(conn, table_name, x, experimental = FALSE) structure( - list( - conn = conn, - table_name = table_name, - categorical_threshold = categorical_threshold - ), + list(conn = conn, table_name = table_name), class = c("data_frame_source", "dbi_source", "querychat_data_source") ) } #' @export -#' @rdname querychat_data_source -querychat_data_source.DBIConnection <- function( - x, - table_name, - categorical_threshold = 20, - ... -) { +as_querychat_data_source.DBIConnection <- function(x, table_name, ...) { # Handle different types of table_name inputs if (inherits(table_name, "Id")) { # DBI::Id object - keep as is @@ -87,21 +71,26 @@ querychat_data_source.DBIConnection <- function( } structure( - list( - conn = x, - table_name = table_name, - categorical_threshold = categorical_threshold - ), + list(conn = x, table_name = table_name), class = c("dbi_source", "querychat_data_source") ) } -#' Execute a SQL query on a data source +is_data_source <- function(x) { + inherits(x, "querychat_data_source") +} + +#' Execute an SQL query on a data source +#' +#' An entrypoint for developers to create custom data source objects for use +#' with querychat. Most users shouldn't use this function directly; instead, +#' they call the `$sql()` method on the [QueryChat] object to run queries. #' #' @param source A querychat_data_source object #' @param query SQL query string #' @param ... Additional arguments passed to methods #' @return Result of the query as a data frame +#' @keywords internal #' @export execute_query <- function(source, query, ...) { UseMethod("execute_query") @@ -122,10 +111,15 @@ execute_query.dbi_source <- function(source, query, ...) { #' Test a SQL query on a data source. #' +#' An entrypoint for developers to create custom data sources for use with +#' querychat. Most users shouldn't use this function directly; instead, they +#' should call the `$sql()` method on the [QueryChat] object to run queries. +#' #' @param source A querychat_data_source object #' @param query SQL query string #' @param ... Additional arguments passed to methods #' @return Result of the query, limited to one row of data. +#' @keywords internal #' @export test_query <- function(source, query, ...) { UseMethod("test_query") @@ -142,9 +136,14 @@ test_query.dbi_source <- function(source, query, ...) { #' Get type information for a data source #' +#' An entrypoint for developers to create custom data sources for use with +#' querychat. Most users shouldn't use this function directly; instead, they +#' should call the `$set_system_prompt()` method on the [QueryChat] object. +#' #' @param source A querychat_data_source object #' @param ... Additional arguments passed to methods #' @return A character string containing the type information +#' @keywords internal #' @export get_db_type <- function(source, ...) { UseMethod("get_db_type") @@ -184,16 +183,24 @@ get_db_type.dbi_source <- function(source, ...) { #' Create a system prompt for the data source #' +#' An entrypoint for developers to create custom data sources for use with +#' querychat. Most users shouldn't use this function directly; instead, they +#' should call the `$set_system_prompt()` method on the [QueryChat] object. +#' #' @param source A querychat_data_source object #' @param data_description Optional description of the data #' @param extra_instructions Optional additional instructions +#' @param categorical_threshold For text columns, the maximum number of unique +#' values to consider as a categorical variable #' @param ... Additional arguments passed to methods #' @return A string with the system prompt +#' @keywords internal #' @export create_system_prompt <- function( source, data_description = NULL, extra_instructions = NULL, + categorical_threshold = 20, ... ) { UseMethod("create_system_prompt") @@ -204,6 +211,7 @@ create_system_prompt.querychat_data_source <- function( source, data_description = NULL, extra_instructions = NULL, + categorical_threshold = 20, ... ) { if (!is.null(data_description)) { @@ -219,7 +227,7 @@ create_system_prompt.querychat_data_source <- function( prompt_text <- paste(prompt_content, collapse = "\n") # Get schema for the data source - schema <- get_schema(source) + schema <- get_schema(source, categorical_threshold = categorical_threshold) # Examine the data source and get the type for the prompt db_type <- get_db_type(source) @@ -238,9 +246,14 @@ create_system_prompt.querychat_data_source <- function( #' Clean up a data source (close connections, etc.) #' +#' An entrypoint for developers to create custom data sources for use with +#' querychat. Most users shouldn't use this function directly; instead, they +#' should call the `$cleanup()` method on the [QueryChat] object. +#' #' @param source A querychat_data_source object #' @param ... Additional arguments passed to methods #' @return NULL (invisibly) +#' @keywords internal #' @export cleanup_source <- function(source, ...) { UseMethod("cleanup_source") @@ -257,19 +270,24 @@ cleanup_source.dbi_source <- function(source, ...) { #' Get schema for a data source #' +#' An entrypoint for developers to create custom data sources for use with +#' querychat. Most users shouldn't use this function directly; instead, they +#' should call the `$set_system_prompt()` method on the [QueryChat] object. +#' #' @param source A querychat_data_source object +#' @param categorical_threshold For text columns, the maximum number of unique values to consider as a categorical variable #' @param ... Additional arguments passed to methods #' @return A character string describing the schema +#' @keywords internal #' @export -get_schema <- function(source, ...) { +get_schema <- function(source, categorical_threshold = 20, ...) { UseMethod("get_schema") } #' @export -get_schema.dbi_source <- function(source, ...) { +get_schema.dbi_source <- function(source, categorical_threshold = 20, ...) { conn <- source$conn table_name <- source$table_name - categorical_threshold <- source$categorical_threshold # Get column information columns <- DBI::dbListFields(conn, table_name) diff --git a/pkg-r/R/deprecated.R b/pkg-r/R/deprecated.R new file mode 100644 index 00000000..c5721e5f --- /dev/null +++ b/pkg-r/R/deprecated.R @@ -0,0 +1,138 @@ +#' Deprecated functions +#' +#' These functions have been replaced by the new `QueryChat` R6 class API. +#' Please update your code to use the new class-based approach. +#' +#' @name deprecated +#' @keywords internal +NULL + +#' @rdname deprecated +#' @export +querychat_init <- function(...) { + lifecycle::deprecate_stop( + when = "0.1.0", + what = "querychat_init()", + with = "QueryChat$new()", + details = c( + "Old code:", + " config <- querychat_init(mtcars, greeting = 'Hello!')", + " ui <- page_sidebar(sidebar = querychat_sidebar('chat'), ...)", + " server <- function(input, output, session) {", + " chat <- querychat_server('chat', config)", + " output$data <- renderDataTable(chat$df())", + " }", + "", + "New code:", + " qc <- QueryChat$new(mtcars, 'mtcars', greeting = 'Hello!')", + " ui <- page_sidebar(sidebar = qc$sidebar(), ...)", + " server <- function(input, output, session) {", + " qc$server()", + " output$data <- renderDataTable(qc$df())", + " }", + "", + "See ?QueryChat for more information." + ) + ) +} + +#' @rdname deprecated +#' @export +querychat_sidebar <- function(...) { + lifecycle::deprecate_stop( + when = "0.1.0", + what = "querychat_sidebar()", + with = "QueryChat$sidebar()", + details = c( + "Old code:", + " querychat_sidebar('chat')", + "", + "New code:", + " qc <- QueryChat$new(data, 'table_name')", + " qc$sidebar()", + "", + "See ?QueryChat for more information." + ) + ) +} + +#' @rdname deprecated +#' @export +querychat_ui <- function(...) { + lifecycle::deprecate_stop( + when = "0.1.0", + what = "querychat_ui()", + with = "QueryChat$ui()", + details = c( + "Old code:", + " querychat_ui('chat')", + "", + "New code:", + " qc <- QueryChat$new(data, 'table_name')", + " qc$ui()", + "", + "See ?QueryChat for more information." + ) + ) +} + +#' @rdname deprecated +#' @export +querychat_server <- function(...) { + lifecycle::deprecate_stop( + when = "0.1.0", + what = "querychat_server()", + with = "QueryChat$server()", + details = c( + "Old code:", + " chat <- querychat_server('chat', config)", + " output$data <- renderDataTable(chat$df())", + "", + "New code:", + " qc <- QueryChat$new(data, 'table_name')", + " qc$server() # Must be called within server function", + " output$data <- renderDataTable(qc$df())", + "", + "See ?QueryChat for more information." + ) + ) +} + +#' @rdname deprecated +#' @export +querychat_greeting <- function(...) { + lifecycle::deprecate_stop( + when = "0.1.0", + what = "querychat_greeting()", + with = "QueryChat$generate_greeting()", + details = c( + "Old code:", + " greeting <- querychat_greeting(config)", + "", + "New code:", + " qc <- QueryChat$new(data, 'table_name')", + " greeting <- qc$generate_greeting(echo = 'text')", + "", + "See ?QueryChat for more information." + ) + ) +} + +#' @rdname deprecated +#' @export +querychat_data_source <- function(...) { + lifecycle::deprecate_stop( + when = "0.1.0", + what = "querychat_data_source()", + with = "QueryChat$new()", + details = c( + "Old code:", + " data_source <- querychat_data_source(db_connection, 'table_name')", + "", + "New code:", + " qc <- QueryChat$new(db_connection, 'table_name')", + "", + "See ?QueryChat for more information." + ) + ) +} diff --git a/pkg-r/R/querychat-package.R b/pkg-r/R/querychat-package.R index 425b3c1c..e19e1636 100644 --- a/pkg-r/R/querychat-package.R +++ b/pkg-r/R/querychat-package.R @@ -1,7 +1,70 @@ +#' querychat: Chat with Your Data Using Natural Language +#' +#' @description +#' querychat provides an interactive chat interface for querying data using +#' natural language. It translates your questions into SQL queries, executes +#' them against your data, and displays the results. The package works with +#' both data frames and database connections. +#' +#' @section Quick Start: +#' The easiest way to get started is with the [QueryChat] R6 class: +#' +#' ```r +#' library(querychat) +#' +#' # Create a QueryChat object +#' qc <- QueryChat$new(mtcars, "mtcars") +#' +#' # Option 1: Run a complete app with sensible defaults +#' qc$app() +#' +#' # Option 2: Build a custom Shiny app +#' ui <- page_sidebar( +#' qc$sidebar(), +#' dataTableOutput("data") +#' ) +#' +#' server <- function(input, output, session) { +#' qc$server() +#' output$data <- renderDataTable(qc$df()) +#' } +#' +#' shinyApp(ui, server) +#' ``` +#' +#' @section Key Features: +#' - **Natural language queries**: Ask questions in plain English +#' - **SQL transparency**: See the generated SQL queries +#' - **Multiple data sources**: Works with data frames and database connections +#' - **Customizable**: Add data descriptions, extra instructions, and custom greetings +#' - **LLM agnostic**: Works with OpenAI, Anthropic, Google, and other providers via ellmer +#' +#' @section Main Components: +#' - [QueryChat]: The main R6 class for creating chat interfaces +#' - [as_querychat_data_source()]: (Advanced) Create custom data source objects +#' +#' @section Examples: +#' To see examples included with the package, run: +#' +#' ```r +#' shiny::runExample(package = "querychat") +#' ``` +#' +#' This provides a list of available examples. To run a specific example, like +#' '01-hello-app', use: +#' +#' ```r +#' shiny::runExample("01-hello-app", package = "querychat") +#' ``` +#' +#' #' @keywords internal "_PACKAGE" ## usethis namespace: start #' @importFrom lifecycle deprecated +#' @importFrom R6 R6Class +#' @importFrom bslib sidebar +#' @importFrom rlang %||% ## usethis namespace: end NULL diff --git a/pkg-r/R/querychat.R b/pkg-r/R/querychat.R deleted file mode 100644 index 1bb9d5e8..00000000 --- a/pkg-r/R/querychat.R +++ /dev/null @@ -1,309 +0,0 @@ -#' Call this once outside of any server function -#' -#' This will perform one-time initialization that can then be shared by all -#' Shiny sessions in the R process. -#' -#' @param data_source A querychat_data_source object created by -#' `querychat_data_source()`. -#' -#' To create a data source: -#' - For data frame: `querychat_data_source(df, tbl_name = "my_table")` -#' - For database: `querychat_data_source(conn, "table_name")` -#' @param greeting A string in Markdown format, containing the initial message -#' to display to the user upon first loading the chatbot. If not provided, the -#' LLM will be invoked at the start of the conversation to generate one. You -#' can also use [querychat_greeting()] to generate a greeting. -#' @param data_description A string containing a data description for the chat -#' model. We have found that formatting the data description as a markdown -#' bulleted list works best. -#' @param extra_instructions A string containing extra instructions for the -#' chat model. -#' @param client An `ellmer::Chat` object, a string to be passed to -#' [ellmer::chat()] describing the model to use (e.g. `"openai/gpt-4o"`), or a -#' function that creates a chat client. When using a function, the function -#' should take `system_prompt` as an argument and return an `ellmer::Chat` -#' object. -#' -#' If `client` is not provided, querychat consults the `querychat.client` R -#' option, which can be any of the described options, or the -#' `QUERYCHAT_CLIENT` environment variable, which can be set to a a -#' provider-model string. If no option is provided, querychat defaults to -#' using [ellmer::chat_openai()]. -#' @param create_chat_func `r lifecycle::badge('deprecated')`. Use the `client` -#' argument instead. -#' @param system_prompt A string containing the system prompt for the chat -#' model. The default generates a generic prompt, which you can enhance via -#' the `data_description` and `extra_instructions` arguments. -#' @param auto_close_data_source Should the data source connection be -#' automatically closed when the shiny app stops? Defaults to TRUE. -#' -#' @returns An object that can be passed to `querychat_server()` as the -#' `querychat_config` argument. By convention, this object should be named -#' `querychat_config`. -#' -#' @export -querychat_init <- function( - data_source, - greeting = NULL, - data_description = NULL, - extra_instructions = NULL, - create_chat_func = deprecated(), - system_prompt = NULL, - auto_close_data_source = TRUE, - client = NULL -) { - if (lifecycle::is_present(create_chat_func)) { - lifecycle::deprecate_warn( - "0.0.1", - "querychat_init(create_chat_func=)", - "querychat_init(client =)" - ) - if (!is.null(client)) { - rlang::abort( - "You cannot pass both `create_chat_func` and `client` to `querychat_init()`." - ) - } - client <- create_chat_func - } - - client <- querychat_client(client) - - # If the user passes a data.frame to data_source, create a correct data source for them - if (inherits(data_source, "data.frame")) { - data_source <- querychat_data_source( - data_source, - table_name = deparse(substitute(data_source)) - ) - } - - # Check that data_source is a querychat_data_source object - if (!inherits(data_source, "querychat_data_source")) { - rlang::abort( - "`data_source` must be a querychat_data_source object. Use querychat_data_source() to create one." - ) - } - - if (auto_close_data_source) { - # Close the data source when the Shiny app stops (or, if some reason the - # querychat_init call is within a specific session, when the session ends) - shiny::onStop(function() { - message("Closing data source...") - cleanup_source(data_source) - }) - } - - # Generate system prompt if not provided - if (is.null(system_prompt)) { - system_prompt <- create_system_prompt( - data_source, - data_description = data_description, - extra_instructions = extra_instructions - ) - } - - # Validate system prompt - stopifnot( - "system_prompt must be a string" = is.character(system_prompt) - ) - - if (!is.null(greeting)) { - greeting <- paste(collapse = "\n", greeting) - } else { - rlang::warn(c( - "No greeting provided; the LLM will be invoked at the start of the conversation to generate one.", - "*" = "For faster startup, lower cost, and determinism, please save a greeting and pass it to querychat_init().", - "i" = "You can generate a greeting by passing this config object to `querychat_greeting()`." - )) - } - - structure( - list( - data_source = data_source, - system_prompt = system_prompt, - greeting = greeting, - client = client - ), - class = "querychat_config" - ) -} - -#' UI components for querychat -#' -#' These functions create UI components for the querychat interface. -#' `querychat_ui()` creates a basic chat interface, while `querychat_sidebar()` -#' wraps the chat interface in a [bslib::sidebar()] component designed to be -#' used as the `sidebar` argument to [bslib::page_sidebar()]. -#' -#' @param id The ID of the module instance. -#' @param width,height In `querychat_sidebar()`: the width and height of the -#' sidebar. -#' @param ... In `querychat_sidebar()`: additional arguments passed to -#' [bslib::sidebar()]. -#' -#' @return A UI object that can be embedded in a Shiny app. -#' -#' @name querychat_ui -#' @export -querychat_sidebar <- function(id, width = 400, height = "100%", ...) { - bslib::sidebar( - width = width, - height = height, - class = "querychat-sidebar", - ..., - # purposely NOT using ns() for `id`, we're just a passthrough - querychat_ui(id) - ) -} - -#' @rdname querychat_ui -#' @export -querychat_ui <- function(id) { - ns <- shiny::NS(id) - htmltools::tagList( - htmltools::htmlDependency( - "querychat", - version = "0.0.1", - package = "querychat", - src = "htmldep", - script = "querychat.js", - stylesheet = "styles.css" - ), - shinychat::chat_ui( - ns("chat"), - height = "100%", - fill = TRUE, - class = "querychat" - ) - ) -} - -#' Initialize the querychat server -#' -#' @param id The ID of the module instance. Must match the ID passed to -#' the corresponding call to `querychat_ui()`. -#' @param querychat_config An object created by `querychat_init()`. -#' -#' @returns A querychat instance, which is a named list with the following -#' elements: -#' -#' - `sql`: A reactive that returns the current SQL query. -#' - `title`: A reactive that returns the current title. -#' - `df`: A reactive that returns the filtered data as a data.frame. -#' - `chat`: The [ellmer::Chat] object that powers the chat interface. -#' -#' @export -querychat_server <- function(id, querychat_config) { - shiny::moduleServer(id, function(input, output, session) { - # 🔄 Reactive state/computation -------------------------------------------- - - data_source <- querychat_config[["data_source"]] - system_prompt <- querychat_config[["system_prompt"]] - greeting <- querychat_config[["greeting"]] - client <- querychat_config[["client"]] - - current_title <- shiny::reactiveVal(NULL, label = "current_title") - current_query <- shiny::reactiveVal("", label = "current_query") - filtered_df <- shiny::reactive(label = "filtered_df", { - execute_query(data_source, query = DBI::SQL(current_query())) - }) - - append_output <- function(...) { - txt <- paste0(...) - shinychat::chat_append_message( - "chat", - list(role = "assistant", content = txt), - chunk = TRUE, - operation = "append", - session = session - ) - } - - reset_query <- function() { - current_query("") - current_title(NULL) - querychat_tool_result(action = "reset") - } - - # Preload the conversation with the system prompt. These are instructions for - # the chat model, and must not be shown to the end user. - chat <- client$clone() - chat$set_turns(list()) - chat$set_system_prompt(system_prompt) - chat$register_tool( - tool_update_dashboard(data_source, current_query, current_title) - ) - chat$register_tool(tool_query(data_source)) - chat$register_tool(tool_reset_dashboard(reset_query)) - - # Prepopulate the chat UI with a welcome message that appears to be from the - # chat model (but is actually hard-coded). This is just for the user, not for - # the chat model to see. - shinychat::chat_append( - "chat", - querychat_greeting(querychat_config, generate = TRUE, stream = TRUE) - ) - - append_stream_task <- shiny::ExtendedTask$new( - function(client, user_input) { - stream <- client$stream_async( - user_input, - stream = "content" - ) - - p <- promises::promise_resolve(stream) - promises::then(p, function(stream) { - shinychat::chat_append("chat", stream) - }) - } - ) - - shiny::observeEvent(input$chat_user_input, label = "on_chat_user_input", { - append_stream_task$invoke(chat, input$chat_user_input) - }) - - shiny::observeEvent(input$chat_update, label = "on_chat_update", { - current_query(input$chat_update$query) - current_title(input$chat_update$title) - }) - - list( - chat = chat, - sql = shiny::reactive(current_query()), - title = shiny::reactive(current_title()), - df = filtered_df, - update_query = function(query, title = NULL) { - current_query(query) - current_title(title) - } - ) - }) -} - -df_to_html <- function(df, maxrows = 5) { - df_short <- if (nrow(df) > 10) utils::head(df, maxrows) else df - - tbl_html <- utils::capture.output( - df_short |> - xtable::xtable() |> - print( - type = "html", - include.rownames = FALSE, - html.table.attributes = NULL - ) - ) |> - paste(collapse = "\n") - - if (nrow(df_short) != nrow(df)) { - rows_notice <- paste0( - "\n\n(Showing only the first ", - maxrows, - " rows out of ", - nrow(df), - ".)\n" - ) - } else { - rows_notice <- "" - } - - paste0(tbl_html, "\n", rows_notice) -} diff --git a/pkg-r/R/querychat_app.R b/pkg-r/R/querychat_app.R deleted file mode 100644 index 06d55759..00000000 --- a/pkg-r/R/querychat_app.R +++ /dev/null @@ -1,138 +0,0 @@ -#' A Simple App for Chatting with Data -#' -#' Creates a Shiny app that allows users to interact with a data source using -#' natural language queries. The app uses a pre-configured Shiny app built on -#' [querychat_sidebar()] and [querychat_server()] to provide a quick-and-easy -#' way to chat with your data. -#' -#' @examplesIf rlang::is_interactive() -#' # Pass in a data frame to start querychatting -#' querychat_app(mtcars) -#' -#' # Or choose your LLM client using ellmer::chat_*() functions -#' querychat_app(mtcars, client = ellmer::chat_anthropic()) -#' -#' @param config A `querychat_config` object or a data source that can be used -#' to create one. -#' @param ... Additional arguments passed to [querychat_init()] if `config` is -#' not already a `querychat_config` object. -#' @inheritParams shinychat::chat_app -#' -#' @return Invisibly returns the `chat` object containing the chat history. -#' -#' @export -querychat_app <- function(config, ..., bookmark_store = "url") { - rlang::check_installed("DT") - rlang::check_installed("bsicons") - - if (!inherits(config, "querychat_config")) { - if (inherits(config, "querychat_data_source")) { - data_source <- config - } else { - data_source <- querychat_data_source( - config, - table_name = deparse(substitute(config)) - ) - } - config <- querychat_init(data_source, ...) - } - - ui <- function(req) { - bslib::page_sidebar( - title = shiny::HTML(paste0( - "querychat with ", - config$data_source$table_name, - "" - )), - class = "bslib-page-dashboard", - sidebar = querychat_sidebar("chat"), - shiny::useBusyIndicators(pulse = TRUE, spinners = FALSE), - bslib::card( - fill = FALSE, - style = bslib::css(max_height = "33%"), - bslib::card_header( - shiny::div( - class = "hstack", - shiny::div( - bsicons::bs_icon("terminal-fill"), - shiny::textOutput("query_title", inline = TRUE) - ), - shiny::div( - class = "ms-auto", - shiny::uiOutput("ui_reset", inline = TRUE) - ) - ) - ), - shiny::uiOutput("sql_output") - ), - bslib::card( - bslib::card_header(bsicons::bs_icon("table"), "Data"), - DT::DTOutput("dt") - ), - shiny::actionButton( - "close_btn", - label = "", - class = "btn-close", - style = "position: fixed; top: 6px; right: 6px;" - ) - ) - } - - chat <- NULL - - server <- function(input, output, session) { - qc <- querychat_server("chat", config) - chat <<- qc$chat - - output$query_title <- shiny::renderText({ - if (shiny::isTruthy(qc$title())) { - qc$title() - } else { - "SQL Query" - } - }) - - output$ui_reset <- shiny::renderUI({ - shiny::req(qc$sql()) - - shiny::actionButton( - "reset_query", - label = "Reset Query", - class = "btn btn-outline-danger btn-sm lh-1" - ) - }) - - shiny::observeEvent(input$reset_query, label = "on_reset_query", { - qc$update_query("", NULL) - }) - - output$dt <- DT::renderDT({ - DT::datatable(qc$df()) - }) - - output$sql_output <- shiny::renderUI({ - sql <- if (shiny::isTruthy(qc$sql())) { - qc$sql() - } else { - paste("SELECT * FROM", config$data_source$table_name) - } - - sql_code <- paste(c("```sql", sql, "```"), collapse = "\n") - - shinychat::output_markdown_stream( - "sql_code", - content = sql_code, - auto_scroll = FALSE, - width = "100%" - ) - }) - - shiny::observeEvent(input$close_btn, label = "on_close_btn", { - shiny::stopApp() - }) - } - - app <- shiny::shinyApp(ui, server, enableBookmarking = bookmark_store) - tryCatch(shiny::runGadget(app), interrupt = function(cnd) NULL) - invisible(chat) -} diff --git a/pkg-r/R/querychat_client.R b/pkg-r/R/querychat_client.R deleted file mode 100644 index 98741d8a..00000000 --- a/pkg-r/R/querychat_client.R +++ /dev/null @@ -1,42 +0,0 @@ -querychat_client <- function(client = NULL) { - if (is.null(client)) { - client <- querychat_client_option() - } - - if (is.null(client)) { - # Use OpenAI with ellmer's default model - return(ellmer::chat_openai()) - } - - if (rlang::is_function(client)) { - # `client` as a function was the first interface we supported and expected - # `system_prompt` as an argument. This avoids breaking existing code. - client <- client(system_prompt = NULL) - } - - if (rlang::is_string(client)) { - client <- ellmer::chat(client) - } - - if (!inherits(client, "Chat")) { - rlang::abort( - "`client` must be an {ellmer} Chat object or a function that returns one.", - ) - } - - client -} - -querychat_client_option <- function() { - opt <- getOption("querychat.client", NULL) - if (!is.null(opt)) { - return(opt) - } - - env <- Sys.getenv("QUERYCHAT_CLIENT", "") - if (nzchar(env)) { - return(env) - } - - NULL -} diff --git a/pkg-r/R/querychat_greeting.R b/pkg-r/R/querychat_greeting.R deleted file mode 100644 index f7464160..00000000 --- a/pkg-r/R/querychat_greeting.R +++ /dev/null @@ -1,77 +0,0 @@ -#' Generate or retrieve a greeting message -#' -#' Use this function to generate a friendly greeting message using the chat -#' client and data source specified in the `querychat_config` object. You can -#' pass this greeting to [querychat_init()] to set an initial greeting for users -#' for faster startup times and lower costs. If you don't provide a greeting in -#' [querychat_init()], one will be generated at the start of every new -#' conversation, using this function. -#' -#' @examplesIf interactive() -#' penguins_config <- querychat_init(penguins) -#' -#' # Generate a new greeting -#' querychat_greeting(penguins_config) -#' -#' # Update the config with the generated greeting -#' penguins_config <- querychat_init( -#' penguins, -#' greeting = "Hello! I’m here to help you explore and analyze the penguins..." -#' ) -#' -#' # Alternatively, you could generate the greeting once when starting up your -#' # Shiny app server, to be shared across all users. -#' penguins_config <- querychat_init(penguins) -#' penguins_config$greeting <- querychat_greeting(penguins_config) -#' -#' @param querychat_config A `querychat_config` object from [querychat_init()]. -#' @param generate If `TRUE` and if `querychat_config` does not include a -#' `greeting`, a new greeting is generated. If `FALSE`, returns the existing -#' greeting from the configuration (if any). -#' @param stream If `TRUE`, calls `$stream_async()` on the [ellmer::Chat] -#' client, suitable for streaming the greeting into a Shiny app with -#' [shinychat::chat_append()]. If `FALSE` (default), calls `$chat()` to get -#' the full greeting at once. Only relevant when `generate = TRUE`. -#' -#' @return -#' - When `generate = FALSE`: Returns the existing greeting as a string or -#' `NULL` if no greeting exists. -#' - When `generate = TRUE`: Returns the chat response containing a greeting and -#' sample prompts. -#' -#' @export -querychat_greeting <- function( - querychat_config, - generate = TRUE, - stream = FALSE -) { - if (!inherits(querychat_config, "querychat_config")) { - rlang::abort("`querychat_config` must be a `querychat_config` object.") - } - - greeting <- querychat_config$greeting - - has_greeting <- !is.null(greeting) && any(nzchar(greeting)) - - if (has_greeting) { - return(greeting) - } - - if (!isTRUE(generate)) { - # No greeting and not generating one - return(NULL) - } - - chat <- querychat_config$client$clone() - chat$set_system_prompt(querychat_config$system_prompt) - - prompt <- "Please give me a friendly greeting. Include a few sample prompts in a two-level bulleted list." - - if (isTRUE(stream)) { - chat$stream_async(prompt) - } else { - is_user_facing <- rlang::env_is_user_facing(rlang::caller_env()) - echo = if (is_user_facing) "output" else "none" - chat$chat(prompt, echo = echo) - } -} diff --git a/pkg-r/R/querychat_module.R b/pkg-r/R/querychat_module.R new file mode 100644 index 00000000..8e3dc20c --- /dev/null +++ b/pkg-r/R/querychat_module.R @@ -0,0 +1,104 @@ +# Main module UI function +mod_ui <- function(id, ...) { + ns <- shiny::NS(id) + htmltools::tagList( + htmltools::htmlDependency( + "querychat", + version = "0.0.1", + package = "querychat", + src = "htmldep", + script = "querychat.js", + stylesheet = "styles.css" + ), + shinychat::chat_ui( + ns("chat"), + height = "100%", + class = "querychat", + ... + ) + ) +} + +# Main module server function +mod_server <- function(id, data_source, greeting, client) { + shiny::moduleServer(id, function(input, output, session) { + current_title <- shiny::reactiveVal(NULL, label = "current_title") + current_query <- shiny::reactiveVal("", label = "current_query") + filtered_df <- shiny::reactive(label = "filtered_df", { + execute_query(data_source, query = DBI::SQL(current_query())) + }) + + append_output <- function(...) { + txt <- paste0(...) + shinychat::chat_append_message( + "chat", + list(role = "assistant", content = txt), + chunk = TRUE, + operation = "append", + session = session + ) + } + + reset_query <- function() { + current_query("") + current_title(NULL) + querychat_tool_result(action = "reset") + } + + # Set up the chat object for this session + chat <- client$clone() + chat$register_tool( + tool_update_dashboard(data_source, current_query, current_title) + ) + chat$register_tool(tool_query(data_source)) + chat$register_tool(tool_reset_dashboard(reset_query)) + + # Prepopulate the chat UI with a welcome message that appears to be from the + # chat model (but is actually hard-coded). This is just for the user, not for + # the chat model to see. + greeting_content <- if (!is.null(greeting) && any(nzchar(greeting))) { + greeting + } else { + # Generate greeting on the fly if none provided + rlang::warn(c( + "No greeting provided; generating one now. This adds latency and cost.", + "i" = "Consider using $generate_greeting() to create a reusable greeting." + )) + chat_temp <- client$clone() + prompt <- "Please give me a friendly greeting. Include a few sample prompts in a two-level bulleted list." + chat_temp$stream_async(prompt) + } + + shinychat::chat_append("chat", greeting_content) + + append_stream_task <- shiny::ExtendedTask$new( + function(client, user_input) { + stream <- client$stream_async( + user_input, + stream = "content" + ) + + p <- promises::promise_resolve(stream) + promises::then(p, function(stream) { + shinychat::chat_append("chat", stream) + }) + } + ) + + shiny::observeEvent(input$chat_user_input, label = "on_chat_user_input", { + append_stream_task$invoke(chat, input$chat_user_input) + }) + + shiny::observeEvent(input$chat_update, label = "on_chat_update", { + current_query(input$chat_update$query) + current_title(input$chat_update$title) + }) + + list( + client = chat, + sql = current_query, + title = current_title, + df = filtered_df + ) + }) +} diff --git a/pkg-r/R/querychat_tools.R b/pkg-r/R/querychat_tools.R index 02baaf25..e37a7a7b 100644 --- a/pkg-r/R/querychat_tools.R +++ b/pkg-r/R/querychat_tools.R @@ -6,8 +6,7 @@ tool_update_dashboard <- function( data_source, current_query, - current_title, - filtered_df + current_title ) { db_type <- get_db_type(data_source) diff --git a/pkg-r/R/utils-ellmer.R b/pkg-r/R/utils-ellmer.R index 50f4eb48..51002f8d 100644 --- a/pkg-r/R/utils-ellmer.R +++ b/pkg-r/R/utils-ellmer.R @@ -13,3 +13,47 @@ interpolate_package <- function(path, ..., .envir = parent.frame()) { ellmer::interpolate_file(path, ..., .envir = .envir) } + + +as_querychat_client <- function(client = NULL) { + if (is.null(client)) { + client <- querychat_client_option() + } + + if (is.null(client)) { + # Use OpenAI with ellmer's default model + return(ellmer::chat_openai()) + } + + if (rlang::is_function(client)) { + # `client` as a function was the first interface we supported and expected + # `system_prompt` as an argument. This avoids breaking existing code. + client <- client(system_prompt = NULL) + } + + if (rlang::is_string(client)) { + client <- ellmer::chat(client) + } + + if (!inherits(client, "Chat")) { + rlang::abort( + "`client` must be an {ellmer} Chat object or a function that returns one.", + ) + } + + client +} + +querychat_client_option <- function() { + opt <- getOption("querychat.client", NULL) + if (!is.null(opt)) { + return(opt) + } + + env <- Sys.getenv("QUERYCHAT_CLIENT", "") + if (nzchar(env)) { + return(env) + } + + NULL +} diff --git a/pkg-r/README.md b/pkg-r/README.md index ff75d6ec..23a67b46 100644 --- a/pkg-r/README.md +++ b/pkg-r/README.md @@ -20,36 +20,46 @@ pak::pak("posit-dev/querychat/pkg-r") First, you'll need an OpenAI API key. See the [instructions from Ellmer](https://ellmer.tidyverse.org/reference/chat_openai.html). (Or use a different LLM provider, see below.) -Here's a very minimal example that shows the three function calls you need to make. +### Quick Start + +The fastest way to get started is with the built-in app: + +```r +library(querychat) + +qc <- QueryChat$new(mtcars, "mtcars") +qc$app() +``` + +This launches a complete Shiny app with a chat interface, SQL query display, and data table. Perfect for quick exploration and prototyping! + +### Custom Shiny Apps + +For more control, integrate querychat into your own Shiny app: ```r library(shiny) library(bslib) library(querychat) -# 1. Create a data source for querychat -mtcars_source <- querychat_data_source(mtcars) - -# 2. Configure querychat with the data source -querychat_config <- querychat_init(mtcars_source) +# 1. Create a QueryChat instance with your data +qc <- QueryChat$new(mtcars, "mtcars") ui <- page_sidebar( - # 3. Use querychat_sidebar(id) in a bslib::page_sidebar. - # Alternatively, use querychat_ui(id) elsewhere if you don't want your + # 2. Use qc$sidebar() in a bslib::page_sidebar. + # Alternatively, use qc$ui() elsewhere if you don't want your # chat interface to live in a sidebar. - sidebar = querychat_sidebar("chat"), + sidebar = qc$sidebar(), DT::DTOutput("dt") ) server <- function(input, output, session) { - - # 4. Create a querychat object using the config from step 2. - querychat <- querychat_server("chat", querychat_config) + # 3. Initialize the QueryChat server (returns session-specific reactive values) + qc_vals <- qc$server() output$dt <- DT::renderDT({ - # 5. Use the filtered/sorted data frame anywhere you wish, via the - # querychat$df() reactive. - DT::datatable(querychat$df()) + # 4. Use the filtered/sorted data frame anywhere you wish, via qc_vals$df() + DT::datatable(qc_vals$df()) }) } @@ -70,13 +80,11 @@ library(RSQLite) # 1. Connect to a database conn <- DBI::dbConnect(RSQLite::SQLite(), "path/to/database.db") -# 2. Create a database data source for querychat -db_source <- querychat_data_source(conn, "table_name") - -# 3. Configure querychat with the database source -querychat_config <- querychat_init(db_source) +# 2. Create a QueryChat instance with the database connection +qc <- QueryChat$new(conn, "table_name") -# Then use querychat_config in your Shiny app as shown above +# 3. Use it in your Shiny app as shown above +qc$app() ``` ## How it works @@ -107,7 +115,7 @@ Currently, querychat uses DuckDB for its SQL engine when working with data frame ### Provide a greeting (recommended) -When the querychat UI first appears, you will usually want it to greet the user with some basic instructions. By default, these instructions are auto-generated every time a user arrives; this is slow, wasteful, and unpredictable. Instead, you should create a file called `greeting.md`, and when calling `querychat_init`, pass `greeting = readLines("greeting.md")`. +When the querychat UI first appears, you will usually want it to greet the user with some basic instructions. By default, these instructions are auto-generated every time a user arrives; this is potentially slow, wasteful, and unpredictable. Instead, you should create a file called `greeting.md`, and when creating your `QueryChat` instance, pass `greeting = "greeting.md"` (or use `readLines()` to read the file as a string). You can provide suggestions to the user by using the ` ` tag. @@ -128,11 +136,18 @@ For example: These suggestions appear in the greeting and automatically populate the chat text box when clicked. This gives the user a few ideas to explore on their own. -If you need help coming up with a greeting, your own app can help you! Just launch it and paste this into the chat interface: +You can use the `$generate_greeting()` method to help create a greeting: -> Help me create a greeting for your future users. Include some example questions. Format your suggested greeting as Markdown, in a code block. +```r +qc <- QueryChat$new(mtcars, "mtcars") +greeting <- qc$generate_greeting(echo = "text") -And keep giving it feedback until you're happy with the result, which will then be ready to be pasted into `greeting.md`. +# Save it for reuse +writeLines(greeting, "greeting.md") + +# Then use it in your app +qc <- QueryChat$new(mtcars, "mtcars", greeting = "greeting.md") +``` Alternatively, you can completely suppress the greeting by passing `greeting = ""`. @@ -183,13 +198,10 @@ performance for 32 automobiles (1973–74 models). which you can then pass via: ```r -# Create data source first -mtcars_source <- querychat_data_source(mtcars, tbl_name = "cars") - -# Then initialize with the data source and description -querychat_config <- querychat_init( - data_source = mtcars_source, - data_description = readLines("data_description.md") +qc <- QueryChat$new( + mtcars, + "mtcars", + data_description = "data_description.md" ) ``` @@ -197,15 +209,12 @@ querychat doesn't need this information in any particular format; just put whate #### Additional instructions -You can add additional instructions of your own to the end of the system prompt, by passing `extra_instructions` into `query_init`. +You can add additional instructions of your own to the end of the system prompt, by passing `extra_instructions` to `QueryChat$new()`. ```r -# Create data source first -mtcars_source <- querychat_data_source(mtcars, tbl_name = "cars") - -# Then initialize with instructions -querychat_config <- querychat_init( - data_source = mtcars_source, +qc <- QueryChat$new( + mtcars, + "mtcars", extra_instructions = c( "You're speaking to a British audience--please use appropriate spelling conventions.", "Use lots of emojis! 😃 Emojis everywhere, 🌍 emojis forever. ♾️", @@ -214,22 +223,20 @@ querychat_config <- querychat_init( ) ``` -You can also put these instructions in a separate file and use `readLines()` to load them, as we did for `data_description` above. +You can also put these instructions in a separate file and pass the file path, as we did for `data_description` above. **Warning:** It is not 100% guaranteed that the LLM will always—or in many cases, ever—obey your instructions, and it can be difficult to predict which instructions will be a problem. So be sure to test extensively each time you change your instructions, and especially, if you change the model you use. ### Use a different LLM provider -By default, querychat uses OpenAI with the default model chosen by `ellmer::chat_openai()`. If you want to use a different model, you can provide an ellmer chat object to the `client` argument of `querychat_init()`. +By default, querychat uses OpenAI with the default model chosen by `ellmer::chat_openai()`. If you want to use a different model, you can provide an ellmer chat object to the `client` argument of `QueryChat$new()`. ```r library(ellmer) -library(purrr) - -mtcars_source <- querychat_data_source(mtcars, tbl_name = "cars") -querychat_config <- querychat_init( - data_source = mtcars_source, +qc <- QueryChat$new( + mtcars, + "mtcars", client = ellmer::chat_anthropic(model = "claude-3-7-sonnet-latest") ) ``` @@ -240,8 +247,9 @@ See the [instructions from Ellmer](https://ellmer.tidyverse.org/reference/chat_a Alternatively, you can use a provider-model string, which will be passed to `ellmer::chat()`: ```r -querychat_config <- querychat_init( - data_source = mtcars_source, +qc <- QueryChat$new( + mtcars, + "mtcars", client = "anthropic/claude-3-7-sonnet-latest" ) ``` @@ -249,5 +257,5 @@ querychat_config <- querychat_init( Or you can set the `querychat.client` R option to a chat object or provider-model string, which will be used as the default client for all querychat apps in your session: ```r -option(querychat.client = "anthropic/claude-3-7-sonnet-latest") +options(querychat.client = "anthropic/claude-3-7-sonnet-latest") ``` diff --git a/pkg-r/inst/examples-shiny/01-hello-app/app.R b/pkg-r/inst/examples-shiny/01-hello-app/app.R new file mode 100644 index 00000000..a8d63a10 --- /dev/null +++ b/pkg-r/inst/examples-shiny/01-hello-app/app.R @@ -0,0 +1,12 @@ +library(querychat) +library(palmerpenguins) + +# Create a QueryChat object and generate a complete app with $app() +qc <- QueryChat$new(penguins, "penguins") +qc$app() + +# That's it! The app includes: +# - A sidebar with the chat interface +# - SQL query display with syntax highlighting +# - Data table showing filtered results +# - Reset button to clear queries diff --git a/pkg-r/inst/examples-shiny/02-sidebar-app/app.R b/pkg-r/inst/examples-shiny/02-sidebar-app/app.R new file mode 100644 index 00000000..450ff6f1 --- /dev/null +++ b/pkg-r/inst/examples-shiny/02-sidebar-app/app.R @@ -0,0 +1,96 @@ +library(shiny) +library(bslib) +library(querychat) +library(palmerpenguins) + +# Define a custom greeting for the penguins app +greeting <- r"( +# Welcome to the Palmer Penguins Explorer! 🐧 + +I can help you explore and analyze the Palmer Penguins dataset. Ask me questions +about the penguins, and I'll generate SQL queries to get the answers. + +Try asking: +- Show me the first 10 rows of the penguins dataset +- What's the average bill length by species? +- Which species has the largest body mass? +- Create a summary of measurements grouped by species and island +)" + +# Create QueryChat object with custom options +qc <- QueryChat$new( + penguins, + "penguins", + greeting = greeting, + data_description = paste( + "The Palmer Penguins dataset contains measurements of bill", + "dimensions, flipper length, body mass, sex, and species", + "(Adelie, Chinstrap, and Gentoo) collected from three islands in", + "the Palmer Archipelago, Antarctica." + ), + extra_instructions = paste( + "When showing results, always explain what the data represents", + "and highlight any interesting patterns you observe." + ) +) + + +# Define custom UI with sidebar +ui <- page_sidebar( + title = "Palmer Penguins Chat Explorer", + sidebar = qc$sidebar(), + + card( + fill = FALSE, + card_header("Current SQL Query"), + verbatimTextOutput("sql_query") + ), + + card( + full_screen = TRUE, + card_header( + "Current Data View", + tooltip( + bsicons::bs_icon("question-circle-fill", class = "mx-1"), + "The table below shows the current filtered data based on your chat queries" + ), + tooltip( + bsicons::bs_icon("info-circle-fill"), + "The penguins dataset contains measurements on 344 penguins." + ) + ), + DT::DTOutput("data_table"), + card_footer( + markdown( + "Data source: [palmerpenguins package](https://allisonhorst.github.io/palmerpenguins/)" + ) + ) + ) +) + +# Define server logic +server <- function(input, output, session) { + # Initialize QueryChat server + qc_vals <- qc$server() + + # Render the data table + output$data_table <- DT::renderDT( + { + qc_vals$df() + }, + fillContainer = TRUE, + options = list(pageLength = 25, scrollX = TRUE) + ) + + # Render the SQL query + output$sql_query <- renderText({ + query <- qc_vals$sql() + if (query == "") { + "No filter applied - showing all data" + } else { + query + } + }) +} + +shinyApp(ui = ui, server = server) diff --git a/pkg-r/inst/examples-shiny/sqlite/README.md b/pkg-r/inst/examples-shiny/sqlite/README.md index 31426f9e..053b0432 100644 --- a/pkg-r/inst/examples-shiny/sqlite/README.md +++ b/pkg-r/inst/examples-shiny/sqlite/README.md @@ -1,6 +1,6 @@ # Database Setup Examples for querychat -This document provides examples of how to set up querychat with various database types using the new `database_source()` functionality. +This document provides examples of how to set up querychat with various database types. ## SQLite @@ -12,15 +12,16 @@ library(querychat) # Connect to SQLite database conn <- dbConnect(RSQLite::SQLite(), "path/to/your/database.db") -# Create database source -db_source <- database_source(conn, "your_table_name") - -# Initialize querychat -config <- querychat_init( - data_source = db_source, +# Create QueryChat instance +qc <- QueryChat$new( + conn, + "your_table_name", greeting = "Welcome! Ask me about your data.", data_description = "Description of your data..." ) + +# Launch the app +qc$app() ``` ## PostgreSQL @@ -40,11 +41,11 @@ conn <- dbConnect( password = "your_password" ) -# Create database source -db_source <- database_source(conn, "your_table_name") +# Create QueryChat instance +qc <- QueryChat$new(conn, "your_table_name") -# Initialize querychat -config <- querychat_init(data_source = db_source) +# Launch the app +qc$app() ``` ## MySQL @@ -63,22 +64,25 @@ conn <- dbConnect( password = "your_password" ) -# Create database source -db_source <- database_source(conn, "your_table_name") +# Create QueryChat instance +qc <- QueryChat$new(conn, "your_table_name") -# Initialize querychat -config <- querychat_init(data_source = db_source) +# Launch the app +qc$app() ``` ## Connection Management -When using database sources in Shiny apps, make sure to properly manage connections: +When using database sources in custom Shiny apps, make sure to properly manage connections: ```r server <- function(input, output, session) { - # Your querychat server logic here - chat <- querychat_server("chat", querychat_config) - + # Initialize QueryChat server + qc$server() + + # Your custom outputs here + output$table <- renderTable(qc$df()) + # Clean up connection when session ends session$onSessionEnded(function() { if (dbIsValid(conn)) { @@ -88,15 +92,6 @@ server <- function(input, output, session) { } ``` -## Configuration Options - -The `database_source()` function accepts a `categorical_threshold` parameter: - -```r -# Columns with <= 50 unique values will be treated as categorical -db_source <- database_source(conn, "table_name", categorical_threshold = 50) -``` - ## Security Considerations - Only SELECT queries are allowed - no INSERT, UPDATE, or DELETE operations diff --git a/pkg-r/inst/examples-shiny/sqlite/app.R b/pkg-r/inst/examples-shiny/sqlite/app.R index 6e7bbbe5..63dcbd4a 100644 --- a/pkg-r/inst/examples-shiny/sqlite/app.R +++ b/pkg-r/inst/examples-shiny/sqlite/app.R @@ -15,8 +15,6 @@ onStop(function() { }) conn <- dbConnect(RSQLite::SQLite(), temp_db) -# The connection will automatically be closed when the app stops, thanks to -# querychat_init # Create sample data in the database dbWriteTable(conn, "penguins", palmerpenguins::penguins, overwrite = TRUE) @@ -35,12 +33,10 @@ Try asking: - Create a summary of measurements grouped by species and island " -# Create data source using querychat_data_source -penguins_source <- querychat_data_source(conn, table_name = "penguins") - -# Configure querychat for database -querychat_config <- querychat_init( - data_source = penguins_source, +# Create QueryChat object with database connection +qc <- QueryChat$new( + conn, + "penguins", greeting = greeting, data_description = "This database contains the Palmer Penguins dataset with measurements of bill dimensions, flipper length, body mass, sex, and species (Adelie, Chinstrap, and Gentoo) collected from three islands in the Palmer Archipelago, Antarctica.", extra_instructions = "When showing results, always explain what the data represents and highlight any interesting patterns you observe." @@ -48,39 +44,49 @@ querychat_config <- querychat_init( ui <- page_sidebar( title = "Database Query Chat", - sidebar = querychat_sidebar("chat"), + sidebar = qc$sidebar(), - h2("Current Data View"), - p( - "The table below shows the current filtered data based on your chat queries:" + card( + fill = FALSE, + card_header("Current SQL Query"), + verbatimTextOutput("sql_query") ), - DT::DTOutput("data_table", fill = FALSE), - - h2("Current SQL Query"), - verbatimTextOutput("sql_query"), - h2("Dataset Information"), - p("This demo database contains:"), - tags$ul( - tags$li("penguins - Palmer Penguins dataset (344 rows, 8 columns)"), - tags$li( - "Columns: species, island, bill_length_mm, bill_depth_mm, flipper_length_mm, body_mass_g, sex, year" + card( + full_screen = TRUE, + card_header( + "Current Data View", + tooltip( + bsicons::bs_icon("question-circle-fill", class = "mx-1"), + "The table below shows the current filtered data based on your chat queries" + ), + tooltip( + bsicons::bs_icon("info-circle-fill"), + "The penguins dataset contains measurements on 344 penguins." + ) + ), + DT::DTOutput("data_table"), + card_footer( + markdown( + "Data source: [palmerpenguins package](https://allisonhorst.github.io/palmerpenguins/)" + ) ) ) ) server <- function(input, output, session) { - chat <- querychat_server("chat", querychat_config) + qc_vals <- qc$server() output$data_table <- DT::renderDT( { - chat$df() + qc_vals$df() }, + fillContainer = TRUE, options = list(pageLength = 10, scrollX = TRUE) ) output$sql_query <- renderText({ - query <- chat$sql() + query <- qc_vals$sql() if (query == "") { "No filter applied - showing all data" } else { diff --git a/pkg-r/man/QueryChat.Rd b/pkg-r/man/QueryChat.Rd new file mode 100644 index 00000000..6612e235 --- /dev/null +++ b/pkg-r/man/QueryChat.Rd @@ -0,0 +1,618 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/QueryChat.R +\name{QueryChat} +\alias{QueryChat} +\title{QueryChat: Interactive Data Querying with Natural Language} +\description{ +\code{QueryChat} is an R6 class built on Shiny, shinychat, and ellmer to enable +interactive querying of data using natural language. It leverages large +language models (LLMs) to translate user questions into SQL queries, execute +them against a data source (data frame or database), and various ways of +accessing/displaying the results. +} +\details{ +The \code{QueryChat} class takes your data (a data frame or database connection) +as input and provides methods to: +\itemize{ +\item Generate a chat UI for natural language queries (e.g., \verb{$app()}, \verb{$sidebar()}) +\item Initialize server logic that returns session-specific reactive values (via \verb{$server()}) +\item Access reactive data, SQL queries, and titles through the returned server values +} +} +\section{Usage}{ + + +\if{html}{\out{
}}\preformatted{library(querychat) + +# Create a QueryChat object +qc <- QueryChat$new(mtcars, "mtcars") + +# Quick start: run a complete app +qc$app() + +# Or build a custom Shiny app +ui <- page_sidebar( + qc$sidebar(), + verbatimTextOutput("sql"), + dataTableOutput("data") +) + +server <- function(input, output, session) \{ + qc_vals <- qc$server() + + output$sql <- renderText(qc_vals$sql()) + output$data <- renderDataTable(qc_vals$df()) +\} + +shinyApp(ui, server) +}\if{html}{\out{
}} +} + +\examples{ +\dontrun{ +# Basic usage with a data frame +qc <- QueryChat$new(mtcars, "mtcars") +app <- qc$app() + +# With a custom greeting +greeting <- "Welcome! Ask me about the mtcars dataset." +qc <- QueryChat$new(mtcars, "mtcars", greeting = greeting) + +# With a specific LLM provider +qc <- QueryChat$new(mtcars, "mtcars", client = "anthropic/claude-sonnet-4-5") + +# Generate a greeting for reuse +qc <- QueryChat$new(mtcars, "mtcars") +greeting <- qc$generate_greeting(echo = "text") +# Save greeting for next time +writeLines(greeting, "mtcars_greeting.md") +} + +## ------------------------------------------------ +## Method `QueryChat$new` +## ------------------------------------------------ + +\dontrun{ +# Basic usage +qc <- QueryChat$new(mtcars, "mtcars") + +# With options +qc <- QueryChat$new( + mtcars, + "mtcars", + greeting = "Welcome to the mtcars explorer!", + client = "openai/gpt-4o", + data_description = "Motor Trend car road tests dataset" +) + +# With database +library(DBI) +conn <- dbConnect(RSQLite::SQLite(), ":memory:") +dbWriteTable(conn, "mtcars", mtcars) +qc <- QueryChat$new(conn, "mtcars") +} + +## ------------------------------------------------ +## Method `QueryChat$app` +## ------------------------------------------------ + +\dontrun{ +library(querychat) + +qc <- QueryChat$new(mtcars, "mtcars") +qc$app() +} + + +## ------------------------------------------------ +## Method `QueryChat$app_obj` +## ------------------------------------------------ + +\dontrun{ +library(querychat) + +qc <- QueryChat$new(mtcars, "mtcars") +app <- qc$app_obj() +shiny::runApp(app) +} + + +## ------------------------------------------------ +## Method `QueryChat$sidebar` +## ------------------------------------------------ + +\dontrun{ +qc <- QueryChat$new(mtcars, "mtcars") + +ui <- page_sidebar( + qc$sidebar(), + # Main content here +) +} + +## ------------------------------------------------ +## Method `QueryChat$ui` +## ------------------------------------------------ + +\dontrun{ +qc <- QueryChat$new(mtcars, "mtcars") + +ui <- fluidPage( + qc$ui() +) +} + +## ------------------------------------------------ +## Method `QueryChat$server` +## ------------------------------------------------ + +\dontrun{ +qc <- QueryChat$new(mtcars, "mtcars") + +server <- function(input, output, session) { + qc_vals <- qc$server() + + output$data <- renderDataTable(qc_vals$df()) + output$query <- renderText(qc_vals$sql()) + output$title <- renderText(qc_vals$title() \%||\% "No Query") +} +} + +## ------------------------------------------------ +## Method `QueryChat$generate_greeting` +## ------------------------------------------------ + +\dontrun{ +# Create QueryChat object +qc <- QueryChat$new(mtcars, "mtcars") + +# Generate a greeting and save it +greeting <- qc$generate_greeting() +writeLines(greeting, "mtcars_greeting.md") + +# Later, use the saved greeting +qc2 <- QueryChat$new(mtcars, "mtcars", greeting = "mtcars_greeting.md") +} +} +\section{Public fields}{ +\if{html}{\out{
}} +\describe{ +\item{\code{greeting}}{The greeting message displayed to users.} + +\item{\code{id}}{The module ID for namespacing.} +} +\if{html}{\out{
}} +} +\section{Active bindings}{ +\if{html}{\out{
}} +\describe{ +\item{\code{system_prompt}}{Get the system prompt.} + +\item{\code{data_source}}{Get the current data source.} +} +\if{html}{\out{
}} +} +\section{Methods}{ +\subsection{Public methods}{ +\itemize{ +\item \href{#method-QueryChat-new}{\code{QueryChat$new()}} +\item \href{#method-QueryChat-app}{\code{QueryChat$app()}} +\item \href{#method-QueryChat-app_obj}{\code{QueryChat$app_obj()}} +\item \href{#method-QueryChat-sidebar}{\code{QueryChat$sidebar()}} +\item \href{#method-QueryChat-ui}{\code{QueryChat$ui()}} +\item \href{#method-QueryChat-server}{\code{QueryChat$server()}} +\item \href{#method-QueryChat-generate_greeting}{\code{QueryChat$generate_greeting()}} +\item \href{#method-QueryChat-cleanup}{\code{QueryChat$cleanup()}} +\item \href{#method-QueryChat-clone}{\code{QueryChat$clone()}} +} +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-QueryChat-new}{}}} +\subsection{Method \code{new()}}{ +Create a new QueryChat object. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{QueryChat$new( + data_source, + table_name, + ..., + id = NULL, + greeting = NULL, + client = NULL, + data_description = NULL, + categorical_threshold = 20, + extra_instructions = NULL, + prompt_template = NULL, + cleanup = NA +)}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{data_source}}{Either a data.frame or a database connection (e.g., DBI +connection).} + +\item{\code{table_name}}{A string specifying the table name to use in SQL queries. +If \code{data_source} is a data.frame, this is the name to refer to it by in +queries (typically the variable name). If \code{data_source} is a database +connection, this is the name of the table in the database.} + +\item{\code{...}}{Additional arguments (currently unused).} + +\item{\code{id}}{Optional module ID for the QueryChat instance. If not provided, +will be auto-generated from \code{table_name}. The ID is used to namespace +the Shiny module.} + +\item{\code{greeting}}{Optional initial message to display to users. Can be a +character string (in Markdown format) or a file path. If not provided, +a greeting will be generated at the start of each conversation using the +LLM, which adds latency and cost. Use \verb{$generate_greeting()} to create +a greeting to save and reuse.} + +\item{\code{client}}{Optional chat client. Can be: +\itemize{ +\item An \link[ellmer:Chat]{ellmer::Chat} object +\item A string to pass to \code{\link[ellmer:chat-any]{ellmer::chat()}} (e.g., \code{"openai/gpt-4o"}) +\item \code{NULL} (default): Uses the \code{querychat.client} option, the +\code{QUERYCHAT_CLIENT} environment variable, or defaults to +\code{\link[ellmer:chat_openai]{ellmer::chat_openai()}} +}} + +\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.} + +\item{\code{categorical_threshold}}{For text columns, the maximum number of unique +values to consider as a categorical variable. Default is 20.} + +\item{\code{extra_instructions}}{Optional additional instructions for the chat +model in plain text or Markdown. Can be a string or a file path.} + +\item{\code{prompt_template}}{Optional path to or string of a custom prompt +template file. If not provided, the default querychat template will be +used. See the package prompts directory for the default template format.} + +\item{\code{cleanup}}{Whether or not to automatically run \verb{$cleanup()} when the +Shiny session/app stops. By default, cleanup only occurs if \code{QueryChat} +gets created within a Shiny session. Set to \code{TRUE} to always clean up, +or \code{FALSE} to never clean up automatically.} +} +\if{html}{\out{
}} +} +\subsection{Returns}{ +A new \code{QueryChat} object. +} +\subsection{Examples}{ +\if{html}{\out{
}} +\preformatted{\dontrun{ +# Basic usage +qc <- QueryChat$new(mtcars, "mtcars") + +# With options +qc <- QueryChat$new( + mtcars, + "mtcars", + greeting = "Welcome to the mtcars explorer!", + client = "openai/gpt-4o", + data_description = "Motor Trend car road tests dataset" +) + +# With database +library(DBI) +conn <- dbConnect(RSQLite::SQLite(), ":memory:") +dbWriteTable(conn, "mtcars", mtcars) +qc <- QueryChat$new(conn, "mtcars") +} +} +\if{html}{\out{
}} + +} + +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-QueryChat-app}{}}} +\subsection{Method \code{app()}}{ +Create and run a Shiny gadget for chatting with data + +Runs a Shiny gadget (designed for interactive use) that provides +a complete interface for chatting with your data using natural language. +If you're looking to deploy this app or run it through some other means, +see \verb{$app_obj()}. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{QueryChat$app(..., bookmark_store = "url")}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{...}}{Arguments passed to \verb{$app_obj()}.} + +\item{\code{bookmark_store}}{The bookmarking storage method. Passed to +\code{\link[shiny:enableBookmarking]{shiny::enableBookmarking()}}. If \code{"url"} or \code{"server"}, the chat state +(including current query) will be bookmarked. Default is \code{"url"}.} +} +\if{html}{\out{
}} +} +\subsection{Returns}{ +Invisibly returns a list of session-specific values: +\itemize{ +\item \code{df}: The final filtered data frame +\item \code{sql}: The final SQL query string +\item \code{title}: The final title +\item \code{client}: The session-specific chat client instance +} +} +\subsection{Examples}{ +\if{html}{\out{
}} +\preformatted{\dontrun{ +library(querychat) + +qc <- QueryChat$new(mtcars, "mtcars") +qc$app() +} + +} +\if{html}{\out{
}} + +} + +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-QueryChat-app_obj}{}}} +\subsection{Method \code{app_obj()}}{ +A streamlined Shiny app for chatting with data + +Creates a Shiny app designed for chatting with data, with: +\itemize{ +\item A sidebar containing the chat interface +\item A card displaying the current SQL query +\item A card displaying the filtered data table +\item A reset button to clear the query +} +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{QueryChat$app_obj(..., bookmark_store = "url")}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{...}}{Additional arguments (currently unused).} + +\item{\code{bookmark_store}}{The bookmarking storage method. Passed to +\code{\link[shiny:enableBookmarking]{shiny::enableBookmarking()}}. If \code{"url"} or \code{"server"}, the chat state +(including current query) will be bookmarked. Default is \code{"url"}.} +} +\if{html}{\out{
}} +} +\subsection{Returns}{ +A Shiny app object that can be run with \code{shiny::runApp()}. +} +\subsection{Examples}{ +\if{html}{\out{
}} +\preformatted{\dontrun{ +library(querychat) + +qc <- QueryChat$new(mtcars, "mtcars") +app <- qc$app_obj() +shiny::runApp(app) +} + +} +\if{html}{\out{
}} + +} + +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-QueryChat-sidebar}{}}} +\subsection{Method \code{sidebar()}}{ +Create a sidebar containing the querychat UI. + +This method generates a \code{\link[bslib:sidebar]{bslib::sidebar()}} component containing the chat +interface, suitable for use with \code{\link[bslib:page_sidebar]{bslib::page_sidebar()}} or similar layouts. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{QueryChat$sidebar(width = 400, height = "100\%", ...)}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{width}}{Width of the sidebar in pixels. Default is 400.} + +\item{\code{height}}{Height of the sidebar. Default is "100\%".} + +\item{\code{...}}{Additional arguments passed to \code{\link[bslib:sidebar]{bslib::sidebar()}}.} +} +\if{html}{\out{
}} +} +\subsection{Returns}{ +A \code{\link[bslib:sidebar]{bslib::sidebar()}} UI component. +} +\subsection{Examples}{ +\if{html}{\out{
}} +\preformatted{\dontrun{ +qc <- QueryChat$new(mtcars, "mtcars") + +ui <- page_sidebar( + qc$sidebar(), + # Main content here +) +} +} +\if{html}{\out{
}} + +} + +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-QueryChat-ui}{}}} +\subsection{Method \code{ui()}}{ +Create the UI for the querychat chat interface. + +This method generates the chat UI component. Typically you'll use +\verb{$sidebar()} instead, which wraps this in a sidebar layout. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{QueryChat$ui(...)}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{...}}{Additional arguments passed to \code{\link[shinychat:chat_ui]{shinychat::chat_ui()}}.} +} +\if{html}{\out{
}} +} +\subsection{Returns}{ +A UI component containing the chat interface. +} +\subsection{Examples}{ +\if{html}{\out{
}} +\preformatted{\dontrun{ +qc <- QueryChat$new(mtcars, "mtcars") + +ui <- fluidPage( + qc$ui() +) +} +} +\if{html}{\out{
}} + +} + +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-QueryChat-server}{}}} +\subsection{Method \code{server()}}{ +Initialize the querychat server logic. + +This method must be called within a Shiny server function. It sets up +the reactive logic for the chat interface and returns session-specific +reactive values. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{QueryChat$server(session = shiny::getDefaultReactiveDomain())}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{session}}{The Shiny session object.} +} +\if{html}{\out{
}} +} +\subsection{Returns}{ +A list containing session-specific reactive values and the chat +client with the following elements: +\itemize{ +\item \code{df}: Reactive expression returning the current filtered data frame +\item \code{sql}: Reactive value for the current SQL query string +\item \code{title}: Reactive value for the current title +\item \code{client}: The session-specific chat client instance +} +} +\subsection{Examples}{ +\if{html}{\out{
}} +\preformatted{\dontrun{ +qc <- QueryChat$new(mtcars, "mtcars") + +server <- function(input, output, session) { + qc_vals <- qc$server() + + output$data <- renderDataTable(qc_vals$df()) + output$query <- renderText(qc_vals$sql()) + output$title <- renderText(qc_vals$title() \%||\% "No Query") +} +} +} +\if{html}{\out{
}} + +} + +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-QueryChat-generate_greeting}{}}} +\subsection{Method \code{generate_greeting()}}{ +Generate a welcome greeting for the chat. + +By default, \code{QueryChat$new()} generates a greeting at the start of every +new conversation, which is convenient for getting started and development, +but also might add unnecessary latency and cost. Use this method to +generate a greeting once and save it for reuse. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{QueryChat$generate_greeting(echo = c("none", "output"))}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{echo}}{Whether to print the greeting to the console. Options are +\code{"none"} (default, no output) or \code{"output"} (print to console).} +} +\if{html}{\out{
}} +} +\subsection{Returns}{ +The greeting string in Markdown format. +} +\subsection{Examples}{ +\if{html}{\out{
}} +\preformatted{\dontrun{ +# Create QueryChat object +qc <- QueryChat$new(mtcars, "mtcars") + +# Generate a greeting and save it +greeting <- qc$generate_greeting() +writeLines(greeting, "mtcars_greeting.md") + +# Later, use the saved greeting +qc2 <- QueryChat$new(mtcars, "mtcars", greeting = "mtcars_greeting.md") +} +} +\if{html}{\out{
}} + +} + +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-QueryChat-cleanup}{}}} +\subsection{Method \code{cleanup()}}{ +Clean up resources associated with the data source. + +This method releases any resources (e.g., database connections) +associated with the data source. Call this when you are done using +the QueryChat object to avoid resource leaks. + +Note: If \code{auto_cleanup} was set to \code{TRUE} in the constructor, +this will be called automatically when the Shiny app stops. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{QueryChat$cleanup()}\if{html}{\out{
}} +} + +\subsection{Returns}{ +Invisibly returns \code{NULL}. Resources are cleaned up internally. +} +} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-QueryChat-clone}{}}} +\subsection{Method \code{clone()}}{ +The objects of this class are cloneable with this method. +\subsection{Usage}{ +\if{html}{\out{
}}\preformatted{QueryChat$clone(deep = FALSE)}\if{html}{\out{
}} +} + +\subsection{Arguments}{ +\if{html}{\out{
}} +\describe{ +\item{\code{deep}}{Whether to make a deep clone.} +} +\if{html}{\out{
}} +} +} +} diff --git a/pkg-r/man/as_querychat_data_source.Rd b/pkg-r/man/as_querychat_data_source.Rd new file mode 100644 index 00000000..aca764ac --- /dev/null +++ b/pkg-r/man/as_querychat_data_source.Rd @@ -0,0 +1,26 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/data_source.R +\name{as_querychat_data_source} +\alias{as_querychat_data_source} +\title{Create a data source for querychat} +\usage{ +as_querychat_data_source(x, table_name = NULL, ...) +} +\arguments{ +\item{x}{A data frame or DBI connection} + +\item{table_name}{The name to use for the table in the data source. Can be: +\itemize{ +\item A character string (e.g., "table_name") +\item Or, for tables contained within catalogs or schemas, a \code{\link[DBI:Id]{DBI::Id()}} object (e.g., \code{DBI::Id(schema = "schema_name", table = "table_name")}) +}} +} +\value{ +A querychat_data_source object +} +\description{ +An entrypoint for developers to create custom data sources for use with +querychat. Most users shouldn't use this function directly; instead, they +should pass their data to \code{QueryChat$new()}. +} +\keyword{internal} diff --git a/pkg-r/man/cleanup_source.Rd b/pkg-r/man/cleanup_source.Rd index 25f3f31e..938f585b 100644 --- a/pkg-r/man/cleanup_source.Rd +++ b/pkg-r/man/cleanup_source.Rd @@ -15,5 +15,8 @@ cleanup_source(source, ...) NULL (invisibly) } \description{ -Clean up a data source (close connections, etc.) +An entrypoint for developers to create custom data sources for use with +querychat. Most users shouldn't use this function directly; instead, they +should call the \verb{$cleanup()} method on the \link{QueryChat} object. } +\keyword{internal} diff --git a/pkg-r/man/create_system_prompt.Rd b/pkg-r/man/create_system_prompt.Rd index 34269018..46e93c4b 100644 --- a/pkg-r/man/create_system_prompt.Rd +++ b/pkg-r/man/create_system_prompt.Rd @@ -8,6 +8,7 @@ create_system_prompt( source, data_description = NULL, extra_instructions = NULL, + categorical_threshold = 20, ... ) } @@ -18,11 +19,17 @@ create_system_prompt( \item{extra_instructions}{Optional additional instructions} +\item{categorical_threshold}{For text columns, the maximum number of unique +values to consider as a categorical variable} + \item{...}{Additional arguments passed to methods} } \value{ A string with the system prompt } \description{ -Create a system prompt for the data source +An entrypoint for developers to create custom data sources for use with +querychat. Most users shouldn't use this function directly; instead, they +should call the \verb{$set_system_prompt()} method on the \link{QueryChat} object. } +\keyword{internal} diff --git a/pkg-r/man/deprecated.Rd b/pkg-r/man/deprecated.Rd new file mode 100644 index 00000000..7cf475a1 --- /dev/null +++ b/pkg-r/man/deprecated.Rd @@ -0,0 +1,29 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/deprecated.R +\name{deprecated} +\alias{deprecated} +\alias{querychat_init} +\alias{querychat_sidebar} +\alias{querychat_ui} +\alias{querychat_server} +\alias{querychat_greeting} +\alias{querychat_data_source} +\title{Deprecated functions} +\usage{ +querychat_init(...) + +querychat_sidebar(...) + +querychat_ui(...) + +querychat_server(...) + +querychat_greeting(...) + +querychat_data_source(...) +} +\description{ +These functions have been replaced by the new \code{QueryChat} R6 class API. +Please update your code to use the new class-based approach. +} +\keyword{internal} diff --git a/pkg-r/man/execute_query.Rd b/pkg-r/man/execute_query.Rd index 00bc34fb..ab8fd054 100644 --- a/pkg-r/man/execute_query.Rd +++ b/pkg-r/man/execute_query.Rd @@ -2,7 +2,7 @@ % Please edit documentation in R/data_source.R \name{execute_query} \alias{execute_query} -\title{Execute a SQL query on a data source} +\title{Execute an SQL query on a data source} \usage{ execute_query(source, query, ...) } @@ -17,5 +17,8 @@ execute_query(source, query, ...) Result of the query as a data frame } \description{ -Execute a SQL query on a data source +An entrypoint for developers to create custom data source objects for use +with querychat. Most users shouldn't use this function directly; instead, +they call the \verb{$sql()} method on the \link{QueryChat} object to run queries. } +\keyword{internal} diff --git a/pkg-r/man/get_db_type.Rd b/pkg-r/man/get_db_type.Rd index e3fd6429..70d4e01b 100644 --- a/pkg-r/man/get_db_type.Rd +++ b/pkg-r/man/get_db_type.Rd @@ -15,5 +15,8 @@ get_db_type(source, ...) A character string containing the type information } \description{ -Get type information for a data source +An entrypoint for developers to create custom data sources for use with +querychat. Most users shouldn't use this function directly; instead, they +should call the \verb{$set_system_prompt()} method on the \link{QueryChat} object. } +\keyword{internal} diff --git a/pkg-r/man/get_schema.Rd b/pkg-r/man/get_schema.Rd index 22d24ff1..2b4c9ded 100644 --- a/pkg-r/man/get_schema.Rd +++ b/pkg-r/man/get_schema.Rd @@ -4,16 +4,21 @@ \alias{get_schema} \title{Get schema for a data source} \usage{ -get_schema(source, ...) +get_schema(source, categorical_threshold = 20, ...) } \arguments{ \item{source}{A querychat_data_source object} +\item{categorical_threshold}{For text columns, the maximum number of unique values to consider as a categorical variable} + \item{...}{Additional arguments passed to methods} } \value{ A character string describing the schema } \description{ -Get schema for a data source +An entrypoint for developers to create custom data sources for use with +querychat. Most users shouldn't use this function directly; instead, they +should call the \verb{$set_system_prompt()} method on the \link{QueryChat} object. } +\keyword{internal} diff --git a/pkg-r/man/querychat-convenience.Rd b/pkg-r/man/querychat-convenience.Rd new file mode 100644 index 00000000..14902139 --- /dev/null +++ b/pkg-r/man/querychat-convenience.Rd @@ -0,0 +1,125 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/QueryChat.R +\name{querychat} +\alias{querychat} +\alias{querychat_app} +\title{QueryChat convenience functions} +\usage{ +querychat( + data_source, + table_name, + ..., + id = NULL, + greeting = NULL, + client = NULL, + data_description = NULL, + categorical_threshold = 20, + extra_instructions = NULL, + prompt_template = NULL, + cleanup = NA +) + +querychat_app( + data_source, + table_name, + ..., + id = NULL, + greeting = NULL, + client = NULL, + data_description = NULL, + categorical_threshold = 20, + extra_instructions = NULL, + prompt_template = NULL, + cleanup = TRUE, + bookmark_store = "url" +) +} +\arguments{ +\item{data_source}{Either a data.frame or a database connection (e.g., DBI +connection).} + +\item{table_name}{A string specifying the table name to use in SQL queries. +If \code{data_source} is a data.frame, this is the name to refer to it by in +queries (typically the variable name). If \code{data_source} is a database +connection, this is the name of the table in the database.} + +\item{...}{Additional arguments (currently unused).} + +\item{id}{Optional module ID for the QueryChat instance. If not provided, +will be auto-generated from \code{table_name}. The ID is used to namespace +the Shiny module.} + +\item{greeting}{Optional initial message to display to users. Can be a +character string (in Markdown format) or a file path. If not provided, +a greeting will be generated at the start of each conversation using the +LLM, which adds latency and cost. Use \verb{$generate_greeting()} to create +a greeting to save and reuse.} + +\item{client}{Optional chat client. Can be: +\itemize{ +\item An \link[ellmer:Chat]{ellmer::Chat} object +\item A string to pass to \code{\link[ellmer:chat-any]{ellmer::chat()}} (e.g., \code{"openai/gpt-4o"}) +\item \code{NULL} (default): Uses the \code{querychat.client} option, the +\code{QUERYCHAT_CLIENT} environment variable, or defaults to +\code{\link[ellmer:chat_openai]{ellmer::chat_openai()}} +}} + +\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 +LLM about what the data represents.} + +\item{categorical_threshold}{For text columns, the maximum number of unique +values to consider as a categorical variable. Default is 20.} + +\item{extra_instructions}{Optional additional instructions for the chat +model in plain text or Markdown. Can be a string or a file path.} + +\item{prompt_template}{Optional path to or string of a custom prompt +template file. If not provided, the default querychat template will be +used. See the package prompts directory for the default template format.} + +\item{cleanup}{Whether or not to automatically run \verb{$cleanup()} when the +Shiny session/app stops. By default, cleanup only occurs if \code{QueryChat} +gets created within a Shiny session. Set to \code{TRUE} to always clean up, +or \code{FALSE} to never clean up automatically.} + +\item{bookmark_store}{The bookmarking storage method. Passed to +\code{\link[shiny:enableBookmarking]{shiny::enableBookmarking()}}. If \code{"url"} or \code{"server"}, the chat state +(including current query) will be bookmarked. Default is \code{"url"}.} +} +\value{ +A \code{QueryChat} object. See \link{QueryChat} for available methods. + +Invisibly returns the chat object after the app stops. +} +\description{ +Convenience functions for wrapping \link{QueryChat} creation (i.e., \code{querychat()}) +and app launching (i.e., \code{querychat_app()}). +} +\examples{ +\dontrun{ +# Quick start - chat with mtcars dataset in one line +querychat_app(mtcars, "mtcars") + +# Add options +querychat_app( + mtcars, + "mtcars", + greeting = "Welcome to the mtcars explorer!", + client = "openai/gpt-4o" +) + +# Chat with a database table +library(DBI) +conn <- dbConnect(RSQLite::SQLite(), ":memory:") +dbWriteTable(conn, "mtcars", mtcars) +querychat_app(conn, "mtcars") + +# Create QueryChat class object +qc <- querychat(mtcars, "mtcars") + +# Run the app later +qc$app() + +} +} diff --git a/pkg-r/man/querychat-package.Rd b/pkg-r/man/querychat-package.Rd index 1cae4261..5a74574b 100644 --- a/pkg-r/man/querychat-package.Rd +++ b/pkg-r/man/querychat-package.Rd @@ -2,12 +2,74 @@ % Please edit documentation in R/querychat-package.R \docType{package} \name{querychat-package} -\alias{querychat} \alias{querychat-package} -\title{querychat: Filter and Query Data Frames in 'shiny' Using an LLM Chat Interface} +\title{querychat: Chat with Your Data Using Natural Language} \description{ -Adds an LLM-powered chatbot to your 'shiny' app, that can turn your users' natural language questions into SQL queries that run against your data, and return the result as a reactive dataframe. Use it to drive reactive calculations, visualizations, downloads, etc. +querychat provides an interactive chat interface for querying data using +natural language. It translates your questions into SQL queries, executes +them against your data, and displays the results. The package works with +both data frames and database connections. } +\section{Quick Start}{ + +The easiest way to get started is with the \link{QueryChat} R6 class: + +\if{html}{\out{
}}\preformatted{library(querychat) + +# Create a QueryChat object +qc <- QueryChat$new(mtcars, "mtcars") + +# Option 1: Run a complete app with sensible defaults +qc$app() + +# Option 2: Build a custom Shiny app +ui <- page_sidebar( + qc$sidebar(), + dataTableOutput("data") +) + +server <- function(input, output, session) \{ + qc$server() + output$data <- renderDataTable(qc$df()) +\} + +shinyApp(ui, server) +}\if{html}{\out{
}} +} + +\section{Key Features}{ + +\itemize{ +\item \strong{Natural language queries}: Ask questions in plain English +\item \strong{SQL transparency}: See the generated SQL queries +\item \strong{Multiple data sources}: Works with data frames and database connections +\item \strong{Customizable}: Add data descriptions, extra instructions, and custom greetings +\item \strong{LLM agnostic}: Works with OpenAI, Anthropic, Google, and other providers via ellmer +} +} + +\section{Main Components}{ + +\itemize{ +\item \link{QueryChat}: The main R6 class for creating chat interfaces +\item \code{\link[=as_querychat_data_source]{as_querychat_data_source()}}: (Advanced) Create custom data source objects +} +} + +\section{Examples}{ + +To see examples included with the package, run: + +\if{html}{\out{
}}\preformatted{shiny::runExample(package = "querychat") +}\if{html}{\out{
}} + +This provides a list of available examples. To run a specific example, like +'01-hello-app', use: + +\if{html}{\out{
}}\preformatted{shiny::runExample("01-hello-app", package = "querychat") +}\if{html}{\out{
}} +} + \seealso{ Useful links: \itemize{ diff --git a/pkg-r/man/querychat_app.Rd b/pkg-r/man/querychat_app.Rd deleted file mode 100644 index 156c0157..00000000 --- a/pkg-r/man/querychat_app.Rd +++ /dev/null @@ -1,39 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/querychat_app.R -\name{querychat_app} -\alias{querychat_app} -\title{A Simple App for Chatting with Data} -\usage{ -querychat_app(config, ..., bookmark_store = "url") -} -\arguments{ -\item{config}{A \code{querychat_config} object or a data source that can be used -to create one.} - -\item{...}{Additional arguments passed to \code{\link[=querychat_init]{querychat_init()}} if \code{config} is -not already a \code{querychat_config} object.} - -\item{bookmark_store}{The bookmarking store to use for the app. Passed to -\code{enable_bookmarking} in \code{\link[shiny:shinyApp]{shiny::shinyApp()}}. Defaults to \code{"url"}, which -uses the URL to store the chat state. URL-based bookmarking is limited in -size; use \code{"server"} to store the state on the server side without size -limitations; or disable bookmarking by setting this to \code{"disable"}.} -} -\value{ -Invisibly returns the \code{chat} object containing the chat history. -} -\description{ -Creates a Shiny app that allows users to interact with a data source using -natural language queries. The app uses a pre-configured Shiny app built on -\code{\link[=querychat_sidebar]{querychat_sidebar()}} and \code{\link[=querychat_server]{querychat_server()}} to provide a quick-and-easy -way to chat with your data. -} -\examples{ -\dontshow{if (rlang::is_interactive()) withAutoprint(\{ # examplesIf} -# Pass in a data frame to start querychatting -querychat_app(mtcars) - -# Or choose your LLM client using ellmer::chat_*() functions -querychat_app(mtcars, client = ellmer::chat_anthropic()) -\dontshow{\}) # examplesIf} -} diff --git a/pkg-r/man/querychat_data_source.Rd b/pkg-r/man/querychat_data_source.Rd deleted file mode 100644 index 7d99ac5a..00000000 --- a/pkg-r/man/querychat_data_source.Rd +++ /dev/null @@ -1,34 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/data_source.R -\name{querychat_data_source} -\alias{querychat_data_source} -\alias{querychat_data_source.data.frame} -\alias{querychat_data_source.DBIConnection} -\title{Create a data source for querychat} -\usage{ -querychat_data_source(x, ...) - -\method{querychat_data_source}{data.frame}(x, table_name = NULL, categorical_threshold = 20, ...) - -\method{querychat_data_source}{DBIConnection}(x, table_name, categorical_threshold = 20, ...) -} -\arguments{ -\item{x}{A data frame or DBI connection} - -\item{...}{Additional arguments passed to specific methods} - -\item{table_name}{The name to use for the table in the data source. Can be: -\itemize{ -\item A character string (e.g., "table_name") -\item Or, for tables contained within catalogs or schemas, a \code{\link[DBI:Id]{DBI::Id()}} object (e.g., \code{DBI::Id(schema = "schema_name", table = "table_name")}) -}} - -\item{categorical_threshold}{For text columns, the maximum number of unique values to consider as a categorical variable} -} -\value{ -A querychat_data_source object -} -\description{ -Generic function to create a data source for querychat. This function -dispatches to appropriate methods based on input. -} diff --git a/pkg-r/man/querychat_greeting.Rd b/pkg-r/man/querychat_greeting.Rd deleted file mode 100644 index 4bb295a3..00000000 --- a/pkg-r/man/querychat_greeting.Rd +++ /dev/null @@ -1,55 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/querychat_greeting.R -\name{querychat_greeting} -\alias{querychat_greeting} -\title{Generate or retrieve a greeting message} -\usage{ -querychat_greeting(querychat_config, generate = TRUE, stream = FALSE) -} -\arguments{ -\item{querychat_config}{A \code{querychat_config} object from \code{\link[=querychat_init]{querychat_init()}}.} - -\item{generate}{If \code{TRUE} and if \code{querychat_config} does not include a -\code{greeting}, a new greeting is generated. If \code{FALSE}, returns the existing -greeting from the configuration (if any).} - -\item{stream}{If \code{TRUE}, calls \verb{$stream_async()} on the \link[ellmer:Chat]{ellmer::Chat} -client, suitable for streaming the greeting into a Shiny app with -\code{\link[shinychat:chat_append]{shinychat::chat_append()}}. If \code{FALSE} (default), calls \verb{$chat()} to get -the full greeting at once. Only relevant when \code{generate = TRUE}.} -} -\value{ -\itemize{ -\item When \code{generate = FALSE}: Returns the existing greeting as a string or -\code{NULL} if no greeting exists. -\item When \code{generate = TRUE}: Returns the chat response containing a greeting and -sample prompts. -} -} -\description{ -Use this function to generate a friendly greeting message using the chat -client and data source specified in the \code{querychat_config} object. You can -pass this greeting to \code{\link[=querychat_init]{querychat_init()}} to set an initial greeting for users -for faster startup times and lower costs. If you don't provide a greeting in -\code{\link[=querychat_init]{querychat_init()}}, one will be generated at the start of every new -conversation, using this function. -} -\examples{ -\dontshow{if (interactive()) withAutoprint(\{ # examplesIf} -penguins_config <- querychat_init(penguins) - -# Generate a new greeting -querychat_greeting(penguins_config) - -# Update the config with the generated greeting -penguins_config <- querychat_init( - penguins, - greeting = "Hello! I’m here to help you explore and analyze the penguins..." -) - -# Alternatively, you could generate the greeting once when starting up your -# Shiny app server, to be shared across all users. -penguins_config <- querychat_init(penguins) -penguins_config$greeting <- querychat_greeting(penguins_config) -\dontshow{\}) # examplesIf} -} diff --git a/pkg-r/man/querychat_init.Rd b/pkg-r/man/querychat_init.Rd deleted file mode 100644 index 5d77c05c..00000000 --- a/pkg-r/man/querychat_init.Rd +++ /dev/null @@ -1,70 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/querychat.R -\name{querychat_init} -\alias{querychat_init} -\title{Call this once outside of any server function} -\usage{ -querychat_init( - data_source, - greeting = NULL, - data_description = NULL, - extra_instructions = NULL, - create_chat_func = deprecated(), - system_prompt = NULL, - auto_close_data_source = TRUE, - client = NULL -) -} -\arguments{ -\item{data_source}{A querychat_data_source object created by -\code{querychat_data_source()}. - -To create a data source: -\itemize{ -\item For data frame: \code{querychat_data_source(df, tbl_name = "my_table")} -\item For database: \code{querychat_data_source(conn, "table_name")} -}} - -\item{greeting}{A string in Markdown format, containing the initial message -to display to the user upon first loading the chatbot. If not provided, the -LLM will be invoked at the start of the conversation to generate one. You -can also use \code{\link[=querychat_greeting]{querychat_greeting()}} to generate a greeting.} - -\item{data_description}{A string containing a data description for the chat -model. We have found that formatting the data description as a markdown -bulleted list works best.} - -\item{extra_instructions}{A string containing extra instructions for the -chat model.} - -\item{create_chat_func}{\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#deprecated}{\figure{lifecycle-deprecated.svg}{options: alt='[Deprecated]'}}}{\strong{[Deprecated]}}. Use the \code{client} -argument instead.} - -\item{system_prompt}{A string containing the system prompt for the chat -model. The default generates a generic prompt, which you can enhance via -the \code{data_description} and \code{extra_instructions} arguments.} - -\item{auto_close_data_source}{Should the data source connection be -automatically closed when the shiny app stops? Defaults to TRUE.} - -\item{client}{An \code{ellmer::Chat} object, a string to be passed to -\code{\link[ellmer:chat-any]{ellmer::chat()}} describing the model to use (e.g. \code{"openai/gpt-4o"}), or a -function that creates a chat client. When using a function, the function -should take \code{system_prompt} as an argument and return an \code{ellmer::Chat} -object. - -If \code{client} is not provided, querychat consults the \code{querychat.client} R -option, which can be any of the described options, or the -\code{QUERYCHAT_CLIENT} environment variable, which can be set to a a -provider-model string. If no option is provided, querychat defaults to -using \code{\link[ellmer:chat_openai]{ellmer::chat_openai()}}.} -} -\value{ -An object that can be passed to \code{querychat_server()} as the -\code{querychat_config} argument. By convention, this object should be named -\code{querychat_config}. -} -\description{ -This will perform one-time initialization that can then be shared by all -Shiny sessions in the R process. -} diff --git a/pkg-r/man/querychat_server.Rd b/pkg-r/man/querychat_server.Rd deleted file mode 100644 index 25bed75f..00000000 --- a/pkg-r/man/querychat_server.Rd +++ /dev/null @@ -1,27 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/querychat.R -\name{querychat_server} -\alias{querychat_server} -\title{Initialize the querychat server} -\usage{ -querychat_server(id, querychat_config) -} -\arguments{ -\item{id}{The ID of the module instance. Must match the ID passed to -the corresponding call to \code{querychat_ui()}.} - -\item{querychat_config}{An object created by \code{querychat_init()}.} -} -\value{ -A querychat instance, which is a named list with the following -elements: -\itemize{ -\item \code{sql}: A reactive that returns the current SQL query. -\item \code{title}: A reactive that returns the current title. -\item \code{df}: A reactive that returns the filtered data as a data.frame. -\item \code{chat}: The \link[ellmer:Chat]{ellmer::Chat} object that powers the chat interface. -} -} -\description{ -Initialize the querychat server -} diff --git a/pkg-r/man/querychat_ui.Rd b/pkg-r/man/querychat_ui.Rd deleted file mode 100644 index 664e3b6a..00000000 --- a/pkg-r/man/querychat_ui.Rd +++ /dev/null @@ -1,29 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/querychat.R -\name{querychat_ui} -\alias{querychat_ui} -\alias{querychat_sidebar} -\title{UI components for querychat} -\usage{ -querychat_sidebar(id, width = 400, height = "100\%", ...) - -querychat_ui(id) -} -\arguments{ -\item{id}{The ID of the module instance.} - -\item{width, height}{In \code{querychat_sidebar()}: the width and height of the -sidebar.} - -\item{...}{In \code{querychat_sidebar()}: additional arguments passed to -\code{\link[bslib:sidebar]{bslib::sidebar()}}.} -} -\value{ -A UI object that can be embedded in a Shiny app. -} -\description{ -These functions create UI components for the querychat interface. -\code{querychat_ui()} creates a basic chat interface, while \code{querychat_sidebar()} -wraps the chat interface in a \code{\link[bslib:sidebar]{bslib::sidebar()}} component designed to be -used as the \code{sidebar} argument to \code{\link[bslib:page_sidebar]{bslib::page_sidebar()}}. -} diff --git a/pkg-r/man/test_query.Rd b/pkg-r/man/test_query.Rd index ec3411de..d8307c6f 100644 --- a/pkg-r/man/test_query.Rd +++ b/pkg-r/man/test_query.Rd @@ -17,5 +17,8 @@ test_query(source, query, ...) Result of the query, limited to one row of data. } \description{ -Test a SQL query on a data source. +An entrypoint for developers to create custom data sources for use with +querychat. Most users shouldn't use this function directly; instead, they +should call the \verb{$sql()} method on the \link{QueryChat} object to run queries. } +\keyword{internal} diff --git a/pkg-r/pkgdown/_pkgdown.yml b/pkg-r/pkgdown/_pkgdown.yml index f94529c9..7534c3eb 100644 --- a/pkg-r/pkgdown/_pkgdown.yml +++ b/pkg-r/pkgdown/_pkgdown.yml @@ -32,9 +32,4 @@ template: reference: - title: Chat interfaces contents: - - querychat_init - - querychat_server - - querychat_sidebar - - querychat_system_prompt - - querychat_ui - - querychat_app + - QueryChat diff --git a/pkg-r/tests/testthat/apps/basic/app.R b/pkg-r/tests/testthat/apps/basic/app.R index 7caf0253..35a55797 100644 --- a/pkg-r/tests/testthat/apps/basic/app.R +++ b/pkg-r/tests/testthat/apps/basic/app.R @@ -21,20 +21,20 @@ conn <- dbConnect(RSQLite::SQLite(), temp_db) dbWriteTable(conn, "iris", iris, overwrite = TRUE) dbDisconnect(conn) -# Setup database source +# Setup database source and QueryChat instance db_conn <- dbConnect(RSQLite::SQLite(), temp_db) -iris_source <- querychat_data_source(db_conn, "iris") -# Configure querychat with mock -querychat_config <- querychat_init( - data_source = iris_source, +# Create QueryChat instance +qc <- QueryChat$new( + data_source = db_conn, + table_name = "iris", greeting = "Welcome to the test app!", client = MockChat$new(ellmer::Provider("test", "test", "test")) ) ui <- page_sidebar( title = "Test Database App", - sidebar = querychat_sidebar("chat"), + sidebar = qc$sidebar(), h2("Data"), DT::DTOutput("data_table"), h3("SQL Query"), @@ -42,17 +42,17 @@ ui <- page_sidebar( ) server <- function(input, output, session) { - chat <- querychat_server("chat", querychat_config) + qc_vals <- qc$server() output$data_table <- DT::renderDT( { - chat$df() + qc_vals$df() }, options = list(pageLength = 5) ) output$sql_query <- renderText({ - query <- chat$sql() + query <- qc_vals$sql() if (query == "") "No filter applied" else query }) diff --git a/pkg-r/tests/testthat/test-data-source.R b/pkg-r/tests/testthat/test-data-source.R index a4e324ce..343f336a 100644 --- a/pkg-r/tests/testthat/test-data-source.R +++ b/pkg-r/tests/testthat/test-data-source.R @@ -3,7 +3,7 @@ library(DBI) library(RSQLite) library(querychat) -test_that("querychat_data_source.data.frame creates proper S3 object", { +test_that("as_querychat_data_source.data.frame creates proper S3 object", { # Create a simple data frame test_df <- data.frame( id = 1:5, @@ -13,7 +13,7 @@ test_that("querychat_data_source.data.frame creates proper S3 object", { ) # Test with explicit table name - source <- querychat_data_source(test_df, table_name = "test_table") + source <- as_querychat_data_source(test_df, table_name = "test_table") withr::defer(cleanup_source(source)) expect_s3_class(source, "data_frame_source") @@ -22,7 +22,7 @@ test_that("querychat_data_source.data.frame creates proper S3 object", { expect_true(inherits(source$conn, "DBIConnection")) }) -test_that("querychat_data_source.DBIConnection creates proper S3 object", { +test_that("as_querychat_data_source.DBIConnection creates proper S3 object", { # Create temporary SQLite database temp_db <- withr::local_tempfile(fileext = ".db") conn <- dbConnect(RSQLite::SQLite(), temp_db) @@ -39,11 +39,10 @@ test_that("querychat_data_source.DBIConnection creates proper S3 object", { dbWriteTable(conn, "users", test_data, overwrite = TRUE) # Test DBI source creation - db_source <- querychat_data_source(conn, "users") + db_source <- as_querychat_data_source(conn, "users") expect_s3_class(db_source, "dbi_source") expect_s3_class(db_source, "querychat_data_source") expect_equal(db_source$table_name, "users") - expect_equal(db_source$categorical_threshold, 20) }) test_that("get_schema methods return proper schema", { @@ -55,7 +54,7 @@ test_that("get_schema methods return proper schema", { stringsAsFactors = FALSE ) - df_source <- querychat_data_source(test_df, table_name = "test_table") + df_source <- as_querychat_data_source(test_df, table_name = "test_table") withr::defer(cleanup_source(df_source)) schema <- get_schema(df_source) @@ -76,7 +75,7 @@ test_that("get_schema methods return proper schema", { dbWriteTable(conn, "test_table", test_df, overwrite = TRUE) - dbi_source <- querychat_data_source(conn, "test_table") + dbi_source <- as_querychat_data_source(conn, "test_table") schema <- get_schema(dbi_source) expect_type(schema, "character") expect_match(schema, "Table: `test_table`") @@ -95,7 +94,7 @@ test_that("execute_query works for both source types", { stringsAsFactors = FALSE ) - df_source <- querychat_data_source(test_df, table_name = "test_table") + df_source <- as_querychat_data_source(test_df, table_name = "test_table") withr::defer(cleanup_source(df_source)) result <- execute_query( df_source, @@ -110,7 +109,7 @@ test_that("execute_query works for both source types", { withr::defer(dbDisconnect(conn)) dbWriteTable(conn, "test_table", test_df, overwrite = TRUE) - dbi_source <- querychat_data_source(conn, "test_table") + dbi_source <- as_querychat_data_source(conn, "test_table") result <- execute_query( dbi_source, "SELECT * FROM test_table WHERE value > 25" @@ -127,7 +126,7 @@ test_that("execute_query works with empty/null queries", { stringsAsFactors = FALSE ) - df_source <- querychat_data_source(test_df, table_name = "test_table") + df_source <- as_querychat_data_source(test_df, table_name = "test_table") withr::defer(cleanup_source(df_source)) # Test with NULL query @@ -149,7 +148,7 @@ test_that("execute_query works with empty/null queries", { dbWriteTable(conn, "test_table", test_df, overwrite = TRUE) - dbi_source <- querychat_data_source(conn, "test_table") + dbi_source <- as_querychat_data_source(conn, "test_table") # Test with NULL query result_null <- execute_query(dbi_source, NULL) @@ -174,7 +173,7 @@ test_that("get_schema correctly reports min/max values for numeric columns", { stringsAsFactors = FALSE ) - df_source <- querychat_data_source(test_df, table_name = "test_metrics") + df_source <- as_querychat_data_source(test_df, table_name = "test_metrics") withr::defer(cleanup_source(df_source)) schema <- get_schema(df_source) @@ -192,7 +191,7 @@ test_that("create_system_prompt generates appropriate system prompt", { stringsAsFactors = FALSE ) - df_source <- querychat_data_source(test_df, table_name = "test_table") + df_source <- as_querychat_data_source(test_df, table_name = "test_table") withr::defer(cleanup_source(df_source)) prompt <- create_system_prompt( @@ -205,27 +204,34 @@ test_that("create_system_prompt generates appropriate system prompt", { expect_match(prompt, "Table: test_table") }) -test_that("querychat_init automatically handles data.frame inputs", { - # Test that querychat_init accepts data frames directly +test_that("QueryChat$new() automatically handles data.frame inputs", { + # Test that QueryChat$new() accepts data frames directly test_df <- data.frame(id = 1:3, name = c("A", "B", "C")) # Should work with data frame and auto-convert it - config <- querychat_init(data_source = test_df, greeting = "Test greeting") - withr::defer(cleanup_source(config$data_source)) + qc <- QueryChat$new( + data_source = test_df, + table_name = "test_df", + greeting = "Test greeting" + ) + withr::defer(qc$cleanup()) - expect_s3_class(config, "querychat_config") - expect_s3_class(config$data_source, "querychat_data_source") - expect_s3_class(config$data_source, "data_frame_source") + expect_s3_class(qc$data_source, "querychat_data_source") + expect_s3_class(qc$data_source, "data_frame_source") # Should work with proper data source too - df_source <- querychat_data_source(test_df, table_name = "test_table") + df_source <- as_querychat_data_source(test_df, table_name = "test_table") withr::defer(cleanup_source(df_source)) - config <- querychat_init(data_source = df_source, greeting = "Test greeting") - expect_s3_class(config, "querychat_config") + qc2 <- QueryChat$new( + data_source = df_source, + table_name = "test_table", + greeting = "Test greeting" + ) + expect_s3_class(qc2$data_source, "querychat_data_source") }) -test_that("querychat_init works with both source types", { +test_that("QueryChat$new() works with both source types", { # Test with data frame test_df <- data.frame( id = 1:3, @@ -233,15 +239,18 @@ test_that("querychat_init works with both source types", { stringsAsFactors = FALSE ) - # Create data source and test with querychat_init - df_source <- querychat_data_source(test_df, table_name = "test_source") + # Create data source and test with QueryChat$new() + df_source <- as_querychat_data_source(test_df, table_name = "test_source") withr::defer(cleanup_source(df_source)) - config <- querychat_init(data_source = df_source, greeting = "Test greeting") + qc <- QueryChat$new( + data_source = df_source, + table_name = "test_source", + greeting = "Test greeting" + ) - expect_s3_class(config, "querychat_config") - expect_s3_class(config$data_source, "data_frame_source") - expect_equal(config$data_source$table_name, "test_source") + expect_s3_class(qc$data_source, "data_frame_source") + expect_equal(qc$data_source$table_name, "test_source") # Test with database connection temp_db <- withr::local_tempfile(fileext = ".db") @@ -250,9 +259,12 @@ test_that("querychat_init works with both source types", { dbWriteTable(conn, "test_table", test_df, overwrite = TRUE) - dbi_source <- querychat_data_source(conn, "test_table") - config <- querychat_init(data_source = dbi_source, greeting = "Test greeting") - expect_s3_class(config, "querychat_config") - expect_s3_class(config$data_source, "dbi_source") - expect_equal(config$data_source$table_name, "test_table") + dbi_source <- as_querychat_data_source(conn, "test_table") + qc2 <- QueryChat$new( + data_source = dbi_source, + table_name = "test_table", + greeting = "Test greeting" + ) + expect_s3_class(qc2$data_source, "dbi_source") + expect_equal(qc2$data_source$table_name, "test_table") }) diff --git a/pkg-r/tests/testthat/test-db-type.R b/pkg-r/tests/testthat/test-db-type.R index 72181823..7894d6e8 100644 --- a/pkg-r/tests/testthat/test-db-type.R +++ b/pkg-r/tests/testthat/test-db-type.R @@ -3,7 +3,7 @@ library(testthat) test_that("get_db_type returns correct type for data_frame_source", { # Create a simple data frame source df <- data.frame(x = 1:5, y = letters[1:5]) - df_source <- querychat_data_source(df, "test_table") + df_source <- as_querychat_data_source(df, "test_table") # Test that get_db_type returns "DuckDB" expect_equal(get_db_type(df_source), "DuckDB") @@ -17,7 +17,7 @@ test_that("get_db_type returns correct type for dbi_source with SQLite", { conn <- DBI::dbConnect(RSQLite::SQLite(), temp_db) withr::defer(DBI::dbDisconnect(conn)) DBI::dbWriteTable(conn, "test_table", data.frame(x = 1:5, y = letters[1:5])) - db_source <- querychat_data_source(conn, "test_table") + db_source <- as_querychat_data_source(conn, "test_table") # Test that get_db_type returns the correct database type expect_equal(get_db_type(db_source), "SQLite") @@ -26,7 +26,7 @@ test_that("get_db_type returns correct type for dbi_source with SQLite", { test_that("get_db_type is correctly used in create_system_prompt", { # Create a simple data frame source df <- data.frame(x = 1:5, y = letters[1:5]) - df_source <- querychat_data_source(df, "test_table") + df_source <- as_querychat_data_source(df, "test_table") # Generate system prompt sys_prompt <- create_system_prompt(df_source) @@ -38,7 +38,7 @@ test_that("get_db_type is correctly used in create_system_prompt", { test_that("get_db_type is used to customize prompt template", { # Create a simple data frame source df <- data.frame(x = 1:5, y = letters[1:5]) - df_source <- querychat_data_source(df, "test_table") + df_source <- as_querychat_data_source(df, "test_table") # Get the db_type db_type <- get_db_type(df_source) diff --git a/pkg-r/tests/testthat/test-querychat-greeting.R b/pkg-r/tests/testthat/test-querychat-greeting.R deleted file mode 100644 index f5bfa848..00000000 --- a/pkg-r/tests/testthat/test-querychat-greeting.R +++ /dev/null @@ -1,46 +0,0 @@ -test_that("querychat_greeting without an initial greeting", { - called <- NULL - - expect_warning( - config <- querychat_init( - mtcars, - client = ellmer::chat_openai(api_key = "boop") - ) - ) - - result <- querychat_greeting(config, generate = FALSE) - expect_null(result) -}) - -test_that("querychat_greeting handles empty greeting correctly", { - # Create a basic config - mock_client <- ellmer::chat_openai(api_key = "boop") - config <- querychat_init(mtcars, client = mock_client, greeting = "") - - # Test that empty greeting returns NULL when generate = FALSE - result <- querychat_greeting(config, generate = FALSE) - expect_null(result) -}) - -test_that("querychat_greeting returns existing greeting correctly", { - # Create a config with a greeting - mock_client <- ellmer::chat_openai(api_key = "boop") - test_greeting <- "Hello! This is a test greeting." - config <- querychat_init( - mtcars, - greeting = test_greeting, - client = mock_client - ) - - # Test that existing greeting is returned when generate = FALSE - result <- querychat_greeting(config, generate = FALSE) - expect_equal(result, test_greeting) -}) - -test_that("querychat_greeting validates input type", { - # Test that non-querychat_config input raises error - expect_error( - querychat_greeting("not a config"), - "`querychat_config` must be a `querychat_config` object." - ) -}) diff --git a/pkg-r/tests/testthat/test-querychat-server.R b/pkg-r/tests/testthat/test-querychat-server.R index 3e6421f8..503620d0 100644 --- a/pkg-r/tests/testthat/test-querychat-server.R +++ b/pkg-r/tests/testthat/test-querychat-server.R @@ -20,7 +20,7 @@ test_that("database source query functionality", { dbWriteTable(conn, "users", test_data, overwrite = TRUE) # Create database source - db_source <- querychat_data_source(conn, "users") + db_source <- as_querychat_data_source(conn, "users") # Test that we can execute queries result <- execute_query(db_source, "SELECT * FROM users WHERE age > 30") diff --git a/pkg-r/tests/testthat/test-shiny-app.R b/pkg-r/tests/testthat/test-shiny-app.R index ff183c7e..b4b7cf3c 100644 --- a/pkg-r/tests/testthat/test-shiny-app.R +++ b/pkg-r/tests/testthat/test-shiny-app.R @@ -35,30 +35,31 @@ test_that("database reactive functionality works correctly", { db_conn <- dbConnect(RSQLite::SQLite(), temp_db) withr::defer(dbDisconnect(db_conn)) - iris_source <- querychat_data_source(db_conn, "iris") + iris_source <- as_querychat_data_source(db_conn, "iris") # Mock chat function mock_client <- ellmer::chat_openai(api_key = "boop") - # Test querychat_init with database source - config <- querychat_init( + # Test QueryChat$new() with database source + qc <- QueryChat$new( data_source = iris_source, + table_name = "iris", greeting = "Test greeting", client = mock_client ) - expect_s3_class(config$data_source, "dbi_source") - expect_s3_class(config$data_source, "querychat_data_source") + expect_s3_class(qc$data_source, "dbi_source") + expect_s3_class(qc$data_source, "querychat_data_source") # Test that we can get all data - result_data <- execute_query(config$data_source, NULL) + result_data <- execute_query(qc$data_source, NULL) expect_s3_class(result_data, "data.frame") expect_equal(nrow(result_data), 150) expect_equal(ncol(result_data), 5) # Test with a specific query query_result <- execute_query( - config$data_source, + qc$data_source, "SELECT \"Sepal.Length\", \"Sepal.Width\" FROM iris WHERE \"Species\" = 'setosa'" ) expect_s3_class(query_result, "data.frame") diff --git a/pkg-r/tests/testthat/test-sql-comments.R b/pkg-r/tests/testthat/test-sql-comments.R index 39a0415c..737093ca 100644 --- a/pkg-r/tests/testthat/test-sql-comments.R +++ b/pkg-r/tests/testthat/test-sql-comments.R @@ -12,7 +12,7 @@ test_that("execute_query handles SQL with inline comments", { ) # Create data source - df_source <- querychat_data_source(test_df, table_name = "test_table") + df_source <- as_querychat_data_source(test_df, table_name = "test_table") withr::defer(cleanup_source(df_source)) # Test with inline comments @@ -51,7 +51,7 @@ test_that("execute_query handles SQL with multiline comments", { ) # Create data source - df_source <- querychat_data_source(test_df, table_name = "test_table") + df_source <- as_querychat_data_source(test_df, table_name = "test_table") withr::defer(cleanup_source(df_source)) # Test with multiline comments @@ -95,7 +95,7 @@ test_that("execute_query handles SQL with trailing semicolons", { ) # Create data source - df_source <- querychat_data_source(test_df, table_name = "test_table") + df_source <- as_querychat_data_source(test_df, table_name = "test_table") withr::defer(cleanup_source(df_source)) # Test with trailing semicolon @@ -132,7 +132,7 @@ test_that("execute_query handles SQL with mixed comments and semicolons", { ) # Create data source - df_source <- querychat_data_source(test_df, table_name = "test_table") + df_source <- as_querychat_data_source(test_df, table_name = "test_table") withr::defer(cleanup_source(df_source)) # Test with a mix of comment styles and semicolons @@ -180,7 +180,7 @@ test_that("execute_query handles SQL with unusual whitespace patterns", { ) # Create data source - df_source <- querychat_data_source(test_df, table_name = "test_table") + df_source <- as_querychat_data_source(test_df, table_name = "test_table") withr::defer(cleanup_source(df_source)) # Test with unusual whitespace patterns (which LLMs might generate) diff --git a/pkg-r/tests/testthat/test-test-query.R b/pkg-r/tests/testthat/test-test-query.R index 0df278ab..afdb0f54 100644 --- a/pkg-r/tests/testthat/test-test-query.R +++ b/pkg-r/tests/testthat/test-test-query.R @@ -18,7 +18,7 @@ test_that("test_query.dbi_source correctly retrieves one row of data", { dbWriteTable(conn, "test_table", test_df, overwrite = TRUE) - dbi_source <- querychat_data_source(conn, "test_table") + dbi_source <- as_querychat_data_source(conn, "test_table") withr::defer(cleanup_source(dbi_source)) # Test basic query - should only return one row @@ -62,7 +62,7 @@ test_that("test_query.dbi_source handles errors correctly", { ) dbWriteTable(conn, "test_table", test_df, overwrite = TRUE) - dbi_source <- querychat_data_source(conn, "test_table") + dbi_source <- as_querychat_data_source(conn, "test_table") withr::defer(cleanup_source(dbi_source), priority = "last") # Test with invalid SQL @@ -96,7 +96,7 @@ test_that("test_query.dbi_source works with different data types", { dbWriteTable(conn, "types_table", test_df, overwrite = TRUE) - dbi_source <- querychat_data_source(conn, "types_table") + dbi_source <- as_querychat_data_source(conn, "types_table") withr::defer(cleanup_source(dbi_source), priority = "last") # Test query with different column types