# 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 = "" ) }