--- title: "Getting started with text" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Getting started with text} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include = FALSE} # Without the suggested packages, the code is shown but not run knitr::opts_chunk$set( collapse = TRUE, comment = "#>", eval = rlang::is_installed(c("dplyr", "yardstick")) ) old_options <- options(width = 100, pillar.sigfig = 3) ``` ```{r library} library(decisions) ``` ## Overview A decision model reads an input and some typed questions. It returns a probability for each allowed answer. The decisions package sends the inputs to the model and gives you a tibble with one row per input. This vignette uses short texts. It shows the three question types, one input and then many inputs, and how to copy the agent skill of the package. The results below come from live calls. The package saved them in the package folder `extdata`, so this vignette builds without a key and without a network. The code that made them is in `vignette_results.R` in the folder `data-raw` of the [GitHub repository](https://github.com/fmegahed/decisions). ## Data The vignette uses ten support tickets. We wrote them for this vignette. ```{r tickets, script = TRUE} tickets <- tibble::tibble( ticket = 1:10, text = c( "I was charged twice for order 4471. Please refund the extra charge today.", "My parcel has not arrived. The tracking page has said 'in transit' for two weeks.", "The app crashes each time I open the settings page. I cannot log in, and I have a meeting in ten minutes.", "Thank you for the quick delivery. The product works well.", "I want my money back. The blender broke after one day.", "How do I change the email address on my account?", "The invoice shows the wrong company name. Please send a new one when you can.", "The package arrived with a broken seal. Please send a replacement. There is no hurry.", "Your website shows an error when I pay by card. I must order before the sale ends tonight.", "Can you tell me which sizes the jacket comes in?" ) ) ``` ## Decider A decider names the provider and the model. Here we use Jev, a decision model of TypeSafe. When you give no model, the function uses its default model and prints its name, as ellmer does. ```{r decider, script = TRUE} jev <- decider_typesafe() jev ``` The decider holds no key. `decide()` reads the key when it sends the requests. Put the key in the environment variable `TYPESAFE_API_KEY`, for example in `~/.Renviron`. You can also give a `credentials` function that returns the key: ```{r credentials, eval = FALSE} jev <- decider_typesafe(credentials = \() Sys.getenv("MY_JEV_KEY")) ``` ## Questions A question has instructions and named answers. The package has three types. * `question_noul()` has two answers, `true` and `false`. * `question_choice()` has two or more named choices. Each name is an allowed answer, and each value describes it. * `question_score()` has ordered levels, from low to high. ```{r questions, script = TRUE} refund <- question_noul("Does the customer ask for a refund?") topic <- question_choice( "Which team should answer this ticket?", c( billing = "Charges, invoices and refunds.", shipping = "Parcels and delivery.", technical = "Errors, and how to use the product." ) ) urgency <- question_score( "How urgent is this ticket?", c( low = "The customer can wait several days.", medium = "The customer needs an answer today.", high = "The customer needs an answer now." ) ) ``` ## One input `decide()` takes the decider, the question and a text. It returns a tibble with one row for the input. ```{r one-noul, eval = FALSE, script = TRUE} one_noul <- decide(jev, refund, text = tickets$text[1]) one_noul ``` ```{r saved, echo = FALSE} saved <- readRDS(system.file("extdata", "getting-started.rds", package = "decisions")) ``` ```{r show-one-noul, echo = FALSE} saved$one_noul ``` TypeSafe does not report a cost. So `decide()` prints one message that says so, and `.cost` is `NA`. `.input_tokens` has the input tokens. The columns are: * `.pred_class`: the answer with the highest probability. It is a factor. Its levels are the allowed answers, kept exactly as you wrote them. * `.pred_true` and `.pred_false`: the probability of each allowed answer. There is one `.pred_` column for each answer. The column name is the answer with letters, digits and `_` only. For example, the answer `"not sure"` gives `.pred_not_sure`. The yardstick and probably packages read these columns. * `.model`: the model that the provider reports. * `.input_tokens`: the input tokens that the provider reports. * `.cost`: the cost that the provider reports, or `NA`. * `.seconds`: the time of the response. * `.error`: `NA`, or the reason why the answers of the row are `NA`. A choice question gives one `.pred_` column for each choice. ```{r one-choice, eval = FALSE, script = TRUE} one_choice <- decide(jev, topic, text = tickets$text[2]) one_choice ``` ```{r show-one-choice, echo = FALSE} saved$one_choice ``` A score question gives one `.pred_` column for each level, and `.score`. The score is the expected level: 0 is the lowest level. ```{r one-score, eval = FALSE, script = TRUE} one_score <- decide(jev, urgency, text = tickets$text[3]) one_score ``` ```{r show-one-score, echo = FALSE} saved$one_score ``` ## Many inputs Give a character vector to ask about many texts. The tibble has one row for each text, in the same order. Every text keeps its row, also when a call fails. A failed row has `NA` answers, and `.error` has the text of the provider. ```{r many-noul, eval = FALSE, script = TRUE} many_noul <- decide(jev, refund, text = tickets$text) dplyr::bind_cols(tickets["ticket"], many_noul) ``` ```{r show-many-noul, echo = FALSE} many_noul <- saved$many_noul dplyr::bind_cols(tickets["ticket"], many_noul) ``` The requests go out in parallel. `.seconds` is the time of each response. ## Two questions Give a named list to ask more than one question about each text. The names are the question names. The column names get the name of the question and `_` as a prefix. ```{r many-two, eval = FALSE, script = TRUE} many_two <- decide(jev, list(topic = topic, urgency = urgency), text = tickets$text) names(many_two) ``` ```{r show-many-two, echo = FALSE} many_two <- saved$many_two names(many_two) ``` The request has all the questions, so each text costs one call, whatever the number of questions. ```{r many-two-table, script = TRUE} dplyr::bind_cols( tickets["ticket"], many_two[c("topic_.pred_class", "urgency_.pred_class", "urgency_.score")] ) ``` ## Scores with known answers The tickets have no known urgency. To check a score question, we need data with a known ordered answer. The dataset `ohio_recalls` has food recalls of firms in Ohio from the openFDA reports of the U.S. Food and Drug Administration (FDA). The FDA puts each recall in one of three classes, from low to high risk: Class III, Class II and Class I. We take 30 recalls, 10 of each class. The text of a recall is its product and its reason. ```{r recalls, script = TRUE} set.seed(2026) recalls <- ohio_recalls |> dplyr::slice_sample(n = 10, by = classification) |> dplyr::mutate( text = paste0("Product: ", product_description, "\nReason: ", reason_for_recall) ) ``` The levels of the question are the classes of the FDA, from low to high. The descriptions are the definitions of 21 CFR 7.3(m) in plain words. ```{r recall-question, script = TRUE} risk <- question_score( "How serious are the health consequences of using this recalled food?", c( `Class III` = "Using the food is not likely to cause adverse health consequences.", `Class II` = "Using the food may cause temporary or reversible health problems, or serious harm is remote.", `Class I` = "Using the food is likely to cause serious health problems or death." ) ) ``` ```{r recall-decide, eval = FALSE, script = TRUE} recall_out <- decide(jev, risk, text = recalls$text) recall_out <- dplyr::bind_cols(recalls["classification"], recall_out) ``` ```{r recall-saved, echo = FALSE} saved_recalls <- readRDS(system.file("extdata", "getting-started-recalls.rds", package = "decisions")) recall_out <- saved_recalls$recall_out ``` The table counts the answers of the model against the class of the FDA. Accuracy is the share of equal answers. The weighted kappa measures the agreement of two ratings of ordered classes, beyond chance. The quadratic weights count a miss of two classes more than a miss of one class. A kappa of 1 is full agreement, and 0 is the agreement that chance gives. ```{r recall-metrics, script = TRUE} table(FDA = recall_out$classification, Jev = recall_out$.pred_class) yardstick::accuracy(recall_out, classification, .pred_class) yardstick::kap(recall_out, classification, .pred_class, weighting = "quadratic") ``` The column `.score` is the expected level of the question, as in the ticket example. It is a number from 0, the lowest level (Class III), to 2, the highest level (Class I). It lies between two levels when the model gives some probability to both. Here are two rows of each class. ```{r recall-score, script = TRUE} recall_out |> dplyr::slice_head(n = 2, by = classification) |> dplyr::select(classification, .pred_class, .score) ``` The numbers are rough. The reason text alone does not always fix the class, because the FDA also weighs the product and how far it went. A sample of 30 recalls also gives wide error margins. ## Long runs For a long run, give `path`, the path of a JSON file. `decide()` writes the finished answers to the file. If the run stops, a new call with the same `path` sends only the inputs that the file lacks. ```{r path, eval = FALSE} decide(jev, refund, text = tickets$text, path = "refund-answers.json") ``` The file holds no key. If you change the model or a question, `decide()` stops and names the hash that does not match. ## Agent skill The package has a skill for coding agents. It explains how to write questions, tune the descriptions of the answers, and run the one-time test. Copy the folder of the skill into your project. Claude Code reads `.claude/skills`. Other agents read `.agents/skills`. ```{r skill, eval = FALSE} skill <- system.file("skills", "decisions", package = "decisions") # Claude Code dir.create(".claude/skills", recursive = TRUE, showWarnings = FALSE) file.copy(skill, ".claude/skills", recursive = TRUE) # Other agents dir.create(".agents/skills", recursive = TRUE, showWarnings = FALSE) file.copy(skill, ".agents/skills", recursive = TRUE) ``` ```{r reset, include = FALSE} options(old_options) ```