From 8572ecafa16e813c7a5bdc3973dd742a22518594 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jun 2026 11:42:15 -0400 Subject: [PATCH 1/8] feat: add read_measures() to define measures from R scripts Derive measures from documented functions in R scripts. Every top-level function with a roxygen2 block becomes a measure: name from the function, description from @title/@description/@return, and arguments from @param. Argument types are declared with a leading type code span, e.g. `enum[day, week, month]` or `string[]`; required is taken from the signature and untyped params are inferred from their defaults. --- DESCRIPTION | 1 + NAMESPACE | 1 + R/read-measures.R | 181 +++++++++++++++++++++++++ man/read_measures.Rd | 43 ++++++ tests/testthat/_snaps/read-measures.md | 16 +++ tests/testthat/test-read-measures.R | 168 +++++++++++++++++++++++ 6 files changed, 410 insertions(+) create mode 100644 R/read-measures.R create mode 100644 man/read_measures.Rd create mode 100644 tests/testthat/_snaps/read-measures.md create mode 100644 tests/testthat/test-read-measures.R diff --git a/DESCRIPTION b/DESCRIPTION index 3c1f936a..5b8893ac 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -38,6 +38,7 @@ Suggests: pins, promises, readr, + roxygen2, shiny, shinychat, testthat (>= 3.0.0), diff --git a/NAMESPACE b/NAMESPACE index 93960693..10641e5f 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -9,6 +9,7 @@ export(data_source) export(data_source_pins) export(list_tables) export(measure) +export(read_measures) export(read_trajectories) export(semantic_layer) importFrom(R6,R6Class) diff --git a/R/read-measures.R b/R/read-measures.R new file mode 100644 index 00000000..f5b89524 --- /dev/null +++ b/R/read-measures.R @@ -0,0 +1,181 @@ +#' Read measures from R scripts +#' +#' Reads [measure()] definitions from one or more R scripts, deriving each +#' measure from a documented function. Every top-level function assignment that +#' carries a roxygen2 block becomes a measure: its name is the function name, +#' its description is the `@title`, `@description`, and `@return`, its body is +#' the function, and its arguments come from the `@param` tags. +#' +#' Argument types are declared with a leading type code span in the `@param` +#' description: +#' +#' ```r +#' #' @param region `string` The sales region. +#' #' @param period `enum[day, week, month]` Aggregation period. +#' #' @param tags `string[]` Tag filters. +#' ``` +#' +#' Supported types are `string`, `integer`, `number`, `boolean`, `enum[...]` +#' for a fixed set of values, and `{type}[]` for an array. An argument is +#' required when its formal has no default value. When a `@param` has no type +#' code span, its type is inferred from the formal's default, falling back to a +#' string. +#' +#' @param paths Character vector of paths to R scripts or directories. For a +#' directory, all `.R` files in it are read. +#' +#' @return A list of [measure()] objects, suitable for [semantic_layer()]. +#' +#' @examples +#' \dontrun{ +#' semantic_layer(read_measures("measures.R")) +#' } +#' +#' @export +read_measures <- function(paths) { + rlang::check_installed("roxygen2") + + if (!is.character(paths)) { + cli::cli_abort("{.arg paths} must be a character vector of file or directory paths.") + } + + files <- resolve_measure_files(paths) + measures <- unlist(lapply(files, read_measures_file), recursive = FALSE) + measures %||% list() +} + +resolve_measure_files <- function(paths, call = rlang::caller_env()) { + missing <- paths[!file.exists(paths)] + if (length(missing)) { + cli::cli_abort( + "{cli::qty(missing)}Path{?s} {?does/do} not exist: {.path {missing}}.", + call = call + ) + } + + files <- unlist(lapply(paths, function(path) { + if (dir.exists(path)) { + list.files(path, pattern = "[.][Rr]$", full.names = TRUE) + } else { + path + } + })) + unique(files) +} + +read_measures_file <- function(file) { + blocks <- roxygen2::parse_file(file) + measures <- lapply(blocks, block_to_measure) + Filter(Negate(is.null), measures) +} + +block_to_measure <- function(block) { + fn <- block$object$value + if (!is.function(fn)) { + return(NULL) + } + + name <- block$object$topic + description <- block_description(block) + arguments <- block_arguments(block, fn) + + measure(name, description, fn, arguments = arguments) +} + +block_description <- function(block) { + parts <- c( + roxygen2::block_get_tag_value(block, "title"), + roxygen2::block_get_tag_value(block, "description"), + { + ret <- roxygen2::block_get_tag_value(block, "return") + if (!is.null(ret)) paste0("Returns: ", ret) + } + ) + paste(parts, collapse = "\n\n") +} + +block_arguments <- function(block, fn) { + formals <- formals(fn) + param_text <- block_param_text(block) + + args <- list() + for (nm in names(formals)) { + required <- identical(formals[[nm]], quote(expr = )) + args[[nm]] <- param_type( + param_text[[nm]] %||% "", + default = formals[[nm]], + required = required + ) + } + args +} + +# The raw tag text is read instead of `val$description` so the type code span +# survives verbatim: the markdown roclet would otherwise turn the `[...]` inside +# it into a `\link{...}` once roxygen2's markdown state is active. +block_param_text <- function(block) { + tags <- roxygen2::block_get_tags(block, "param") + names <- vapply(tags, function(t) t$val$name, character(1)) + text <- lapply(tags, function(t) trimws(sub("^\\s*\\S+\\s*", "", t$raw))) + names(text) <- names + text +} + +# Parse a `@param` description into an ellmer type, using a leading type code +# span (e.g. `enum[a, b]`) when present and otherwise inferring from the +# formal's default. +param_type <- function(text, default, required) { + re <- "^\\s*`([a-zA-Z]+)(\\[[^]]*\\])?`\\s*(.*)$" + m <- regmatches(text, regexec(re, text, perl = TRUE))[[1]] + + if (length(m) == 0) { + return(infer_type(default, description = trimws(text), required = required)) + } + + kind <- tolower(m[2]) + bracket <- m[3] + description <- trimws(m[4]) + + if (nzchar(bracket)) { + inner <- trimws(substr(bracket, 2, nchar(bracket) - 1)) + if (kind == "enum") { + values <- trimws(strsplit(inner, ",")[[1]]) + return(ellmer::type_enum( + values = values, + description = description, + required = required + )) + } + return(ellmer::type_array( + items = scalar_type(kind, ""), + description = description, + required = required + )) + } + + scalar_type(kind, description, required = required) +} + +scalar_type <- function(kind, description, required = TRUE) { + switch( + kind, + integer = ellmer::type_integer(description, required = required), + number = ellmer::type_number(description, required = required), + boolean = ellmer::type_boolean(description, required = required), + ellmer::type_string(description, required = required) + ) +} + +infer_type <- function(default, description, required) { + value <- tryCatch(eval(default), error = function(e) NULL) + kind <- if (is.logical(value)) { + "boolean" + } else if (is.integer(value)) { + "integer" + } else if (is.numeric(value)) { + "number" + } else { + "string" + } + scalar_type(kind, description, required = required) +} diff --git a/man/read_measures.Rd b/man/read_measures.Rd new file mode 100644 index 00000000..f27fde87 --- /dev/null +++ b/man/read_measures.Rd @@ -0,0 +1,43 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/read-measures.R +\name{read_measures} +\alias{read_measures} +\title{Read measures from R scripts} +\usage{ +read_measures(paths) +} +\arguments{ +\item{paths}{Character vector of paths to R scripts or directories. For a +directory, all \code{.R} files in it are read.} +} +\value{ +A list of \code{\link[=measure]{measure()}} objects, suitable for \code{\link[=semantic_layer]{semantic_layer()}}. +} +\description{ +Reads \code{\link[=measure]{measure()}} definitions from one or more R scripts, deriving each +measure from a documented function. Every top-level function assignment that +carries a roxygen2 block becomes a measure: its name is the function name, +its description is the \verb{@title}, \verb{@description}, and \verb{@return}, its body is +the function, and its arguments come from the \verb{@param} tags. +} +\details{ +Argument types are declared with a leading type code span in the \verb{@param} +description: + +\if{html}{\out{
}}\preformatted{#' @param region `string` The sales region. +#' @param period `enum[day, week, month]` Aggregation period. +#' @param tags `string[]` Tag filters. +}\if{html}{\out{
}} + +Supported types are \code{string}, \code{integer}, \code{number}, \code{boolean}, \code{enum[...]} +for a fixed set of values, and \code{{type}[]} for an array. An argument is +required when its formal has no default value. When a \verb{@param} has no type +code span, its type is inferred from the formal's default, falling back to a +string. +} +\examples{ +\dontrun{ +semantic_layer(read_measures("measures.R")) +} + +} diff --git a/tests/testthat/_snaps/read-measures.md b/tests/testthat/_snaps/read-measures.md new file mode 100644 index 00000000..e8536cb8 --- /dev/null +++ b/tests/testthat/_snaps/read-measures.md @@ -0,0 +1,16 @@ +# read_measures validates its inputs + + Code + read_measures(123) + Condition + Error in `read_measures()`: + ! `paths` must be a character vector of file or directory paths. + +--- + + Code + read_measures("does-not-exist.R") + Condition + Error in `read_measures()`: + ! Path does not exist: 'does-not-exist.R'. + diff --git a/tests/testthat/test-read-measures.R b/tests/testthat/test-read-measures.R new file mode 100644 index 00000000..fea3b63a --- /dev/null +++ b/tests/testthat/test-read-measures.R @@ -0,0 +1,168 @@ +measures_script <- function(lines) { + path <- withr::local_tempfile(fileext = ".R", .local_envir = parent.frame()) + writeLines(lines, path) + path +} + +test_that("read_measures derives a measure from a documented function", { + skip_if_not_installed("roxygen2") + + path <- measures_script(c( + "#' Count orders", + "#'", + "#' @description Total orders, optionally by region.", + "#'", + "#' @param region `string` The sales region.", + "#'", + "#' @return An integer count.", + "order_count <- function(region = NULL) {", + " 42L", + "}" + )) + + measures <- read_measures(path) + + expect_length(measures, 1) + td <- measures[[1]] + expect_equal(tool_name(td), "order_count") + expect_match(tool_description(td), "Count orders") + expect_match(tool_description(td), "Total orders") + expect_match(tool_description(td), "Returns: An integer count") + expect_equal(do.call(td, list()), 42L) +}) + +test_that("read_measures maps param type code spans to ellmer types", { + skip_if_not_installed("roxygen2") + + path <- measures_script(c( + "#' Measure", + "#' @description A measure.", + "#' @param a `string` A string.", + "#' @param b `integer` An integer.", + "#' @param c `number` A number.", + "#' @param d `boolean` A boolean.", + "#' @param e `enum[x, y, z]` An enum.", + "#' @param f `string[]` An array.", + "m <- function(a, b, c, d, e, f) NULL" + )) + + props <- tool_properties(read_measures(path)[[1]]) + + expect_equal(type_kind(props$a), "string") + expect_equal(type_kind(props$b), "integer") + expect_equal(type_kind(props$c), "number") + expect_equal(type_kind(props$d), "boolean") + expect_equal(type_kind(props$e), "enum") + expect_equal(type_values(props$e), c("x", "y", "z")) + expect_equal(type_kind(props$f), "array") + expect_equal(type_kind(S7::prop(props$f, "items")), "string") +}) + +test_that("read_measures derives required from the signature, not the type", { + skip_if_not_installed("roxygen2") + + path <- measures_script(c( + "#' Measure", + "#' @description A measure.", + "#' @param required_arg `string` Required.", + "#' @param optional_arg `string` Optional.", + "m <- function(required_arg, optional_arg = NULL) NULL" + )) + + props <- tool_properties(read_measures(path)[[1]]) + + expect_true(S7::prop(props$required_arg, "required")) + expect_false(S7::prop(props$optional_arg, "required")) +}) + +test_that("read_measures uses the param text as the type description", { + skip_if_not_installed("roxygen2") + + path <- measures_script(c( + "#' Measure", + "#' @description A measure.", + "#' @param region `string` The sales region.", + "m <- function(region) NULL" + )) + + props <- tool_properties(read_measures(path)[[1]]) + expect_equal(S7::prop(props$region, "description"), "The sales region.") +}) + +test_that("read_measures infers untyped args from their defaults", { + skip_if_not_installed("roxygen2") + + path <- measures_script(c( + "#' Measure", + "#' @description A measure.", + "#' @param i An integer default.", + "#' @param n A number default.", + "#' @param b A boolean default.", + "#' @param s No default.", + "m <- function(i = 10L, n = 1.5, b = TRUE, s) NULL" + )) + + props <- tool_properties(read_measures(path)[[1]]) + + expect_equal(type_kind(props$i), "integer") + expect_equal(type_kind(props$n), "number") + expect_equal(type_kind(props$b), "boolean") + expect_equal(type_kind(props$s), "string") +}) + +test_that("read_measures ignores undocumented functions", { + skip_if_not_installed("roxygen2") + + path <- measures_script(c( + "#' Measure", + "#' @description A measure.", + "#' @param a `string` An arg.", + "m <- function(a) NULL", + "", + "helper <- function(x) x" + )) + + measures <- read_measures(path) + + expect_length(measures, 1) + expect_equal(tool_name(measures[[1]]), "m") +}) + +test_that("read_measures reads multiple files and directories", { + skip_if_not_installed("roxygen2") + + dir <- withr::local_tempdir() + writeLines( + c("#' One", "#' @description First.", "one <- function() 1L"), + file.path(dir, "one.R") + ) + writeLines( + c("#' Two", "#' @description Second.", "two <- function() 2L"), + file.path(dir, "two.R") + ) + + measures <- read_measures(dir) + + expect_setequal(vapply(measures, tool_name, character(1)), c("one", "two")) +}) + +test_that("read_measures produces measures usable in a semantic_layer", { + skip_if_not_installed("roxygen2") + + path <- measures_script(c( + "#' Count orders", + "#' @description Counts orders.", + "#' @param region `enum[EMEA, APAC]` The region.", + "order_count <- function(region) 7L" + )) + + layer <- semantic_layer(read_measures(path)) + + expect_s3_class(layer, "commons_semantic_layer") + expect_named(layer$measures, "order_count") +}) + +test_that("read_measures validates its inputs", { + expect_snapshot(read_measures(123), error = TRUE) + expect_snapshot(read_measures("does-not-exist.R"), error = TRUE) +}) From 5c258b124d5196d85c4614f9581ff745749fccc5 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jun 2026 11:51:02 -0400 Subject: [PATCH 2/8] docs: document read_measures() in README and cross-link references Add a 'Defining measures' section to the README showing the roxygen2 tag syntax for read_measures(), and add @seealso cross-links between measure(), semantic_layer(), and read_measures(). --- R/measures.R | 6 ++++++ R/read-measures.R | 3 +++ README.Rmd | 29 +++++++++++++++++++++++++++++ README.md | 39 +++++++++++++++++++++++++++++++++++++++ man/measure.Rd | 6 +++++- man/read_measures.Rd | 4 ++++ man/semantic_layer.Rd | 4 ++++ 7 files changed, 90 insertions(+), 1 deletion(-) diff --git a/R/measures.R b/R/measures.R index 6906fa32..cdc40e49 100644 --- a/R/measures.R +++ b/R/measures.R @@ -7,6 +7,9 @@ #' #' @return A `commons_semantic_layer` object. #' +#' @seealso [measure()] to define a measure, and [read_measures()] to load +#' measures from documented R scripts. +#' #' @examples #' semantic_layer( #' measure( @@ -55,6 +58,9 @@ semantic_layer <- function(...) { #' #' @return A measure object. #' +#' @seealso [semantic_layer()] to collect measures into a layer, and +#' [read_measures()] to define measures in documented R scripts instead. +#' #' @export measure <- function(name, description, fn, arguments = list(), title = NULL) { title <- title %||% humanize_name(name) diff --git a/R/read-measures.R b/R/read-measures.R index f5b89524..b1cabd04 100644 --- a/R/read-measures.R +++ b/R/read-measures.R @@ -26,6 +26,9 @@ #' #' @return A list of [measure()] objects, suitable for [semantic_layer()]. #' +#' @seealso [measure()] to define a measure directly, and [semantic_layer()] to +#' collect the result into a layer. +#' #' @examples #' \dontrun{ #' semantic_layer(read_measures("measures.R")) diff --git a/README.Rmd b/README.Rmd index 3e51d07f..7764a15c 100644 --- a/README.Rmd +++ b/README.Rmd @@ -31,6 +31,35 @@ To get started, configure a database connection with `data_source()` and then tw * Assemble a `semantic_layer()`, a pool of pre-vetted queries that directly use the definitions defined by your data science team. This can make use of existing semantic layers defined with other technologies, or can be assembled using existing, trusted data artifacts (like dashboards and parameterized reports). The semantic layer is the agents' happy path, relying on existing, trusted definitions. * Assemble a `context_layer()`, a pool of free-text knowledge that the agent can search through when a data query is not covered by the semantic layer. This layer informs how the agent will author fallback SQL queries. +### Defining measures + +A semantic layer is built from **measures**: governed calculations that the agent can call by name. You can write each measure with `measure()`, or -- often more naturally -- define them as ordinary documented R functions and load them with `read_measures()`. + +Every top-level function in the script with a roxygen2 block becomes a measure. Its name, description, and arguments are read directly from the documentation: + +```r +#' Count orders +#' +#' @description Total orders, optionally filtered by region and period. +#' +#' @param region `string` The sales region. Omit for all regions. +#' @param period `enum[day, week, month]` Aggregation period. +#' @param top_n `integer` Maximum number of rows to return. +#' +#' @return An integer count of orders. +order_count <- function(region = NULL, period, top_n = 10L) { + # ... ordinary R that computes the measure ... +} +``` + +The argument type is declared with a leading code span in each `@param`: `string`, `integer`, `number`, `boolean`, `enum[...]` for a fixed set of values, or `type[]` for an array (e.g. `string[]`). An argument is required when it has no default in the function signature; otherwise it is optional. Untyped arguments fall back to a type inferred from their default. + +Load the script into a semantic layer with: + +```r +semantic_layer(read_measures("measures.R")) +``` + With those two pieces, you've got the necessary pieces to ship on Posit Connect, in Slack/Teams, or via an email inbox. In production, the agent will search the context layer to determine the correct queries to answer user questions (or decline to answer). If you want, commons can log interactions, run live evals, collect metrics (like Thumbs up/down), and integrate with your existing data request intake flows. After this initial proof-of-concept, you'll want to evaluate the agent. Your existing data artifacts provide a source of known-correct analysis flows; with these sources, commons provides a skill to create a set of **offline evals** that allow you to benchmark your agent's correctness. With these evals in place, you can: diff --git a/README.md b/README.md index 84ba750d..7e3e7727 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,45 @@ then two layers on top of it: semantic layer. This layer informs how the agent will author fallback SQL queries. +### Defining measures + +A semantic layer is built from **measures**: governed calculations that +the agent can call by name. You can write each measure with `measure()`, +or – often more naturally – define them as ordinary documented R +functions and load them with `read_measures()`. + +Every top-level function in the script with a roxygen2 block becomes a +measure. Its name, description, and arguments are read directly from the +documentation: + +``` r +#' Count orders +#' +#' @description Total orders, optionally filtered by region and period. +#' +#' @param region `string` The sales region. Omit for all regions. +#' @param period `enum[day, week, month]` Aggregation period. +#' @param top_n `integer` Maximum number of rows to return. +#' +#' @return An integer count of orders. +order_count <- function(region = NULL, period, top_n = 10L) { + # ... ordinary R that computes the measure ... +} +``` + +The argument type is declared with a leading code span in each `@param`: +`string`, `integer`, `number`, `boolean`, `enum[...]` for a fixed set of +values, or `type[]` for an array (e.g. `string[]`). An argument is +required when it has no default in the function signature; otherwise it +is optional. Untyped arguments fall back to a type inferred from their +default. + +Load the script into a semantic layer with: + +``` r +semantic_layer(read_measures("measures.R")) +``` + With those two pieces, you’ve got the necessary pieces to ship on Posit Connect, in Slack/Teams, or via an email inbox. In production, the agent will search the context layer to determine the correct queries to answer diff --git a/man/measure.Rd b/man/measure.Rd index d5e304e6..fbcc44f6 100644 --- a/man/measure.Rd +++ b/man/measure.Rd @@ -14,7 +14,7 @@ measure(name, description, fn, arguments = list(), title = NULL) \item{fn}{Function that computes the measure. Its formals are the measure's arguments.} -\item{arguments}{A named list of \code{\link[ellmer:type_boolean]{ellmer::type_string()}} and friends, one per +\item{arguments}{A named list of \code{\link[ellmer:type_string]{ellmer::type_string()}} and friends, one per formal of \code{fn}.} \item{title}{Human-readable measure title to show in user interfaces. If @@ -28,3 +28,7 @@ A measure is a governed calculation inside a \code{\link[=semantic_layer]{semant body is ordinary R; its \code{arguments} schema tells the model what inputs it can supply. } +\seealso{ +\code{\link[=semantic_layer]{semantic_layer()}} to collect measures into a layer, and +\code{\link[=read_measures]{read_measures()}} to define measures in documented R scripts instead. +} diff --git a/man/read_measures.Rd b/man/read_measures.Rd index f27fde87..7c5192d7 100644 --- a/man/read_measures.Rd +++ b/man/read_measures.Rd @@ -41,3 +41,7 @@ semantic_layer(read_measures("measures.R")) } } +\seealso{ +\code{\link[=measure]{measure()}} to define a measure directly, and \code{\link[=semantic_layer]{semantic_layer()}} to +collect the result into a layer. +} diff --git a/man/semantic_layer.Rd b/man/semantic_layer.Rd index d6c9d86e..251122ef 100644 --- a/man/semantic_layer.Rd +++ b/man/semantic_layer.Rd @@ -27,3 +27,7 @@ semantic_layer( ) } +\seealso{ +\code{\link[=measure]{measure()}} to define a measure, and \code{\link[=read_measures]{read_measures()}} to load +measures from documented R scripts. +} From 00b7d6ab552f7cdf0323bb1f95fc869bc994a75d Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jun 2026 12:01:05 -0400 Subject: [PATCH 3/8] feat: accept R script paths directly in semantic_layer() Character inputs to semantic_layer() are now passed to read_measures(), so file paths, directories, and inline measure() objects can be freely mixed: semantic_layer("measures.R", my_inline_measure). Paths may be scalar strings or character vectors. --- R/measures.R | 26 ++++++++++++++++++++------ README.Rmd | 6 ++++-- README.md | 8 ++++++-- man/semantic_layer.Rd | 4 +++- tests/testthat/_snaps/measures.md | 10 +++++++++- tests/testthat/test-measures.R | 20 +++++++++++++++++++- 6 files changed, 61 insertions(+), 13 deletions(-) diff --git a/R/measures.R b/R/measures.R index cdc40e49..ac79ba12 100644 --- a/R/measures.R +++ b/R/measures.R @@ -3,7 +3,9 @@ #' A semantic layer is a collection of governed measures available to a #' [commons()] agent. #' -#' @param ... [measure()] objects. You can also supply a single list of measures. +#' @param ... [measure()] objects, lists of measures, or paths to R scripts or +#' directories. Paths are passed to [read_measures()], so file and inline +#' measures can be freely mixed. #' #' @return A `commons_semantic_layer` object. #' @@ -22,11 +24,7 @@ #' #' @export semantic_layer <- function(...) { - measures <- rlang::list2(...) - - if (length(measures) == 1 && is_measure_list(measures[[1]])) { - measures <- measures[[1]] - } + measures <- expand_measures(rlang::list2(...)) check_measures(measures) names(measures) <- vapply(measures, tool_name, character(1)) @@ -41,6 +39,22 @@ semantic_layer <- function(...) { new_semantic_layer(measures) } +# Expand each `...` element into measures: character vectors are read from disk +# with `read_measures()`, lists of measures are spliced in, and a lone measure +# is kept as is. +expand_measures <- function(args) { + expanded <- lapply(args, function(arg) { + if (is.character(arg)) { + read_measures(arg) + } else if (is_measure_list(arg)) { + arg + } else { + list(arg) + } + }) + unlist(expanded, recursive = FALSE) %||% list() +} + #' Create a measure #' #' A measure is a governed calculation inside a [semantic_layer()]. Its function diff --git a/README.Rmd b/README.Rmd index 7764a15c..9763d142 100644 --- a/README.Rmd +++ b/README.Rmd @@ -54,12 +54,14 @@ order_count <- function(region = NULL, period, top_n = 10L) { The argument type is declared with a leading code span in each `@param`: `string`, `integer`, `number`, `boolean`, `enum[...]` for a fixed set of values, or `type[]` for an array (e.g. `string[]`). An argument is required when it has no default in the function signature; otherwise it is optional. Untyped arguments fall back to a type inferred from their default. -Load the script into a semantic layer with: +Pass the script -- or a directory of scripts -- straight to `semantic_layer()`, alongside any inline `measure()` definitions: ```r -semantic_layer(read_measures("measures.R")) +semantic_layer("measures.R") ``` +Paths are read with `read_measures()`, which you can also call directly when you want the list of measures on its own. + With those two pieces, you've got the necessary pieces to ship on Posit Connect, in Slack/Teams, or via an email inbox. In production, the agent will search the context layer to determine the correct queries to answer user questions (or decline to answer). If you want, commons can log interactions, run live evals, collect metrics (like Thumbs up/down), and integrate with your existing data request intake flows. After this initial proof-of-concept, you'll want to evaluate the agent. Your existing data artifacts provide a source of known-correct analysis flows; with these sources, commons provides a skill to create a set of **offline evals** that allow you to benchmark your agent's correctness. With these evals in place, you can: diff --git a/README.md b/README.md index 7e3e7727..38af2d81 100644 --- a/README.md +++ b/README.md @@ -69,12 +69,16 @@ required when it has no default in the function signature; otherwise it is optional. Untyped arguments fall back to a type inferred from their default. -Load the script into a semantic layer with: +Pass the script – or a directory of scripts – straight to +`semantic_layer()`, alongside any inline `measure()` definitions: ``` r -semantic_layer(read_measures("measures.R")) +semantic_layer("measures.R") ``` +Paths are read with `read_measures()`, which you can also call directly +when you want the list of measures on its own. + With those two pieces, you’ve got the necessary pieces to ship on Posit Connect, in Slack/Teams, or via an email inbox. In production, the agent will search the context layer to determine the correct queries to answer diff --git a/man/semantic_layer.Rd b/man/semantic_layer.Rd index 251122ef..d004524c 100644 --- a/man/semantic_layer.Rd +++ b/man/semantic_layer.Rd @@ -7,7 +7,9 @@ semantic_layer(...) } \arguments{ -\item{...}{\code{\link[=measure]{measure()}} objects. You can also supply a single list of measures.} +\item{...}{\code{\link[=measure]{measure()}} objects, lists of measures, or paths to R scripts or +directories. Paths are passed to \code{\link[=read_measures]{read_measures()}}, so file and inline +measures can be freely mixed.} } \value{ A \code{commons_semantic_layer} object. diff --git a/tests/testthat/_snaps/measures.md b/tests/testthat/_snaps/measures.md index 2fa8ea8e..5c4636eb 100644 --- a/tests/testthat/_snaps/measures.md +++ b/tests/testthat/_snaps/measures.md @@ -1,7 +1,7 @@ # semantic_layer validates its measures Code - semantic_layer("not a measure") + semantic_layer(42) Condition Error in `semantic_layer()`: ! Every item in `semantic_layer` must be created by `measure()`. @@ -14,6 +14,14 @@ Error in `semantic_layer()`: ! Measure names must be unique; duplicated name: "order_count". +# semantic_layer surfaces read_measures errors for bad paths + + Code + semantic_layer("not a measure") + Condition + Error in `read_measures()`: + ! Path does not exist: 'not a measure'. + # validate_measure_args rejects out-of-vocabulary enum values Code diff --git a/tests/testthat/test-measures.R b/tests/testthat/test-measures.R index cd2ccf02..1ba6d1c5 100644 --- a/tests/testthat/test-measures.R +++ b/tests/testthat/test-measures.R @@ -12,13 +12,31 @@ test_that("semantic_layer accepts a list of measures", { }) test_that("semantic_layer validates its measures", { - expect_snapshot(semantic_layer("not a measure"), error = TRUE) + expect_snapshot(semantic_layer(42), error = TRUE) expect_snapshot( semantic_layer(count_measure_tool(), count_measure_tool()), error = TRUE ) }) +test_that("semantic_layer reads measures from path inputs", { + skip_if_not_installed("roxygen2") + + path <- withr::local_tempfile(fileext = ".R") + writeLines( + c("#' Counter", "#' @description Counts.", "counter <- function() 1L"), + path + ) + + layer <- semantic_layer(path, count_measure_tool()) + + expect_named(layer$measures, c("counter", "order_count")) +}) + +test_that("semantic_layer surfaces read_measures errors for bad paths", { + expect_snapshot(semantic_layer("not a measure"), error = TRUE) +}) + test_that("validate_measure_args coerces valid arguments", { td <- count_measure_tool() args <- validate_measure_args( From 7e10891f035595a90a46f6301c2b8a8492e51807 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jun 2026 12:42:07 -0400 Subject: [PATCH 4/8] feat: require @measure marker and share env across files read_measures() now treats @measure as a required opt-in marker: only functions whose roxygen2 block carries @measure become measures, so helper functions can live alongside measures in the same file. This replaces the old "every documented function is a measure" rule and mirrors how @export marks a function. All files passed in a single read_measures() call are sourced in order into one shared environment (parent = globalenv()), so a measure can call helpers defined in sibling files of the same call. Separate arguments to semantic_layer() remain isolated. The @measure tag is registered at runtime via a roxy_tag_parse S3 method (tag_toggle), since roxygen2 is only a suggested dependency. --- R/read-measures.R | 48 +++++++++++---- README.Rmd | 5 +- README.md | 13 ++++- man/read_measures.Rd | 14 +++-- tests/testthat/test-measures.R | 2 +- tests/testthat/test-read-measures.R | 91 ++++++++++++++++++++++++++++- 6 files changed, 152 insertions(+), 21 deletions(-) diff --git a/R/read-measures.R b/R/read-measures.R index b1cabd04..6a812bcd 100644 --- a/R/read-measures.R +++ b/R/read-measures.R @@ -1,10 +1,16 @@ #' Read measures from R scripts #' #' Reads [measure()] definitions from one or more R scripts, deriving each -#' measure from a documented function. Every top-level function assignment that -#' carries a roxygen2 block becomes a measure: its name is the function name, -#' its description is the `@title`, `@description`, and `@return`, its body is -#' the function, and its arguments come from the `@param` tags. +#' measure from a documented function. A function becomes a measure only when +#' its roxygen2 block carries a `@measure` tag (mirroring how `@export` marks a +#' function for export): its name is the function name, its description is the +#' `@title`, `@description`, and `@return`, its body is the function, and its +#' arguments come from the `@param` tags. Documented functions without +#' `@measure` are ignored, so helper functions can live alongside measures. +#' +#' All files in a single `read_measures()` call share one environment, sourced +#' in order, so a measure in one file can call a helper defined in a sibling +#' file of the same call. #' #' Argument types are declared with a leading type code span in the `@param` #' description: @@ -38,12 +44,30 @@ read_measures <- function(paths) { rlang::check_installed("roxygen2") + registerS3method( + "roxy_tag_parse", + "roxy_tag_measure", + function(x) roxygen2::tag_toggle(x), + envir = asNamespace("roxygen2") + ) + if (!is.character(paths)) { cli::cli_abort("{.arg paths} must be a character vector of file or directory paths.") } files <- resolve_measure_files(paths) - measures <- unlist(lapply(files, read_measures_file), recursive = FALSE) + + # Source every file into one shared env (in order) so a measure can call a + # helper defined in a sibling file. The parsed block only reads tags. + env <- new.env(parent = globalenv()) + for (file in files) { + sys.source(file, envir = env) + } + + measures <- unlist( + lapply(files, function(file) read_measures_file(file, env)), + recursive = FALSE + ) measures %||% list() } @@ -66,19 +90,23 @@ resolve_measure_files <- function(paths, call = rlang::caller_env()) { unique(files) } -read_measures_file <- function(file) { +read_measures_file <- function(file, env) { blocks <- roxygen2::parse_file(file) - measures <- lapply(blocks, block_to_measure) + measures <- lapply(blocks, function(block) block_to_measure(block, env)) Filter(Negate(is.null), measures) } -block_to_measure <- function(block) { - fn <- block$object$value - if (!is.function(fn)) { +block_to_measure <- function(block, env) { + if (is.null(roxygen2::block_get_tag(block, "measure"))) { return(NULL) } name <- block$object$topic + fn <- get(name, envir = env) + if (!is.function(fn)) { + return(NULL) + } + description <- block_description(block) arguments <- block_arguments(block, fn) diff --git a/README.Rmd b/README.Rmd index 9763d142..2abb9fa0 100644 --- a/README.Rmd +++ b/README.Rmd @@ -35,7 +35,7 @@ To get started, configure a database connection with `data_source()` and then tw A semantic layer is built from **measures**: governed calculations that the agent can call by name. You can write each measure with `measure()`, or -- often more naturally -- define them as ordinary documented R functions and load them with `read_measures()`. -Every top-level function in the script with a roxygen2 block becomes a measure. Its name, description, and arguments are read directly from the documentation: +A function becomes a measure when its roxygen2 block is marked with `#' @measure` -- much like `@export` marks a function as part of a package's public interface. Other documented functions in the file are ignored, so helpers can live alongside your measures. The measure's name, description, and arguments are read directly from the documentation: ```r #' Count orders @@ -47,6 +47,7 @@ Every top-level function in the script with a roxygen2 block becomes a measure. #' @param top_n `integer` Maximum number of rows to return. #' #' @return An integer count of orders. +#' @measure order_count <- function(region = NULL, period, top_n = 10L) { # ... ordinary R that computes the measure ... } @@ -54,6 +55,8 @@ order_count <- function(region = NULL, period, top_n = 10L) { The argument type is declared with a leading code span in each `@param`: `string`, `integer`, `number`, `boolean`, `enum[...]` for a fixed set of values, or `type[]` for an array (e.g. `string[]`). An argument is required when it has no default in the function signature; otherwise it is optional. Untyped arguments fall back to a type inferred from their default. +A measure can call helper functions defined in the same file -- or in sibling files passed together in a single `read_measures()` call -- because all files loaded in one call are sourced into a shared environment. + Pass the script -- or a directory of scripts -- straight to `semantic_layer()`, alongside any inline `measure()` definitions: ```r diff --git a/README.md b/README.md index 38af2d81..5d6e8beb 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,11 @@ the agent can call by name. You can write each measure with `measure()`, or – often more naturally – define them as ordinary documented R functions and load them with `read_measures()`. -Every top-level function in the script with a roxygen2 block becomes a -measure. Its name, description, and arguments are read directly from the +A function becomes a measure when its roxygen2 block is marked with +`#' @measure` – much like `@export` marks a function as part of a +package’s public interface. Other documented functions in the file are +ignored, so helpers can live alongside your measures. The measure’s +name, description, and arguments are read directly from the documentation: ``` r @@ -57,6 +60,7 @@ documentation: #' @param top_n `integer` Maximum number of rows to return. #' #' @return An integer count of orders. +#' @measure order_count <- function(region = NULL, period, top_n = 10L) { # ... ordinary R that computes the measure ... } @@ -69,6 +73,11 @@ required when it has no default in the function signature; otherwise it is optional. Untyped arguments fall back to a type inferred from their default. +A measure can call helper functions defined in the same file – or in +sibling files passed together in a single `read_measures()` call – +because all files loaded in one call are sourced into a shared +environment. + Pass the script – or a directory of scripts – straight to `semantic_layer()`, alongside any inline `measure()` definitions: diff --git a/man/read_measures.Rd b/man/read_measures.Rd index 7c5192d7..03746a49 100644 --- a/man/read_measures.Rd +++ b/man/read_measures.Rd @@ -15,12 +15,18 @@ A list of \code{\link[=measure]{measure()}} objects, suitable for \code{\link[=s } \description{ Reads \code{\link[=measure]{measure()}} definitions from one or more R scripts, deriving each -measure from a documented function. Every top-level function assignment that -carries a roxygen2 block becomes a measure: its name is the function name, -its description is the \verb{@title}, \verb{@description}, and \verb{@return}, its body is -the function, and its arguments come from the \verb{@param} tags. +measure from a documented function. A function becomes a measure only when +its roxygen2 block carries a \verb{@measure} tag (mirroring how \verb{@export} marks a +function for export): its name is the function name, its description is the +\verb{@title}, \verb{@description}, and \verb{@return}, its body is the function, and its +arguments come from the \verb{@param} tags. Documented functions without +\verb{@measure} are ignored, so helper functions can live alongside measures. } \details{ +All files in a single \code{read_measures()} call share one environment, sourced +in order, so a measure in one file can call a helper defined in a sibling +file of the same call. + Argument types are declared with a leading type code span in the \verb{@param} description: diff --git a/tests/testthat/test-measures.R b/tests/testthat/test-measures.R index 1ba6d1c5..7d0d186e 100644 --- a/tests/testthat/test-measures.R +++ b/tests/testthat/test-measures.R @@ -24,7 +24,7 @@ test_that("semantic_layer reads measures from path inputs", { path <- withr::local_tempfile(fileext = ".R") writeLines( - c("#' Counter", "#' @description Counts.", "counter <- function() 1L"), + c("#' Counter", "#' @description Counts.", "#' @measure", "counter <- function() 1L"), path ) diff --git a/tests/testthat/test-read-measures.R b/tests/testthat/test-read-measures.R index fea3b63a..8be116e4 100644 --- a/tests/testthat/test-read-measures.R +++ b/tests/testthat/test-read-measures.R @@ -15,6 +15,7 @@ test_that("read_measures derives a measure from a documented function", { "#' @param region `string` The sales region.", "#'", "#' @return An integer count.", + "#' @measure", "order_count <- function(region = NULL) {", " 42L", "}" @@ -43,6 +44,7 @@ test_that("read_measures maps param type code spans to ellmer types", { "#' @param d `boolean` A boolean.", "#' @param e `enum[x, y, z]` An enum.", "#' @param f `string[]` An array.", + "#' @measure", "m <- function(a, b, c, d, e, f) NULL" )) @@ -66,6 +68,7 @@ test_that("read_measures derives required from the signature, not the type", { "#' @description A measure.", "#' @param required_arg `string` Required.", "#' @param optional_arg `string` Optional.", + "#' @measure", "m <- function(required_arg, optional_arg = NULL) NULL" )) @@ -82,6 +85,7 @@ test_that("read_measures uses the param text as the type description", { "#' Measure", "#' @description A measure.", "#' @param region `string` The sales region.", + "#' @measure", "m <- function(region) NULL" )) @@ -99,6 +103,7 @@ test_that("read_measures infers untyped args from their defaults", { "#' @param n A number default.", "#' @param b A boolean default.", "#' @param s No default.", + "#' @measure", "m <- function(i = 10L, n = 1.5, b = TRUE, s) NULL" )) @@ -110,15 +115,20 @@ test_that("read_measures infers untyped args from their defaults", { expect_equal(type_kind(props$s), "string") }) -test_that("read_measures ignores undocumented functions", { +test_that("read_measures ignores undocumented and untagged functions", { skip_if_not_installed("roxygen2") path <- measures_script(c( "#' Measure", "#' @description A measure.", "#' @param a `string` An arg.", + "#' @measure", "m <- function(a) NULL", "", + "#' Documented helper", + "#' @description Documented but not a measure.", + "documented_helper <- function(x) x", + "", "helper <- function(x) x" )) @@ -128,16 +138,90 @@ test_that("read_measures ignores undocumented functions", { expect_equal(tool_name(measures[[1]]), "m") }) +test_that("read_measures returns only @measure functions", { + skip_if_not_installed("roxygen2") + + path <- measures_script(c( + "#' Tagged", + "#' @description Tagged measure.", + "#' @measure", + "tagged <- function() 1L", + "", + "#' Untagged", + "#' @description Documented but not tagged.", + "untagged <- function() 2L" + )) + + measures <- read_measures(path) + + expect_length(measures, 1) + expect_equal(tool_name(measures[[1]]), "tagged") +}) + +test_that("read_measures shares an env across files in one call", { + skip_if_not_installed("roxygen2") + + dir <- withr::local_tempdir() + a <- file.path(dir, "a.R") + b <- file.path(dir, "b.R") + writeLines( + c("helper <- function(x) x * 2L"), + a + ) + writeLines( + c( + "#' Uses helper", + "#' @description Calls a helper from a sibling file.", + "#' @measure", + "uses_helper <- function() helper(21L)" + ), + b + ) + + measures <- read_measures(c(a, b)) + + expect_length(measures, 1) + td <- measures[[1]] + expect_equal(tool_name(td), "uses_helper") + expect_equal(do.call(td, list()), 42L) +}) + +test_that("semantic_layer isolates measures read from separate path args", { + skip_if_not_installed("roxygen2") + + dir <- withr::local_tempdir() + a <- file.path(dir, "a.R") + b <- file.path(dir, "b.R") + writeLines( + c("helper <- function(x) x * 2L"), + a + ) + writeLines( + c( + "#' Uses helper", + "#' @description Calls a helper from another file.", + "#' @measure", + "uses_helper <- function() helper(21L)" + ), + b + ) + + layer <- semantic_layer(a, b) + + expect_named(layer$measures, "uses_helper") + expect_error(do.call(layer$measures$uses_helper, list())) +}) + test_that("read_measures reads multiple files and directories", { skip_if_not_installed("roxygen2") dir <- withr::local_tempdir() writeLines( - c("#' One", "#' @description First.", "one <- function() 1L"), + c("#' One", "#' @description First.", "#' @measure", "one <- function() 1L"), file.path(dir, "one.R") ) writeLines( - c("#' Two", "#' @description Second.", "two <- function() 2L"), + c("#' Two", "#' @description Second.", "#' @measure", "two <- function() 2L"), file.path(dir, "two.R") ) @@ -153,6 +237,7 @@ test_that("read_measures produces measures usable in a semantic_layer", { "#' Count orders", "#' @description Counts orders.", "#' @param region `enum[EMEA, APAC]` The region.", + "#' @measure", "order_count <- function(region) 7L" )) From 437c3a6924e0d5c8a12bf7e1cb3a5fa981a7bcc5 Mon Sep 17 00:00:00 2001 From: Garrick Aden-Buie Date: Tue, 23 Jun 2026 12:44:57 -0400 Subject: [PATCH 5/8] docs: regenerate man pages with roxygen2 8.0.0 Bumps the recorded roxygen2 version to 8.0.0 and regenerates the package-level and module .Rd files accordingly. --- DESCRIPTION | 2 +- man/commons-package.Rd | 5 + man/commons.Rd | 207 ++++++++++++++++++++--------------------- man/commons_mod_ui.Rd | 10 +- 4 files changed, 113 insertions(+), 111 deletions(-) diff --git a/DESCRIPTION b/DESCRIPTION index 5b8893ac..b81eaf79 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -47,4 +47,4 @@ Suggests: Config/testthat/edition: 3 Encoding: UTF-8 Roxygen: list(markdown = TRUE) -RoxygenNote: 7.3.3 +Config/roxygen2/version: 8.0.0 diff --git a/man/commons-package.Rd b/man/commons-package.Rd index d7fd87c5..ff9147a4 100644 --- a/man/commons-package.Rd +++ b/man/commons-package.Rd @@ -19,6 +19,11 @@ Useful links: \author{ \strong{Maintainer}: Simon Couch \email{simon.couch@posit.co} (\href{https://orcid.org/0000-0001-5676-5107}{ORCID}) +Authors: +\itemize{ + \item Simon Couch \email{simon.couch@posit.co} (\href{https://orcid.org/0000-0001-5676-5107}{ORCID}) +} + Other contributors: \itemize{ \item Posit Software, PBC (\href{https://ror.org/03wc8by49}{ROR}) [copyright holder, funder] diff --git a/man/commons.Rd b/man/commons.Rd index 19a67778..4940fd69 100644 --- a/man/commons.Rd +++ b/man/commons.Rd @@ -108,145 +108,142 @@ sem <- semantic_layer( \code{\link[ellmer:Chat]{ellmer::Chat}} -> \code{Commons} } \section{Public fields}{ -\if{html}{\out{
}} -\describe{ -\item{\code{last_tag}}{How the most recent answer was produced: \code{"A"} for a + \if{html}{\out{
}} + \describe{ + \item{\code{last_tag}}{How the most recent answer was produced: \code{"A"} for a registered measure, \code{"B"} for SQL, or \code{NA}.} -} -\if{html}{\out{
}} + } + \if{html}{\out{
}} } \section{Methods}{ \subsection{Public methods}{ -\itemize{ -\item \href{#method-Commons-new}{\code{Commons$new()}} -\item \href{#method-Commons-chat}{\code{Commons$chat()}} -\item \href{#method-Commons-stream_async}{\code{Commons$stream_async()}} -\item \href{#method-Commons-clone}{\code{Commons$clone()}} -} -} -\if{html}{\out{ -
Inherited methods + \itemize{ + \item \href{#method-Commons-initialize}{\code{Commons$new()}} + \item \href{#method-Commons-chat}{\code{Commons$chat()}} + \item \href{#method-Commons-stream_async}{\code{Commons$stream_async()}} + \item \href{#method-Commons-clone}{\code{Commons$clone()}} + } +} +\if{html}{\out{
Inherited methods -
-}} +
}} \if{html}{\out{
}} -\if{html}{\out{}} -\if{latex}{\out{\hypertarget{method-Commons-new}{}}} -\subsection{Method \code{new()}}{ -Create a Commons agent. Most users should call \code{\link[=commons]{commons()}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-Commons-initialize}{}}} +\subsection{\code{Commons$new()}}{ + Create a Commons agent. Most users should call \code{\link[=commons]{commons()}} rather than this method directly. -\subsection{Usage}{ -\if{html}{\out{
}}\preformatted{Commons$new( + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Commons$new( client, data_source, context_layer = NULL, semantic_layer = NULL, log = FALSE -)}\if{html}{\out{
}} +)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{client}}{An \link[ellmer:Chat]{ellmer::Chat} supplying the provider.} + \item{\code{data_source}}{A \code{\link[=data_source]{data_source()}}.} + \item{\code{context_layer}}{An optional \code{\link[=context_layer]{context_layer()}}.} + \item{\code{semantic_layer}}{An optional \code{\link[=semantic_layer]{semantic_layer()}}.} + \item{\code{log}}{Whether to log conversation trajectories.} + } + \if{html}{\out{
}} + } } -\subsection{Arguments}{ -\if{html}{\out{
}} -\describe{ -\item{\code{client}}{An \link[ellmer:Chat]{ellmer::Chat} supplying the provider.} - -\item{\code{data_source}}{A \code{\link[=data_source]{data_source()}}.} - -\item{\code{context_layer}}{An optional \code{\link[=context_layer]{context_layer()}}.} - -\item{\code{semantic_layer}}{An optional \code{\link[=semantic_layer]{semantic_layer()}}.} - -\item{\code{log}}{Whether to log conversation trajectories.} -} -\if{html}{\out{
}} -} -} \if{html}{\out{
}} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-Commons-chat}{}}} -\subsection{Method \code{chat()}}{ -Submit input and return the response. Also updates +\subsection{\code{Commons$chat()}}{ + Submit input and return the response. Also updates \verb{$last_tag} and writes a turn log. See \link[ellmer:Chat]{ellmer::Chat} for arguments. -\subsection{Usage}{ -\if{html}{\out{
}}\preformatted{Commons$chat(..., echo = NULL)}\if{html}{\out{
}} + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Commons$chat(..., echo = NULL)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{...}}{Input to send to the model.} + \item{\code{echo}}{Whether to echo output; see \link[ellmer:Chat]{ellmer::Chat}.} + } + \if{html}{\out{
}} + } } -\subsection{Arguments}{ -\if{html}{\out{
}} -\describe{ -\item{\code{...}}{Input to send to the model.} - -\item{\code{echo}}{Whether to echo output; see \link[ellmer:Chat]{ellmer::Chat}.} -} -\if{html}{\out{
}} -} -} \if{html}{\out{
}} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-Commons-stream_async}{}}} -\subsection{Method \code{stream_async()}}{ -Stream input and return the response stream. Also updates +\subsection{\code{Commons$stream_async()}}{ + Stream input and return the response stream. Also updates \verb{$last_tag} as tools are requested. See \link[ellmer:Chat]{ellmer::Chat} for arguments. -\subsection{Usage}{ -\if{html}{\out{
}}\preformatted{Commons$stream_async( + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Commons$stream_async( ..., tool_mode = c("concurrent", "sequential"), stream = c("text", "content"), controller = NULL -)}\if{html}{\out{
}} +)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{...}}{Input to send to the model.} + \item{\code{tool_mode}}{Whether tool calls may run concurrently or sequentially.} + \item{\code{stream}}{Whether to stream plain text or \link[ellmer:Content]{ellmer::Content} objects.} + \item{\code{controller}}{Optional \code{\link[ellmer:stream_controller]{ellmer::stream_controller()}}.} + } + \if{html}{\out{
}} + } } -\subsection{Arguments}{ -\if{html}{\out{
}} -\describe{ -\item{\code{...}}{Input to send to the model.} - -\item{\code{tool_mode}}{Whether tool calls may run concurrently or sequentially.} - -\item{\code{stream}}{Whether to stream plain text or \link[ellmer:Content]{ellmer::Content} objects.} - -\item{\code{controller}}{Optional \code{\link[ellmer:stream_controller]{ellmer::stream_controller()}}.} -} -\if{html}{\out{
}} -} -} \if{html}{\out{
}} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-Commons-clone}{}}} -\subsection{Method \code{clone()}}{ -The objects of this class are cloneable with this method. -\subsection{Usage}{ -\if{html}{\out{
}}\preformatted{Commons$clone(deep = FALSE)}\if{html}{\out{
}} +\subsection{\code{Commons$clone()}}{ + The objects of this class are cloneable with this method. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{Commons$clone(deep = FALSE)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{deep}}{Whether to make a deep clone.} + } + \if{html}{\out{
}} + } } -\subsection{Arguments}{ -\if{html}{\out{
}} -\describe{ -\item{\code{deep}}{Whether to make a deep clone.} -} -\if{html}{\out{
}} -} -} } diff --git a/man/commons_mod_ui.Rd b/man/commons_mod_ui.Rd index a2b6aa97..1a80f77b 100644 --- a/man/commons_mod_ui.Rd +++ b/man/commons_mod_ui.Rd @@ -17,11 +17,11 @@ commons_mod_server( \arguments{ \item{id}{Module ID.} -\item{...}{Arguments passed to \code{\link[shinychat:chat_app]{shinychat::chat_mod_ui()}} or -\code{\link[shinychat:chat_app]{shinychat::chat_mod_server()}}.} +\item{...}{Arguments passed to \code{\link[shinychat:chat_mod_ui]{shinychat::chat_mod_ui()}} or +\code{\link[shinychat:chat_mod_server]{shinychat::chat_mod_server()}}.} \item{messages}{Initial messages shown in the chat. Passed to -\code{\link[shinychat:chat_app]{shinychat::chat_mod_ui()}}.} +\code{\link[shinychat:chat_mod_ui]{shinychat::chat_mod_ui()}}.} \item{height}{Chat container height. Defaults to \code{"100\%"} so the chat input stays docked at the bottom of fill layouts.} @@ -36,8 +36,8 @@ bookmarking hooks for user inputs and assistant responses.} shinychat module server result. } \description{ -These functions wrap \code{\link[shinychat:chat_app]{shinychat::chat_mod_ui()}} and -\code{\link[shinychat:chat_app]{shinychat::chat_mod_server()}} with commons-specific answer provenance UI. +These functions wrap \code{\link[shinychat:chat_mod_ui]{shinychat::chat_mod_ui()}} and +\code{\link[shinychat:chat_mod_server]{shinychat::chat_mod_server()}} with commons-specific answer provenance UI. Answers produced from registered measures get a compact verified-answer pill. Answers produced from fallback SQL get a caution pill with a review request. } From 552efcfa321a095ca168c709f5e2d6648adf8fa8 Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Tue, 23 Jun 2026 13:17:18 -0500 Subject: [PATCH 6/8] remove new readme text --- README.Rmd | 34 ---------------------------------- README.md | 52 ---------------------------------------------------- 2 files changed, 86 deletions(-) diff --git a/README.Rmd b/README.Rmd index 2abb9fa0..3e51d07f 100644 --- a/README.Rmd +++ b/README.Rmd @@ -31,40 +31,6 @@ To get started, configure a database connection with `data_source()` and then tw * Assemble a `semantic_layer()`, a pool of pre-vetted queries that directly use the definitions defined by your data science team. This can make use of existing semantic layers defined with other technologies, or can be assembled using existing, trusted data artifacts (like dashboards and parameterized reports). The semantic layer is the agents' happy path, relying on existing, trusted definitions. * Assemble a `context_layer()`, a pool of free-text knowledge that the agent can search through when a data query is not covered by the semantic layer. This layer informs how the agent will author fallback SQL queries. -### Defining measures - -A semantic layer is built from **measures**: governed calculations that the agent can call by name. You can write each measure with `measure()`, or -- often more naturally -- define them as ordinary documented R functions and load them with `read_measures()`. - -A function becomes a measure when its roxygen2 block is marked with `#' @measure` -- much like `@export` marks a function as part of a package's public interface. Other documented functions in the file are ignored, so helpers can live alongside your measures. The measure's name, description, and arguments are read directly from the documentation: - -```r -#' Count orders -#' -#' @description Total orders, optionally filtered by region and period. -#' -#' @param region `string` The sales region. Omit for all regions. -#' @param period `enum[day, week, month]` Aggregation period. -#' @param top_n `integer` Maximum number of rows to return. -#' -#' @return An integer count of orders. -#' @measure -order_count <- function(region = NULL, period, top_n = 10L) { - # ... ordinary R that computes the measure ... -} -``` - -The argument type is declared with a leading code span in each `@param`: `string`, `integer`, `number`, `boolean`, `enum[...]` for a fixed set of values, or `type[]` for an array (e.g. `string[]`). An argument is required when it has no default in the function signature; otherwise it is optional. Untyped arguments fall back to a type inferred from their default. - -A measure can call helper functions defined in the same file -- or in sibling files passed together in a single `read_measures()` call -- because all files loaded in one call are sourced into a shared environment. - -Pass the script -- or a directory of scripts -- straight to `semantic_layer()`, alongside any inline `measure()` definitions: - -```r -semantic_layer("measures.R") -``` - -Paths are read with `read_measures()`, which you can also call directly when you want the list of measures on its own. - With those two pieces, you've got the necessary pieces to ship on Posit Connect, in Slack/Teams, or via an email inbox. In production, the agent will search the context layer to determine the correct queries to answer user questions (or decline to answer). If you want, commons can log interactions, run live evals, collect metrics (like Thumbs up/down), and integrate with your existing data request intake flows. After this initial proof-of-concept, you'll want to evaluate the agent. Your existing data artifacts provide a source of known-correct analysis flows; with these sources, commons provides a skill to create a set of **offline evals** that allow you to benchmark your agent's correctness. With these evals in place, you can: diff --git a/README.md b/README.md index 5d6e8beb..84ba750d 100644 --- a/README.md +++ b/README.md @@ -36,58 +36,6 @@ then two layers on top of it: semantic layer. This layer informs how the agent will author fallback SQL queries. -### Defining measures - -A semantic layer is built from **measures**: governed calculations that -the agent can call by name. You can write each measure with `measure()`, -or – often more naturally – define them as ordinary documented R -functions and load them with `read_measures()`. - -A function becomes a measure when its roxygen2 block is marked with -`#' @measure` – much like `@export` marks a function as part of a -package’s public interface. Other documented functions in the file are -ignored, so helpers can live alongside your measures. The measure’s -name, description, and arguments are read directly from the -documentation: - -``` r -#' Count orders -#' -#' @description Total orders, optionally filtered by region and period. -#' -#' @param region `string` The sales region. Omit for all regions. -#' @param period `enum[day, week, month]` Aggregation period. -#' @param top_n `integer` Maximum number of rows to return. -#' -#' @return An integer count of orders. -#' @measure -order_count <- function(region = NULL, period, top_n = 10L) { - # ... ordinary R that computes the measure ... -} -``` - -The argument type is declared with a leading code span in each `@param`: -`string`, `integer`, `number`, `boolean`, `enum[...]` for a fixed set of -values, or `type[]` for an array (e.g. `string[]`). An argument is -required when it has no default in the function signature; otherwise it -is optional. Untyped arguments fall back to a type inferred from their -default. - -A measure can call helper functions defined in the same file – or in -sibling files passed together in a single `read_measures()` call – -because all files loaded in one call are sourced into a shared -environment. - -Pass the script – or a directory of scripts – straight to -`semantic_layer()`, alongside any inline `measure()` definitions: - -``` r -semantic_layer("measures.R") -``` - -Paths are read with `read_measures()`, which you can also call directly -when you want the list of measures on its own. - With those two pieces, you’ve got the necessary pieces to ship on Posit Connect, in Slack/Teams, or via an email inbox. In production, the agent will search the context layer to determine the correct queries to answer From 704875a0eacf785b458133d94dfe5d804f135854 Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Tue, 23 Jun 2026 13:19:59 -0500 Subject: [PATCH 7/8] =?UTF-8?q?4=EF=B8=8F=E2=83=A32=EF=B8=8F=E2=83=A3?= =?UTF-8?q?=E2=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/testthat/_snaps/measures.md | 2 +- tests/testthat/test-measures.R | 2 +- tests/testthat/test-read-measures.R | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/testthat/_snaps/measures.md b/tests/testthat/_snaps/measures.md index 5c4636eb..ce878638 100644 --- a/tests/testthat/_snaps/measures.md +++ b/tests/testthat/_snaps/measures.md @@ -1,7 +1,7 @@ # semantic_layer validates its measures Code - semantic_layer(42) + semantic_layer(2026) Condition Error in `semantic_layer()`: ! Every item in `semantic_layer` must be created by `measure()`. diff --git a/tests/testthat/test-measures.R b/tests/testthat/test-measures.R index 7d0d186e..dba6a660 100644 --- a/tests/testthat/test-measures.R +++ b/tests/testthat/test-measures.R @@ -12,7 +12,7 @@ test_that("semantic_layer accepts a list of measures", { }) test_that("semantic_layer validates its measures", { - expect_snapshot(semantic_layer(42), error = TRUE) + expect_snapshot(semantic_layer(2026), error = TRUE) expect_snapshot( semantic_layer(count_measure_tool(), count_measure_tool()), error = TRUE diff --git a/tests/testthat/test-read-measures.R b/tests/testthat/test-read-measures.R index 8be116e4..bfcb0f21 100644 --- a/tests/testthat/test-read-measures.R +++ b/tests/testthat/test-read-measures.R @@ -17,7 +17,7 @@ test_that("read_measures derives a measure from a documented function", { "#' @return An integer count.", "#' @measure", "order_count <- function(region = NULL) {", - " 42L", + " 2026L", "}" )) @@ -29,7 +29,7 @@ test_that("read_measures derives a measure from a documented function", { expect_match(tool_description(td), "Count orders") expect_match(tool_description(td), "Total orders") expect_match(tool_description(td), "Returns: An integer count") - expect_equal(do.call(td, list()), 42L) + expect_equal(do.call(td, list()), 2026L) }) test_that("read_measures maps param type code spans to ellmer types", { @@ -173,7 +173,7 @@ test_that("read_measures shares an env across files in one call", { "#' Uses helper", "#' @description Calls a helper from a sibling file.", "#' @measure", - "uses_helper <- function() helper(21L)" + "uses_helper <- function() helper(1013L)" ), b ) @@ -183,7 +183,7 @@ test_that("read_measures shares an env across files in one call", { expect_length(measures, 1) td <- measures[[1]] expect_equal(tool_name(td), "uses_helper") - expect_equal(do.call(td, list()), 42L) + expect_equal(do.call(td, list()), 2026L) }) test_that("semantic_layer isolates measures read from separate path args", { @@ -201,7 +201,7 @@ test_that("semantic_layer isolates measures read from separate path args", { "#' Uses helper", "#' @description Calls a helper from another file.", "#' @measure", - "uses_helper <- function() helper(21L)" + "uses_helper <- function() helper(1013L)" ), b ) From 7d56866fdf33ce20f713f1a2e6936646ff8d1f51 Mon Sep 17 00:00:00 2001 From: Simon Couch Date: Tue, 23 Jun 2026 13:24:12 -0500 Subject: [PATCH 8/8] remove `read_measures()` helper --- NAMESPACE | 1 - R/measures.R | 14 ++++-------- R/read-measures.R | 43 ----------------------------------- man/measure.Rd | 3 +-- man/read_measures.Rd | 53 ------------------------------------------- man/semantic_layer.Rd | 6 ++--- 6 files changed, 8 insertions(+), 112 deletions(-) delete mode 100644 man/read_measures.Rd diff --git a/NAMESPACE b/NAMESPACE index 10641e5f..93960693 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -9,7 +9,6 @@ export(data_source) export(data_source_pins) export(list_tables) export(measure) -export(read_measures) export(read_trajectories) export(semantic_layer) importFrom(R6,R6Class) diff --git a/R/measures.R b/R/measures.R index ac79ba12..f6bbf86b 100644 --- a/R/measures.R +++ b/R/measures.R @@ -4,13 +4,11 @@ #' [commons()] agent. #' #' @param ... [measure()] objects, lists of measures, or paths to R scripts or -#' directories. Paths are passed to [read_measures()], so file and inline -#' measures can be freely mixed. +#' directories. File and inline measures can be freely mixed. #' #' @return A `commons_semantic_layer` object. #' -#' @seealso [measure()] to define a measure, and [read_measures()] to load -#' measures from documented R scripts. +#' @seealso [measure()] to define a measure. #' #' @examples #' semantic_layer( @@ -39,9 +37,8 @@ semantic_layer <- function(...) { new_semantic_layer(measures) } -# Expand each `...` element into measures: character vectors are read from disk -# with `read_measures()`, lists of measures are spliced in, and a lone measure -# is kept as is. +# Expand each `...` element into measures: character vectors are read from disk, +# lists of measures are spliced in, and a lone measure is kept as is. expand_measures <- function(args) { expanded <- lapply(args, function(arg) { if (is.character(arg)) { @@ -72,8 +69,7 @@ expand_measures <- function(args) { #' #' @return A measure object. #' -#' @seealso [semantic_layer()] to collect measures into a layer, and -#' [read_measures()] to define measures in documented R scripts instead. +#' @seealso [semantic_layer()] to collect measures into a layer. #' #' @export measure <- function(name, description, fn, arguments = list(), title = NULL) { diff --git a/R/read-measures.R b/R/read-measures.R index 6a812bcd..ed9af24a 100644 --- a/R/read-measures.R +++ b/R/read-measures.R @@ -1,46 +1,3 @@ -#' Read measures from R scripts -#' -#' Reads [measure()] definitions from one or more R scripts, deriving each -#' measure from a documented function. A function becomes a measure only when -#' its roxygen2 block carries a `@measure` tag (mirroring how `@export` marks a -#' function for export): its name is the function name, its description is the -#' `@title`, `@description`, and `@return`, its body is the function, and its -#' arguments come from the `@param` tags. Documented functions without -#' `@measure` are ignored, so helper functions can live alongside measures. -#' -#' All files in a single `read_measures()` call share one environment, sourced -#' in order, so a measure in one file can call a helper defined in a sibling -#' file of the same call. -#' -#' Argument types are declared with a leading type code span in the `@param` -#' description: -#' -#' ```r -#' #' @param region `string` The sales region. -#' #' @param period `enum[day, week, month]` Aggregation period. -#' #' @param tags `string[]` Tag filters. -#' ``` -#' -#' Supported types are `string`, `integer`, `number`, `boolean`, `enum[...]` -#' for a fixed set of values, and `{type}[]` for an array. An argument is -#' required when its formal has no default value. When a `@param` has no type -#' code span, its type is inferred from the formal's default, falling back to a -#' string. -#' -#' @param paths Character vector of paths to R scripts or directories. For a -#' directory, all `.R` files in it are read. -#' -#' @return A list of [measure()] objects, suitable for [semantic_layer()]. -#' -#' @seealso [measure()] to define a measure directly, and [semantic_layer()] to -#' collect the result into a layer. -#' -#' @examples -#' \dontrun{ -#' semantic_layer(read_measures("measures.R")) -#' } -#' -#' @export read_measures <- function(paths) { rlang::check_installed("roxygen2") diff --git a/man/measure.Rd b/man/measure.Rd index fbcc44f6..a447f898 100644 --- a/man/measure.Rd +++ b/man/measure.Rd @@ -29,6 +29,5 @@ body is ordinary R; its \code{arguments} schema tells the model what inputs it c supply. } \seealso{ -\code{\link[=semantic_layer]{semantic_layer()}} to collect measures into a layer, and -\code{\link[=read_measures]{read_measures()}} to define measures in documented R scripts instead. +\code{\link[=semantic_layer]{semantic_layer()}} to collect measures into a layer. } diff --git a/man/read_measures.Rd b/man/read_measures.Rd deleted file mode 100644 index 03746a49..00000000 --- a/man/read_measures.Rd +++ /dev/null @@ -1,53 +0,0 @@ -% Generated by roxygen2: do not edit by hand -% Please edit documentation in R/read-measures.R -\name{read_measures} -\alias{read_measures} -\title{Read measures from R scripts} -\usage{ -read_measures(paths) -} -\arguments{ -\item{paths}{Character vector of paths to R scripts or directories. For a -directory, all \code{.R} files in it are read.} -} -\value{ -A list of \code{\link[=measure]{measure()}} objects, suitable for \code{\link[=semantic_layer]{semantic_layer()}}. -} -\description{ -Reads \code{\link[=measure]{measure()}} definitions from one or more R scripts, deriving each -measure from a documented function. A function becomes a measure only when -its roxygen2 block carries a \verb{@measure} tag (mirroring how \verb{@export} marks a -function for export): its name is the function name, its description is the -\verb{@title}, \verb{@description}, and \verb{@return}, its body is the function, and its -arguments come from the \verb{@param} tags. Documented functions without -\verb{@measure} are ignored, so helper functions can live alongside measures. -} -\details{ -All files in a single \code{read_measures()} call share one environment, sourced -in order, so a measure in one file can call a helper defined in a sibling -file of the same call. - -Argument types are declared with a leading type code span in the \verb{@param} -description: - -\if{html}{\out{
}}\preformatted{#' @param region `string` The sales region. -#' @param period `enum[day, week, month]` Aggregation period. -#' @param tags `string[]` Tag filters. -}\if{html}{\out{
}} - -Supported types are \code{string}, \code{integer}, \code{number}, \code{boolean}, \code{enum[...]} -for a fixed set of values, and \code{{type}[]} for an array. An argument is -required when its formal has no default value. When a \verb{@param} has no type -code span, its type is inferred from the formal's default, falling back to a -string. -} -\examples{ -\dontrun{ -semantic_layer(read_measures("measures.R")) -} - -} -\seealso{ -\code{\link[=measure]{measure()}} to define a measure directly, and \code{\link[=semantic_layer]{semantic_layer()}} to -collect the result into a layer. -} diff --git a/man/semantic_layer.Rd b/man/semantic_layer.Rd index d004524c..94f1f708 100644 --- a/man/semantic_layer.Rd +++ b/man/semantic_layer.Rd @@ -8,8 +8,7 @@ semantic_layer(...) } \arguments{ \item{...}{\code{\link[=measure]{measure()}} objects, lists of measures, or paths to R scripts or -directories. Paths are passed to \code{\link[=read_measures]{read_measures()}}, so file and inline -measures can be freely mixed.} +directories. File and inline measures can be freely mixed.} } \value{ A \code{commons_semantic_layer} object. @@ -30,6 +29,5 @@ semantic_layer( } \seealso{ -\code{\link[=measure]{measure()}} to define a measure, and \code{\link[=read_measures]{read_measures()}} to load -measures from documented R scripts. +\code{\link[=measure]{measure()}} to define a measure. }