decisions / R /server.R
fmegahed's picture
Deploy 71faef1 from GitHub
39181ba verified
Raw History Blame Contribute Delete
26.2 kB
# Server: the wiring of the page. The logic is in the other files of R/; this
# file keeps the state of a session (the question, the data, the answers and
# the spend) and runs the calls to Jev in chunks, as a background task.
#
# app_deps() holds what a test replaces: decide(), the usage store, the status
# reader, the clock, and `async` (FALSE runs the chunks in this R process).
# app.R makes it once for all sessions, so its `state` (the stop for the UTC
# day after a key or credit error) holds for every visitor.
# The dependencies of the server. With SPACE_MOCK=1 (local checks only; the
# Dockerfile never sets it), the mock of decide() and a store in memory
# replace the defaults.
app_deps <- function(decide_fn = decisions::decide, store = usage_store_hf(),
status_fn = read_status, now = Sys.time, async = TRUE) {
if (Sys.getenv("SPACE_MOCK") == "1" && (missing(decide_fn) || missing(store))) {
mock <- mock_deps()
if (missing(decide_fn)) decide_fn <- mock$decide_fn
if (missing(store)) store <- mock$store
}
list(
decide_fn = decide_fn, store = store, status_fn = status_fn, now = now,
async = async, state = new.env(parent = emptyenv())
)
}
# The mock of the tests (with the saved answers of both samples) and a store in
# memory. The mock stays in tests/: the app code holds no mock. APP_ROOT is the
# app folder (set by app.R, and by the helper of the tests).
mock_deps <- function(path = file.path(APP_ROOT, "tests", "testthat", "helper-mock.R")) {
if (!file.exists(path)) {
stop(
"SPACE_MOCK=1 needs ", path,
". Start the app from the repository folder, or unset SPACE_MOCK.",
call. = FALSE
)
}
env <- new.env(parent = environment(mock_deps))
sys.source(path, envir = env)
lookup <- dplyr::bind_rows(env$sample_lookup("textiles"), env$sample_lookup("injuries"))
list(decide_fn = env$mock_decide(lookup), store = usage_store_memory())
}
# The category that stops the runs today, or NULL
stop_reason <- function(deps) {
if (identical(deps$state$stop_day, utc_date(deps$now()))) deps$state$stop_reason
}
# The accounts of a chunk, in the main R process (never in a daemon): add the
# usage to the store, stop the day after a key or credit error, and write the
# log line. Rows that failed before any request ("config") cost nothing.
account_chunk <- function(answers, deps) {
categories <- classify_error(answers$.error)
use <- chunk_usage(answers[!categories %in% "config", ])
now <- deps$now()
usage <- if (use$calls > 0) usage_record(deps$store, now, use$calls, use$tokens, use$cost)
stop <- dplyr::first(categories[stops_the_day(categories)])
if (!is.na(stop)) {
deps$state$stop_day <- utc_date(now)
deps$state$stop_reason <- stop
}
log_chunk(now, nrow(answers), use, categories)
list(answers = answers, cost = use$cost, usage = usage)
}
app_server <- function(input, output, session, deps = app_deps()) {
# --- The folder of the session: uploads, imports and thumbnails -----------
dir <- tempfile("session")
dir.create(file.path(dir, "thumbs"), recursive = TRUE)
# The thumbnails are served under a prefix that no other visitor can guess
prefix <- paste(openssl::rand_bytes(16), collapse = "")
shiny::addResourcePath(prefix, file.path(dir, "thumbs"))
uploads <- character()
folders <- 0L
new_dir <- function(kind) {
folders <<- folders + 1L
file.path(dir, kind, folders)
}
keep_uploads <- function(files) uploads <<- c(uploads, files$datapath)
job <- new.env(parent = emptyenv())
# Files are deleted only when no chunk is in flight: a chunk reads its images
# and references until it ends, and a missing file would stop the day
after_chunk <- function(delete) {
if (isTRUE(job$in_flight)) job$deletes <- c(job$deletes, delete) else delete()
}
session$onSessionEnded(\() {
shiny::removeResourcePath(prefix)
remove_uploads(uploads)
after_chunk(\() unlink(dir, recursive = TRUE))
})
# The thumbnail of an image, and its address ("" for an image that cannot be read)
thumb_file <- function(path) file.path(dir, "thumbs", paste0(rlang::hash(path), ".jpg"))
thumb <- function(paths) {
purrr::map_chr(paths, \(path) {
dest <- thumb_file(path)
if (file.exists(dest) || make_thumb(path, dest)) paste0(prefix, "/", basename(dest)) else ""
})
}
# --- State --------------------------------------------------------------------
question <- shiny::reactiveVal(START_QUESTION)
refs <- shiny::reactiveVal(list()) # reference paths, one element for each answer
data_mode <- shiny::reactiveVal("image")
data_source <- shiny::reactiveVal("upload") # the page opens empty
image_upload <- shiny::reactiveVal(NULL)
csv_file <- shiny::reactiveVal(NULL)
text_kind <- shiny::reactiveVal("paste")
# list(answers, key, saved, token of the run, rows of Try on 5 or NULL for all)
results <- shiny::reactiveVal(NULL)
mean_tokens <- shiny::reactiveVal(NULL) # list(fingerprint, mean)
session_cost <- shiny::reactiveVal(0)
usage <- shiny::reactiveVal(deps$store$read())
notice <- shiny::reactiveVal(character())
running <- shiny::reactiveVal(FALSE)
rows_version <- shiny::reactiveVal(0) # renders the answer rows again
# --- The spec of the sidebar --------------------------------------------------
text_upload <- shiny::reactive({
csv <- csv_file()
if (text_kind() == "paste" || is.null(csv)) {
return(c(read_text_paste(input$paste_text), list(name = "texts")))
}
if (length(csv$columns) == 0) {
return(list(
inputs = empty_inputs(), problems = "The CSV file could not be read.", name = csv$name
))
}
column <- if (isTRUE(input$csv_text_col %in% csv$columns)) input$csv_text_col else csv$text_col
label <- if (isTRUE(input$csv_label_col %in% csv$columns)) input$csv_label_col
c(read_text_csv(csv$path, column, label, csv$file), list(name = csv$name, text_column = column))
})
data <- shiny::reactive({
id <- data_source()
if (id != "upload") return(list(inputs = sample_data(id)$spec$inputs, name = id))
upload <- if (data_mode() == "image") image_upload() else text_upload()
upload %||% list(inputs = empty_inputs(), name = "upload")
})
matched <- shiny::reactive(match_labels(data()$inputs, question()$answers))
spec <- shiny::reactive({
q <- question()
references <- if (data_mode() == "image" && q$type != "score") {
stats::setNames(refs()[seq_along(q$answers)], names(q$answers)) |> purrr::compact()
}
list(
mode = data_mode(), question = q,
references = if (length(references) > 0) references,
inputs = matched()$inputs, source = data_source(), name = data()$name,
text_column = data()$text_column
)
})
key <- shiny::reactive(answers_key(spec()))
# The type and the names of the answers: the reference rows and the target follow them
layout <- shiny::reactiveVal()
shiny::observe(layout(list(type = question()$type, answers = names(question()$answers))))
# Send the setup of the server to the inputs of the page. The page answers
# each update with the new value, so an observer never sends its own input
# (or one above it: mode, then source, then the question) back: two crossing
# updates would flip the input forever. `send` names the inputs of the data.
show_setup <- function(send = c("mode", "source")) {
q <- question()
if ("mode" %in% send) shiny::updateRadioButtons(session, "mode", selected = data_mode())
if ("source" %in% send) {
shiny::updateSelectInput(
session, "source", choices = source_choices(data_mode()), selected = data_source()
)
}
shiny::updateRadioButtons(session, "qtype", selected = q$type)
shiny::updateTextAreaInput(session, "instructions", value = q$instructions)
shiny::updateRadioButtons(
session, "view", selected = if (data_mode() == "image") "gallery" else "table"
)
rows_version(rows_version() + 1)
}
# A sample opens with no answers and makes no call. A run of the unchanged
# sample then shows its saved run (see start_run()).
load_sample <- function(id, send = c("mode", "source")) {
sample <- sample_data(id)
question(sample$spec$question)
refs(unname(sample$spec$references) %||% list())
data_mode(sample$spec$mode)
data_source(id)
notice(character())
show_setup(send)
}
# The page opens empty: Images, your own data, no sample. The first HTML of
# the page holds the same values, so the server sends no update at the start.
# --- Data -------------------------------------------------------------------
shiny::observeEvent(input$sample_textiles, load_sample("textiles"))
shiny::observeEvent(input$sample_injuries, load_sample("injuries"))
shiny::observeEvent(input$mode, {
if (input$mode == data_mode()) return()
if (data_source() != "upload") {
return(load_sample(if (input$mode == "image") "textiles" else "injuries", send = "source"))
}
data_mode(input$mode)
refs(list())
notice(character())
show_setup(send = "source")
})
shiny::observeEvent(input$source, {
# A source of the other mode is the late answer to an older update
if (input$source == data_source() || !input$source %in% source_choices(data_mode())) return()
if (input$source != "upload") return(load_sample(input$source, send = character()))
data_source("upload")
refs(list())
notice(character())
})
shiny::observeEvent(input$upload_images, {
files <- input$upload_images
keep_uploads(files)
notice(character())
old <- image_upload()
if (!is.null(old)) {
after_chunk(\() unlink(c(old$dir, thumb_file(old$inputs$path)), recursive = TRUE))
}
read <- read_images(files, dir = new_dir("images"))
bad <- thumb(read$inputs$path) == ""
image_upload(list(
inputs = read$inputs[!bad, ], dir = read$dir,
problems = c(
read$problems, sprintf("%s could not be read and was skipped.", read$inputs$file[bad])
),
name = if (nrow(files) == 1) tools::file_path_sans_ext(files$name) else "images"
))
})
shiny::observeEvent(input$paste_text, text_kind("paste"), ignoreInit = TRUE)
shiny::observeEvent(input$upload_csv, {
file <- input$upload_csv
keep_uploads(file)
notice(character())
path <- file.path(new_dir("csv"), "data.csv")
dir.create(dirname(path), recursive = TRUE)
file.copy(file$datapath, path)
columns <- csv_columns(path)
text_col <- if ("text" %in% columns) "text" else columns[1]
csv_file(list(
path = path, file = file$name, name = tools::file_path_sans_ext(file$name),
columns = columns, text_col = text_col
))
text_kind("csv")
shiny::updateSelectInput(session, "csv_text_col", choices = columns, selected = text_col)
shiny::updateSelectInput(
session, "csv_label_col", choices = c(None = "", columns), selected = ""
)
})
# --- Question ---------------------------------------------------------------
# Each input changes one part of the question. A change that the server made
# comes back from the page with the same value, so it changes nothing.
shiny::observeEvent(input$instructions, {
q <- question()
q$instructions <- input$instructions
question(q)
})
edit_answer <- function(i, part, value) {
q <- question()
if (i > length(q$answers) || (part == "name" && q$type == "noul")) return()
if (part == "name") names(q$answers)[i] <- value else q$answers[[i]] <- value
question(q)
}
purrr::walk(seq_len(max(unlist(ANSWER_RANGE))), \(i) {
shiny::observeEvent(input[[paste0("answer_name_", i)]], {
edit_answer(i, "name", input[[paste0("answer_name_", i)]])
})
shiny::observeEvent(input[[paste0("answer_desc_", i)]], {
edit_answer(i, "desc", input[[paste0("answer_desc_", i)]])
})
shiny::observeEvent(input[[paste0("answer_remove_", i)]], {
q <- question()
if (i > length(q$answers) || length(q$answers) <= answer_range(q$type)[1]) return()
q$answers <- q$answers[-i]
question(q)
refs(refs()[-i])
rows_version(rows_version() + 1)
})
})
shiny::observeEvent(input$answer_add, {
q <- question()
n <- length(q$answers)
if (n >= answer_range(q$type)[2]) return()
name <- utils::tail(make.unique(c(names(q$answers), paste("answer", n + 1)), sep = " "), 1)
q$answers <- c(q$answers, stats::setNames("", name))
question(q)
rows_version(rows_version() + 1)
})
shiny::observeEvent(input$qtype, {
q <- question()
if (input$qtype == q$type) return()
q$answers <- if (input$qtype == "noul") {
c(true = "", false = "")
} else {
utils::head(q$answers, answer_range(input$qtype)[2])
}
q$type <- input$qtype
question(q)
rows_version(rows_version() + 1)
})
# The reference inputs: a new upload replaces the references of its answer
seen <- character()
shiny::observe({
ids <- ref_input_id(shiny::req(layout())$answers)
for (i in seq_along(ids)) {
files <- input[[ids[i]]]
if (is.null(files) || all(files$datapath %in% seen)) next
seen <<- c(seen, files$datapath)
keep_uploads(files)
read <- read_references(files, dir = new_dir("references"))
shiny::isolate({
r <- refs()
r[[i]] <- read$paths
refs(r)
notice(read$problems)
})
}
})
# Import restores the question and the references; it makes no call
shiny::observeEvent(input$import_file, {
file <- input$import_file
keep_uploads(file)
read <- read_import(file$datapath, dir = new_dir("import"))
if (is.null(read$question)) return(notice(read$problems))
question(read$question)
refs(purrr::map(names(read$question$answers), \(a) read$references[[a]] %||% character()))
if (length(read$references) > 0 && data_mode() == "text") {
data_mode("image")
data_source("upload")
}
notice(c("The setup is imported.", read$problems))
show_setup()
})
shiny::observe({
l <- shiny::req(layout())
shiny::updateSelectInput(
session, "target", choices = if (l$type == "noul") "true" else l$answers,
selected = target_answer(shiny::isolate(question()), shiny::isolate(input$target))
)
})
# The answers show only while the setup and the inputs are those of the
# answers (table() checks the key). They are not deleted: the page can answer
# an older update late, and the setup then comes back. A change stops a run.
shiny::observe({
k <- key()
if (shiny::isolate(running()) && !identical(job$key, k)) job$stop <- TRUE
})
# --- Runs -------------------------------------------------------------------
mean_for <- function(s) {
m <- mean_tokens()
if (!is.null(m) && identical(m$fingerprint, spec_fingerprint(s))) m$mean
}
# A run of an unchanged sample shows its saved run: it costs nothing, so no
# limit stops it
checks <- shiny::reactive({
s <- spec()
saved <- sample_unchanged(s)
status <- spend_status(session_cost(), usage(), deps$now())
purrr::map(c(try5 = 5, run_all = Inf), \(k) {
n <- min(k, nrow(s$inputs))
cost <- if (saved) 0 else estimate_cost(s, n, mean_for(s))
allowed <- if (saved) list(ok = TRUE, reason = NA_character_) else run_allowed(cost, status)
# No inputs: the buttons stay off, and no limit is the reason
list(ok = allowed$ok && n > 0, reason = allowed$reason, n = n, cost = cost, saved = saved)
})
})
shiny::observe({
shiny::updateActionButton(session, "try5", disabled = !checks()$try5$ok)
shiny::updateActionButton(session, "run_all", disabled = !checks()$run_all$ok)
})
# Each chunk runs in a mirai daemon (or here, when async is FALSE); its
# accounts are kept in this process, also when the session has ended. The
# files that waited for the chunk are deleted when it ends.
task <- shiny::ExtendedTask$new(function(spec, rows) {
job$in_flight <- TRUE
chunk <- tryCatch(
if (deps$async) {
mirai::mirai(
run_chunk(spec, rows, decide_fn),
.args = list(spec = spec, rows = rows, decide_fn = deps$decide_fn)
)
} else {
run_chunk(spec, rows, deps$decide_fn)
},
error = \(e) promises::promise_reject(e)
)
promises::then(promises::promise_resolve(chunk), \(answers) account_chunk(answers, deps)) |>
promises::finally(\() {
job$in_flight <- FALSE
deletes <- job$deletes
job$deletes <- NULL
purrr::walk(deletes, \(delete) delete())
}) |>
# A closed session has no task to finish (it would fail on its destroyed
# values): its chunk was counted above, and the promise stays open
promises::then(
onFulfilled = \(done) if (session$isClosed()) never_settles() else done,
onRejected = \(e) if (session$isClosed()) never_settles() else stop(e)
)
})
never_settles <- function() promises::promise(\(resolve, reject) NULL)
# Before each chunk, its images and references must exist. A missing file
# ends the run; decide() would abort, and that would stop the day.
run_next <- function() {
rows <- job$chunks[[job$done + 1L]]
if (!all(file.exists(chunk_files(job$spec, rows)))) {
notice("The files of this run are not available. Upload them again.")
return(end_run())
}
task$invoke(job$spec, rows)
}
start_run <- function(n) {
if (running()) return()
s <- spec()
n <- min(n, nrow(s$inputs))
# Try on 5 takes the rows of try_rows(); Run all takes all rows (try_rows()
# gives all rows when n is the number of inputs). The R code repeats the
# rows of Try on 5 (`subset`).
rows <- try_rows(s$inputs, n)
subset <- if (n < nrow(s$inputs)) rows
# An unchanged sample shows the saved answers of these rows at once: no
# call, no cost and no write to the usage store
if (sample_unchanged(s)) {
notice(character())
saved <- sample_data(s$source)$answers[rows, ]
return(results(list(answers = saved, key = key(), saved = TRUE, rows = subset)))
}
problems <- run_problems(s)
if (length(problems) > 0) return(notice(problems))
if (!is.null(stop_reason(deps))) return(notice(error_message(stop_reason(deps))))
# The latest totals of all sessions decide, not the copy of this session
latest <- deps$store$read()
usage(latest)
allowed <- run_allowed(
estimate_cost(s, n, mean_for(s)), spend_status(session_cost(), latest, deps$now())
)
if (!allowed$ok) return(notice(limit_message(allowed$reason)))
notice(character())
job$spec <- s
job$key <- key()
job$chunks <- purrr::map(chunk_ids(n), \(i) rows[i])
job$rows <- subset
job$done <- 0L
job$stop <- FALSE
job$answers <- NULL
# The run writes its answers only while the card holds the answers that it
# found or wrote
job$token <- (job$token %||% 0L) + 1L
results(utils::modifyList(results() %||% list(), list(token = job$token)))
running(TRUE)
session$sendCustomMessage("run-state", TRUE)
run_next()
}
end_run <- function() {
running(FALSE)
session$sendCustomMessage("run-state", FALSE)
}
# After each chunk: show its answers, then run the next chunk or stop
finish_chunk <- function(done) {
if (!running()) return()
job$done <- job$done + 1L
if (is.null(done)) {
notice("The run stopped. Try again.")
return(end_run())
}
session_cost(session_cost() + done$cost)
if (!is.null(done$usage)) usage(done$usage)
job$answers <- dplyr::bind_rows(job$answers, done$answers)
shown <- identical(results()$token, job$token)
if (shown) {
results(list(
answers = job$answers, key = job$key, saved = FALSE, token = job$token, rows = job$rows
))
}
# Only calls with tokens count: a mean of 0 would make the next run look free
tokens <- job$answers$.input_tokens
tokens <- tokens[!is.na(tokens) & tokens > 0]
if (length(tokens) > 0) {
mean_tokens(list(fingerprint = spec_fingerprint(job$spec), mean = mean(tokens)))
}
reason <- stop_reason(deps)
if (!is.null(reason)) notice(error_message(reason))
# A run whose answers nobody sees stops (its chunk is counted all the same)
if (job$stop || !shown || !is.null(reason) || job$done >= length(job$chunks)) return(end_run())
run_next()
}
shiny::observe({
if (!task$status() %in% c("success", "error")) return()
done <- tryCatch(task$result(), error = \(e) NULL)
shiny::isolate(finish_chunk(done))
})
shiny::observeEvent(input$try5, start_run(5))
shiny::observeEvent(input$run_all, start_run(Inf))
# Stop takes effect at the end of the current chunk
shiny::observeEvent(input$stop, job$stop <- TRUE, priority = 10)
# --- Outputs: sidebar -------------------------------------------------------
output$answers_ui <- shiny::renderUI({
rows_version()
answer_rows(shiny::isolate(question()))
})
output$references_ui <- shiny::renderUI({
l <- shiny::req(layout())
r <- refs()
srcs <- purrr::map(seq_along(l$answers), \(i) {
if (i <= length(r)) thumb(r[[i]]) else character()
})
reference_rows(
list(type = l$type, answers = stats::setNames(character(length(l$answers)), l$answers)),
stats::setNames(srcs, l$answers)
)
})
# A cost below one cent reads "< $0.01", not "$0.00": a run is never free.
# Only the saved run of an unchanged sample costs nothing.
output$run_estimate <- shiny::renderText({
all <- checks()$run_all
if (all$n == 0) return("")
if (all$saved) return(paste(all$n, "\u00b7 saved run, no cost"))
cost <- if (all$cost > 0 && all$cost < 0.005) "< $0.01" else sprintf("\u2248 $%.2f", all$cost)
paste(all$n, "\u00b7", cost)
})
output$spend_meter <- shiny::renderUI(spend_meter_ui(session_cost()))
output$limits_text <- shiny::renderText({
all <- checks()$run_all
refused <- if (!all$ok && !is.na(all$reason)) limit_message(all$reason)
sentences <- unique(c(notice(), data()$problems, matched()$problems, refused))
paste(stringr::str_replace(sentences, "([^.!?])$", "\\1."), collapse = " ")
})
# --- Outputs: answers -------------------------------------------------------
target <- shiny::reactive(target_answer(question(), input$target))
table <- shiny::reactive({
r <- results()
if (is.null(r$answers) || !identical(r$key, key())) return(NULL)
answers_table(spec(), r$answers, target())
})
# The table of the downloads: no rows while the card shows no answers
download_table <- shiny::reactive(table() %||% empty_table(spec()))
stats <- shiny::reactive(if (!is.null(table())) answer_stats(table(), spec(), target()))
output$stats <- shiny::renderUI({
if (is.null(stats())) return(NULL)
r <- results()
note <- if (r$saved) {
sprintf("Saved run of %s, %s", SAVED_RUN_DATE, MODEL)
} else if (running()) {
sprintf("%d of %d", nrow(r$answers), length(unlist(job$chunks)))
}
stats_ui(stats(), spec()$mode, note)
})
output$empty_answers <- shiny::renderUI({
if (is.null(table())) {
shiny::p(
class = "empty",
"No answers yet. Pick a sample from Samples, or add your own images or texts."
)
}
})
# The facts of a sample describe its full saved run, not Try on 5
output$help_answers <- shiny::renderText({
if (is.null(stats())) return(help_text("answers"))
r <- results()
help_note(spec(), stats(), target(), saved = r$saved, full = is.null(r$rows))
})
output$gallery <- shiny::renderUI({
t <- table()
if (is.null(t) || spec()$mode != "image") return(NULL)
inputs <- spec()$inputs
gallery_ui(t, target(), thumb(inputs$path[match(t$id, inputs$id)]))
})
output$table <- shiny::renderTable(
if (!is.null(table())) table_view(table(), spec()),
digits = 2, na = ""
)
output$download_answers <- shiny::downloadHandler(
filename = \() paste0(run_name(spec()), "-answers.csv"),
content = \(file) readr::write_file(answers_csv(download_table()), file)
)
# --- Outputs: R code and bundle ---------------------------------------------
run_date <- function() as.Date(deps$now())
zip_name <- function() sprintf("%s-%s.zip", run_name(spec()), format(run_date()))
# The code repeats the run on show: after Try on 5, the same rows; with no
# answers on show, or after Run all, all inputs
code_rows <- shiny::reactive(if (!is.null(table())) results()$rows)
code <- shiny::reactive(run_code(spec(), run_date(), code_rows()))
output$code_summary <- shiny::renderText(
sprintf("R code of this run \u00b7 %d lines of code", code_lines(code()))
)
output$code <- shiny::renderText(code())
output$bundle_files <- shiny::renderUI(bundle_files_ui(bundle_names(spec()), zip_name()))
for (id in c("code", "bundle_files", "code_summary")) {
shiny::outputOptions(output, id, suspendWhenHidden = FALSE)
}
output$download_code <- shiny::downloadHandler(
filename = \() paste0(run_name(spec()), ".R"),
content = \(file) readr::write_file(paste0(code(), "\n"), file)
)
output$download_bundle <- shiny::downloadHandler(
filename = zip_name,
content = \(file) write_bundle(spec(), download_table(), file, run_date(), code_rows())
)
# --- Outputs: status ----------------------------------------------------------
# The status file is read at the start and every hour
status <- shiny::reactive({
shiny::invalidateLater(3600000)
status_summary(deps$status_fn(), deps$now())
})
output$status_chip <- shiny::renderText(status_chip(status()))
output$record_table <- shiny::renderTable(
{
s <- status()
s$last |>
dplyr::arrange(dplyr::desc(time_utc)) |>
dplyr::transmute(
`Time (UTC)` = time_utc, HTTP = suppressWarnings(as.integer(http_status)),
Seconds = suppressWarnings(as.numeric(seconds)),
!!sprintf("Answered (%d of %d)", s$answered, s$checks) := answered,
Version = app_version
)
},
digits = 1, na = ""
)
}