27  ellmer

Published

2025-03-16

WARNING

This chapter is being developed. Thank you for your patience.

TLDR  

The ellmer package can streamline your Shiny app development process, offering access to a variety of LLMs, intuitive chat interface, system prompts, and more!

This chapter walks through converting a Shiny application into an app-package with ellmer, covering critical steps like modules, utility functions, and roxygen2 documentation.

Beginners and experienced Shiny developers will find ellmer helpful for building polished, more maintainable applications faster.

The ellmer package servers as the ‘backbone’ to using LLMs in R (we used it to set up the ensure package for writing unit tests in Section 16.1). The package has been thoughtfully developed, is easy to use, and has excellent documentation and examples. This chapter will briefly cover using ellmer’s chat tool in app-package development.

I’ve created the shinypak R package In an effort to make each section accessible and easy to follow:

Install shinypak using pak (or remotes):

# install.packages('pak')
pak::pak('mjfrigaard/shinypak')

Review the chapters in each section:

library(shinypak)
list_apps(regex = '^24')
## # A tibble: 1 × 2
##   branch        last_updated       
##   <chr>         <dttm>             
## 1 24_leprechaun 2024-09-03 14:24:11

Launch an app:

launch(app = "24_llm-ellmer")

27.1 Installing ellmer

Install ellmer like you would any other package:

install.packages('ellmer')
# or the dev version
pak::pak('tidyverse/ellmer')

27.1.1 LLM Provider

After installing the package, we need to set up an LLM model provider. ellmer provides 10+ LLM provider options, but for this example, I’ll use the OpenAI model, which requires an API key (OPENAI_API_KEY) which can be set in the .Renviron file.1

27.1.2 Using the chat

To start a chat, we can call ellmer::chat_openai() and specify the model we want to use.2 We should also be sure to record any system_prompts and the number of turns we’ll be using.

library(ellmer)
chat <- chat_openai(model = "o1-mini")
Using model = "o1-mini".

The chat object can be used directly in the console to submit prompts and get responses.

