File size: 10,293 Bytes
9cee884
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39181ba
 
 
9cee884
 
 
 
 
39181ba
9cee884
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# 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)
  )
}