decisions / R /bundle.R
fmegahed's picture
Deploy 71faef1 from GitHub
39181ba verified
Raw History Blame Contribute Delete
10.3 kB
# Bundle and import. The bundle is one zip file with the code, the setup of
# decisions, the answers and the references of a run. It holds no input image
# of the visitor and no key. Import reads a setup of this app back, with our
# own parser: it never calls decisions::read_setup() and never unserializes.
# The text of the README of a bundle (80% ASD-STE100)
bundle_readme <- function(spec, refs, name, has_data, date) {
files <- c(
sprintf("- `%s.R`: the R code of the run.", name),
sprintf(
"- `%s-setup.json`: the setup of the run. It holds the model, the question, the references and the image settings. It holds no key.",
name
),
sprintf("- `%s-answers.csv`: the answers of the run.", name),
if (nrow(refs) > 0) "- `references/`: the reference images of the run. The code reads them.",
if (has_data) "- `data.csv`: your texts. The code reads them."
)
run <- c(
"Put the files in one folder. Make this folder the working directory of R.",
if (spec$mode == "image" && spec$source != "textiles") {
'Put your images in the folder "images", as the code lists them. This bundle holds no input image.'
},
if (spec$mode == "text" && spec$source == "upload" && !has_data) {
'Save your file as "data.csv" in the same folder.'
},
sprintf("Then run `%s.R`.", name)
)
paste(c(
sprintf("# %s", name),
"",
"This zip file holds one run of the decisions Space.",
"",
"## Files",
"",
files,
"",
"## Versions",
"",
sprintf("- App: %s", APP_VERSION),
sprintf("- decisions: commit %s", DECISIONS_SHA),
sprintf("- Model: %s", MODEL),
sprintf("- Date: %s", format(date)),
"",
"## Install",
"",
"Install the package decisions in R:",
"",
' pak::pak("fmegahed/decisions")',
"",
"## Key",
"",
"The code needs your TypeSafe key. Save the key in the environment variable `TYPESAFE_API_KEY`.",
"For example, add the line `TYPESAFE_API_KEY=` followed by your key to the file `.Renviron` in your home folder. Then restart R.",
"Never write the key in the script.",
"",
"## Run",
"",
run,
"",
"## Cite",
"",
"If you use this run, cite the package and the Space:",
"",
sprintf("- Megahed, F. M., & Chen, Y.-J. (2026). *decisions: Typed decisions from decision models* (R package version %s). https://github.com/fmegahed/decisions", as.character(utils::packageVersion("decisions"))),
sprintf(
"- Megahed, F. M., & Chen, Y.-J. (2026). *decisions Space* (Version %s) [Web application]. Hugging Face. https://huggingface.co/spaces/fmegahed/decisions",
APP_VERSION
)
), collapse = "\n")
}
# Write the bundle of a run to `zipfile` and return its path. `table` is the
# answers table of answers_table(). `rows` are the rows of Try on 5 for the
# code (NULL for all inputs).
write_bundle <- function(spec, table, zipfile, date = Sys.Date(), rows = NULL) {
name <- run_name(spec)
refs <- reference_files(spec)
dir <- tempfile("bundle")
dir.create(dir)
on.exit(unlink(dir, recursive = TRUE))
readr::write_file(paste0(run_code(spec, date, rows), "\n"), file.path(dir, paste0(name, ".R")))
readr::write_file(answers_csv(table), file.path(dir, paste0(name, "-answers.csv")))
# The setup holds the references as the prepared images; the key stays out
references <- if (nrow(refs) > 0) spec$references[unique(refs$answer)]
setup <- decisions::decision_setup(
make_decider(), stats::setNames(list(as_decisions_question(spec$question)), name),
references = references, max_size = IMAGE_SETTINGS$max_size,
upscale = IMAGE_SETTINGS$upscale
)
decisions::write_setup(setup, file.path(dir, paste0(name, "-setup.json")))
# The references of a sample stay in the package (system.file())
if (nrow(refs) > 0 && spec$source != "textiles") {
dest <- file.path(dir, "references", refs$folder, refs$file)
purrr::walk(unique(dirname(dest)), dir.create, recursive = TRUE)
file.copy(refs$path, dest)
}
# Pasted text goes in as data.csv; the file of a CSV upload stays with the visitor
has_data <- spec$mode == "text" && spec$source == "upload" &&
is.null(spec$text_column) && nrow(spec$inputs) > 0
if (has_data) {
readr::write_csv(tibble::tibble(text = spec$inputs$text), file.path(dir, "data.csv"))
}
readr::write_file(
bundle_readme(spec, refs, name, has_data, date), file.path(dir, "README.md")
)
zipfile <- normalizePath(zipfile, mustWork = FALSE)
zip::zip(zipfile, files = list.files(dir), root = dir, mode = "mirror")
zipfile
}
# The files of the bundle of a spec, as write_bundle() makes them (a folder
# ends in "/"; test-bundle.R checks that the two agree)
bundle_names <- function(spec) {
name <- run_name(spec)
has_refs <- nrow(reference_files(spec)) > 0 && spec$source != "textiles"
has_data <- spec$mode == "text" && spec$source == "upload" &&
is.null(spec$text_column) && nrow(spec$inputs) > 0
c(
paste0(name, c(".R", "-setup.json", "-answers.csv")),
if (has_refs) "references/", if (has_data) "data.csv", "README.md"
)
}
# --- Import ---------------------------------------------------------------
IMPORT_REFUSED <- "This file was not made by this app."
# One text value: a string that is not NA
is_one_string <- function(x) is.character(x) && length(x) == 1 && !is.na(x)
# The path of the setup file: the file itself, or the `*-setup.json` of a zip
# file, unpacked below `dir`. NULL when there is none.
import_setup_path <- function(path, dir = tempfile("import")) {
if (!utils::file_test("-f", path) || file.size(path) > mb_bytes(MAX_ZIP_MB)) return(NULL)
if (tolower(tools::file_ext(path)) != "zip") return(path)
entries <- tryCatch(zip::zip_list(path), error = function(e) NULL)
if (is.null(entries)) return(NULL)
found <- entries |>
dplyr::filter(
endsWith(filename, "-setup.json"), purrr::map_lgl(filename, safe_entry),
!purrr::map_lgl(filename, is_junk), uncompressed_size <= mb_bytes(MAX_ZIP_MB)
)
if (nrow(found) == 0) return(NULL)
dest <- file.path(dir, "setup")
zip::unzip(path, files = found$filename[1], exdir = dest)
file.path(dest, found$filename[1])
}
# TRUE when the parsed JSON has the layout and the settings of this app
is_app_setup <- function(json) {
question <- if (length(json$questions) == 1) json$questions[[1]]
answers <- question$answers
settings <- json$image_settings
is.list(json) &&
identical(as.numeric(json$version), 1) &&
is_one_string(json$decider$model) &&
is.null(json$calibrator) && is.null(json$calibrator_hash) &&
is_one_string(question$type) && question$type %in% c("noul", "choice", "score") &&
is_one_string(question$instructions) &&
length(answers) > 0 && !is.null(names(answers)) &&
all(purrr::map_lgl(answers, \(x) is.null(x) || is_one_string(x))) &&
identical(as.numeric(settings$max_size), as.numeric(IMAGE_SETTINGS$max_size)) &&
identical(settings$upscale, IMAGE_SETTINGS$upscale) &&
identical(settings$crop, IMAGE_SETTINGS$crop)
}
# Decode the references of the file below `dir`, as
# references/<answer>/ref_<i>.jpg. NULL when there is a bad reference.
import_references <- function(json, answers, dir = tempfile("import")) {
encoded <- json$references
if (length(encoded) == 0) return(list(paths = NULL, problems = character()))
# The references are an object keyed by answer: names that are all answers
keys <- names(encoded)
if (!is.list(encoded) || is.null(keys) || anyNA(keys) || anyDuplicated(keys) > 0 ||
!all(keys %in% names(answers))) {
return(NULL)
}
folders <- answer_folders(names(answers))
paths <- tryCatch(
purrr::imap(encoded, \(images, answer) {
if (!is.list(images) || length(images) > MAX_REFERENCES) stop("bad references")
purrr::imap_chr(unname(images), \(x, i) {
bytes <- if (is_one_string(x)) jsonlite::base64_dec(x) else raw()
jpeg <- length(bytes) > 3 && all(bytes[1:3] == as.raw(c(0xff, 0xd8, 0xff)))
if (!jpeg || length(bytes) > mb_bytes(MAX_FILE_MB)) stop("bad reference")
path <- file.path(dir, "references", folders[[answer]], sprintf("ref_%s.jpg", i))
dir.create(dirname(path), recursive = TRUE, showWarnings = FALSE)
writeBin(bytes, path)
path
})
}),
error = function(e) NULL
)
if (is.null(paths)) return(NULL)
list(paths = paths, problems = character())
}
# Read a setup of this app: a bundle zip or the setup JSON. Returns the
# question (in the format of spec.R), the references (answer to file paths) and
# the problems. `question` is NULL when the file is refused. Every file of the
# import is written below `dir` (the server gives the folder of the session).
read_import_file <- function(path, dir = tempfile("import")) {
refuse <- function(problems = IMPORT_REFUSED) {
list(question = NULL, references = NULL, problems = problems)
}
setup_path <- import_setup_path(path, dir)
if (is.null(setup_path)) return(refuse())
json <- tryCatch(jsonlite::read_json(setup_path), error = function(e) NULL)
if (is.null(json) || !isTRUE(tryCatch(is_app_setup(json), error = function(e) FALSE))) {
return(refuse())
}
raw <- json$questions[[1]]
answers <- purrr::map_chr(raw$answers, \(x) x %||% "")
if (raw$type == "noul" && !setequal(names(answers), c("true", "false"))) return(refuse())
question <- list(type = raw$type, instructions = raw$instructions, answers = answers)
problems <- check_question(question)
if (length(problems) > 0) return(refuse(problems))
references <- import_references(json, answers, dir)
if (is.null(references) || (raw$type == "score" && length(references$paths) > 0)) return(refuse())
model <- json$decider$model
if (model != MODEL) {
problems <- c(problems, sprintf("This file uses %s. The app uses %s.", model, MODEL))
}
list(
question = question, references = references$paths,
problems = c(problems, references$problems)
)
}
# Read an import file. It never stops: any error refuses the file.
read_import <- function(path, dir = tempfile("import")) {
tryCatch(
read_import_file(path, dir),
error = function(e) list(question = NULL, references = NULL, problems = IMPORT_REFUSED)
)
}