chat$chat("
  What are the benefits of developing my Shiny application in an R package?
  Answer in a markdown-formatted bulleted list.
")

- **Enhanced Code Organization**
  - Structure your application with a standardized file hierarchy, making the codebase 
easier to navigate and maintain.

- **Reusable Components**
  - Develop modular functions and components that can be easily reused across different
projects or parts of the application.

- **Dependency Management**
  - Clearly specify and manage package dependencies, ensuring that all necessary 
libraries are installed and up to date.

- **Simplified Testing**
  - Utilize built-in testing frameworks to implement unit tests and ensure the 
reliability of your application.

- **Comprehensive Documentation**
  - Provide detailed documentation for functions and features using tools like 
Roxygen2, improving understandability for users and contributors.

- **Namespace Control**
  - Avoid function name conflicts and manage the scope of your functions effectively 
through namespaces.

- **Ease of Distribution**
  - Share your Shiny application effortlessly by distributing it as an R package 
through platforms like CRAN or GitHub.

- **Version Control Integration**
  - Manage and track changes systematically with version control systems, facilitating 
collaboration and rollback capabilities.

- **Streamlined Deployment**
  - Leverage package tools for easier deployment to servers or Shiny hosting services, 
ensuring a smoother launch process.

- **Community Contributions**
  - Enable others to contribute to your application more easily by following standard 
package development practices, fostering community growth.

We can copy and paste the output into any Markdown document (vignettes, README, etc.).

27.1.3 Prompts

If we plan on having a conversation with an LLM, we should pick a location to store our prompts (and responses). The ellmer authors recommend storing these in inst/prompts, with a sensible naming convention.3

27.2 Movie explorer redux

We’re going to use ellmer to help us convert an existing Shiny app into a Shiny app-package. The application comes from the Shiny Examples GitHub repo. The README.md from the original app is available below:

Launch app with the shinypak package:

launch('24_llm-ellmer')
051-movie-explorer app

This is the source code for a movie explorer app which runs on R and Shiny. The data is a subset of data from OMDb, which in turn is from IMDb and Rotten Tomatoes. The data is saved in a SQLite database.

To run it locally, you’ll need to install the latest versions of ggvis, Shiny, and dplyr, as well as RSQLite.

install.packages(c('shiny', 'ggvis', 'dplyr', 'RSQLite'))

You may need to restart R to make sure the newly-installed packages work properly.

After all these packages are installed, you can run this app by entering the directory, and then running the following in R:

shiny::runApp()

The application files are also stored in the inst/51-movie-explorer folder of this branch:

inst/51-movies-explorer/
├── DESCRIPTION
├── README.md
├── global.R
├── movies.db
├── server.R
└── ui.R

1 directory, 6 files

27.2.1 System Prompt

We’ll start by passing a system_prompt to chat$chat() to provide some additional information on how the model should respond:

System prompts

show/hide system_prompt
library(ellmer)
chat <- chat_openai(model = "o1", 
    system_prompt = "
  You are an expert Shiny developer and R package author. Return responses in 
  markdown.
  
  Follow the tidyverse style guide:
  * Limit code to 80 characters per line   
  * File names should be machine readable (no spaces, symbols, or special 
    characters)   
  * Variables and function names should all be `snake_case` (i.e., lowercase 
    letters, numbers, and underscores to separate words within a name).   
  * Place a space before and after `=`   
  * Avoid empty lines at the start or end of functions   
  * Only use a single empty line when needed to separate functions or pipes   
  * Use verbs for function names   
  * If a function name and definition can’t fit on a single line, indent the 
    argument name with a single indent (i.e. two spaces).   
  * Comments should be in sentence case   
  * Spread long function calls across multiple lines    
  * Always use double quotes for strings   
  * Use double quotes, not single quotes, for quoting text   
  * Use base pipe `|>` (not `%>%`)    
  ")

I’ve modeled this system_prompt off the example in the ellmer documentation,4 but this can be used to specify expertise, coding style preferences (tidyverse/base R), etc.

27.2.2 Prompt 1: update

Prompt 1: The first prompt (stored in inst/prompts/prompt-01-refactor-mods-functions.md) will focus on creating modules, UI and server functions, and a standalone app function. We’ll provide the ui.R and server.R code, and some instructions for module and function names. It’s recommended to provide prompts using Markdown,5 and I’d used escape characters for the R code chunks.

Prompt text 1

Expand the code chunk below to view the first prompt.

show/hide prompt 1
chat$chat("
1. Convert the following Shiny application (stored in ui.R and server.R) to use
modules for inputs and outputs (i.e., `mod_graph_inputs_ui()` and 
`mod_graph_inputs_server()`, `mod_graph_output_ui()` and 
`mod_graph_output_server()`).

2. Convert the ggvis graph output to a plotly output.

3. Place the module functions in separate app UI and server functions (i.e., 
`db_app_ui()` and `db_app_server()`)

4. Create a standalone app function (`launch_db_app()`) that launches the app 
using: `shiny::shinyApp(ui = db_app_ui(), server = db_app_server)`

The app `ui.R` code: 

\`\`\`r
library(ggvis)

# For dropdown menu
actionLink <- function(inputId, ...) {
  tags$a(href='javascript:void',
         id=inputId,
         class='action-button',
         ...)
}

fluidPage(
  titlePanel('Movie explorer'),
  fluidRow(
    column(3,
      wellPanel(
        h4('Filter'),
        sliderInput('reviews', 'Minimum number of reviews on Rotten Tomatoes',
          10, 300, 80, step = 10),
        sliderInput('year', 'Year released', 1940, 2014, value = c(1970, 2014),
          sep = ''),
        sliderInput('oscars', 'Minimum number of Oscar wins (all categories)',
          0, 4, 0, step = 1),
        sliderInput('boxoffice', 'Dollars at Box Office (millions)',
          0, 800, c(0, 800), step = 1),
        selectInput('genre', 'Genre (a movie can have multiple genres)',
          c('All', 'Action', 'Adventure', 'Animation', 'Biography', 'Comedy',
            'Crime', 'Documentary', 'Drama', 'Family', 'Fantasy', 'History',
            'Horror', 'Music', 'Musical', 'Mystery', 'Romance', 'Sci-Fi',
            'Short', 'Sport', 'Thriller', 'War', 'Western')
        ),
        textInput('director', 'Director name contains (e.g., Miyazaki)'),
        textInput('cast', 'Cast names contains (e.g. Tom Hanks)')
      ),
      wellPanel(
        selectInput('xvar', 'X-axis variable', axis_vars, selected = 'Meter'),
        selectInput('yvar', 'Y-axis variable', axis_vars, selected = 'Reviews'),
        tags$small(paste0(
          'Note: The Tomato Meter is the proportion of positive reviews',
          ' (as judged by the Rotten Tomatoes staff), and the Numeric rating is',
          ' a normalized 1-10 score of those reviews which have star ratings',
          ' (for example, 3 out of 4 stars).'
        ))
      )
    ),
    column(9,
      ggvisOutput('plot1'),
      wellPanel(
        span('Number of movies selected:',
          textOutput('n_movies')
        )
      )
    )
  )
)
\`\`\`

The app `server.R` code:

\`\`\`r
library(ggvis)
library(dplyr)
if (FALSE) {
  library(RSQLite)
  library(dbplyr)
}

# Set up handles to database tables on app start
db <- src_sqlite('movies.db')
omdb <- tbl(db, 'omdb')
tomatoes <- tbl(db, 'tomatoes')

# Join tables, filtering out those with <10 reviews, and select specified columns
all_movies <- inner_join(omdb, tomatoes, by = 'ID') %>%
  filter(Reviews >= 10) %>%
  select(ID, imdbID, Title, Year, Rating_m = Rating.x, Runtime, Genre, Released,
    Director, Writer, imdbRating, imdbVotes, Language, Country, Oscars,
    Rating = Rating.y, Meter, Reviews, Fresh, Rotten, userMeter, userRating, 
    userReviews, BoxOffice, Production, Cast)


function(input, output, session) {

  # Filter the movies, returning a data frame
  movies <- reactive({
    # Due to dplyr issue #318, we need temp variables for input values
    reviews <- input$reviews
    oscars <- input$oscars
    minyear <- input$year[1]
    maxyear <- input$year[2]
    minboxoffice <- input$boxoffice[1] * 1e6
    maxboxoffice <- input$boxoffice[2] * 1e6

    # Apply filters
    m <- all_movies %>%
      filter(
        Reviews >= reviews,
        Oscars >= oscars,
        Year >= minyear,
        Year <= maxyear,
        BoxOffice >= minboxoffice,
        BoxOffice <= maxboxoffice
      ) %>%
      arrange(Oscars)

    # Optional: filter by genre
    if (input$genre != 'All') {
      genre <- paste0('%', input$genre, '%')
      m <- m %>% filter(Genre %like% genre)
    }
    # Optional: filter by director
    if (!is.null(input$director) && input$director != '') {
      director <- paste0('%', input$director, '%')
      m <- m %>% filter(Director %like% director)
    }
    # Optional: filter by cast member
    if (!is.null(input$cast) && input$cast != '') {
      cast <- paste0('%', input$cast, '%')
      m <- m %>% filter(Cast %like% cast)
    }

    m <- as.data.frame(m)

    # Add column which says whether the movie won any Oscars
    # Be a little careful in case we have a zero-row data frame
    m$has_oscar <- character(nrow(m))
    m$has_oscar[m$Oscars == 0] <- 'No'
    m$has_oscar[m$Oscars >= 1] <- 'Yes'
    m
  })

  # Function for generating tooltip text
  movie_tooltip <- function(x) {
    if (is.null(x)) return(NULL)
    if (is.null(x$ID)) return(NULL)

    # Pick out the movie with this ID
    all_movies <- isolate(movies())
    movie <- all_movies[all_movies$ID == x$ID, ]

    paste0('<b>', movie$Title, '</b><br>',
      movie$Year, '<br>',
      '$', format(movie$BoxOffice, big.mark = ',', scientific = FALSE)
    )
  }

  # A reactive expression with the ggvis plot
  vis <- reactive({
    # Lables for axes
    xvar_name <- names(axis_vars)[axis_vars == input$xvar]
    yvar_name <- names(axis_vars)[axis_vars == input$yvar]

    # Normally we could do something like props(x = ~BoxOffice, y = ~Reviews),
    # but since the inputs are strings, we need to do a little more work.
    xvar <- prop('x', as.symbol(input$xvar))
    yvar <- prop('y', as.symbol(input$yvar))

    movies %>%
      ggvis(x = xvar, y = yvar) %>%
      layer_points(size := 50, size.hover := 200,
        fillOpacity := 0.2, fillOpacity.hover := 0.5,
        stroke = ~has_oscar, key := ~ID) %>%
      add_tooltip(movie_tooltip, 'hover') %>%
      add_axis('x', title = xvar_name) %>%
      add_axis('y', title = yvar_name) %>%
      add_legend('stroke', title = 'Won Oscar', values = c('Yes', 'No')) %>%
      scale_nominal('stroke', domain = c('Yes', 'No'),
        range = c('orange', '#aaa')) %>%
      set_options(width = 500, height = 500)
  })

  vis %>% bind_shiny('plot1')

  output$n_movies <- renderText({ nrow(movies()) })
}
\`\`\`

")

27.2.2.1 Response 1: update

The response is generated in the Console:

ellmer chat response

ellmer chat response

We can see the response contains the modules and functions in separate code chunks. I’ve organized them into the callout box below:

Response text 1

show/hide mod_graph_inputs.R
# mod_graph_inputs.R
mod_graph_inputs_ui <- function(id) {
  ns <- shiny::NS(id)
  shiny::tagList(
    shiny::wellPanel(
      shiny::h4("Filter"),
      shiny::sliderInput(
        inputId = ns("reviews"),
        label = "Minimum number of reviews on Rotten Tomatoes",
        min = 10,
        max = 300,
        value = 80,
        step = 10
      ),
      shiny::sliderInput(
        inputId = ns("year"),
        label = "Year released",
        min = 1940,
        max = 2014,
        value = c(1970, 2014),
        sep = ""
      ),
      shiny::sliderInput(
        inputId = ns("oscars"),
        label = "Minimum number of Oscar wins (all categories)",
        min = 0,
        max = 4,
        value = 0,
        step = 1
      ),
      shiny::sliderInput(
        inputId = ns("boxoffice"),
        label = "Dollars at Box Office (millions)",
        min = 0,
        max = 800,
        value = c(0, 800),
        step = 1
      ),
      shiny::selectInput(
        inputId = ns("genre"),
        label = "Genre (a movie can have multiple genres)",
        choices = c(
          "All", "Action", "Adventure", "Animation", "Biography", "Comedy",
          "Crime", "Documentary", "Drama", "Family", "Fantasy", "History",
          "Horror", "Music", "Musical", "Mystery", "Romance", "Sci-Fi",
          "Short", "Sport", "Thriller", "War", "Western"
        ),
        selected = "All"
      ),
      shiny::textInput(
        inputId = ns("director"),
        label = "Director name contains (e.g., Miyazaki)"
      ),
      shiny::textInput(
        inputId = ns("cast"),
        label = "Cast names contains (e.g. Tom Hanks)"
      )
    ),
    shiny::wellPanel(
      shiny::selectInput(
        inputId = ns("xvar"),
        label = "X-axis variable",
        choices = axis_vars,
        selected = "Meter"
      ),
      shiny::selectInput(
        inputId = ns("yvar"),
        label = "Y-axis variable",
        choices = axis_vars,
        selected = "Reviews"
      ),
      shiny::tags$small(
        shiny::p(
          paste0(
            "Note: The Tomato Meter is the proportion of positive reviews ",
            "(as judged by the Rotten Tomatoes staff), and the Numeric rating ",
            "is a normalized 1-10 score of those reviews which have star ",
            "ratings (for example, 3 out of 4 stars)."
          )
        )
      )
    )
  )
}

mod_graph_inputs_server <- function(id, all_movies) {
  shiny::moduleServer(
    id,
    function(input, output, session) {
      # Filter movies based on user inputs
      movies <- shiny::reactive({
        reviews <- input$reviews
        oscars <- input$oscars
        minyear <- input$year[1]
        maxyear <- input$year[2]
        minboxoffice <- input$boxoffice[1] * 1e6
        maxboxoffice <- input$boxoffice[2] * 1e6
        
        m <- all_movies |>
          dplyr::filter(
            Reviews >= reviews,
            Oscars >= oscars,
            Year >= minyear,
            Year <= maxyear,
            BoxOffice >= minboxoffice,
            BoxOffice <= maxboxoffice
          ) |>
          dplyr::arrange(Oscars)
        
        if (input$genre != "All") {
          genre <- paste0("%", input$genre, "%")
          m <- m |>
            dplyr::filter(Genre %like% genre)
        }
        
        if (!is.null(input$director) && input$director != "") {
          director <- paste0("%", input$director, "%")
          m <- m |>
            dplyr::filter(Director %like% director)
        }
        
        if (!is.null(input$cast) && input$cast != "") {
          cast <- paste0("%", input$cast, "%")
          m <- m |>
            dplyr::filter(Cast %like% cast)
        }
        
        m <- as.data.frame(m)
        m$has_oscar <- character(nrow(m))
        m$has_oscar[m$Oscars == 0] <- "No"
        m$has_oscar[m$Oscars >= 1] <- "Yes"
        m
      })
      
      # Return a reactive list giving the xvar, yvar, and filtered data
      shiny::reactive({
        list(
          xvar = input$xvar,
          yvar = input$yvar,
          data = movies()
        )
      })
    }
  )
}
show/hide mod_graph_output.R
# mod_graph_output.R
mod_graph_output_ui <- function(id) {
  ns <- shiny::NS(id)
  shiny::tagList(
    plotly::plotlyOutput(outputId = ns("plot1")),
    shiny::wellPanel(
      shiny::span(
        "Number of movies selected:",
        shiny::textOutput(ns("n_movies"))
      )
    )
  )
}

mod_graph_output_server <- function(id, inputs_list) {
  shiny::moduleServer(
    id,
    function(input, output, session) {
      
      # Render a plotly chart
      output$plot1 <- plotly::renderPlotly({
        xvar <- inputs_list()$xvar
        yvar <- inputs_list()$yvar
        movie_data <- inputs_list()$data
        
        # Axis label from named vector
        xvar_label <- names(axis_vars)[axis_vars == xvar]
        yvar_label <- names(axis_vars)[axis_vars == yvar]
        
        if (is.null(movie_data) || nrow(movie_data) == 0) {
          return(NULL)
        }
        
        # Construct the tooltip
        tooltip_text <- paste0(
          "<b>", movie_data$Title, "</b><br>",
          movie_data$Year, "<br>$",
          format(movie_data$BoxOffice, big.mark = ",", scientific = FALSE)
        )
        
        plotly::plot_ly(
          data = movie_data,
          x = ~.data[[xvar]],
          y = ~.data[[yvar]],
          text = tooltip_text,
          hoverinfo = "text",
          color = ~factor(has_oscar, levels = c("Yes", "No")),
          colors = c("orange", "#aaa"),
          type = "scatter",
          mode = "markers"
        ) |>
          plotly::layout(
            xaxis = list(title = xvar_label),
            yaxis = list(title = yvar_label),
            showlegend = TRUE
          )
      })
      
      output$n_movies <- shiny::renderText({
        nrow(inputs_list()$data)
      })
    }
  )
}
show/hide db_app_ui.R
# db_app_ui.R
db_app_ui <- function() {
  shiny::fluidPage(
    shiny::titlePanel("Movie explorer"),
    shiny::fluidRow(
      shiny::column(
        width = 3,
        mod_graph_inputs_ui("graph_inputs")
      ),
      shiny::column(
        width = 9,
        mod_graph_output_ui("graph_outputs")
      )
    )
  )
}
show/hide db_app_server.R
# db_app_server.R
db_app_server <- function(input, output, session) {
  
  # Set up handles to database tables on app start
  db <- dplyr::src_sqlite("movies.db")
  omdb <- dplyr::tbl(db, "omdb")
  tomatoes <- dplyr::tbl(db, "tomatoes")
  
  # Join tables, filter out those with <10 reviews, and select
  all_movies <- dplyr::inner_join(omdb, tomatoes, by = "ID") |>
    dplyr::filter(Reviews >= 10) |>
    dplyr::select(
      ID, imdbID, Title, Year, Rating_m = Rating.x, Runtime, Genre,
      Released, Director, Writer, imdbRating, imdbVotes, Language,
      Country, Oscars, Rating = Rating.y, Meter, Reviews, Fresh, Rotten,
      userMeter, userRating, userReviews, BoxOffice, Production, Cast
    )
  
  # Call module for inputs
  inputs_reactive <- mod_graph_inputs_server(
    id = "graph_inputs",
    all_movies = all_movies
  )
  
  # Call module for outputs
  mod_graph_output_server(
    id = "graph_outputs",
    inputs_list = inputs_reactive
  )
}
show/hide global.R
# A named vector of possible axis variables
axis_vars <- c(
  "Meter" = "Meter",
  "Reviews" = "Reviews",
  "Box office" = "BoxOffice",
  "IMDB rating" = "imdbRating",
  "IMDB votes" = "imdbVotes",
  "User meter" = "userMeter",
  "User rating" = "userRating"
)
show/hide launch_db_app.R
# Standalone app (im app.R)
launch_db_app <- function() {
  shiny::shinyApp(
    ui = db_app_ui(),
    server = db_app_server
  )
}

Explanation of major changes:

• The ggvis plot and bindings have been replaced with a plotly plot.
• The UI for the plot is now plotlyOutput rather than ggvisOutput.
• The “movie_tooltip” logic is approximated through Plotly’s text and hoverinfo = 'text'.
• Modules (mod_graph_inputs_) handle filtering inputs, and modules (mod_graph_output_) handle the plotly plot output plus the count of displayed movies.
db_app_ui() and db_app_server() assemble everything, and launch_db_app() calls shiny::shinyApp() with those functions.

We’ll create an app folder in inst/db_app, then structure the files like a ‘mini’ R package:

inst/db_app/
├── R
│   ├── db_app_server.R
│   ├── db_app_ui.R
│   ├── global.R
│   ├── mod_graph_inputs.R
│   └── mod_graph_output.R
├── app.R
└── movies.db

2 directories, 7 files

After organizing the files into the structure above, we can run the application using the launch_db_app() function (stored in app.R).

Initial application from ellmer

Initial application from ellmer

The application launches and we can see the plotly scatter plot renders successfully. The inputs in the sidebar are somewhat cramped and out of view, but we can adjust this with future prompts (or by manually editing the UI components).

27.2.3 Prompt 2: refactor

Prompt 2: The second prompt will focus on creating utility functions for the database connection and joining/wrangling the data. I’ve stored the instructions in inst/prompts/prompt-02-utility-functions.md (we’ll use the same system_prompt).

The goal with this prompt is to create testable utility functions and address the dplyr::src_sqlite() warning we’re getting from our database connection:

Warning: `src_sqlite()` was deprecated in dplyr 1.0.0.
ℹ Please use `tbl()` directly with a database connection
Prompt text 2

show/hide prompt 2
chat$chat("
Convert the following code into two utility functions for 1) establishing a 
connection to the `movies.db` database, and 2) joining the omdb and tomatoes
tables, filtering to `Reviews >= 10`, and selecting the necessary columns.

\`\`\`r
db_con.R 
# Set up handles to database tables on app start
db <- dplyr::src_sqlite(path = 'movies.db')
omdb <- dplyr::tbl(db, 'omdb')
tomatoes <- dplyr::tbl(db, 'tomatoes')
\`\`\`


\`\`\`r
# Join tables, filter out those with <10 reviews, and select
all_movies <- dplyr::inner_join(omdb, tomatoes, by = 'ID') |>
dplyr::filter(Reviews >= 10) |>
dplyr::select(
    ID, imdbID, Title, Year, Rating_m = Rating.x, Runtime, Genre,
    Released, Director, Writer, imdbRating, imdbVotes, Language,
    Country, Oscars, Rating = Rating.y, Meter, Reviews, Fresh, Rotten,
    userMeter, userRating, userReviews, BoxOffice, Production, Cast
)
\`\`\`

")

27.2.3.1 Response 2: refactor

The response includes two new functions: connect_database() and get_all_movies()

Response text 2

show/hide connect_database.R
# connect_database.R
connect_database <- function(db_path = "movies.db") {
  # Create a handle to the movies database
  db <- dplyr::src_sqlite(path = db_path)
  db
}
show/hide get_all_movies.R
# get_all_movies.R
get_all_movies <- function(db) {
  # Join tables, filter out those with <10 reviews, and select columns
  omdb <- dplyr::tbl(db, "omdb")
  tomatoes <- dplyr::tbl(db, "tomatoes")
  
  all_movies <- dplyr::inner_join(
    x = omdb,
    y = tomatoes,
    by = "ID"
  ) |>
    dplyr::filter(Reviews >= 10) |>
    dplyr::select(
      ID, imdbID, Title, Year, Rating_m = Rating.x, Runtime, Genre,
      Released, Director, Writer, imdbRating, imdbVotes, Language,
      Country, Oscars, Rating = Rating.y, Meter, Reviews,
      Fresh, Rotten, userMeter, userRating, userReviews, BoxOffice,
      Production, Cast
    )
  
  all_movies
}

Explanation

connect_database() is a simple function that uses dplyr::src_sqlite() to connect to the movies.db SQLite database, returning the database connection object.

get_all_movies() pulls the omdb and tomatoes tables, joins them, filters for Reviews ≥ 10, and selects the required columns.

connect_database() still uses dplyr::src_sqlite(), so we’ll rewrite this function to address the warning, move the movies.db file into the inst/extdata6 folder, and access the file with system.file():

connect_database <- function(pkg, db) {
  # Create connection to movies database
  con <- DBI::dbConnect(RSQLite::SQLite(),
              system.file("extdata", db, package = pkg))
  con
}

We’ll also need to update the db_app_server() function to use the new utility functions:

db_app_server <- function(input, output, session) {
  
  # Set up connection to database tables on app start
  db <- connect_database(pkg = "sap", db = "movies.db")
  
  # Join tables, filter out those with <10 reviews, and select
  all_movies <- get_all_movies(db = db)
  
  # Call module for inputs
  inputs_reactive <- mod_graph_inputs_server(
    id = "graph_inputs",
    all_movies = all_movies
  )
  
  # Call module for outputs
  mod_graph_output_server(
    id = "graph_outputs",
    inputs_list = inputs_reactive
  )
}
1
Replaces the dplyr::src_sqlite() and dplyr::tbl() calls.
2
Replaces the dplyr::inner_join(), dplyr::filter(), and dplyr::select() functions.

To implement these changes, we’ll need to add the new function files to the inst/db_app/R folder:

inst/db_app/
├── R
│   ├── connect_database.R
│   ├── db_app_server.R
│   ├── db_app_ui.R
│   ├── get_all_movies.R
│   ├── global.R
│   ├── mod_graph_inputs.R
│   └── mod_graph_output.R
└── app.R

2 directories, 8 files

This results in the following application:

Updated inst/db_app app

Updated inst/db_app app

We can see the dplyr::src_sqlite() warning is gone and the data is correctly loaded in the application.

27.2.4 Prompt 3: overhaul

Prompt 3: We’ll get even more detailed in the third prompt (stored in inst/prompts/prompt-03-app-ast-design.md). In these instructions, we’ll include an abstract syntax tree (courtesy of lobstr::ast()) with proposed function names and descriptions:

█─launch_app 
├─█─db_app_ui 
│ ├─█─mod_select_input_ui 
│ ├─█─mod_slider_input_ui 
│ ├─█─mod_text_input_ui 
│ ├─█─mod_points_plotly_ui 
│ └─█─mod_table_ui 
└─█─db_app_server 
  ├─█─db_con 
  │ └─█─join_movies_tbls 
  ├─█─mod_slider_input_server 
  ├─█─mod_select_input_server 
  ├─█─mod_text_input_server 
  ├─█─mod_table_server 
  └─█─mod_points_plotly_server 
    └─█─scatter_plotly 

I’ve found this practice is helpful when writing prompts that request nested functions (like modules). We’ll also instruct the model to return the functions with roxygen2 documentation.

Prompt text 3

Expand the code chunk below to view the entire prompt.

show/hide prompt 3
chat$chat("
Restructure the application to use the following modules and functions: 

* `db_app_ui()`: main app UI function

* `db_app_server()`: main server function 

* `mod_select_input_ui()`/`mod_slider_input_server()`: select inputs shiny 
  module 

* `mod_slider_input_ui()`/`mod_slider_input_server()`: slider inputs shiny 
  module 

* `mod_text_input_ui()`/`mod_text_input_server()`: text input shiny module 

* `mod_points_plotly_ui()`/`mod_points_plotly_server()`: scatter-plot 
  (plotly) shiny module (UI)

* `mod_table_ui()`/`mod_table_server()`: reactable table shiny module (UI) 
  that displays the data in the scatter-plot.

* `db_con()`: database connection to `movies.db` (using `DBI::dbConnect()`,
  `RSQLite::SQLite()` and `system.file()`)

* `join_movies_tbls()`: joins the `omdb` and `tomatoes` tables from an SQLite
  database connection, filters out movies with fewer than 5 reviews, and 
  selects relevant columns.

* `scatter_plotly()`: generates an interactive Plotly scatter plot using a 
reactive dataset. The user specifies the x-axis, y-axis, and a categorical 
variable for coloring the points.

An abstract syntax tree for the functions is below for reference:

\`\`\`
█─launch_db_app 
├─█─db_app_ui 
│ ├─█─mod_select_input_ui 
│ ├─█─mod_slider_input_ui 
│ ├─█─mod_text_input_ui 
│ ├─█─mod_points_plotly_ui 
│ └─█─mod_table_ui 
└─█─db_app_server 
  ├─█─db_con 
  │ └─█─join_movies_tbls 
  ├─█─mod_slider_input_server 
  ├─█─mod_select_input_server 
  ├─█─mod_text_input_server 
  ├─█─mod_table_server 
  └─█─mod_points_plotly_server 
    └─█─scatter_plotly 
\`\`\`

Include roxygen2 documentation for each function.
")

27.2.4.1 Response 3: overhaul

View the results in the callout box below (or in the Response section on GitHub).

Response text 3

Expand the code chunks below to view the entire response.

Below is an example folder structure and skeleton code that follows your specifications. Each function has roxygen2 documentation, and the code follows the tidyverse style guide conventions (80-character line limit, snake_case naming, clear function naming, etc.). Adjust the code to meet your specific data and logic requirements.

show/hide R/launch_db_app.R
#' Launch DB app
#'
#' This function launches the Shiny application for exploring the
#' "movies.db" dataset.
#'
#' @return A Shiny app object.
#' @export
launch_db_app <- function() {
  shiny::shinyApp(
    ui    = db_app_ui(),
    server = db_app_server()
  )
}
show/hide R/db_app_ui.R
#' Define the User Interface for the Shiny Application
#'
#' This function constructs the UI layout of the Shiny application, including
#' input filters, slider controls, and movie selection elements.
#'
#' @return A `shiny.tag.list` object representing the application UI.
#'
#' @export
#'
db_app_ui <- function() {
  tagList(
    bslib::page_navbar(
      title = "Movies Explorer",
      id = "nav",
      sidebar = bslib::sidebar(
        conditionalPanel(
          "input.nav === 'Filters'",
          h4("Filters"),
          mod_select_input_ui("selects"),
          mod_slider_input_ui("sliders")
        ),
        conditionalPanel(
          "input.nav === 'Director and Cast'",
          h4("Director and Cast"),
          mod_text_input_ui("text")
        )
      ),
      bslib::nav_panel("Filters",
        bslib::card(
          mod_points_plotly_ui('plotly'),
          bslib::card_footer(
              em(
                paste0(
                  "Note: The Tomato Meter is the proportion of positive reviews",
                  " (as judged by the Rotten Tomatoes staff), and the Numeric rating is",
                  " a normalized 1-10 score of those reviews which have star ratings",
                  " (for example, 3 out of 4 stars)."
                )
              )
          )
        )
      ),
      bslib::nav_panel("Director and Cast",
        bslib::card(
          mod_table_display_ui("filters"),
          bslib::card_header("Movies selected:"),
          verbatimTextOutput("vals")
        )
      )
    )
  )
}
show/hide R/db_app_server.R
#' Define the Server Logic for the Shiny Application
#'
#' This function sets up server-side logic, including reactive movie data,
#' input modules, and filtering operations. It initializes reactive expressions
#' for user input and manages interactions between different UI components.
#'
#' @param input,output,session Standard Shiny server function arguments.
#'
#' @return A `shiny.server` function handling user inputs and outputs.
#' 
#' @export
#'
db_app_server <- function(input, output, session, .dev = FALSE) {

  all_movies <- reactive({
    con <- db_con("sap", "movies.db")
    all_movies <- join_movies_tbls(con = con)
    return(all_movies)
    DBI::dbDisconnect(con) # close connection
  })

    nums <- mod_slider_input_server("sliders")

    vars <- mod_select_input_server('selects')

    txts <- mod_text_input_server('text')

    filtered_movies <- mod_table_display_server(
      id = "filters",
      movies = all_movies,
      var = vars,
      num = nums,
      txt = txts)

    mod_points_plotly_server(
      id = 'plotly',
      data = filtered_movies,
      vars = vars)

    if (.dev) {
      output$vals <- renderPrint({
        vals <- reactiveValuesToList(x = input, all.names = TRUE)
        print(str(vals))
      })
    }


}
show/hide R/mod_select_input.R
#' select inputs shiny module (UI)
#'
#' @param id shiny module id
#'
#' @export
#' 
mod_select_input_ui <- function(id) {

    # Variables that can be put on the x and y axes
    axis_vars <- c(
      "Tomato Meter" = "meter",
      "Numeric Rating (OMDB)" = "omdb_ratings",
      "Numeric Rating (RT)" = "rt_ratings",
      "Number of reviews" = "reviews",
      "Dollars at box office" = "box_office",
      "Year" = "year",
      "Length (minutes)" = "runtime"
    )
    ns <- shiny::NS(id)
        shiny::tagList(
        selectInput(
          inputId = ns("genre"),
          label = strong("Genre*"),
          c("All",
            "Action",
            "Adventure",
            "Animation",
            "Biography",
            "Comedy",
            "Crime",
            "Documentary",
            "Drama",
            "Family",
            "Fantasy",
            "History",
            "Horror",
            "Music",
            "Musical",
            "Mystery",
            "Romance",
            "Sci-Fi",
            "Short",
            "Sport",
            "Thriller",
            "War",
            "Western")),
        tags$small(em("*a movie can have multiple genres")),
        selectInput(inputId = ns("xvar"),
          label = strong("X-axis variable"),
          axis_vars,
          selected = "meter"),
        selectInput(inputId = ns("yvar"),
          label = strong("Y-axis variable"),
          choices = axis_vars,
          selected = "runtime")
        )
    }
#' select inputs shiny module (server)
#'
#' @param id shiny module id
#'
#' @export
#' 
mod_select_input_server <- function(id) {
    shiny::moduleServer(id, function(input, output, session) {
        ns <- session$ns
      return(
        reactive(
          list(
          'yvar' = input$yvar,
          'xvar' = input$xvar,
          'genre' = input$genre
          )
        )
      )

    })
}
show/hide R/mod_slider_input.R
#' slider inputs shiny module (UI)
#'
#' @param id shiny module id
#'
#' @export
#' 
mod_slider_input_ui <- function(id) {
    ns <- shiny::NS(id)
        shiny::tagList(
        sliderInput(
          inputId = ns("reviews"),
          label = strong("Number of Rotten Tomatoes reviews"),
          min = 20,
          max = 300,
          value = 100,
          step = 20
        ),
        sliderInput(
          inputId = ns("year"),
          label = strong("Release year"),
          min = 1940,
          max = 2014,
          value = c(1970, 2014),
          sep = ""
        ),
        sliderInput(
          inputId = ns("oscars"),
          label = strong("Number of Oscar wins"),
          min = 0,
          max = 4,
          value = 0,
          step = 1
        ),
        sliderInput(
          inputId = ns("boxoffice"),
          label = strong("Dollars at Box Office*"),
          min = 0,
          max = 800,
          value = c(0, 500),
          step = 1
        ),
        tags$small(
          em('*In millions')
          )
      )
    }
#' slider inputs shiny module (server)
#'
#' @param id shiny module id
#'
#' @export
#' 
mod_slider_input_server <- function(id) {
    shiny::moduleServer(id, function(input, output, session) {
        ns <- session$ns

      # Due to dplyr issue #318, we need temp variables for input values
      return(
        reactive(
          list(
          'reviews' = input$reviews,
          'oscars' = input$oscars,
          'minyear' = input$year[1],
          'maxyear' = input$year[2],
          'minboxoffice' = input$boxoffice[1] * 1e6,
          'maxboxoffice' = input$boxoffice[2] * 1e6
          )
        )
      )

    })
}
show/hide R/mod_text_input.R
#' text input shiny module (UI)
#'
#' @param id shiny module id
#'
#' @export
#' 
mod_text_input_ui <- function(id) {
  ns <- shiny::NS(id)
  shiny::tagList(
    textInput(
      inputId = ns("director"),
      label = "Director name contains (e.g., Miyazaki)"),
    textInput(
      inputId = ns("cast"),
      label = "Cast names contains (e.g. Tom Hanks)")
    )
}
#' text input shiny module (server)
#'
#' @param id shiny module id
#'
#' @export
#' 
mod_text_input_server <- function(id) {
  shiny::moduleServer(id, function(input, output, session) {
    ns <- session$ns
      return(
        reactive(
          list(
          'director' = input$director,
          'cast' = input$cast
          )
        )
      )
  })
}
show/hide R/mod_points_plotly.R
#' Scatter-plot (plotly) shiny module (UI)
#'
#' @param id shiny module id
#'
#' @export
#' 
mod_points_plotly_ui <- function(id) {
  ns <- shiny::NS(id)
    shiny::tagList(
      plotly::plotlyOutput(ns("graph"))
    )
  }
#' Scatter-plot (plotly) shiny module (server)
#'
#' @param id shiny module id
#' 
#' @export
#' 
mod_points_plotly_server <- function(id, data, vars) {
  shiny::moduleServer(id, function(input, output, session) {
    ns <- session$ns

      output$graph <- plotly::renderPlotly({
         xvar <- vars()[['xvar']]
         yvar <- vars()[['yvar']]
         df <- data()[[1]]
        scatter_plotly(
          data = df,
          xvar = xvar,
          yvar = yvar,
          colvar =  'has_oscar')
      })

  })
}
show/hide R/mod_table.R
#' Reactable table shiny module (UI)
#'
#' @param id shiny module id
#'
#' @export
#' 
mod_table_display_ui <- function(id) {
  ns <- shiny::NS(id)
  shiny::tagList(
    reactable::reactableOutput(ns("tbl"))
  )
}
#' Reactable table shiny module (server)
#'
#' @param id shiny module id
#' @param movies movies connection
#' @param num slider inputs
#' @param var variable inputs
#' @param txt text inputs
#'
#' @export
#' 
mod_table_display_server <- function(id, movies, num, var, txt) {
  shiny::moduleServer(id, function(input, output, session) {
    ns <- session$ns

    # observe({
    #   browser()

    filtered_nums <- reactive({
      dplyr::filter(local(movies()),
        reviews >= !!num()[["reviews"]],
        oscars >= !!num()[["oscars"]],
        year >= !!num()[["minyear"]],
        year <= !!num()[["maxyear"]],
        box_office >= !!num()[["minboxoffice"]],
        box_office <= !!num()[["maxboxoffice"]]
      ) |>
        dplyr::arrange(oscars) |>
        dplyr::collect()
    }) |>
      # update when slider inputs change
      bindEvent(movies(), num())

    filtered_vars <- reactive({
      # Optional: filters
      if (!!var()[["genre"]] != "All") {
        filter_regex <- paste0(var()[["genre"]], "|", var()[["genre"]], ",")
        filtered <- dplyr::filter(filtered_nums(),
                          stringr::str_detect(string = genre, filter_regex))
      } else {
        filtered <- filtered_nums()
      }
      return(filtered)
    }) |>
      # update when slider or select inputs change
      bindEvent(movies(), num(), var())

    filtered_txts <- reactive({
      # first condition on both text inputs being filled
      if (!is.null(txt()[["cast"]]) && !!txt()[["cast"]] != "" & !is.null(txt()[["director"]]) && !!txt()[["director"]] != "") {
        dir_regex <- txt()[["director"]]
        cast_regex <- txt()[["cast"]]
        filtered <- dplyr::filter(filtered_vars(),
                  stringr::str_detect(string = cast, cast_regex),
                  stringr::str_detect(string = director, dir_regex))
      # now only director
      } else if (!is.null(txt()[["director"]]) && !!txt()[["director"]] != "") {
        filter_regex <- txt()[["director"]]
        filtered <- dplyr::filter(filtered_vars(),
                          stringr::str_detect(string = director, filter_regex))
        # only cast
      } else if (!is.null(txt()[["cast"]]) && !!txt()[["cast"]] != "") {
        filter_regex <- txt()[["cast"]]
        filtered <- dplyr::filter(filtered_vars(),
                          stringr::str_detect(string = cast, filter_regex))
        # neither
      } else {
        filtered <- filtered_vars()
      }
        # Add column which says whether the movie won any Oscars
        # Be a little careful in case we have a zero-row data frame
        filtered$has_oscar <- character(nrow(filtered))
        filtered$has_oscar[filtered$oscars == 0] <- "No"
        filtered$has_oscar[filtered$oscars >= 1] <- "Yes"
        return(filtered)
    }) |>
      # update when slider, select, or text inputs change
      bindEvent(movies(), num(), var(), txt())

    observe({
      output$tbl <- reactable::renderReactable({

        reactable::reactable(
          dplyr::select(
            filtered_txts(),
            title, genre, reviews, oscars, has_oscar,
            year, box_office, director, cast,
            # include text values for x and y
            dplyr::all_of(c(var()[["yvar"]], var()[["xvar"]]))
            )
          )
      })
    }) |>
      bindEvent(movies(), num(), var(), txt())

    return(
      reactive(
        list(
          # return with text values for x and y
          dplyr::select(
            filtered_txts(),
            title, genre, reviews, oscars, has_oscar,
            year, box_office, director, cast,
            dplyr::all_of(c(var()[["yvar"]], var()[["xvar"]]))
          )
        )
      )
    )

  })
}
show/hide R/db_con.R
#' Establish a Database Connection
#'
#' This function connects to an SQLite database within an R package's
#'  `extdata` directory.
#'
#' @param pkg A character string specifying the name of the R package where
#' the database file is located.
#' @param db A character string specifying the name of the SQLite database
#' file (e.g., `"movies.db"`).
#'
#' @return A `DBIConnection` object representing the connection to the database.
#'
#' @export
#'
db_con <- function(pkg, db) {
  # Set up handles to database tables on app start
  con <- DBI::dbConnect(RSQLite::SQLite(),
                      system.file("extdata", db, package = pkg))
}
show/hide R/join_movies_tbls.R
#' Join and Filter Movie Tables
#'
#' This function joins the `omdb` and `tomatoes` tables from an SQLite database
#' connection, filters out movies with fewer than 5 reviews, and selects
#' relevant columns.
#'
#' @param con A `DBIConnection` object representing the connection to the SQLite
#' database.
#' @param collect A logical value indicating whether to retrieve the results
#' into a local `data.frame`. If `FALSE` (default), returns a lazy `tbl` for
#' efficient database queries.
#'
#' @return A `tbl` object if `collect = FALSE`, or a `data.frame`
#' if `collect = TRUE`.
#'
#' @export
#' 
join_movies_tbls <- function(con, collect = FALSE) {

  # create connection to both data tables
  omdb <- dplyr::tbl(con, "omdb")
  tomatoes <- dplyr::tbl(con, "tomatoes")

  # join tables, filtering out those with <5 reviews, and select specified columns
  all_movies <- dplyr::inner_join(x = omdb, y = tomatoes,
                                  by = "ID") |>
  dplyr::filter(Reviews >= 5) |>
  dplyr::select(id = ID,
    imdb_id = imdbID,
    title = Title,
    year = Year,
    omdb_ratings = Rating.x,
    rt_ratings = Rating.y,
    omdb_last_updated = lastUpdated.x,
    rt_last_updated = lastUpdated.y,
    runtime = Runtime,
    genre = Genre,
    released = Released,
    director = Director,
    writer = Writer,
    imdb_rating = imdbRating,
    imdb_votes = imdbVotes,
    language = Language,
    country = Country,
    oscars = Oscars,
    meter = Meter,
    reviews = Reviews,
    fresh = Fresh,
    rotten = Rotten,
    user_meter = userMeter,
    user_rating = userRating,
    user_reviews = userReviews,
    box_office = BoxOffice,
    production = Production,
    cast = Cast
  )
  if (collect) {
    return(
      dplyr::collect(all_movies)
    )
  } else {
    return(
      all_movies
    )
  }

}
show/hide R/scatter_plotly.R
#' Create a Plotly Scatter Plot
#'
#' The `scatter_plotly()` function generates an interactive Plotly
#' scatter plot using a reactive dataset.
#' The user specifies the x-axis, y-axis, and a categorical variable for
#' coloring the points.
#'
#' @param data A reactive expression that returns a `tibble` or `data.frame`
#' containing the data.
#' @param xvar A reactive expression specifying the column name for the x-axis
#' variable.
#' @param yvar A reactive expression specifying the column name for the y-axis
#' variable.
#' @param colvar A character string specifying the column name for the
#' categorical variable used for coloring points.
#'
#' @return A `plotly` object representing the scatter plot.
#'
#' @export
#'
scatter_plotly <- function(data, xvar, yvar, colvar) {

  # check column names exist
  if (!all(c(xvar, yvar, colvar) %in% names(data))) {
    stop("One or more specified columns do not exist in the dataset.")
  }

  # convert to factor (for consistent coloring)
  data[[colvar]] <- as.factor(data[[colvar]])

  plotly::plot_ly(
    data = data,
    x = ~ .data[[xvar]],
    y = ~ .data[[yvar]],
    color = ~ .data[[colvar]],
    type = 'scatter',
    mode = 'markers',
    marker = list(size = 8, opacity = 0.6)
  ) |>
    plotly::layout(
      title = tools::toTitleCase(paste("Scatter Plot of", xvar, "vs", yvar)),
      xaxis = list(title = tools::toTitleCase(xvar)),
      yaxis = list(title = tools::toTitleCase(yvar)),
      legend = list(title = list(text = colvar))
    )
}

Usage

  1. Create an R package (or place these files in your Shiny app directory under R/).

  2. Adjust db_path in db_con() to point to your movies.db file.

  3. Run the following to launch the app:

library(mypackage)    
launch_db_app()  

Your Shiny application will start, displaying the UI with the select inputs, slider, text input, plotly scatter plot, and reactable table.

We’ll create these files in the R/ folder and add a shinydb folder to the inst/ directory. After making some minor adjustments to the module names, we can include this application to our launch_app() function.

show/hide updated launch_app()
launch_app <- function(app = NULL, options = list(), run = "p", ...) {
  if (interactive()) {
    display_type(run = run)
  }

  if (is.null(app)) {
    app <- "movies"
  }

  logr_msg(glue::glue("Launching app: {app}"),
    level = "INFO"
  )

  tryCatch(
    {
      if (app == "bslib") {
        shinyApp(
          ui = movies_ui(bslib = TRUE),
          server = movies_server,
          options = options
        )
      } else if (app == "ggp2") {
        shinyAppDir(
          appDir = system.file("tidy-movies", package = "sap"),
          options = options
        )
      } else if (app == "quarto") {
        quarto::quarto_preview(
          system.file("quarto", "index.qmd", package = "sap"),
          render = "all"
        )
      } else if (app == "assist") {
        shinyAppDir(
          appDir = system.file("shiny-assist/movies", package = "sap"),
          options = options
        )
      } else if (app == "db") {
        shinyAppDir(
          appDir = system.file("shinydb", package = "sap"),
          options = options
        )
      } else {
        shinyApp(
          ui = movies_ui(...),
          server = movies_server,
          options = options
        )
      }
    },
    error = function(e) {
      logr_msg(glue::glue("FATAL: Application failed to launch. Reason: {e$message}"),
        level = "FATAL"
      )

      stop("Application launch failed. Check logs for details.")
    }
  )
}

Our updated shinydb application is below:

launch_app(app = 'db')

shinydb app

shinydb app

As we can see, the sidebar is less crowded, and with the information we provided, the chat was able to figure out where each utility function should be placed.

27.3 Finishing touches

From here, we can continue using ellmer or ensure (see Section 16.1) to write unit tests for these new functions. We should also make sure any new dependencies are properly managed (i.e., using attachment::att_amend_desc())

attachment::att_amend_desc()
Updating sap documentation
Writing NAMESPACE
ℹ Loading sap
[+] 5 package(s) added: DBI, dplyr, plotly, reactable, RSQLite.

Recap

The ellmer package simplifies using LLMs, making it easier to include them as a tool in regular app/package development. Storing prompts ensures your project is well documented (especially when converting back to previous versions).


  1. Edit this file using the usethis::edit_r_environ() function.↩︎

  2. The chat_openai() documentation recommends “explicitly choosing a model for all but the most casual use.↩︎

  3. If you have multiple prompts, give them informative names like prompt-extract-metadata.md or prompt-summarize-text.md. If you’re writing a package, put your prompt(s) in inst/prompts” - ellmer documentation↩︎

  4. Use system_prompts to ‘provide additional instructions to the model, shaping its responses to your needs’ - What is a prompt? ellmer documentation↩︎

  5. Markdown is “quite readable to LLMs (and humans), and it allows you to do things like use headers to divide up a prompt into sections and itemised lists to enumerate multiple options.Best Practices, ellmer documentation↩︎

  6. The inst/extdata folder is covered in R Packages, 2nd ed.↩︎