|
| 1 | +#' @include tool-result.R |
| 2 | +NULL |
| 3 | + |
| 4 | +#' Tool: Package Release Notes |
| 5 | +#' |
| 6 | +#' @description |
| 7 | +#' Include release notes for a package, either the release notes for the most |
| 8 | +#' recent package release or release notes matching a search term. |
| 9 | +#' |
| 10 | +#' @examples |
| 11 | +#' # Copy release notes to the clipboard for use in any AI app |
| 12 | +#' btw("@news dplyr", clipboard = FALSE) |
| 13 | +#' |
| 14 | +#' btw("@news dplyr join_by", clipboard = FALSE) |
| 15 | +#' |
| 16 | +#' if (R.version$major == 4 && R.version$minor > "2.0") { |
| 17 | +#' # Should find a NEWS entry from R 4.2 |
| 18 | +#' btw("@news R dynamic rd content", clipboard = FALSE) |
| 19 | +#' } |
| 20 | +#' |
| 21 | +#' # Tool use by LLMs via ellmer or MCP ---- |
| 22 | +#' btw_tool_docs_package_news("dplyr") |
| 23 | +#' |
| 24 | +#' btw_tool_docs_package_news("dplyr", "join_by") |
| 25 | +#' |
| 26 | +#' @param package_name The name of the package as a string, e.g. `"shiny"`. |
| 27 | +#' @param search_term A regular expression to search for in the NEWS entries. |
| 28 | +#' If empty, the release notes of the current installed version is included. |
| 29 | +#' |
| 30 | +#' @returns Returns the release notes for the currently installed version of the |
| 31 | +#' package, or the release notes matching the search term. |
| 32 | +#' |
| 33 | +#' @seealso [btw_tools()] |
| 34 | +#' @family Tools |
| 35 | +#' @export |
| 36 | +btw_tool_docs_package_news <- function(package_name, search_term = "") { |
| 37 | + news <- package_news_search(package_name, search_term %||% "") |
| 38 | + |
| 39 | + if (!nrow(news)) { |
| 40 | + if (nzchar(search_term)) { |
| 41 | + cli::cli_abort( |
| 42 | + "No NEWS entries found for package '{package_name}' matching '{search_term}'." |
| 43 | + ) |
| 44 | + } else { |
| 45 | + cli::cli_abort( |
| 46 | + "No NEWS entries found for package '{package_name}' v{package_version(package_name)}." |
| 47 | + ) |
| 48 | + } |
| 49 | + } |
| 50 | + |
| 51 | + BtwPackageNewsToolResult(unclass(btw_this(news))) |
| 52 | +} |
| 53 | + |
| 54 | +.btw_add_to_tools( |
| 55 | + "btw_tool_docs_package_news", |
| 56 | + group = "docs", |
| 57 | + tool = function() { |
| 58 | + ellmer::tool( |
| 59 | + btw_tool_docs_package_news, |
| 60 | + .description = paste0( |
| 61 | + "Read the release notes (NEWS) for a package.", |
| 62 | + "\n\n", |
| 63 | + "Use this tool when you need to learn what changed in a package release, i.e. when code no longer works after a package update, or when the user asks to learn about new features.", |
| 64 | + "\n\n", |
| 65 | + "If no search term is provided, the release notes for the current installed version are returned. ", |
| 66 | + "If a search term is provided, the tool returns relevant entries in the NEWS file matching the search term from the most recent 5 versions of the package where the term is matched.", |
| 67 | + "\n\n", |
| 68 | + "Use a search term to learn about recent changes to a function, feature or argument over the last few package releases. ", |
| 69 | + "For example, if a user recently updated a package and asks why a function no longer works, you can use this tool to find out what changed in the package release notes." |
| 70 | + ), |
| 71 | + .annotations = ellmer::tool_annotations( |
| 72 | + title = "Package Release Notes", |
| 73 | + read_only_hint = TRUE, |
| 74 | + open_world_hint = FALSE |
| 75 | + ), |
| 76 | + package_name = ellmer::type_string( |
| 77 | + "The name of the package.", |
| 78 | + required = TRUE |
| 79 | + ), |
| 80 | + search_term = ellmer::type_string( |
| 81 | + paste( |
| 82 | + "A regular expression to use to search the NEWS entries.", |
| 83 | + "Use simple regular expressions (perl style is supported).", |
| 84 | + "The search term is case-insensitive.", |
| 85 | + "If empty, the tool returns the release notes for the current installed version." |
| 86 | + ), |
| 87 | + required = FALSE |
| 88 | + ) |
| 89 | + ) |
| 90 | + } |
| 91 | +) |
| 92 | + |
| 93 | +BtwPackageNewsToolResult <- S7::new_class( |
| 94 | + "BtwPackageNewsToolResult", |
| 95 | + parent = BtwToolResult |
| 96 | +) |
| 97 | + |
| 98 | +#' @export |
| 99 | +btw_this.news_db <- function(x, ...) { |
| 100 | + news <- x |
| 101 | + package_name <- attr(x, "package") |
| 102 | + |
| 103 | + if (!"match" %in% names(x)) { |
| 104 | + news$match <- news$HTML |
| 105 | + } |
| 106 | + |
| 107 | + if (!inherits(news, "btw_filtered_news_db")) { |
| 108 | + news <- news[news$Version == package_version(package_name), ] |
| 109 | + } |
| 110 | + |
| 111 | + if (!nrow(news)) { |
| 112 | + return(btw_ignore()) |
| 113 | + } |
| 114 | + |
| 115 | + news$Category[news$Category == "Full changelog"] <- "" |
| 116 | + |
| 117 | + news <- dplyr::summarize( |
| 118 | + news, |
| 119 | + md = paste(.data$match, collapse = "\n\n"), |
| 120 | + .by = c("Version", "Category") |
| 121 | + ) |
| 122 | + news$md <- pandoc_convert_text(news$md, to = "markdown") |
| 123 | + |
| 124 | + has_cat <- nzchar(news$Category) |
| 125 | + news$Category[has_cat] <- paste0("#### ", news$Category[has_cat], "\n\n") |
| 126 | + |
| 127 | + news <- dplyr::summarize( |
| 128 | + news, |
| 129 | + md = paste(paste0(.data$Category, .data$md), collapse = "\n\n"), |
| 130 | + .by = "Version" |
| 131 | + ) |
| 132 | + |
| 133 | + news <- news[order(news$Version, decreasing = TRUE), ] |
| 134 | + news$Version <- as.character(news$Version) |
| 135 | + |
| 136 | + news_md <- glue_( |
| 137 | + " |
| 138 | +### {{package_name}} v{{news$Version}} |
| 139 | +
|
| 140 | +{{news$md}}", |
| 141 | + ) |
| 142 | + |
| 143 | + # Returns as-is so that btw(news(package = package_name)) is treated as |
| 144 | + # pre-formatted text and not formatted as an object/result pair |
| 145 | + I(paste(news_md, collapse = "\n\n")) |
| 146 | +} |
| 147 | + |
| 148 | +package_news <- function(package_name) { |
| 149 | + news <- utils::news(package = package_name) |
| 150 | + if (package_name %in% r_docs_versions()) { |
| 151 | + news$Version <- map_chr(news$Version, as_package_or_r_version) |
| 152 | + } |
| 153 | + news |
| 154 | +} |
| 155 | + |
| 156 | +r_docs_versions <- function() { |
| 157 | + c("R", sprintf("R-%d", seq_len(R.version$major))) |
| 158 | +} |
| 159 | + |
| 160 | +package_news_search <- function(package_name, search_term = "") { |
| 161 | + r_docs <- r_docs_versions() |
| 162 | + if (!package_name %in% r_docs) { |
| 163 | + check_installed(package_name) |
| 164 | + } else { |
| 165 | + if (package_name == sprintf("R-%s", R.version$major)) { |
| 166 | + package_name <- "R" |
| 167 | + } |
| 168 | + } |
| 169 | + |
| 170 | + news <- package_news(package_name) |
| 171 | + if (is.null(news)) { |
| 172 | + return(data.frame()) |
| 173 | + } |
| 174 | + news$Version <- base::package_version(news$Version) |
| 175 | + |
| 176 | + if (!nzchar(search_term)) { |
| 177 | + version <- |
| 178 | + if (!package_name %in% setdiff(r_docs, "R")) { |
| 179 | + package_version(package_name) |
| 180 | + } else { |
| 181 | + max(news$Version) |
| 182 | + } |
| 183 | + news <- news[news$Version == version, ] |
| 184 | + news$match <- news$HTML |
| 185 | + } else { |
| 186 | + news$match <- map_chr( |
| 187 | + news$HTML, |
| 188 | + extract_relevant_news, |
| 189 | + search_term = search_term |
| 190 | + ) |
| 191 | + news <- news[!is.na(news$match), ] |
| 192 | + |
| 193 | + # Take at most the results from the 5 most recent versions |
| 194 | + versions <- unique(news$Version) |
| 195 | + if (length(versions) > 5) { |
| 196 | + versions <- sort(versions, decreasing = TRUE)[1:5] |
| 197 | + } |
| 198 | + news <- news[news$Version %in% versions, ] |
| 199 | + } |
| 200 | + |
| 201 | + class(news) <- c("btw_filtered_news_db", class(news)) |
| 202 | + news |
| 203 | +} |
| 204 | + |
| 205 | +as_package_or_r_version <- function(v) { |
| 206 | + if (!grepl("[^\\d.-]", v)) { |
| 207 | + return(v) |
| 208 | + } |
| 209 | + |
| 210 | + if (identical(v, "R-devel")) { |
| 211 | + # Assuming the presence of `R-devel` means we're using dev R |
| 212 | + return(package_version("R")) |
| 213 | + } |
| 214 | + |
| 215 | + if (grepl("patched", v)) { |
| 216 | + # Remove " patched" suffix |
| 217 | + v <- sub(" patched", "", v, fixed = TRUE) |
| 218 | + v <- unclass(base::package_version(v))[[1]] |
| 219 | + v[3] <- v[3] + 1L |
| 220 | + return(paste(v, collapse = ".")) |
| 221 | + } |
| 222 | + |
| 223 | + v |
| 224 | +} |
| 225 | + |
| 226 | +extract_relevant_news <- function(news_html, search_term) { |
| 227 | + doc <- xml2::read_html(news_html) |
| 228 | + |
| 229 | + # Find all first-level <li> elements and top-level <p> elements |
| 230 | + # First-level <li> are direct children of <ul> or <ol> |
| 231 | + # Top-level <p> are direct children of the root (not nested in <li>) |
| 232 | + |
| 233 | + li_elements <- xml2::xml_find_all( |
| 234 | + doc, |
| 235 | + "//ul[not(ancestor::ul) and not(ancestor::ol)]/li" |
| 236 | + ) |
| 237 | + top_level_p <- xml2::xml_find_all( |
| 238 | + doc, |
| 239 | + "//p[not(ancestor::li)]" |
| 240 | + ) |
| 241 | + |
| 242 | + all_elements <- c(li_elements, top_level_p) |
| 243 | + |
| 244 | + has_text <- map_lgl(all_elements, function(el) { |
| 245 | + grepl(search_term, xml2::xml_text(el), perl = TRUE, ignore.case = TRUE) |
| 246 | + }) |
| 247 | + |
| 248 | + if (!any(has_text)) { |
| 249 | + return(NA_character_) |
| 250 | + } |
| 251 | + |
| 252 | + res <- map_chr(all_elements[has_text], as.character) |
| 253 | + is_li <- grepl("^<li>", res, fixed = TRUE) |
| 254 | + res[is_li] <- paste0("<ul>", res[is_li], "</ul>") |
| 255 | + paste(res, collapse = "\n") |
| 256 | +} |
0 commit comments