---
title: "Getting started with commons"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Getting started with commons}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r}
#| include: false
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>",
  eval = rlang::is_installed(c("duckdb", "yaml"))
)
```

A commons agent combines one or more `data_source()` objects with a
`semantic_layer()` of governed calculations. For each question, the agent
searches the semantic layer first. If a measure matches, the agent runs a
calculation defined by your data team. Otherwise, it reads the data
documentation, inspects the relevant tables, and writes a SQL query.

```{r}
#| message: false
library(commons)
```

## Connecting a data source

A data source wraps a DBI connection. The agent queries the database directly,
without copying its data.

The examples use a small in-process DuckDB database.

```{r}
con <- DBI::dbConnect(duckdb::duckdb())

DBI::dbWriteTable(con, "orders", data.frame(
  order_id = 1:6,
  rep = c("Ada", "Ada", "Bo", "Cy", "Bo", "Ada"),
  region = c("EMEA", "Americas", "EMEA", "APAC", "Americas", "EMEA"),
  revenue = c(500, 900, 1200, 300, 2000, 750),
  refunded = c(0, 100, 0, 0, 0, 50)
))

DBI::dbWriteTable(con, "reps", data.frame(
  rep = c("Ada", "Bo", "Cy"),
  hired = as.Date(c("2021-03-01", "2023-07-15", "2024-01-20"))
))
```

By default, every table on the connection is listed in the system prompt:

```{r}
data_source(con)$tables
```

In production, list only the relevant tables and open a read-only connection
where the backend supports it:

```{r}
data_source(con, tables = c("orders", "reps"))$tables
```

`tables` also accepts schema-qualified names like `"analytics.orders"` or
`DBI::Id()` objects.

## Documenting the data

Table names rarely provide enough context. A data dictionary in the
[data-dict.yaml](https://data-dict.tidyverse.org/) format describes what each
table's rows represent, what its columns mean, how tables join, and what your
organization's domain terms mean.

```{r}
dictionary <- tempfile(fileext = ".yaml")
writeLines(
  '
name: Sales
description: One row per closed order, plus the reps who closed them.
details: >
  Revenue figures are gross. Net revenue subtracts the refunded column;
  always report net revenue unless asked otherwise.
tables:
  - name: orders
    description: Closed orders, one row each.
    columns:
      - name: revenue
        type: number
        units: USD
        description: Gross revenue for the order.
      - name: refunded
        type: number
        units: USD
        description: Amount refunded against the order.
      - name: region
        description: Sales region.
        values: [EMEA, Americas, APAC]
  - name: reps
    description: One row per sales representative.
relationships:
  - join: orders.rep = reps.rep
    cardinality: many-to-one
    description: Each order is credited to exactly one rep.
glossary:
  net revenue: Gross revenue minus refunds.
',
  dictionary
)

sales <- data_source(con, dictionary = dictionary)
```

commons uses the dictionary in three places:

* The system prompt includes data source-wide prose and the glossary.
* The first tool result that touches a table includes its full entry.
* `search_context` searches the dictionary's prose.

## Defining measures

A measure is an ordinary R function documented with roxygen comments and marked
with `@measure`. Documented arguments are supplied by the model; undocumented
arguments are hidden from it.

`commons()` supplies arguments omitted from `arguments`, and the model does not
see them. An argument named after a data source receives that source's
connection, so the measure need not depend on a variable defined elsewhere.

```{r}
measure_file <- tempfile(fileext = ".R")
writeLines(
  c(
    "#' Net Revenue by Region",
    "#'",
    "#' @param region `enum[EMEA, Americas, APAC]` Sales region.",
    "#' @measure",
    "net_revenue_by_region <- function(region, warehouse) {",
    "  DBI::dbGetQuery(",
    "    warehouse,",
    "    'SELECT sum(revenue - refunded) AS net_revenue FROM orders WHERE region = ?',",
    "    params = list(region)",
    "  )",
    "}"
  ),
  measure_file
)

layer <- semantic_layer(measure_file)
unlink(measure_file)
```

The `region` enum limits the model to values present in the data. `warehouse`
does not appear in the model's schema.

`semantic_layer()` reads the documented measures from the file into a layer.

## Building the agent

`commons()` takes a chat client that supplies the provider and model. It returns
an `ellmer::Chat` with commons' system prompt and tools.

The data source name `warehouse` connects it to the measure argument of the
same name.

```{r}
#| eval: false
agent <- commons(
  ellmer::chat_anthropic(),
  data_sources = list(warehouse = sales),
  semantic_layer = layer
)

agent$chat("What was net revenue in EMEA?")
#> Net revenue in EMEA was $2,400.
```

The agent finds `net_revenue_by_region` with
`search_measures("net revenue in EMEA")`, then calls it with `region = "EMEA"`.
The measure supplies the definition of net revenue.

Without a matching measure, the agent can run SQL against the connection:

```{r}
#| eval: false
agent$chat("Which rep was hired most recently?")
#> Cy, hired 2024-01-20.
```

Here the agent searches the data documentation, describes the `reps` table,
and runs a SQL query.

## The agent's tools

Every commons agent has five tools:

| Tool | What it does |
|:--|:--|
| `search_measures` | Finds measures matching a question, with their argument schemas |
| `call_measure` | Runs a measure |
| `search_context` | Searches your data documentation |
| `describe_table` | Returns a table's columns, types, and sample rows |
| `run_sql` | Runs a SQL query after checking its leading statement keyword |

`run_sql` checks the query's leading statement keyword against a denylist of
common data- and schema-modifying operations before passing it to the database.
The check is a keyword filter; database permissions remain the access-control
boundary. Open a read-only connection where possible, with access limited to
the tables the agent needs.

## Deploying in Shiny

`commons()` returns an `ellmer::Chat`, so it works with
[shinychat](https://posit-dev.github.io/shinychat/). Build a fresh agent for
each session so users do not share a conversation.

```{r}
#| eval: false
library(shiny)
library(shinychat)

ui <- bslib::page_fillable(chat_mod_ui("chat"))

server <- function(input, output, session) {
  agent <- commons(
    ellmer::chat_anthropic(),
    data_sources = list(warehouse = sales),
    semantic_layer = layer
  )
  chat_mod_server("chat", client = agent)
}

shinyApp(ui, server)
```

## Customizing the system prompt

The default prompt is a markdown file shipped with commons. To customize it,
copy the file into your project and interpolate the edited version:

```{r}
#| eval: false
file.copy(
  system.file("prompts/system-prompt.md", package = "commons"),
  "system-prompt.md"
)

commons(
  ellmer::chat_anthropic(),
  data_sources = list(warehouse = sales),
  semantic_layer = layer,
  system_prompt = ellmer::interpolate_file(
    "system-prompt.md",
    date = Sys.Date()
  )
)
```

commons appends the available tables and data dictionaries to the prompt. Omit
them from the file.

```{r}
#| include: false
DBI::dbDisconnect(con, shutdown = TRUE)
```
