fmegahed commited on
Commit
9cee884
·
verified ·
1 Parent(s): 50028d8

Deploy 7a572e1 from GitHub

Browse files
.dockerignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ .Renviron
3
+ .Rhistory
4
+ .RData
5
+ .Rproj.user/
6
+ .DS_Store
7
+ .git
8
+ .github
9
+ tests/
10
+ status/
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM rocker/shiny:4.6.1
2
+
3
+ # Install system dependencies for R packages
4
+ # - libcurl4-openssl-dev, libssl-dev: for httr2 and curl
5
+ # - libxml2-dev: for xml2
6
+ # - libmagick++-dev: for magick (decisions resizes images with it)
7
+ # - libfontconfig1-dev, libfreetype6-dev: for magick text rendering
8
+ RUN apt-get update && apt-get install -y \
9
+ libcurl4-openssl-dev \
10
+ libssl-dev \
11
+ libxml2-dev \
12
+ libmagick++-dev \
13
+ libfontconfig1-dev \
14
+ libfreetype6-dev \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ WORKDIR /code
18
+
19
+ # Install pak, then decisions at a fixed commit and the other packages at fixed
20
+ # versions. The last line fails the build if any of them cannot be loaded.
21
+ RUN R -e "install.packages('pak', repos = 'https://cloud.r-project.org')" \
22
+ && R -e "pak::pak('url::https://github.com/fmegahed/decisions/archive/b25fc9ffa7908e9a6a2d9833f080de1456c9d50a.tar.gz')" \
23
+ && R -e "pak::pak(c('shiny@1.14.0', 'mirai@2.7.1', 'dplyr@1.2.1', 'purrr@1.2.2', 'stringr@1.6.0', 'tibble@3.3.1', 'readr@2.2.0', 'jsonlite@2.0.0', 'httr2@1.2.3', 'yardstick@1.4.0', 'magick@2.9.0', 'zip@3.0.0', 'janitor@2.2.1', 'openssl@2.4.2'))" \
24
+ && R -e "for (p in c('shiny', 'decisions', 'mirai', 'dplyr', 'purrr', 'stringr', 'tibble', 'readr', 'jsonlite', 'httr2', 'yardstick', 'magick', 'zip', 'janitor', 'openssl')) library(p, character.only = TRUE)"
25
+
26
+ # Copy only what the app needs (tests, .env and .git are excluded by .dockerignore)
27
+ COPY app.R ./
28
+ COPY R/ ./R/
29
+ COPY www/ ./www/
30
+
31
+ CMD ["R", "--quiet", "-e", "shiny::runApp(host='0.0.0.0', port=7860)"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fadel M. Megahed and Ying-Ju Chen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
R/_disable_autoload.R ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ # Shiny sources every file in R/ by itself. app.R sources the files in a fixed
2
+ # order, so the existence of this file turns the automatic sourcing off.
3
+ # The file holds no code.
R/answers.R ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Answers: the table of a run, its stats, the notes and the CSV.
2
+ # The table holds only the category message of an error, never the raw .error.
3
+
4
+ # The table of answers, sorted by the probability of the target, highest first
5
+ answers_table <- function(spec, answers, target) {
6
+ names_ans <- names(spec$question$answers)
7
+ probs <- answers[pred_column(names_ans)] |>
8
+ stats::setNames(paste0("p_", janitor::make_clean_names(names_ans, case = "none")))
9
+ error <- answers[[".error"]] %||% rep(NA_character_, nrow(answers))
10
+ tibble::tibble(
11
+ id = answers$id,
12
+ answer = as.character(answers$.pred_class),
13
+ p_target = answers[[pred_column(target)]],
14
+ probs,
15
+ tokens = answers$.input_tokens,
16
+ seconds = answers$.seconds,
17
+ problem = error_message(classify_error(error))
18
+ ) |>
19
+ dplyr::left_join(
20
+ spec$inputs |> dplyr::select(id, file, text, label),
21
+ by = "id"
22
+ ) |>
23
+ dplyr::mutate(text = stringr::str_trunc(text, 150, ellipsis = "...")) |>
24
+ dplyr::select(
25
+ id, file, text, answer, p_target, dplyr::starts_with("p_"),
26
+ label, tokens, seconds, problem
27
+ ) |>
28
+ dplyr::arrange(dplyr::desc(p_target))
29
+ }
30
+
31
+ # Number of inputs, cost (USD), accuracy and AUC; NA when they do not apply
32
+ answer_stats <- function(table, spec, target) {
33
+ names_ans <- names(spec$question$answers)
34
+ labelled <- table |> dplyr::filter(!is.na(label), !is.na(answer))
35
+ accuracy <- if (nrow(labelled) == 0) {
36
+ NA_real_
37
+ } else {
38
+ yardstick::accuracy_vec(
39
+ factor(labelled$label, levels = names_ans),
40
+ factor(labelled$answer, levels = names_ans)
41
+ )
42
+ }
43
+ scored <- labelled |> dplyr::filter(!is.na(p_target))
44
+ auc <- if (length(names_ans) == 2 && dplyr::n_distinct(scored$label) == 2) {
45
+ yardstick::roc_auc_vec(
46
+ factor(scored$label, levels = c(target, setdiff(names_ans, target))),
47
+ scored$p_target, event_level = "first"
48
+ )
49
+ } else {
50
+ NA_real_
51
+ }
52
+ list(
53
+ n = nrow(table),
54
+ cost = sum(table$tokens, na.rm = TRUE) * PRICE_PER_TOKEN,
55
+ accuracy = accuracy, auc = auc
56
+ )
57
+ }
58
+
59
+ # The text for the image of a row, for screen readers
60
+ alt_text <- function(row, target) {
61
+ sprintf(
62
+ "%s: answer %s, probability of %s %s",
63
+ row$file, row$answer, target, format(round(row$p_target, 2), nsmall = 2)
64
+ )
65
+ }
66
+
67
+ # What the probability is. The facts of a sample follow only for its saved
68
+ # answers and its default target.
69
+ help_note <- function(spec, stats, target, saved = FALSE) {
70
+ generic <- sprintf(
71
+ "The probability is the raw probability of Jev for %s. It is not calibrated.",
72
+ target
73
+ )
74
+ facts <- spec$source %in% names(SAMPLE_FACTS_TEXT) && saved &&
75
+ identical(target, target_answer(spec$question))
76
+ if (!facts) return(generic)
77
+ paste(c(generic, SAMPLE_FACTS_TEXT[[spec$source]]), collapse = " ")
78
+ }
79
+
80
+ # The CSV text of the table for the download
81
+ answers_csv <- function(table) {
82
+ readr::format_csv(table, na = "")
83
+ }
84
+
85
+ # The answers belong to one setup and one set of inputs. The ids alone repeat
86
+ # ("Text 1", "Row 1", "001_a.jpg"), so the key holds the paths and the texts too.
87
+ answers_key <- function(spec) {
88
+ content <- spec$inputs[intersect(c("id", "path", "text"), names(spec$inputs))]
89
+ rlang::hash(list(spec_fingerprint(spec), content))
90
+ }
91
+
92
+ # The answers table as the page shows it: the input, the answer, the
93
+ # probability of each answer, the label, the tokens, the seconds, the problem
94
+ table_view <- function(table, spec) {
95
+ answers <- names(spec$question$answers)
96
+ probs <- table[paste0("p_", janitor::make_clean_names(answers, case = "none"))] |>
97
+ stats::setNames(paste("p", answers))
98
+ shown <- tibble::tibble(Input = dplyr::coalesce(table$text, table$file), Answer = table$answer) |>
99
+ dplyr::bind_cols(probs) |>
100
+ dplyr::mutate(
101
+ Label = table$label, Tokens = as.integer(table$tokens), Seconds = table$seconds,
102
+ Problem = table$problem
103
+ )
104
+ empty <- c(Label = all(is.na(table$label)), Problem = all(is.na(table$problem)))
105
+ shown |> dplyr::select(-dplyr::any_of(names(empty)[empty]))
106
+ }
107
+
108
+ # The answers table of a spec with no answers (for a download before a run)
109
+ empty_table <- function(spec) {
110
+ answers <- empty_answers(spec$question, 0) |> dplyr::mutate(id = character(), .before = 1)
111
+ answers_table(spec, answers, target_answer(spec$question))
112
+ }
R/bundle.R ADDED
@@ -0,0 +1,235 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Bundle and import. The bundle is one zip file with the code, the setup of
2
+ # decisions, the answers and the references of a run. It holds no input image
3
+ # of the visitor and no key. Import reads a setup of this app back, with our
4
+ # own parser: it never calls decisions::read_setup() and never unserializes.
5
+
6
+ # The text of the README of a bundle (80% ASD-STE100)
7
+ bundle_readme <- function(spec, refs, name, has_data, date) {
8
+ files <- c(
9
+ sprintf("- `%s.R`: the R code of the run.", name),
10
+ sprintf(
11
+ "- `%s-setup.json`: the setup of the run. It holds the model, the question, the references and the image settings. It holds no key.",
12
+ name
13
+ ),
14
+ sprintf("- `%s-answers.csv`: the answers of the run.", name),
15
+ if (nrow(refs) > 0) "- `references/`: the reference images of the run. The code reads them.",
16
+ if (has_data) "- `data.csv`: your texts. The code reads them."
17
+ )
18
+ run <- c(
19
+ "Put the files in one folder. Make this folder the working directory of R.",
20
+ if (spec$mode == "image" && spec$source != "textiles") {
21
+ 'Put your images in the folder "images", as the code lists them. This bundle holds no input image.'
22
+ },
23
+ if (spec$mode == "text" && spec$source == "upload" && !has_data) {
24
+ 'Save your file as "data.csv" in the same folder.'
25
+ },
26
+ sprintf("Then run `%s.R`.", name)
27
+ )
28
+ paste(c(
29
+ sprintf("# %s", name),
30
+ "",
31
+ "This zip file holds one run of the decisions Space.",
32
+ "",
33
+ "## Files",
34
+ "",
35
+ files,
36
+ "",
37
+ "## Versions",
38
+ "",
39
+ sprintf("- App: %s", APP_VERSION),
40
+ sprintf("- decisions: commit %s", DECISIONS_SHA),
41
+ sprintf("- Model: %s", MODEL),
42
+ sprintf("- Date: %s", format(date)),
43
+ "",
44
+ "## Install",
45
+ "",
46
+ "Install the package decisions in R:",
47
+ "",
48
+ ' pak::pak("fmegahed/decisions")',
49
+ "",
50
+ "## Key",
51
+ "",
52
+ "The code needs your TypeSafe key. Save the key in the environment variable `TYPESAFE_API_KEY`.",
53
+ "For example, add the line `TYPESAFE_API_KEY=` followed by your key to the file `.Renviron` in your home folder. Then restart R.",
54
+ "Never write the key in the script.",
55
+ "",
56
+ "## Run",
57
+ "",
58
+ run,
59
+ "",
60
+ "## Cite",
61
+ "",
62
+ "If you use this run, cite the package and the Space:",
63
+ "",
64
+ 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"))),
65
+ sprintf(
66
+ "- Megahed, F. M., & Chen, Y.-J. (2026). *decisions Space* (Version %s) [Web application]. Hugging Face. https://huggingface.co/spaces/fmegahed/decisions",
67
+ APP_VERSION
68
+ )
69
+ ), collapse = "\n")
70
+ }
71
+
72
+ # Write the bundle of a run to `zipfile` and return its path. `table` is the
73
+ # answers table of answers_table().
74
+ write_bundle <- function(spec, table, zipfile, date = Sys.Date()) {
75
+ name <- run_name(spec)
76
+ refs <- reference_files(spec)
77
+ dir <- tempfile("bundle")
78
+ dir.create(dir)
79
+ on.exit(unlink(dir, recursive = TRUE))
80
+ readr::write_file(paste0(run_code(spec, date), "\n"), file.path(dir, paste0(name, ".R")))
81
+ readr::write_file(answers_csv(table), file.path(dir, paste0(name, "-answers.csv")))
82
+ # The setup holds the references as the prepared images; the key stays out
83
+ references <- if (nrow(refs) > 0) spec$references[unique(refs$answer)]
84
+ setup <- decisions::decision_setup(
85
+ make_decider(), stats::setNames(list(as_decisions_question(spec$question)), name),
86
+ references = references, max_size = IMAGE_SETTINGS$max_size,
87
+ upscale = IMAGE_SETTINGS$upscale
88
+ )
89
+ decisions::write_setup(setup, file.path(dir, paste0(name, "-setup.json")))
90
+ # The references of a sample stay in the package (system.file())
91
+ if (nrow(refs) > 0 && spec$source != "textiles") {
92
+ dest <- file.path(dir, "references", refs$folder, refs$file)
93
+ purrr::walk(unique(dirname(dest)), dir.create, recursive = TRUE)
94
+ file.copy(refs$path, dest)
95
+ }
96
+ # Pasted text goes in as data.csv; the file of a CSV upload stays with the visitor
97
+ has_data <- spec$mode == "text" && spec$source == "upload" &&
98
+ is.null(spec$text_column) && nrow(spec$inputs) > 0
99
+ if (has_data) {
100
+ readr::write_csv(tibble::tibble(text = spec$inputs$text), file.path(dir, "data.csv"))
101
+ }
102
+ readr::write_file(
103
+ bundle_readme(spec, refs, name, has_data, date), file.path(dir, "README.md")
104
+ )
105
+ zipfile <- normalizePath(zipfile, mustWork = FALSE)
106
+ zip::zip(zipfile, files = list.files(dir), root = dir, mode = "mirror")
107
+ zipfile
108
+ }
109
+
110
+ # The files of the bundle of a spec, as write_bundle() makes them (a folder
111
+ # ends in "/"; test-bundle.R checks that the two agree)
112
+ bundle_names <- function(spec) {
113
+ name <- run_name(spec)
114
+ has_refs <- nrow(reference_files(spec)) > 0 && spec$source != "textiles"
115
+ has_data <- spec$mode == "text" && spec$source == "upload" &&
116
+ is.null(spec$text_column) && nrow(spec$inputs) > 0
117
+ c(
118
+ paste0(name, c(".R", "-setup.json", "-answers.csv")),
119
+ if (has_refs) "references/", if (has_data) "data.csv", "README.md"
120
+ )
121
+ }
122
+
123
+ # --- Import ---------------------------------------------------------------
124
+
125
+ IMPORT_REFUSED <- "This file was not made by this app."
126
+
127
+ # One text value: a string that is not NA
128
+ is_one_string <- function(x) is.character(x) && length(x) == 1 && !is.na(x)
129
+
130
+ # The path of the setup file: the file itself, or the `*-setup.json` of a zip
131
+ # file, unpacked below `dir`. NULL when there is none.
132
+ import_setup_path <- function(path, dir = tempfile("import")) {
133
+ if (!utils::file_test("-f", path) || file.size(path) > mb_bytes(MAX_ZIP_MB)) return(NULL)
134
+ if (tolower(tools::file_ext(path)) != "zip") return(path)
135
+ entries <- tryCatch(zip::zip_list(path), error = function(e) NULL)
136
+ if (is.null(entries)) return(NULL)
137
+ found <- entries |>
138
+ dplyr::filter(
139
+ endsWith(filename, "-setup.json"), purrr::map_lgl(filename, safe_entry),
140
+ !purrr::map_lgl(filename, is_junk), uncompressed_size <= mb_bytes(MAX_ZIP_MB)
141
+ )
142
+ if (nrow(found) == 0) return(NULL)
143
+ dest <- file.path(dir, "setup")
144
+ zip::unzip(path, files = found$filename[1], exdir = dest)
145
+ file.path(dest, found$filename[1])
146
+ }
147
+
148
+ # TRUE when the parsed JSON has the layout and the settings of this app
149
+ is_app_setup <- function(json) {
150
+ question <- if (length(json$questions) == 1) json$questions[[1]]
151
+ answers <- question$answers
152
+ settings <- json$image_settings
153
+ is.list(json) &&
154
+ identical(as.numeric(json$version), 1) &&
155
+ is_one_string(json$decider$model) &&
156
+ is.null(json$calibrator) && is.null(json$calibrator_hash) &&
157
+ is_one_string(question$type) && question$type %in% c("noul", "choice", "score") &&
158
+ is_one_string(question$instructions) &&
159
+ length(answers) > 0 && !is.null(names(answers)) &&
160
+ all(purrr::map_lgl(answers, \(x) is.null(x) || is_one_string(x))) &&
161
+ identical(as.numeric(settings$max_size), as.numeric(IMAGE_SETTINGS$max_size)) &&
162
+ identical(settings$upscale, IMAGE_SETTINGS$upscale) &&
163
+ identical(settings$crop, IMAGE_SETTINGS$crop)
164
+ }
165
+
166
+ # Decode the references of the file below `dir`, as
167
+ # references/<answer>/ref_<i>.jpg. NULL when there is a bad reference.
168
+ import_references <- function(json, answers, dir = tempfile("import")) {
169
+ encoded <- json$references
170
+ if (length(encoded) == 0) return(list(paths = NULL, problems = character()))
171
+ # The references are an object keyed by answer: names that are all answers
172
+ keys <- names(encoded)
173
+ if (!is.list(encoded) || is.null(keys) || anyNA(keys) || anyDuplicated(keys) > 0 ||
174
+ !all(keys %in% names(answers))) {
175
+ return(NULL)
176
+ }
177
+ folders <- answer_folders(names(answers))
178
+ paths <- tryCatch(
179
+ purrr::imap(encoded, \(images, answer) {
180
+ if (!is.list(images) || length(images) > MAX_REFERENCES) stop("bad references")
181
+ purrr::imap_chr(unname(images), \(x, i) {
182
+ bytes <- if (is_one_string(x)) jsonlite::base64_dec(x) else raw()
183
+ jpeg <- length(bytes) > 3 && all(bytes[1:3] == as.raw(c(0xff, 0xd8, 0xff)))
184
+ if (!jpeg || length(bytes) > mb_bytes(MAX_FILE_MB)) stop("bad reference")
185
+ path <- file.path(dir, "references", folders[[answer]], sprintf("ref_%s.jpg", i))
186
+ dir.create(dirname(path), recursive = TRUE, showWarnings = FALSE)
187
+ writeBin(bytes, path)
188
+ path
189
+ })
190
+ }),
191
+ error = function(e) NULL
192
+ )
193
+ if (is.null(paths)) return(NULL)
194
+ list(paths = paths, problems = character())
195
+ }
196
+
197
+ # Read a setup of this app: a bundle zip or the setup JSON. Returns the
198
+ # question (in the format of spec.R), the references (answer to file paths) and
199
+ # the problems. `question` is NULL when the file is refused. Every file of the
200
+ # import is written below `dir` (the server gives the folder of the session).
201
+ read_import_file <- function(path, dir = tempfile("import")) {
202
+ refuse <- function(problems = IMPORT_REFUSED) {
203
+ list(question = NULL, references = NULL, problems = problems)
204
+ }
205
+ setup_path <- import_setup_path(path, dir)
206
+ if (is.null(setup_path)) return(refuse())
207
+ json <- tryCatch(jsonlite::read_json(setup_path), error = function(e) NULL)
208
+ if (is.null(json) || !isTRUE(tryCatch(is_app_setup(json), error = function(e) FALSE))) {
209
+ return(refuse())
210
+ }
211
+ raw <- json$questions[[1]]
212
+ answers <- purrr::map_chr(raw$answers, \(x) x %||% "")
213
+ if (raw$type == "noul" && !setequal(names(answers), c("true", "false"))) return(refuse())
214
+ question <- list(type = raw$type, instructions = raw$instructions, answers = answers)
215
+ problems <- check_question(question)
216
+ if (length(problems) > 0) return(refuse(problems))
217
+ references <- import_references(json, answers, dir)
218
+ if (is.null(references) || (raw$type == "score" && length(references$paths) > 0)) return(refuse())
219
+ model <- json$decider$model
220
+ if (model != MODEL) {
221
+ problems <- c(problems, sprintf("This file uses %s. The app uses %s.", model, MODEL))
222
+ }
223
+ list(
224
+ question = question, references = references$paths,
225
+ problems = c(problems, references$problems)
226
+ )
227
+ }
228
+
229
+ # Read an import file. It never stops: any error refuses the file.
230
+ read_import <- function(path, dir = tempfile("import")) {
231
+ tryCatch(
232
+ read_import_file(path, dir),
233
+ error = function(e) list(question = NULL, references = NULL, problems = IMPORT_REFUSED)
234
+ )
235
+ }
R/code.R ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # R code of a run: the text that the visitor copies, downloads or finds in the
2
+ # bundle. It uses only functions of decisions and base R (dplyr only for the
3
+ # subset of the injuries sample). Every string of the visitor goes through
4
+ # encodeString(), so quotes, backticks and new lines stay valid R.
5
+
6
+ # An R string with double quotes
7
+ r_string <- function(x) encodeString(x, quote = '"')
8
+
9
+ # A name for the left side of `name = value`: bare when R allows it, else quoted
10
+ r_name <- function(x) ifelse(make.names(x) == x, x, r_string(x))
11
+
12
+ # Folder names for the answers: a name with one of `/ \ : * ? " < > |`, a
13
+ # control character or a leading dot is cleaned, and the clean names stay
14
+ # unique. The names of the result are the answers.
15
+ answer_folders <- function(answers) {
16
+ folder <- answers |>
17
+ stringr::str_replace_all('[/\\\\:*?"<>|[:cntrl:]]', "_") |>
18
+ stringr::str_remove("^\\.+") |>
19
+ stringr::str_squish()
20
+ folder[folder == ""] <- "answer"
21
+ stats::setNames(make.unique(folder, sep = "_"), answers)
22
+ }
23
+
24
+ # The reference files of a spec: one row for each file, with its answer, the
25
+ # folder of the answer, the source path and the file name in the folder.
26
+ # A score question has none (Jev attaches references to noul and choice only).
27
+ reference_files <- function(spec) {
28
+ empty <- tibble::tibble(
29
+ answer = character(), folder = character(), path = character(), file = character()
30
+ )
31
+ if (is.null(spec$references) || spec$question$type == "score") return(empty)
32
+ folders <- answer_folders(names(spec$question$answers))
33
+ purrr::imap(spec$references, \(paths, answer) {
34
+ file <- basename(paths)
35
+ twice <- duplicated(file) | duplicated(file, fromLast = TRUE)
36
+ file[twice] <- paste0(seq_along(file)[twice], "_", file[twice])
37
+ tibble::tibble(answer = answer, folder = folders[[answer]], path = paths, file = file)
38
+ }) |>
39
+ purrr::list_rbind()
40
+ }
41
+
42
+ # The name of a run for file names: "my parts" gives "my_parts"
43
+ run_name <- function(spec) {
44
+ name <- janitor::make_clean_names(spec$name, case = "snake")
45
+ if (name == "") "run" else name
46
+ }
47
+
48
+ # The variable that holds the question in the code
49
+ question_var <- function(spec) {
50
+ switch(spec$source, textiles = "weave", injuries = "injury", "question")
51
+ }
52
+
53
+ # Strings in lines of at most `width` characters, with a comma after each
54
+ # string but the last. Used for long lists of files.
55
+ pack_lines <- function(items, indent = " ", width = 80) {
56
+ lines <- character()
57
+ line <- ""
58
+ for (i in seq_along(items)) {
59
+ piece <- if (i < length(items)) paste0(items[i], ",") else items[i]
60
+ if (nzchar(line) && nchar(indent) + nchar(line) + 1 + nchar(piece) > width) {
61
+ lines <- c(lines, paste0(indent, line))
62
+ line <- piece
63
+ } else {
64
+ line <- stringr::str_trim(paste(line, piece))
65
+ }
66
+ }
67
+ c(lines, paste0(indent, line))
68
+ }
69
+
70
+ # c("a", "b") as code: on one line when short, else in packed lines
71
+ r_vector <- function(strings, indent = "") {
72
+ one <- paste0("c(", paste(r_string(strings), collapse = ", "), ")")
73
+ if (nchar(indent) + nchar(one) <= 80) return(one)
74
+ lines <- pack_lines(r_string(strings), paste0(indent, " "))
75
+ paste0("c(\n", paste(lines, collapse = "\n"), "\n", indent, ")")
76
+ }
77
+
78
+ # The six lines of the comment on paths
79
+ paths_comment <- function(spec, refs) {
80
+ folders <- if (nrow(refs) > 0) {
81
+ 'the folder "images" and the folder "references"'
82
+ } else {
83
+ 'the folder "images"'
84
+ }
85
+ working_directory <- c(
86
+ "working directory (RStudio: Session > Set Working Directory > To Source",
87
+ "File Location). getwd() shows where R looks."
88
+ )
89
+ lines <- if (spec$mode == "text" && spec$source == "injuries") {
90
+ c(
91
+ "Paths. This run used the sample texts of the package: the dataset",
92
+ "ohio_injuries comes with decisions, so no path needs a change.",
93
+ 'Your own texts use a file next to this script: "data.csv".',
94
+ 'Put this script and the file "data.csv" in one folder, and make it the',
95
+ working_directory
96
+ )
97
+ } else if (spec$mode == "text") {
98
+ column <- if (is.null(spec$text_column)) {
99
+ 'The bundle holds your texts there, in the column "text".'
100
+ } else {
101
+ sprintf(
102
+ 'Name your file "data.csv". The texts are in the column %s.',
103
+ r_string(spec$text_column)
104
+ )
105
+ }
106
+ c(
107
+ "Paths. This run used your own texts. The code reads them from the file",
108
+ '"data.csv" next to this script.',
109
+ column,
110
+ 'Put this script and the file "data.csv" in one folder, and make it the',
111
+ working_directory
112
+ )
113
+ } else if (spec$source == "textiles") {
114
+ c(
115
+ "Paths. This run used the sample images of the package: system.file()",
116
+ "finds them on any computer, so no path needs a change.",
117
+ 'Your own images use relative paths, such as "images/part_001.jpg".',
118
+ sprintf("Put this script, %s in one", folders),
119
+ "folder, and make it the working directory (RStudio: Session > Set Working",
120
+ "Directory > To Source File Location). getwd() shows where R looks."
121
+ )
122
+ } else {
123
+ c(
124
+ "Paths. This run used your own images. The code reads them with relative",
125
+ 'paths, such as "images/part_001.jpg". The bundle holds no input image.',
126
+ sprintf("Put this script, %s in one", folders),
127
+ "folder, and make it the working directory (RStudio: Session > Set Working",
128
+ "Directory > To Source File Location). getwd() shows where R looks.",
129
+ 'Put your images in the folder "images", as the code below lists them.'
130
+ )
131
+ }
132
+ paste0("# ", lines, collapse = "\n")
133
+ }
134
+
135
+ # The definition of `references` from relative paths (empty without references)
136
+ references_code <- function(refs) {
137
+ if (nrow(refs) == 0) return("")
138
+ by_answer <- refs |>
139
+ dplyr::summarise(
140
+ code = paste0(
141
+ " ", r_name(dplyr::first(answer)), " = ",
142
+ r_vector(paste0("references/", dplyr::first(folder), "/", file), indent = " ")
143
+ ),
144
+ .by = answer
145
+ )
146
+ paste0("\nreferences <- list(\n", paste(by_answer$code, collapse = ",\n"), "\n)")
147
+ }
148
+
149
+ # The lines that define `texts` (or `injuries`)
150
+ text_code <- function(spec) {
151
+ if (spec$source == "injuries") {
152
+ return(paste(
153
+ "set.seed(2026)",
154
+ "# This line needs the package dplyr.",
155
+ "injuries <- ohio_injuries |>",
156
+ " dplyr::slice_sample(n = 20, by = collapsed_nature_title)",
157
+ sep = "\n"
158
+ ))
159
+ }
160
+ if (is.null(spec$text_column)) {
161
+ return('texts <- read.csv("data.csv")$text')
162
+ }
163
+ # read.csv() changes odd column names unless check.names is FALSE
164
+ read <- if (make.names(spec$text_column) == spec$text_column) {
165
+ 'read.csv("data.csv")'
166
+ } else {
167
+ 'read.csv("data.csv", check.names = FALSE)'
168
+ }
169
+ paste0("texts <- ", read, "[[", r_string(spec$text_column), "]]")
170
+ }
171
+
172
+ # The lines that define `images` and `references`
173
+ inputs_code <- function(spec, refs) {
174
+ if (spec$mode == "text") return(text_code(spec))
175
+ if (spec$source == "textiles") {
176
+ by_answer <- refs |>
177
+ dplyr::summarise(
178
+ code = paste0(
179
+ " ", r_name(dplyr::first(answer)), " = file.path(folder, ",
180
+ r_vector(file, indent = " "), ")"
181
+ ),
182
+ .by = answer
183
+ )
184
+ return(paste0(
185
+ 'folder <- system.file("extdata", "textiles", package = "decisions")\n',
186
+ 'images <- file.path(folder, textiles$file[textiles$role == "input"])\n',
187
+ "references <- list(\n", paste(by_answer$code, collapse = ",\n"), "\n)"
188
+ ))
189
+ }
190
+ inputs <- spec$inputs
191
+ images <- if (nrow(inputs) == 0) {
192
+ 'images <- list.files("images", full.names = TRUE)'
193
+ } else {
194
+ labels <- unique(stats::na.omit(inputs$label))
195
+ folders <- answer_folders(union(names(spec$question$answers), labels))
196
+ sub <- ifelse(is.na(inputs$label), "", paste0(folders[inputs$label], "/"))
197
+ files <- r_string(paste0("images/", sub, inputs$file))
198
+ paste0("images <- c(\n", paste(pack_lines(files), collapse = "\n"), "\n)")
199
+ }
200
+ paste0(images, references_code(refs))
201
+ }
202
+
203
+ # The call of the question constructor, with the exact text of the question
204
+ question_code <- function(question, var) {
205
+ answers <- question$answers
206
+ args <- r_string(question$instructions)
207
+ if (question$type == "noul") {
208
+ given <- answers[nzchar(answers)]
209
+ if (length(given) > 0) args <- c(args, paste0(names(given), " = ", r_string(given)))
210
+ } else {
211
+ names_r <- r_name(names(answers))
212
+ padded <- stringr::str_pad(names_r, max(nchar(names_r)), side = "right")
213
+ args <- c(args, paste0(
214
+ "c(\n", paste0(" ", padded, " = ", r_string(answers), collapse = ",\n"), "\n )"
215
+ ))
216
+ }
217
+ paste0(
218
+ var, " <- question_", question$type, "(\n",
219
+ paste0(" ", args, collapse = ",\n"), "\n)"
220
+ )
221
+ }
222
+
223
+ # The code of a run, as one string. `date` is the day of the run.
224
+ run_code <- function(spec, date = Sys.Date()) {
225
+ refs <- reference_files(spec)
226
+ var <- question_var(spec)
227
+ data_arg <- if (spec$mode == "text") {
228
+ paste0("text = ", if (spec$source == "injuries") "injuries$final_narrative" else "texts")
229
+ } else {
230
+ "image = images"
231
+ }
232
+ if (nrow(refs) > 0) data_arg <- paste0(data_arg, ", references = references")
233
+ paste(
234
+ sprintf("# The run of the decisions Space on %s, in R. Run it with your own key.", format(date)),
235
+ "library(decisions)",
236
+ "",
237
+ paths_comment(spec, refs),
238
+ inputs_code(spec, refs),
239
+ "",
240
+ question_code(spec$question, var),
241
+ "",
242
+ "answers <- decide(",
243
+ sprintf(' decider_typesafe("%s"), %s,', MODEL, var),
244
+ paste0(" ", data_arg, ","),
245
+ sprintf(
246
+ " max_size = %s, upscale = %s",
247
+ IMAGE_SETTINGS$max_size, toupper(IMAGE_SETTINGS$upscale)
248
+ ),
249
+ ")",
250
+ "answers",
251
+ sep = "\n"
252
+ )
253
+ }
254
+
255
+ # The number of lines of the code, for the closed card
256
+ code_lines <- function(code) {
257
+ stringr::str_count(code, "\n") + 1L
258
+ }
R/config.R ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Configuration: the single place for the model id, the limits and the links.
2
+ # Everything a user reads about the model or the limits comes from these
3
+ # values (see app_config() at the end), so the text cannot drift from the
4
+ # behaviour.
5
+
6
+ # Version of this app
7
+ APP_VERSION <- "1.0.0"
8
+ APP_DATE <- "24 October 2026"
9
+
10
+ # Model used for every call. This is the ONLY place a model id may be written
11
+ # (tests/testthat/test-config.R enforces this).
12
+ MODEL <- "jev-1.14.0"
13
+
14
+ # Commit of the decisions package that the app uses
15
+ DECISIONS_SHA <- "b25fc9ffa7908e9a6a2d9833f080de1456c9d50a"
16
+
17
+ # Price in USD for each input token (USD 0.042 for each million tokens)
18
+ PRICE_PER_TOKEN <- 0.042 / 1e6
19
+
20
+ # Spending limits in USD (all days and months are in UTC)
21
+ LIMIT_SESSION <- 0.25
22
+ LIMIT_DAY <- 2
23
+ LIMIT_MONTH <- 20
24
+
25
+ # Token guesses for the cost check before the first call
26
+ TOKENS_GUESS <- c(image = 16000, text = 1000)
27
+
28
+ # Input limits
29
+ MAX_FILE_MB <- 5
30
+ MAX_ZIP_MB <- 50
31
+ MAX_IMAGES <- 100
32
+ MAX_TEXTS <- 200
33
+ MAX_TEXT_CHARS <- 4000
34
+ MAX_INSTRUCTIONS <- 500
35
+ MAX_DESCRIPTION <- 300
36
+
37
+ # Number of answers for a choice question and of levels for a score question
38
+ ANSWER_RANGE <- list(choice = c(2, 6), score = c(2, 5))
39
+
40
+ # Reference images for each answer, and inputs for each call to Jev
41
+ MAX_REFERENCES <- 2
42
+ CHUNK_SIZE <- 10
43
+
44
+ # Image settings that decisions applies before it sends an image
45
+ IMAGE_SETTINGS <- list(max_size = 768, upscale = TRUE, crop = "none")
46
+
47
+ # Names of the environment variables (the values are never in the code)
48
+ KEY_VAR <- "HF_JEV_API"
49
+ USAGE_TOKEN_VAR <- "HF_USAGE_TOKEN"
50
+
51
+ # Private dataset that stores the usage, and the public status file
52
+ USAGE_REPO <- "fmegahed/decisions-space-usage"
53
+ STATUS_URL <- "https://raw.githubusercontent.com/fmegahed/decisions-space/main/status/status.csv"
54
+
55
+ # Address of the Space, and the video (empty until the video exists)
56
+ SPACE_HOST <- "https://fmegahed-decisions.hf.space"
57
+ VIDEO_URL <- ""
58
+
59
+ # The values that the help text uses, in one named list
60
+ app_config <- function() {
61
+ list(
62
+ version = APP_VERSION, date = APP_DATE, model = MODEL,
63
+ price_per_million = PRICE_PER_TOKEN * 1e6,
64
+ limit_session = LIMIT_SESSION, limit_day = LIMIT_DAY,
65
+ limit_month = LIMIT_MONTH, tokens_guess = TOKENS_GUESS,
66
+ max_file_mb = MAX_FILE_MB, max_zip_mb = MAX_ZIP_MB,
67
+ max_images = MAX_IMAGES, max_texts = MAX_TEXTS,
68
+ max_text_chars = MAX_TEXT_CHARS, max_instructions = MAX_INSTRUCTIONS,
69
+ max_description = MAX_DESCRIPTION, answer_range = ANSWER_RANGE,
70
+ max_references = MAX_REFERENCES, chunk_size = CHUNK_SIZE,
71
+ image_settings = IMAGE_SETTINGS, space_host = SPACE_HOST,
72
+ video_url = VIDEO_URL
73
+ )
74
+ }
R/data.R ADDED
@@ -0,0 +1,326 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Data of the visitor: images (loose files or zip files), references, pasted
2
+ # text and CSV files. Each reader returns a list with `inputs` (the inputs
3
+ # tibble of spec.R) and `problems` (short sentences for the visitor). The
4
+ # readers never stop on bad data: they skip it and say why.
5
+
6
+ IMAGE_EXTENSIONS <- c("jpg", "jpeg", "png", "webp")
7
+
8
+ # Bytes in a limit given in MB
9
+ mb_bytes <- function(mb) mb * 1024^2
10
+
11
+ # TRUE when a file name is an image that Jev can read
12
+ is_image_name <- function(x) tolower(tools::file_ext(x)) %in% IMAGE_EXTENSIONS
13
+
14
+ # TRUE when a zip entry stays inside the folder of the zip: no absolute path,
15
+ # no drive letter, no backslash and no `..`
16
+ safe_entry <- function(x) {
17
+ !startsWith(x, "/") && !grepl("^[A-Za-z]:", x) && !grepl("\\", x, fixed = TRUE) &&
18
+ !any(strsplit(x, "/", fixed = TRUE)[[1]] == "..")
19
+ }
20
+
21
+ # TRUE for the files that macOS and Windows add to a zip file: a folder
22
+ # __MACOSX, a dot file or folder (._a.jpg, .DS_Store), Thumbs.db, desktop.ini
23
+ is_junk <- function(x) {
24
+ parts <- strsplit(x, "/", fixed = TRUE)[[1]]
25
+ any(parts == "__MACOSX" | startsWith(parts, ".")) ||
26
+ tolower(utils::tail(parts, 1)) %in% c("thumbs.db", "desktop.ini")
27
+ }
28
+
29
+ # The inputs tibble with no rows
30
+ empty_inputs <- function() {
31
+ tibble::tibble(
32
+ id = character(), path = character(), text = character(),
33
+ file = character(), label = character()
34
+ )
35
+ }
36
+
37
+ # Unique ids: a running number joined with the file name, so that the same
38
+ # name in two folders gives two ids. Spaces and odd characters become "_".
39
+ make_ids <- function(file) {
40
+ clean <- stringr::str_replace_all(file, "[^\\p{L}\\p{N}._-]+", "_")
41
+ sprintf("%03d_%s", seq_along(file), clean)
42
+ }
43
+
44
+ # Unpack the safe image entries of a zip file into the folder `dest`. The
45
+ # entries are counted from the listing first: when they are more than
46
+ # `budget`, nothing is extracted. An entry over the file limit is never
47
+ # extracted. One call to zip::unzip() extracts the rest, below `dest` only.
48
+ # Returns the files (`rel`, `path`, `size`), the count of other files, the
49
+ # count of images and the problems.
50
+ unpack_zip <- function(zipfile, name, dest, budget = MAX_IMAGES) {
51
+ entries <- tryCatch(zip::zip_list(zipfile), error = function(e) NULL)
52
+ if (is.null(entries)) {
53
+ return(list(
54
+ files = NULL, other = 0L, count = 0L,
55
+ problems = sprintf("%s could not be opened. Check that it is a zip file.", name)
56
+ ))
57
+ }
58
+ kept <- entries |>
59
+ dplyr::filter(!endsWith(filename, "/")) |>
60
+ dplyr::filter(purrr::map_lgl(filename, safe_entry)) |>
61
+ dplyr::filter(!purrr::map_lgl(filename, is_junk))
62
+ # A single top folder around the label folders is not a label
63
+ parts <- strsplit(kept$filename, "/", fixed = TRUE)
64
+ tops <- unique(purrr::map_chr(parts, 1))
65
+ rel <- kept$filename
66
+ if (length(tops) == 1 && any(lengths(parts) >= 3)) {
67
+ rel <- stringr::str_remove(rel, stringr::fixed(paste0(tops, "/")))
68
+ }
69
+ kept <- kept |> dplyr::mutate(rel = rel)
70
+ is_img <- is_image_name(kept$filename)
71
+ too_big <- is_img & kept$uncompressed_size > mb_bytes(MAX_FILE_MB)
72
+ use <- kept |> dplyr::filter(is_img, !too_big)
73
+ problems <- sprintf("%s was skipped. A file can be at most %s MB.", basename(kept$rel[too_big]), MAX_FILE_MB)
74
+ out <- list(files = NULL, other = sum(!is_img), count = nrow(use), problems = problems)
75
+ if (nrow(use) == 0 || nrow(use) > budget) return(out)
76
+ dir.create(dest, recursive = TRUE, showWarnings = FALSE)
77
+ zip::unzip(zipfile, files = use$filename, exdir = dest)
78
+ out$files <- use |>
79
+ dplyr::mutate(path = file.path(dest, filename), size = uncompressed_size) |>
80
+ dplyr::select(rel, path, size)
81
+ out
82
+ }
83
+
84
+ # Sentence for the files that are not images
85
+ not_image_problem <- function(n) {
86
+ if (n == 0) return(character())
87
+ if (n == 1) return("1 file is not an image and was skipped. Use JPEG, PNG or WebP.")
88
+ sprintf("%d files are not images and were skipped. Use JPEG, PNG or WebP.", n)
89
+ }
90
+
91
+ # Copy a loose upload into `dir` (one subfolder for each upload); returns the new path
92
+ keep_upload <- function(path, name, dir, i) {
93
+ dest <- file.path(dir, sprintf("file%03d", i))
94
+ dir.create(dest, recursive = TRUE, showWarnings = FALSE)
95
+ file.copy(path, file.path(dest, basename(name)))
96
+ file.path(dest, basename(name))
97
+ }
98
+
99
+ # Images from the files of shiny::fileInput() (columns `name`, `datapath`).
100
+ # A folder in a zip file gives the label; loose files have no label. Every
101
+ # file is written below `dir`, so the caller can delete `dir` at the end of
102
+ # the session. The result carries `dir`.
103
+ read_images <- function(files, dir = tempfile("upload")) {
104
+ if (is.null(files) || nrow(files) == 0) {
105
+ return(list(inputs = empty_inputs(), problems = character(), dir = dir))
106
+ }
107
+ problems <- character()
108
+ other <- 0L
109
+ loaded <- 0L
110
+ counted <- 0L
111
+ found <- purrr::pmap(list(files$name, files$datapath, seq_len(nrow(files))), \(name, path, i) {
112
+ if (tolower(tools::file_ext(name)) == "zip") {
113
+ if (file.size(path) > mb_bytes(MAX_ZIP_MB)) {
114
+ problems <<- c(problems, sprintf("%s was skipped. A zip file can be at most %s MB.", name, MAX_ZIP_MB))
115
+ return(NULL)
116
+ }
117
+ zipped <- unpack_zip(path, name, file.path(dir, sprintf("zip%03d", i)), MAX_IMAGES - loaded)
118
+ problems <<- c(problems, zipped$problems)
119
+ other <<- other + zipped$other
120
+ counted <<- counted + zipped$count
121
+ loaded <<- loaded + NROW(zipped$files)
122
+ return(zipped$files)
123
+ }
124
+ if (is_junk(name)) return(NULL)
125
+ if (!is_image_name(name)) {
126
+ other <<- other + 1L
127
+ return(NULL)
128
+ }
129
+ if (file.size(path) > mb_bytes(MAX_FILE_MB)) {
130
+ problems <<- c(problems, sprintf("%s was skipped. A file can be at most %s MB.", name, MAX_FILE_MB))
131
+ return(NULL)
132
+ }
133
+ counted <<- counted + 1L
134
+ if (loaded >= MAX_IMAGES) return(NULL)
135
+ loaded <<- loaded + 1L
136
+ tibble::tibble(rel = name, path = keep_upload(path, name, dir, i), size = file.size(path))
137
+ })
138
+ images <- dplyr::bind_rows(found)
139
+ problems <- c(problems, not_image_problem(other))
140
+ if (counted > MAX_IMAGES) {
141
+ problems <- c(problems, sprintf(
142
+ "A run can have at most %d images. This upload has %d. Upload fewer images.",
143
+ MAX_IMAGES, counted
144
+ ))
145
+ images <- images[0, ]
146
+ }
147
+ if (nrow(images) == 0) return(list(inputs = empty_inputs(), problems = problems, dir = dir))
148
+ parts <- strsplit(images$rel, "/", fixed = TRUE)
149
+ file <- purrr::map_chr(parts, utils::tail, 1)
150
+ label <- purrr::map_chr(parts, \(p) if (length(p) >= 2) p[1] else NA_character_)
151
+ inputs <- tibble::tibble(
152
+ id = make_ids(file), path = images$path, text = NA_character_,
153
+ file = file, label = label
154
+ )
155
+ list(inputs = inputs, problems = problems, dir = dir)
156
+ }
157
+
158
+ # Reference images: loose image files, at most MAX_REFERENCES, copied below `dir`
159
+ read_references <- function(files, dir = tempfile("upload")) {
160
+ if (is.null(files) || nrow(files) == 0) {
161
+ return(list(paths = character(), problems = character(), dir = dir))
162
+ }
163
+ is_img <- is_image_name(files$name)
164
+ too_big <- is_img & file.size(files$datapath) > mb_bytes(MAX_FILE_MB)
165
+ problems <- c(
166
+ sprintf("%s was skipped. A file can be at most %s MB.", files$name[too_big], MAX_FILE_MB),
167
+ not_image_problem(sum(!is_img))
168
+ )
169
+ use <- which(is_img & !too_big)
170
+ if (length(use) > MAX_REFERENCES) {
171
+ problems <- c(problems, sprintf(
172
+ "You can add at most %d references for each answer. The app used the first %d.",
173
+ MAX_REFERENCES, MAX_REFERENCES
174
+ ))
175
+ use <- use[seq_len(MAX_REFERENCES)]
176
+ }
177
+ paths <- purrr::map_chr(use, \(i) keep_upload(files$datapath[i], files$name[i], dir, i))
178
+ list(paths = paths, problems = problems, dir = dir)
179
+ }
180
+
181
+ # Inputs from texts: skip the texts that are too long, and stop when there
182
+ # are too many. `file` is the display name of each text.
183
+ texts_to_inputs <- function(text, file, label, problems = character()) {
184
+ long <- stringr::str_length(text) > MAX_TEXT_CHARS
185
+ if (any(long)) {
186
+ problems <- c(problems, sprintf(
187
+ "%s was skipped. A text can have at most %s characters.",
188
+ file[long], format(MAX_TEXT_CHARS, big.mark = ",")
189
+ ))
190
+ }
191
+ keep <- !long
192
+ if (sum(keep) > MAX_TEXTS) {
193
+ problems <- c(problems, sprintf(
194
+ "A run can have at most %d texts. This upload has %d. Use fewer texts.",
195
+ MAX_TEXTS, sum(keep)
196
+ ))
197
+ keep <- rep(FALSE, length(text))
198
+ }
199
+ if (!any(keep)) return(list(inputs = empty_inputs(), problems = problems))
200
+ inputs <- tibble::tibble(
201
+ id = make_ids(file[keep]), path = NA_character_, text = text[keep],
202
+ file = file[keep], label = label[keep]
203
+ )
204
+ list(inputs = inputs, problems = problems)
205
+ }
206
+
207
+ # One input for each paragraph; one or more blank lines separate paragraphs
208
+ read_text_paste <- function(text) {
209
+ if (is.null(text) || length(text) == 0 || is.na(text[1])) text <- ""
210
+ paragraphs <- text[1] |>
211
+ stringr::str_replace_all("\r\n?", "\n") |>
212
+ stringr::str_split("\\n\\s*\\n") |>
213
+ unlist() |>
214
+ stringr::str_trim()
215
+ paragraphs <- paragraphs[paragraphs != ""]
216
+ texts_to_inputs(
217
+ paragraphs, sprintf("Text %d", seq_along(paragraphs)),
218
+ rep(NA_character_, length(paragraphs))
219
+ )
220
+ }
221
+
222
+ # The encoding of a CSV file: UTF-8 when the bytes are valid UTF-8 (a BOM is
223
+ # fine), else the best guess of readr, else Latin-1
224
+ csv_encoding <- function(path) {
225
+ bytes <- readBin(path, "raw", file.size(path))
226
+ if (!0x00 %in% bytes && validUTF8(rawToChar(bytes))) return("UTF-8")
227
+ guess <- readr::guess_encoding(path)$encoding
228
+ if (length(guess) == 0 || is.na(guess[1])) "latin1" else guess[1]
229
+ }
230
+
231
+ # Read a CSV file with every column as text; NULL when it cannot be read
232
+ read_csv_text <- function(path, n_max = Inf) {
233
+ tryCatch(
234
+ readr::read_csv(
235
+ path, locale = readr::locale(encoding = csv_encoding(path)),
236
+ col_types = readr::cols(.default = "c"), na = character(),
237
+ n_max = n_max, progress = FALSE
238
+ ),
239
+ error = function(e) NULL
240
+ )
241
+ }
242
+
243
+ # The column names of a CSV file
244
+ csv_columns <- function(path) {
245
+ if (file.size(path) > mb_bytes(MAX_FILE_MB)) return(character())
246
+ names(read_csv_text(path, n_max = 0))
247
+ }
248
+
249
+ # Inputs from a CSV file: the text column, and optionally the label column.
250
+ # Empty texts are dropped with a count; blank labels stay unlabelled.
251
+ read_text_csv <- function(path, text_col, label_col = NULL, name = basename(path)) {
252
+ if (file.size(path) > mb_bytes(MAX_FILE_MB)) {
253
+ return(list(
254
+ inputs = empty_inputs(),
255
+ problems = sprintf("%s was skipped. A file can be at most %s MB.", name, MAX_FILE_MB)
256
+ ))
257
+ }
258
+ data <- read_csv_text(path)
259
+ if (is.null(data)) {
260
+ return(list(inputs = empty_inputs(), problems = "The CSV file could not be read."))
261
+ }
262
+ if (!text_col %in% names(data)) {
263
+ return(list(
264
+ inputs = empty_inputs(),
265
+ problems = sprintf("The column %s is not in the file.", text_col)
266
+ ))
267
+ }
268
+ has_label <- !is.null(label_col) && label_col %in% names(data)
269
+ data <- tibble::tibble(
270
+ text = stringr::str_trim(data[[text_col]]),
271
+ label = if (has_label) stringr::str_trim(data[[label_col]]) else NA_character_,
272
+ file = sprintf("Row %d", seq_len(nrow(data)))
273
+ ) |>
274
+ dplyr::mutate(label = dplyr::na_if(label, ""))
275
+ empty <- data$text == ""
276
+ problems <- if (sum(empty) == 1) {
277
+ "1 empty text was dropped"
278
+ } else if (sum(empty) > 1) {
279
+ sprintf("%d empty texts were dropped", sum(empty))
280
+ } else {
281
+ character()
282
+ }
283
+ data <- data[!empty, ]
284
+ texts_to_inputs(data$text, data$file, data$label, problems)
285
+ }
286
+
287
+ # Match the labels to the answer names after str_squish() and str_to_lower().
288
+ # A matched label gets the exact answer name; any other label becomes NA (the
289
+ # input stays, unlabelled) and is listed once. `answers` is the vector of
290
+ # answers (named) or of answer names.
291
+ match_labels <- function(inputs, answers) {
292
+ clean <- \(x) stringr::str_to_lower(stringr::str_squish(x))
293
+ answer_names <- if (is.null(names(answers))) answers else names(answers)
294
+ hit <- match(clean(inputs$label), clean(answer_names))
295
+ unmatched <- stringr::str_squish(inputs$label[is.na(hit) & !is.na(inputs$label)])
296
+ unmatched <- unmatched[!duplicated(clean(unmatched))]
297
+ inputs$label <- answer_names[hit]
298
+ list(inputs = inputs, problems = sprintf("%s is not an answer", unmatched))
299
+ }
300
+
301
+ # A small JPEG copy of an image (at most 320 pixels on the long side) for the
302
+ # page; FALSE when the image cannot be read
303
+ make_thumb <- function(path, dest) {
304
+ tryCatch(
305
+ {
306
+ magick::image_read(path)[1] |>
307
+ magick::image_background("white") |>
308
+ magick::image_scale("320x320>") |>
309
+ magick::image_write(dest, format = "jpeg")
310
+ TRUE
311
+ },
312
+ error = \(e) FALSE
313
+ )
314
+ }
315
+
316
+ # Delete the files of Shiny uploads, and their folders when they are empty
317
+ remove_uploads <- function(paths) {
318
+ unlink(paths)
319
+ folders <- setdiff(
320
+ unique(normalizePath(dirname(paths), winslash = "/", mustWork = FALSE)),
321
+ normalizePath(tempdir(), winslash = "/")
322
+ )
323
+ files <- lengths(lapply(folders, list.files, all.files = TRUE, no.. = TRUE))
324
+ empty <- folders[dir.exists(folders) & files == 0]
325
+ unlink(empty, recursive = TRUE)
326
+ }
R/help.R ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Help: the text of the intro, the help lines, the notes, the Record tab and
2
+ # About. All text is in 80% ASD-STE100: short sentences, active voice, no
3
+ # contractions. Numbers and versions come from app_config(), so the text
4
+ # cannot drift from the behaviour.
5
+
6
+ # The keys of help_text()
7
+ HELP_KEYS <- c(
8
+ "intro", "data", "question", "references", "references_score", "answers",
9
+ "code", "privacy", "record", "about"
10
+ )
11
+
12
+ # The authors, as the footer and About show them
13
+ AUTHORS <- "Fadel M. Megahed (Miami University) \u00b7 Ying-Ju (Tessa) Chen (University of Dayton)"
14
+
15
+ # Links of the package, the records and the source of the app
16
+ LINKS <- list(
17
+ package = "https://github.com/fmegahed/decisions",
18
+ package_record = "https://fmegahed.github.io/decisions/record/",
19
+ project_record = "https://github.com/fmegahed/decision-models-for-industrial-image-qc/tree/main/replication_materials",
20
+ source = "https://github.com/fmegahed/decisions-space",
21
+ license = "https://github.com/fmegahed/decisions-space/blob/main/LICENSE",
22
+ issues = "https://github.com/fmegahed/decisions-space/issues",
23
+ status = "https://github.com/fmegahed/decisions-space/blob/main/status/status.csv"
24
+ )
25
+
26
+ # The installed version of decisions, as "0.1.0"
27
+ package_version_text <- function() as.character(utils::packageVersion("decisions"))
28
+
29
+ # The version of decisions and the short commit, as "0.1.0 @ b25fc9f"
30
+ decisions_version <- function() {
31
+ sprintf("%s @ %s", package_version_text(), substr(DECISIONS_SHA, 1, 7))
32
+ }
33
+
34
+ # The help text of `key`, as one string. The text of "about" is Markdown.
35
+ help_text <- function(key) {
36
+ key <- rlang::arg_match(key, HELP_KEYS)
37
+ cfg <- app_config()
38
+ switch(key,
39
+ intro = "The app sends your question to Jev, a decision model of TypeSafe. Start from a sample, or use your own images or texts.",
40
+ data = "Your own images: one zip file with one folder for each answer (labelled), or image files (unlabelled). Your own texts: paste them with a blank line between two texts, or use a CSV file.",
41
+ question = sprintf(
42
+ "Give each answer a short name and a description. A choice question has %d to %d answers. A score question has %d to %d levels, from low to high. Yes / no has the answers true and false.",
43
+ cfg$answer_range$choice[1], cfg$answer_range$choice[2],
44
+ cfg$answer_range$score[1], cfg$answer_range$score[2]
45
+ ),
46
+ references = sprintf(
47
+ "Give at most %d example images for each answer. They show the model what to look for.",
48
+ cfg$max_references
49
+ ),
50
+ references_score = "A score question takes no references. References attach only to Yes / no and Choice questions.",
51
+ answers = "The answers show here after a run. A sample shows its saved answers at once, at no cost.",
52
+ code = "The code repeats this run: the question, the references, the model and one decide() call. Run it in your own R with your TypeSafe key in TYPESAFE_API_KEY. Install the package with pak::pak(\"fmegahed/decisions\"). For your own images, read the comment on paths at the top of the code.",
53
+ privacy = "Your images and texts go to TypeSafe (Jev). Do not upload confidential data.",
54
+ record = "A GitHub workflow opens this app twice a day, as a visitor does. Each visit adds a row to a public file. The visit also keeps the app awake. The table shows the last 60 visits.",
55
+ about = about_text(cfg)
56
+ )
57
+ }
58
+
59
+ # The text of the About tab, as Markdown
60
+ about_text <- function(cfg = app_config()) {
61
+ usd <- \(x) paste("USD", format(x, big.mark = ","))
62
+ num <- \(x) format(x, big.mark = ",")
63
+ paste(c(
64
+ "## App",
65
+ "",
66
+ "This app asks Jev, a decision model of TypeSafe, a question about each image or text in a set. For each input, it shows the answer and the probability of each answer. It also gives the R code of the run and a bundle with the setup and the answers. You need no key and no account.",
67
+ "",
68
+ "## Package",
69
+ "",
70
+ sprintf(
71
+ "The app uses the R package [decisions](%s), version %s. The [package record](%s) shows how the package was made and tested. The method comes from the paper \"Beyond CLIP: Calibrated (Few-Shot) Defect Detection With Structured Decision Models\" (in preparation). The [project record](%s) holds its decisions and its data.",
72
+ LINKS$package, package_version_text(), LINKS$package_record, LINKS$project_record
73
+ ),
74
+ "",
75
+ "## Model",
76
+ "",
77
+ sprintf(
78
+ "Each call goes to %s. The app sends each image with a longest side of %d pixels. It shows the raw probabilities of Jev. They are not calibrated. The package can calibrate them.",
79
+ cfg$model, cfg$image_settings$max_size
80
+ ),
81
+ "",
82
+ "## Limits",
83
+ "",
84
+ sprintf("- Each file: %d MB. Each zip file: %d MB.", cfg$max_file_mb, cfg$max_zip_mb),
85
+ sprintf(
86
+ "- Each run: %d images or %d texts. Each text: %s characters.",
87
+ cfg$max_images, cfg$max_texts, num(cfg$max_text_chars)
88
+ ),
89
+ sprintf(
90
+ "- Instructions: %d characters. Each description: %d characters.",
91
+ cfg$max_instructions, cfg$max_description
92
+ ),
93
+ sprintf(
94
+ "- Choice: %d to %d answers. Score: %d to %d levels. References: at most %d for each answer.",
95
+ cfg$answer_range$choice[1], cfg$answer_range$choice[2],
96
+ cfg$answer_range$score[1], cfg$answer_range$score[2], cfg$max_references
97
+ ),
98
+ sprintf(
99
+ "- Cost: %s for each visit, %s each day and %s each month for all visitors together (UTC). When a limit is reached, the samples and the R code still work.",
100
+ usd(cfg$limit_session), usd(cfg$limit_day), usd(cfg$limit_month)
101
+ ),
102
+ sprintf(
103
+ "- The app estimates the cost from the input tokens, at %s for each million tokens.",
104
+ usd(cfg$price_per_million)
105
+ ),
106
+ "",
107
+ "## Privacy",
108
+ "",
109
+ "Your images, texts and references go to TypeSafe (Jev). Do not upload confidential data. The app keeps no input after your session. It stores only the daily totals of calls, tokens and cost. The bundle and the logs hold no key.",
110
+ "",
111
+ "## Versions",
112
+ "",
113
+ sprintf(
114
+ "decisions-space %s (%s) \u00b7 decisions %s \u00b7 %s",
115
+ cfg$version, cfg$date, decisions_version(), cfg$model
116
+ ),
117
+ "",
118
+ "## Cite",
119
+ "",
120
+ sprintf(
121
+ "- Megahed, F. M., & Chen, Y.-J. (2026). *decisions: Typed decisions from decision models* (R package version %s). %s",
122
+ package_version_text(), LINKS$package
123
+ ),
124
+ sprintf(
125
+ "- Megahed, F. M., & Chen, Y.-J. (2026). *decisions Space* (Version %s) [Web application]. Hugging Face. https://huggingface.co/spaces/fmegahed/decisions",
126
+ cfg$version
127
+ ),
128
+ "",
129
+ "## Authors",
130
+ "",
131
+ AUTHORS,
132
+ "",
133
+ "## Source",
134
+ "",
135
+ sprintf(
136
+ "The source code is on [GitHub](%s), under the [MIT license](%s). Report a problem as an [issue](%s).",
137
+ LINKS$source, LINKS$license, LINKS$issues
138
+ )
139
+ ), collapse = "\n")
140
+ }
R/render.R ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Output builders: the dynamic inputs of the question (answer_rows,
2
+ # reference_rows) and the parts of the outputs that the server renders
3
+ # (spending meter, stats line, gallery, bundle files). The look is in
4
+ # www/theme.css. They use sr(), upload_input() and IMAGE_ACCEPT of R/ui.R,
5
+ # which R finds when a builder runs.
6
+
7
+ # The answers of a question: a name and a description for each answer, with
8
+ # buttons to add and remove answers. Yes / no has the fixed names true and
9
+ # false, so it has no name inputs and no buttons.
10
+ answer_rows <- function(question) {
11
+ answers <- question$answers
12
+ fixed <- question$type == "noul"
13
+ range <- answer_range(question$type)
14
+ rows <- purrr::map(seq_along(answers), \(i) {
15
+ shiny::div(
16
+ class = "answer-row",
17
+ if (fixed) {
18
+ shiny::span(class = "k", names(answers)[i])
19
+ } else {
20
+ shiny::textInput(paste0("answer_name_", i), sr(paste("Name of answer", i)), names(answers)[i])
21
+ },
22
+ shiny::textAreaInput(
23
+ paste0("answer_desc_", i), sr(paste("Description of answer", i)), answers[[i]], rows = 3
24
+ ),
25
+ if (!fixed && length(answers) > range[1]) {
26
+ shiny::actionButton(
27
+ paste0("answer_remove_", i), shiny::span(`aria-hidden` = "true", "\u00d7"),
28
+ class = "btn-quiet btn-icon", `aria-label` = paste("Remove answer", i)
29
+ )
30
+ }
31
+ )
32
+ })
33
+ shiny::tagList(
34
+ shiny::div(
35
+ class = "answer-head", `aria-hidden` = "true",
36
+ shiny::span(if (fixed) "Answer" else "Name"), shiny::span("Description")
37
+ ),
38
+ rows,
39
+ if (!fixed && length(answers) < range[2]) {
40
+ shiny::actionButton("answer_add", "Add answer", class = "btn-quiet sm")
41
+ }
42
+ )
43
+ }
44
+
45
+ # The id of the reference input of each answer: "ref_" and the clean name
46
+ ref_input_id <- function(answers) {
47
+ paste0("ref_", janitor::make_clean_names(answers, case = "none"))
48
+ }
49
+
50
+ # One row for each answer: its reference images and a file input. `srcs` is a
51
+ # named list of image addresses. A score question takes no references.
52
+ reference_rows <- function(question, srcs = list()) {
53
+ if (question$type == "score") return(shiny::tagList())
54
+ answers <- names(question$answers)
55
+ ids <- ref_input_id(answers)
56
+ purrr::map(seq_along(answers), \(i) {
57
+ thumbs <- purrr::imap(srcs[[answers[i]]] %||% character(), \(src, j) {
58
+ shiny::img(src = src, alt = sprintf("Reference %d of %s", j, answers[i]))
59
+ })
60
+ shiny::div(
61
+ class = "ref-row",
62
+ shiny::span(class = "k-plain", answers[i]),
63
+ shiny::div(
64
+ shiny::div(class = "thumbs", thumbs),
65
+ upload_input(
66
+ ids[i], sr(paste("References of", answers[i])), IMAGE_ACCEPT,
67
+ multiple = TRUE, button = "Add"
68
+ )
69
+ )
70
+ )
71
+ }) |>
72
+ shiny::tagList()
73
+ }
74
+
75
+ # The spending meter of the visit
76
+ spend_meter_ui <- function(spent, limit = LIMIT_SESSION) {
77
+ shiny::div(
78
+ class = "spend",
79
+ shiny::div(
80
+ class = "spend-row", shiny::span("This visit"),
81
+ shiny::span(class = "mono", sprintf("$%.2f of $%.2f", spent, limit))
82
+ ),
83
+ shiny::div(
84
+ class = "meter", `aria-hidden` = "true",
85
+ shiny::tags$i(style = sprintf("width:%.0f%%", min(100, 100 * spent / limit)))
86
+ )
87
+ )
88
+ }
89
+
90
+ # The stats line of answer_stats(); `note` is a short tag such as "Saved run"
91
+ stats_ui <- function(stats, mode, note = NULL) {
92
+ item <- \(value, label) {
93
+ shiny::span(class = "stat", shiny::span(class = "v", value), shiny::span(class = "l", label))
94
+ }
95
+ shiny::div(
96
+ class = "stats",
97
+ if (!is.na(stats$auc)) item(sprintf("%.2f", stats$auc), "AUC"),
98
+ if (!is.na(stats$accuracy)) item(sprintf("%.2f", stats$accuracy), "accuracy"),
99
+ item(stats$n, if (mode == "image") "images" else "texts"),
100
+ item(sprintf("$%.3f", stats$cost), "cost"),
101
+ if (!is.null(note)) shiny::span(class = "chip", note)
102
+ )
103
+ }
104
+
105
+ # The gallery: one tile for each row of answers_table(), with the probability
106
+ # of the target as a number and a bar, the answer and the label. `src` holds
107
+ # the address of the image of each row.
108
+ gallery_ui <- function(table, target, src) {
109
+ tiles <- purrr::map(seq_len(nrow(table)), \(i) {
110
+ row <- table[i, ]
111
+ p <- row$p_target
112
+ caption <- c(
113
+ if (!is.na(row$answer)) paste("answer", row$answer),
114
+ if (!is.na(row$label)) paste("label", row$label)
115
+ )
116
+ shiny::tags$figure(
117
+ class = "tile",
118
+ shiny::img(src = src[i], alt = alt_text(row, target), loading = "lazy"),
119
+ shiny::tags$figcaption(
120
+ if (is.na(p)) {
121
+ shiny::div(class = "t", if (is.na(row$problem)) "No answer" else row$problem)
122
+ } else {
123
+ shiny::div(
124
+ class = "p", sprintf("%.2f", p),
125
+ shiny::span(
126
+ class = "bar", `aria-hidden` = "true",
127
+ shiny::tags$i(style = sprintf("width:%.0f%%", 100 * p))
128
+ )
129
+ )
130
+ },
131
+ shiny::div(class = "t", paste(caption, collapse = " \u00b7 "))
132
+ )
133
+ )
134
+ })
135
+ shiny::div(class = "gallery", tiles)
136
+ }
137
+
138
+ # The files of the bundle, with one line on each. `files` are the names in the
139
+ # zip file (a folder ends in "/").
140
+ BUNDLE_FILE_TEXT <- c(
141
+ "\\.R$" = "The code above.",
142
+ "-setup\\.json$" = "Model, question, references and image settings. Read it with read_setup().",
143
+ "-answers\\.csv$" = "Each input: answer, probabilities, label, tokens, seconds.",
144
+ "^README\\.md$" = "Versions, date, install, key and citation. Import reopens the setup here.",
145
+ "^references/?$" = "The reference images of the run.",
146
+ "^data\\.csv$" = "Your texts. The code reads them."
147
+ )
148
+ bundle_files_ui <- function(files, zip_name) {
149
+ describe <- \(f) {
150
+ hit <- purrr::detect_index(names(BUNDLE_FILE_TEXT), \(pattern) stringr::str_detect(f, pattern))
151
+ if (hit == 0) "" else BUNDLE_FILE_TEXT[[hit]]
152
+ }
153
+ shiny::tagList(
154
+ shiny::h3(
155
+ class = "eyebrow", shiny::tags$b(`aria-hidden` = "true", "\u203a"), " Bundle \u00b7 ", zip_name
156
+ ),
157
+ shiny::tags$ul(
158
+ class = "files",
159
+ purrr::map(files, \(f) {
160
+ shiny::tags$li(class = "file", shiny::div(class = "n", f), shiny::div(class = "d", describe(f)))
161
+ })
162
+ )
163
+ )
164
+ }
R/runs.R ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Runs: calls to Jev in chunks, the classes of errors, and the cost.
2
+ # The raw .error text of decide() can hold a part of a key. It must never be
3
+ # shown or logged: the app uses only the category of classify_error().
4
+
5
+ # The decider of TypeSafe; the key is read only when decide() asks for it
6
+ make_decider <- function() {
7
+ decisions::decider_typesafe(MODEL, credentials = \() Sys.getenv(KEY_VAR))
8
+ }
9
+
10
+ # Row numbers 1..n in chunks of `size`, as a list of integer vectors
11
+ chunk_ids <- function(n, size = CHUNK_SIZE) {
12
+ split(seq_len(n), ceiling(seq_len(n) / size)) |> unname()
13
+ }
14
+
15
+ # Names of the probability columns of decide(), one for each answer name
16
+ pred_column <- function(answers) {
17
+ paste0(".pred_", janitor::make_clean_names(answers, case = "none"))
18
+ }
19
+
20
+ # Columns of a decide() output with NA answers, one row for each input.
21
+ # The column names follow decide(): .pred_<cleaned answer name>.
22
+ empty_answers <- function(question, n, error = "config") {
23
+ answers <- names(question$answers)
24
+ cols <- stats::setNames(
25
+ rep(list(rep(NA_real_, n)), length(answers)),
26
+ pred_column(answers)
27
+ )
28
+ out <- tibble::tibble(
29
+ .pred_class = factor(rep(NA_character_, n), levels = answers),
30
+ !!!cols
31
+ )
32
+ if (question$type == "score") out$.score <- rep(NA_real_, n)
33
+ out |>
34
+ dplyr::mutate(
35
+ .model = MODEL, .input_tokens = NA_real_, .cost = NA_real_,
36
+ .seconds = NA_real_, .error = error
37
+ )
38
+ }
39
+
40
+ # Run the inputs `rows` of a spec through decide(); one row for each input.
41
+ # When decide() aborts (for example with no key), every row gets
42
+ # .error = "config" and NA answers, so that the chunks can be bound.
43
+ run_chunk <- function(spec, rows, decide_fn = decisions::decide) {
44
+ inputs <- spec$inputs[rows, ]
45
+ data_arg <- if (spec$mode == "image") {
46
+ list(image = inputs$path)
47
+ } else {
48
+ list(text = inputs$text)
49
+ }
50
+ answers <- tryCatch(
51
+ rlang::exec(
52
+ decide_fn, make_decider(), as_decisions_question(spec$question),
53
+ !!!data_arg, references = spec$references,
54
+ max_size = IMAGE_SETTINGS$max_size, upscale = IMAGE_SETTINGS$upscale,
55
+ crop = IMAGE_SETTINGS$crop
56
+ ),
57
+ error = \(e) empty_answers(spec$question, nrow(inputs))
58
+ )
59
+ tibble::as_tibble(answers) |>
60
+ dplyr::mutate(id = inputs$id, .before = 1)
61
+ }
62
+
63
+ # Category of an error text (vectorised): NA when there is no error
64
+ classify_error <- function(text) {
65
+ dplyr::case_when(
66
+ is.na(text) ~ NA_character_,
67
+ text == "config" ~ "config",
68
+ stringr::str_detect(text, "^HTTP 402") |
69
+ stringr::str_detect(text, stringr::regex(
70
+ "credit|balance|insufficient|payment", ignore_case = TRUE
71
+ )) ~ "credit",
72
+ stringr::str_detect(text, "^HTTP 40[13]") ~ "auth",
73
+ stringr::str_detect(text, "^HTTP 429") ~ "rate",
74
+ stringr::str_detect(text, "^HTTP 5") |
75
+ stringr::str_detect(text, stringr::regex(
76
+ "timeout|timed out", ignore_case = TRUE
77
+ )) ~ "server",
78
+ stringr::str_detect(text, "^HTTP 4") ~ "input",
79
+ .default = "answer"
80
+ )
81
+ }
82
+
83
+ # The sentence that the visitor reads for a category
84
+ error_message <- function(category) {
85
+ unname(c(
86
+ config = "The demo is not available right now.",
87
+ auth = "The demo is not available right now.",
88
+ credit = "The credit of this demo is used up. Try again later.",
89
+ rate = "Too many requests. Try again in a minute.",
90
+ server = "TypeSafe did not answer. Try again.",
91
+ input = "TypeSafe refused this input.",
92
+ answer = "The answer could not be read."
93
+ )[category])
94
+ }
95
+
96
+ # These categories stop all runs for the rest of the day
97
+ stops_the_day <- function(category) {
98
+ category %in% c("config", "auth", "credit")
99
+ }
100
+
101
+ # Calls, input tokens and cost (USD) of the answers of a chunk
102
+ chunk_usage <- function(answers) {
103
+ tokens <- sum(answers$.input_tokens, na.rm = TRUE)
104
+ list(calls = nrow(answers), tokens = tokens, cost = tokens * PRICE_PER_TOKEN)
105
+ }
106
+
107
+ # Cost (USD) of n inputs, with the guess or with the measured mean of tokens
108
+ estimate_cost <- function(spec, n, mean_tokens = NULL) {
109
+ n * (mean_tokens %||% TOKENS_GUESS[[spec$mode]]) * PRICE_PER_TOKEN
110
+ }
111
+
112
+ # The files that a chunk of a spec reads: the images of its rows and every
113
+ # reference. decide() aborts when one is missing, so the server checks them first.
114
+ chunk_files <- function(spec, rows) {
115
+ c(character(), if (spec$mode == "image") spec$inputs$path[rows], unlist(spec$references, use.names = FALSE))
116
+ }
117
+
118
+ # The reasons not to start a run (empty when it can start). decisions checks the
119
+ # question here, in this process: an error inside a chunk counts as a key
120
+ # problem, and it stops the runs of the day.
121
+ run_problems <- function(spec) {
122
+ if (nrow(spec$inputs) == 0) {
123
+ return(if (spec$mode == "image") "Add images first." else "Add texts first.")
124
+ }
125
+ problems <- check_question(spec$question)
126
+ if (length(problems) > 0) return(problems)
127
+ valid <- tryCatch(!is.null(as_decisions_question(spec$question)), error = \(e) FALSE)
128
+ if (!valid) {
129
+ return(paste(
130
+ "The app cannot use this question.",
131
+ "Write the instructions, and give each answer a different name."
132
+ ))
133
+ }
134
+ character()
135
+ }
136
+
137
+ # One log line for each chunk: the time, the counts, the tokens, the cost and
138
+ # the error categories. Never an input, a file name, the question or an
139
+ # .error text.
140
+ log_chunk <- function(now, n, use, categories) {
141
+ counts <- table(categories[!is.na(categories)])
142
+ errors <- if (length(counts) == 0) "none" else paste(names(counts), counts, collapse = ", ")
143
+ message(sprintf(
144
+ "%s chunk: %d inputs, %d calls, %.0f tokens, USD %.6f, errors: %s",
145
+ format(now, "%Y-%m-%d %H:%M:%S UTC", tz = "UTC"), n, use$calls, use$tokens,
146
+ use$cost, errors
147
+ ))
148
+ }
R/samples.R ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Samples: the two runs of the vignettes of decisions, with their saved answers.
2
+ # The inputs and the answers come from the installed package, so the app and
3
+ # the vignettes cannot drift apart.
4
+
5
+ # Numbers of the saved runs and the sentences that the help text shows
6
+ SAMPLE_FACTS <- list(
7
+ textiles = c(auc = 0.75, accuracy = 0.80),
8
+ injuries = c(accuracy = 0.96)
9
+ )
10
+ SAMPLE_FACTS_TEXT <- list(
11
+ textiles = c(
12
+ sprintf(
13
+ "The saved run has an AUC of %.2f and an accuracy of %.2f.",
14
+ SAMPLE_FACTS$textiles[["auc"]], SAMPLE_FACTS$textiles[["accuracy"]]
15
+ ),
16
+ "Jev puts all 14 local flaws (one broken spot) at the top.",
17
+ "The 9 global flaws, where the whole pattern is distorted, score like regular weaves (0.14 to 0.26).",
18
+ "A sharper description of \"defective\" or another reference can help there."
19
+ ),
20
+ injuries = sprintf(
21
+ "The saved run has an accuracy of %.2f.", SAMPLE_FACTS$injuries[["accuracy"]]
22
+ )
23
+ )
24
+
25
+ # The questions, copied from the vignettes images.Rmd and choosing-a-model.Rmd
26
+ sample_question <- function(id) {
27
+ switch(id,
28
+ textiles = list(
29
+ type = "choice",
30
+ instructions = "Does the woven pattern in `inspection_image` have a flaw?",
31
+ answers = c(
32
+ nominal = "The weave is regular. The pattern repeats with no break.",
33
+ defective = "The weave has a flaw. A part of the pattern is broken, or the whole pattern is distorted."
34
+ )
35
+ ),
36
+ injuries = list(
37
+ type = "choice",
38
+ instructions = "Which injury does this report of a work injury describe? Choose the main injury.",
39
+ answers = c(
40
+ amputation = "A body part is cut or torn off, in whole or in part.",
41
+ fracture = "A bone is broken or cracked.",
42
+ `crushing injury` = "A body part is squeezed hard between objects, and the tissue is damaged.",
43
+ `thermal burn` = "Heat, flame, hot liquid or a hot surface burns the skin.",
44
+ concussion = "A blow or a shake to the head hurts the brain."
45
+ )
46
+ )
47
+ )
48
+ }
49
+
50
+ # Folder of the textile images in the installed package
51
+ textiles_folder <- function() {
52
+ system.file("extdata", "textiles", package = "decisions")
53
+ }
54
+
55
+ # The spec of a sample, id "textiles" or "injuries"
56
+ sample_spec <- function(id) {
57
+ id <- rlang::arg_match(id, c("textiles", "injuries"))
58
+ if (id == "textiles") {
59
+ files <- decisions::textiles
60
+ inputs <- files |>
61
+ dplyr::filter(role == "input") |>
62
+ dplyr::transmute(
63
+ id = file, path = file.path(textiles_folder(), file),
64
+ text = NA_character_, file = file, label = as.character(class)
65
+ )
66
+ refs <- files |>
67
+ dplyr::filter(role == "reference") |>
68
+ dplyr::arrange(class, reference_rank) |>
69
+ dplyr::mutate(path = file.path(textiles_folder(), file))
70
+ references <- list(
71
+ nominal = refs$path[refs$class == "nominal"],
72
+ defective = refs$path[refs$class == "defective"]
73
+ )
74
+ mode <- "image"
75
+ } else {
76
+ saved <- readRDS(system.file("extdata", "choosing-a-model.rds", package = "decisions"))
77
+ inputs <- saved$injuries |>
78
+ dplyr::transmute(
79
+ id = id, path = NA_character_, text = final_narrative, file = id,
80
+ label = as.character(collapsed_nature_title)
81
+ )
82
+ references <- NULL
83
+ mode <- "text"
84
+ }
85
+ list(
86
+ mode = mode, question = sample_question(id), references = references,
87
+ inputs = inputs, source = id, name = id
88
+ )
89
+ }
90
+
91
+ # The saved output of decide() for Jev, with the id of each input
92
+ sample_answers <- function(id) {
93
+ id <- rlang::arg_match(id, c("textiles", "injuries"))
94
+ saved <- readRDS(system.file(
95
+ "extdata", if (id == "textiles") "images.rds" else "choosing-a-model.rds",
96
+ package = "decisions"
97
+ ))
98
+ stopifnot(nrow(saved$answers$jev) == nrow(sample_spec(id)$inputs))
99
+ saved$answers$jev |>
100
+ dplyr::mutate(id = sample_spec(id)$inputs$id, .before = 1)
101
+ }
102
+
103
+ # The two samples, read once for each R process
104
+ SAMPLE_CACHE <- new.env(parent = emptyenv())
105
+ sample_data <- function(id) {
106
+ SAMPLE_CACHE[[id]] <- SAMPLE_CACHE[[id]] %||%
107
+ list(spec = sample_spec(id), answers = sample_answers(id))
108
+ SAMPLE_CACHE[[id]]
109
+ }
R/server.R ADDED
@@ -0,0 +1,589 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Server: the wiring of the page. The logic is in the other files of R/; this
2
+ # file keeps the state of a session (the question, the data, the answers and
3
+ # the spend) and runs the calls to Jev in chunks, as a background task.
4
+ #
5
+ # app_deps() holds what a test replaces: decide(), the usage store, the status
6
+ # reader, the clock, and `async` (FALSE runs the chunks in this R process).
7
+ # app.R makes it once for all sessions, so its `state` (the stop for the UTC
8
+ # day after a key or credit error) holds for every visitor.
9
+
10
+ # The dependencies of the server. With SPACE_MOCK=1 (local checks only; the
11
+ # Dockerfile never sets it), the mock of decide() and a store in memory
12
+ # replace the defaults.
13
+ app_deps <- function(decide_fn = decisions::decide, store = usage_store_hf(),
14
+ status_fn = read_status, now = Sys.time, async = TRUE) {
15
+ if (Sys.getenv("SPACE_MOCK") == "1" && (missing(decide_fn) || missing(store))) {
16
+ mock <- mock_deps()
17
+ if (missing(decide_fn)) decide_fn <- mock$decide_fn
18
+ if (missing(store)) store <- mock$store
19
+ }
20
+ list(
21
+ decide_fn = decide_fn, store = store, status_fn = status_fn, now = now,
22
+ async = async, state = new.env(parent = emptyenv())
23
+ )
24
+ }
25
+
26
+ # The mock of the tests (with the saved answers of both samples) and a store in
27
+ # memory. The mock stays in tests/: the app code holds no mock. APP_ROOT is the
28
+ # app folder (set by app.R, and by the helper of the tests).
29
+ mock_deps <- function(path = file.path(APP_ROOT, "tests", "testthat", "helper-mock.R")) {
30
+ if (!file.exists(path)) {
31
+ stop(
32
+ "SPACE_MOCK=1 needs ", path,
33
+ ". Start the app from the repository folder, or unset SPACE_MOCK.",
34
+ call. = FALSE
35
+ )
36
+ }
37
+ env <- new.env(parent = environment(mock_deps))
38
+ sys.source(path, envir = env)
39
+ lookup <- dplyr::bind_rows(env$sample_lookup("textiles"), env$sample_lookup("injuries"))
40
+ list(decide_fn = env$mock_decide(lookup), store = usage_store_memory())
41
+ }
42
+
43
+ # The category that stops the runs today, or NULL
44
+ stop_reason <- function(deps) {
45
+ if (identical(deps$state$stop_day, utc_date(deps$now()))) deps$state$stop_reason
46
+ }
47
+
48
+ # The accounts of a chunk, in the main R process (never in a daemon): add the
49
+ # usage to the store, stop the day after a key or credit error, and write the
50
+ # log line. Rows that failed before any request ("config") cost nothing.
51
+ account_chunk <- function(answers, deps) {
52
+ categories <- classify_error(answers$.error)
53
+ use <- chunk_usage(answers[!categories %in% "config", ])
54
+ now <- deps$now()
55
+ usage <- if (use$calls > 0) usage_record(deps$store, now, use$calls, use$tokens, use$cost)
56
+ stop <- dplyr::first(categories[stops_the_day(categories)])
57
+ if (!is.na(stop)) {
58
+ deps$state$stop_day <- utc_date(now)
59
+ deps$state$stop_reason <- stop
60
+ }
61
+ log_chunk(now, nrow(answers), use, categories)
62
+ list(answers = answers, cost = use$cost, usage = usage)
63
+ }
64
+
65
+ app_server <- function(input, output, session, deps = app_deps()) {
66
+ # --- The folder of the session: uploads, imports and thumbnails -----------
67
+ dir <- tempfile("session")
68
+ dir.create(file.path(dir, "thumbs"), recursive = TRUE)
69
+ # The thumbnails are served under a prefix that no other visitor can guess
70
+ prefix <- paste(openssl::rand_bytes(16), collapse = "")
71
+ shiny::addResourcePath(prefix, file.path(dir, "thumbs"))
72
+ uploads <- character()
73
+ folders <- 0L
74
+ new_dir <- function(kind) {
75
+ folders <<- folders + 1L
76
+ file.path(dir, kind, folders)
77
+ }
78
+ keep_uploads <- function(files) uploads <<- c(uploads, files$datapath)
79
+ job <- new.env(parent = emptyenv())
80
+ # Files are deleted only when no chunk is in flight: a chunk reads its images
81
+ # and references until it ends, and a missing file would stop the day
82
+ after_chunk <- function(delete) {
83
+ if (isTRUE(job$in_flight)) job$deletes <- c(job$deletes, delete) else delete()
84
+ }
85
+ session$onSessionEnded(\() {
86
+ shiny::removeResourcePath(prefix)
87
+ remove_uploads(uploads)
88
+ after_chunk(\() unlink(dir, recursive = TRUE))
89
+ })
90
+
91
+ # The thumbnail of an image, and its address ("" for an image that cannot be read)
92
+ thumb_file <- function(path) file.path(dir, "thumbs", paste0(rlang::hash(path), ".jpg"))
93
+ thumb <- function(paths) {
94
+ purrr::map_chr(paths, \(path) {
95
+ dest <- thumb_file(path)
96
+ if (file.exists(dest) || make_thumb(path, dest)) paste0(prefix, "/", basename(dest)) else ""
97
+ })
98
+ }
99
+
100
+ # --- State --------------------------------------------------------------------
101
+ question <- shiny::reactiveVal()
102
+ refs <- shiny::reactiveVal(list()) # reference paths, one element for each answer
103
+ data_mode <- shiny::reactiveVal("image")
104
+ data_source <- shiny::reactiveVal("textiles")
105
+ image_upload <- shiny::reactiveVal(NULL)
106
+ csv_file <- shiny::reactiveVal(NULL)
107
+ text_kind <- shiny::reactiveVal("paste")
108
+ results <- shiny::reactiveVal(NULL) # list(answers, key, saved, token of the run)
109
+ mean_tokens <- shiny::reactiveVal(NULL) # list(fingerprint, mean)
110
+ session_cost <- shiny::reactiveVal(0)
111
+ usage <- shiny::reactiveVal(deps$store$read())
112
+ notice <- shiny::reactiveVal(character())
113
+ running <- shiny::reactiveVal(FALSE)
114
+ rows_version <- shiny::reactiveVal(0) # renders the answer rows again
115
+
116
+ # --- The spec of the sidebar --------------------------------------------------
117
+ text_upload <- shiny::reactive({
118
+ csv <- csv_file()
119
+ if (text_kind() == "paste" || is.null(csv)) {
120
+ return(c(read_text_paste(input$paste_text), list(name = "texts")))
121
+ }
122
+ if (length(csv$columns) == 0) {
123
+ return(list(
124
+ inputs = empty_inputs(), problems = "The CSV file could not be read.", name = csv$name
125
+ ))
126
+ }
127
+ column <- if (isTRUE(input$csv_text_col %in% csv$columns)) input$csv_text_col else csv$text_col
128
+ label <- if (isTRUE(input$csv_label_col %in% csv$columns)) input$csv_label_col
129
+ c(read_text_csv(csv$path, column, label, csv$file), list(name = csv$name, text_column = column))
130
+ })
131
+ data <- shiny::reactive({
132
+ id <- data_source()
133
+ if (id != "upload") return(list(inputs = sample_data(id)$spec$inputs, name = id))
134
+ upload <- if (data_mode() == "image") image_upload() else text_upload()
135
+ upload %||% list(inputs = empty_inputs(), name = "upload")
136
+ })
137
+ matched <- shiny::reactive(match_labels(data()$inputs, question()$answers))
138
+ spec <- shiny::reactive({
139
+ q <- question()
140
+ references <- if (data_mode() == "image" && q$type != "score") {
141
+ stats::setNames(refs()[seq_along(q$answers)], names(q$answers)) |> purrr::compact()
142
+ }
143
+ list(
144
+ mode = data_mode(), question = q,
145
+ references = if (length(references) > 0) references,
146
+ inputs = matched()$inputs, source = data_source(), name = data()$name,
147
+ text_column = data()$text_column
148
+ )
149
+ })
150
+ key <- shiny::reactive(answers_key(spec()))
151
+ # The type and the names of the answers: the reference rows and the target follow them
152
+ layout <- shiny::reactiveVal()
153
+ shiny::observe(layout(list(type = question()$type, answers = names(question()$answers))))
154
+
155
+ # Send the setup of the server to the inputs of the page. The page answers
156
+ # each update with the new value, so an observer never sends its own input
157
+ # (or one above it: mode, then source, then the question) back: two crossing
158
+ # updates would flip the input forever. `send` names the inputs of the data.
159
+ show_setup <- function(send = c("mode", "source")) {
160
+ q <- question()
161
+ if ("mode" %in% send) shiny::updateRadioButtons(session, "mode", selected = data_mode())
162
+ if ("source" %in% send) {
163
+ shiny::updateSelectInput(
164
+ session, "source", choices = source_choices(data_mode()), selected = data_source()
165
+ )
166
+ }
167
+ shiny::updateRadioButtons(session, "qtype", selected = q$type)
168
+ shiny::updateTextAreaInput(session, "instructions", value = q$instructions)
169
+ shiny::updateRadioButtons(
170
+ session, "view", selected = if (data_mode() == "image") "gallery" else "table"
171
+ )
172
+ rows_version(rows_version() + 1)
173
+ }
174
+
175
+ # A sample opens with its saved answers and makes no call
176
+ load_sample <- function(id, send = c("mode", "source")) {
177
+ sample <- sample_data(id)
178
+ question(sample$spec$question)
179
+ refs(unname(sample$spec$references) %||% list())
180
+ data_mode(sample$spec$mode)
181
+ data_source(id)
182
+ results(list(answers = sample$answers, key = answers_key(sample$spec), saved = TRUE))
183
+ notice(character())
184
+ show_setup(send)
185
+ }
186
+ # The page opens with Images and the textile sample; only the choices of the source change
187
+ shiny::isolate(load_sample("textiles", send = "source"))
188
+
189
+ # --- Data -------------------------------------------------------------------
190
+ shiny::observeEvent(input$sample_textiles, load_sample("textiles"))
191
+ shiny::observeEvent(input$sample_injuries, load_sample("injuries"))
192
+ shiny::observeEvent(input$mode, {
193
+ if (input$mode == data_mode()) return()
194
+ if (data_source() != "upload") {
195
+ return(load_sample(if (input$mode == "image") "textiles" else "injuries", send = "source"))
196
+ }
197
+ data_mode(input$mode)
198
+ refs(list())
199
+ notice(character())
200
+ show_setup(send = "source")
201
+ })
202
+ shiny::observeEvent(input$source, {
203
+ # A source of the other mode is the late answer to an older update
204
+ if (input$source == data_source() || !input$source %in% source_choices(data_mode())) return()
205
+ if (input$source != "upload") return(load_sample(input$source, send = character()))
206
+ data_source("upload")
207
+ refs(list())
208
+ notice(character())
209
+ })
210
+ shiny::observeEvent(input$upload_images, {
211
+ files <- input$upload_images
212
+ keep_uploads(files)
213
+ notice(character())
214
+ old <- image_upload()
215
+ if (!is.null(old)) {
216
+ after_chunk(\() unlink(c(old$dir, thumb_file(old$inputs$path)), recursive = TRUE))
217
+ }
218
+ read <- read_images(files, dir = new_dir("images"))
219
+ bad <- thumb(read$inputs$path) == ""
220
+ image_upload(list(
221
+ inputs = read$inputs[!bad, ], dir = read$dir,
222
+ problems = c(
223
+ read$problems, sprintf("%s could not be read and was skipped.", read$inputs$file[bad])
224
+ ),
225
+ name = if (nrow(files) == 1) tools::file_path_sans_ext(files$name) else "images"
226
+ ))
227
+ })
228
+ shiny::observeEvent(input$paste_text, text_kind("paste"), ignoreInit = TRUE)
229
+ shiny::observeEvent(input$upload_csv, {
230
+ file <- input$upload_csv
231
+ keep_uploads(file)
232
+ notice(character())
233
+ path <- file.path(new_dir("csv"), "data.csv")
234
+ dir.create(dirname(path), recursive = TRUE)
235
+ file.copy(file$datapath, path)
236
+ columns <- csv_columns(path)
237
+ text_col <- if ("text" %in% columns) "text" else columns[1]
238
+ csv_file(list(
239
+ path = path, file = file$name, name = tools::file_path_sans_ext(file$name),
240
+ columns = columns, text_col = text_col
241
+ ))
242
+ text_kind("csv")
243
+ shiny::updateSelectInput(session, "csv_text_col", choices = columns, selected = text_col)
244
+ shiny::updateSelectInput(
245
+ session, "csv_label_col", choices = c(None = "", columns), selected = ""
246
+ )
247
+ })
248
+
249
+ # --- Question ---------------------------------------------------------------
250
+ # Each input changes one part of the question. A change that the server made
251
+ # comes back from the page with the same value, so it changes nothing.
252
+ shiny::observeEvent(input$instructions, {
253
+ q <- question()
254
+ q$instructions <- input$instructions
255
+ question(q)
256
+ })
257
+ edit_answer <- function(i, part, value) {
258
+ q <- question()
259
+ if (i > length(q$answers) || (part == "name" && q$type == "noul")) return()
260
+ if (part == "name") names(q$answers)[i] <- value else q$answers[[i]] <- value
261
+ question(q)
262
+ }
263
+ purrr::walk(seq_len(max(unlist(ANSWER_RANGE))), \(i) {
264
+ shiny::observeEvent(input[[paste0("answer_name_", i)]], {
265
+ edit_answer(i, "name", input[[paste0("answer_name_", i)]])
266
+ })
267
+ shiny::observeEvent(input[[paste0("answer_desc_", i)]], {
268
+ edit_answer(i, "desc", input[[paste0("answer_desc_", i)]])
269
+ })
270
+ shiny::observeEvent(input[[paste0("answer_remove_", i)]], {
271
+ q <- question()
272
+ if (i > length(q$answers) || length(q$answers) <= answer_range(q$type)[1]) return()
273
+ q$answers <- q$answers[-i]
274
+ question(q)
275
+ refs(refs()[-i])
276
+ rows_version(rows_version() + 1)
277
+ })
278
+ })
279
+ shiny::observeEvent(input$answer_add, {
280
+ q <- question()
281
+ n <- length(q$answers)
282
+ if (n >= answer_range(q$type)[2]) return()
283
+ name <- utils::tail(make.unique(c(names(q$answers), paste("answer", n + 1)), sep = " "), 1)
284
+ q$answers <- c(q$answers, stats::setNames("", name))
285
+ question(q)
286
+ rows_version(rows_version() + 1)
287
+ })
288
+ shiny::observeEvent(input$qtype, {
289
+ q <- question()
290
+ if (input$qtype == q$type) return()
291
+ q$answers <- if (input$qtype == "noul") {
292
+ c(true = "", false = "")
293
+ } else {
294
+ utils::head(q$answers, answer_range(input$qtype)[2])
295
+ }
296
+ q$type <- input$qtype
297
+ question(q)
298
+ rows_version(rows_version() + 1)
299
+ })
300
+ # The reference inputs: a new upload replaces the references of its answer
301
+ seen <- character()
302
+ shiny::observe({
303
+ ids <- ref_input_id(shiny::req(layout())$answers)
304
+ for (i in seq_along(ids)) {
305
+ files <- input[[ids[i]]]
306
+ if (is.null(files) || all(files$datapath %in% seen)) next
307
+ seen <<- c(seen, files$datapath)
308
+ keep_uploads(files)
309
+ read <- read_references(files, dir = new_dir("references"))
310
+ shiny::isolate({
311
+ r <- refs()
312
+ r[[i]] <- read$paths
313
+ refs(r)
314
+ notice(read$problems)
315
+ })
316
+ }
317
+ })
318
+ # Import restores the question and the references; it makes no call
319
+ shiny::observeEvent(input$import_file, {
320
+ file <- input$import_file
321
+ keep_uploads(file)
322
+ read <- read_import(file$datapath, dir = new_dir("import"))
323
+ if (is.null(read$question)) return(notice(read$problems))
324
+ question(read$question)
325
+ refs(purrr::map(names(read$question$answers), \(a) read$references[[a]] %||% character()))
326
+ if (length(read$references) > 0 && data_mode() == "text") {
327
+ data_mode("image")
328
+ data_source("upload")
329
+ }
330
+ notice(c("The setup is imported.", read$problems))
331
+ show_setup()
332
+ })
333
+ shiny::observe({
334
+ l <- shiny::req(layout())
335
+ shiny::updateSelectInput(
336
+ session, "target", choices = if (l$type == "noul") "true" else l$answers,
337
+ selected = target_answer(shiny::isolate(question()), shiny::isolate(input$target))
338
+ )
339
+ })
340
+
341
+ # The answers show only while the setup and the inputs are those of the
342
+ # answers (table() checks the key). They are not deleted: the page can answer
343
+ # an older update late, and the setup then comes back. A change stops a run.
344
+ shiny::observe({
345
+ k <- key()
346
+ if (shiny::isolate(running()) && !identical(job$key, k)) job$stop <- TRUE
347
+ })
348
+
349
+ # --- Runs -------------------------------------------------------------------
350
+ mean_for <- function(s) {
351
+ m <- mean_tokens()
352
+ if (!is.null(m) && identical(m$fingerprint, spec_fingerprint(s))) m$mean
353
+ }
354
+ checks <- shiny::reactive({
355
+ s <- spec()
356
+ status <- spend_status(session_cost(), usage(), deps$now())
357
+ purrr::map(c(try5 = 5, run_all = Inf), \(k) {
358
+ n <- min(k, nrow(s$inputs))
359
+ cost <- estimate_cost(s, n, mean_for(s))
360
+ c(run_allowed(cost, status), list(n = n, cost = cost))
361
+ })
362
+ })
363
+ shiny::observe({
364
+ shiny::updateActionButton(session, "try5", disabled = !checks()$try5$ok)
365
+ shiny::updateActionButton(session, "run_all", disabled = !checks()$run_all$ok)
366
+ })
367
+
368
+ # Each chunk runs in a mirai daemon (or here, when async is FALSE); its
369
+ # accounts are kept in this process, also when the session has ended. The
370
+ # files that waited for the chunk are deleted when it ends.
371
+ task <- shiny::ExtendedTask$new(function(spec, rows) {
372
+ job$in_flight <- TRUE
373
+ chunk <- tryCatch(
374
+ if (deps$async) {
375
+ mirai::mirai(
376
+ run_chunk(spec, rows, decide_fn),
377
+ .args = list(spec = spec, rows = rows, decide_fn = deps$decide_fn)
378
+ )
379
+ } else {
380
+ run_chunk(spec, rows, deps$decide_fn)
381
+ },
382
+ error = \(e) promises::promise_reject(e)
383
+ )
384
+ promises::then(promises::promise_resolve(chunk), \(answers) account_chunk(answers, deps)) |>
385
+ promises::finally(\() {
386
+ job$in_flight <- FALSE
387
+ deletes <- job$deletes
388
+ job$deletes <- NULL
389
+ purrr::walk(deletes, \(delete) delete())
390
+ }) |>
391
+ # A closed session has no task to finish (it would fail on its destroyed
392
+ # values): its chunk was counted above, and the promise stays open
393
+ promises::then(
394
+ onFulfilled = \(done) if (session$isClosed()) never_settles() else done,
395
+ onRejected = \(e) if (session$isClosed()) never_settles() else stop(e)
396
+ )
397
+ })
398
+ never_settles <- function() promises::promise(\(resolve, reject) NULL)
399
+ # Before each chunk, its images and references must exist. A missing file
400
+ # ends the run; decide() would abort, and that would stop the day.
401
+ run_next <- function() {
402
+ rows <- job$chunks[[job$done + 1L]]
403
+ if (!all(file.exists(chunk_files(job$spec, rows)))) {
404
+ notice("The files of this run are not available. Upload them again.")
405
+ return(end_run())
406
+ }
407
+ task$invoke(job$spec, rows)
408
+ }
409
+
410
+ start_run <- function(n) {
411
+ if (running()) return()
412
+ s <- spec()
413
+ problems <- run_problems(s)
414
+ if (length(problems) > 0) return(notice(problems))
415
+ if (!is.null(stop_reason(deps))) return(notice(error_message(stop_reason(deps))))
416
+ # The latest totals of all sessions decide, not the copy of this session
417
+ latest <- deps$store$read()
418
+ usage(latest)
419
+ n <- min(n, nrow(s$inputs))
420
+ allowed <- run_allowed(
421
+ estimate_cost(s, n, mean_for(s)), spend_status(session_cost(), latest, deps$now())
422
+ )
423
+ if (!allowed$ok) return(notice(limit_message(allowed$reason)))
424
+ notice(character())
425
+ job$spec <- s
426
+ job$key <- key()
427
+ job$chunks <- chunk_ids(n)
428
+ job$done <- 0L
429
+ job$stop <- FALSE
430
+ job$answers <- NULL
431
+ # The run writes its answers only while the card holds the answers that it
432
+ # found or wrote: a sample loaded during the run keeps its saved answers
433
+ job$token <- (job$token %||% 0L) + 1L
434
+ results(utils::modifyList(results() %||% list(), list(token = job$token)))
435
+ running(TRUE)
436
+ session$sendCustomMessage("run-state", TRUE)
437
+ run_next()
438
+ }
439
+ end_run <- function() {
440
+ running(FALSE)
441
+ session$sendCustomMessage("run-state", FALSE)
442
+ }
443
+ # After each chunk: show its answers, then run the next chunk or stop
444
+ finish_chunk <- function(done) {
445
+ if (!running()) return()
446
+ job$done <- job$done + 1L
447
+ if (is.null(done)) {
448
+ notice("The run stopped. Try again.")
449
+ return(end_run())
450
+ }
451
+ session_cost(session_cost() + done$cost)
452
+ if (!is.null(done$usage)) usage(done$usage)
453
+ job$answers <- dplyr::bind_rows(job$answers, done$answers)
454
+ shown <- identical(results()$token, job$token)
455
+ if (shown) results(list(answers = job$answers, key = job$key, saved = FALSE, token = job$token))
456
+ # Only calls with tokens count: a mean of 0 would make the next run look free
457
+ tokens <- job$answers$.input_tokens
458
+ tokens <- tokens[!is.na(tokens) & tokens > 0]
459
+ if (length(tokens) > 0) {
460
+ mean_tokens(list(fingerprint = spec_fingerprint(job$spec), mean = mean(tokens)))
461
+ }
462
+ reason <- stop_reason(deps)
463
+ if (!is.null(reason)) notice(error_message(reason))
464
+ # A run whose answers nobody sees stops (its chunk is counted all the same)
465
+ if (job$stop || !shown || !is.null(reason) || job$done >= length(job$chunks)) return(end_run())
466
+ run_next()
467
+ }
468
+ shiny::observe({
469
+ if (!task$status() %in% c("success", "error")) return()
470
+ done <- tryCatch(task$result(), error = \(e) NULL)
471
+ shiny::isolate(finish_chunk(done))
472
+ })
473
+ shiny::observeEvent(input$try5, start_run(5))
474
+ shiny::observeEvent(input$run_all, start_run(Inf))
475
+ # Stop takes effect at the end of the current chunk
476
+ shiny::observeEvent(input$stop, job$stop <- TRUE, priority = 10)
477
+
478
+ # --- Outputs: sidebar -------------------------------------------------------
479
+ output$answers_ui <- shiny::renderUI({
480
+ rows_version()
481
+ answer_rows(shiny::isolate(question()))
482
+ })
483
+ output$references_ui <- shiny::renderUI({
484
+ l <- shiny::req(layout())
485
+ r <- refs()
486
+ srcs <- purrr::map(seq_along(l$answers), \(i) {
487
+ if (i <= length(r)) thumb(r[[i]]) else character()
488
+ })
489
+ reference_rows(
490
+ list(type = l$type, answers = stats::setNames(character(length(l$answers)), l$answers)),
491
+ stats::setNames(srcs, l$answers)
492
+ )
493
+ })
494
+ # A cost below one cent reads "< $0.01", not "$0.00": a run is never free
495
+ output$run_estimate <- shiny::renderText({
496
+ all <- checks()$run_all
497
+ cost <- if (all$cost > 0 && all$cost < 0.005) "< $0.01" else sprintf("\u2248 $%.2f", all$cost)
498
+ paste(all$n, "\u00b7", cost)
499
+ })
500
+ output$spend_meter <- shiny::renderUI(spend_meter_ui(session_cost()))
501
+ output$limits_text <- shiny::renderText({
502
+ all <- checks()$run_all
503
+ refused <- if (!all$ok) limit_message(all$reason)
504
+ sentences <- unique(c(notice(), data()$problems, matched()$problems, refused))
505
+ paste(stringr::str_replace(sentences, "([^.!?])$", "\\1."), collapse = " ")
506
+ })
507
+
508
+ # --- Outputs: answers -------------------------------------------------------
509
+ target <- shiny::reactive(target_answer(question(), input$target))
510
+ table <- shiny::reactive({
511
+ r <- results()
512
+ if (is.null(r$answers) || !identical(r$key, key())) return(NULL)
513
+ answers_table(spec(), r$answers, target())
514
+ })
515
+ # The table of the downloads: no rows while the card shows no answers
516
+ download_table <- shiny::reactive(table() %||% empty_table(spec()))
517
+ stats <- shiny::reactive(if (!is.null(table())) answer_stats(table(), spec(), target()))
518
+ output$stats <- shiny::renderUI({
519
+ if (is.null(stats())) return(NULL)
520
+ r <- results()
521
+ note <- if (r$saved) {
522
+ paste0("Saved run, ", MODEL)
523
+ } else if (running()) {
524
+ sprintf("%d of %d", nrow(r$answers), length(unlist(job$chunks)))
525
+ }
526
+ stats_ui(stats(), spec()$mode, note)
527
+ })
528
+ output$help_answers <- shiny::renderText({
529
+ if (is.null(stats())) return(help_text("answers"))
530
+ help_note(spec(), stats(), target(), saved = results()$saved)
531
+ })
532
+ output$gallery <- shiny::renderUI({
533
+ t <- table()
534
+ if (is.null(t) || spec()$mode != "image") return(NULL)
535
+ inputs <- spec()$inputs
536
+ gallery_ui(t, target(), thumb(inputs$path[match(t$id, inputs$id)]))
537
+ })
538
+ output$table <- shiny::renderTable(
539
+ if (!is.null(table())) table_view(table(), spec()),
540
+ digits = 2, na = ""
541
+ )
542
+ output$download_answers <- shiny::downloadHandler(
543
+ filename = \() paste0(run_name(spec()), "-answers.csv"),
544
+ content = \(file) readr::write_file(answers_csv(download_table()), file)
545
+ )
546
+
547
+ # --- Outputs: R code and bundle ---------------------------------------------
548
+ run_date <- function() as.Date(deps$now())
549
+ zip_name <- function() sprintf("%s-%s.zip", run_name(spec()), format(run_date()))
550
+ code <- shiny::reactive(run_code(spec(), run_date()))
551
+ output$code_summary <- shiny::renderText(
552
+ sprintf("R code of this run \u00b7 %d lines", code_lines(code()))
553
+ )
554
+ output$code <- shiny::renderText(code())
555
+ output$bundle_files <- shiny::renderUI(bundle_files_ui(bundle_names(spec()), zip_name()))
556
+ for (id in c("code", "bundle_files", "code_summary")) {
557
+ shiny::outputOptions(output, id, suspendWhenHidden = FALSE)
558
+ }
559
+ output$download_code <- shiny::downloadHandler(
560
+ filename = \() paste0(run_name(spec()), ".R"),
561
+ content = \(file) readr::write_file(paste0(code(), "\n"), file)
562
+ )
563
+ output$download_bundle <- shiny::downloadHandler(
564
+ filename = zip_name,
565
+ content = \(file) write_bundle(spec(), download_table(), file, run_date())
566
+ )
567
+
568
+ # --- Outputs: status ----------------------------------------------------------
569
+ # The status file is read at the start and every hour
570
+ status <- shiny::reactive({
571
+ shiny::invalidateLater(3600000)
572
+ status_summary(deps$status_fn(), deps$now())
573
+ })
574
+ output$status_chip <- shiny::renderText(status_chip(status()))
575
+ output$record_table <- shiny::renderTable(
576
+ {
577
+ s <- status()
578
+ s$last |>
579
+ dplyr::arrange(dplyr::desc(time_utc)) |>
580
+ dplyr::transmute(
581
+ `Time (UTC)` = time_utc, HTTP = suppressWarnings(as.integer(http_status)),
582
+ Seconds = suppressWarnings(as.numeric(seconds)),
583
+ !!sprintf("Answered (%d of %d)", s$answered, s$checks) := answered,
584
+ Version = app_version
585
+ )
586
+ },
587
+ digits = 1, na = ""
588
+ )
589
+ }
R/spec.R ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Run spec: the question, the references and the inputs of one run.
2
+ #
3
+ # A question is list(type, instructions, answers). The type is "noul",
4
+ # "choice" or "score". The names of answers are the answer names. The values
5
+ # are the descriptions ("" for none). For noul the names are "true" and "false".
6
+ #
7
+ # A spec is list(mode, question, references, inputs, source, name). The inputs
8
+ # are a tibble with id, path, text, file and label.
9
+
10
+ # The smallest and the largest number of answers of a type. Yes / no has the
11
+ # two fixed answers true and false.
12
+ answer_range <- function(type) ANSWER_RANGE[[type]] %||% c(2, 2)
13
+
14
+ # Problems of a question, as a character vector (empty when it is valid)
15
+ check_question <- function(question) {
16
+ answers <- question$answers
17
+ problems <- character()
18
+ if (question$type %in% names(ANSWER_RANGE)) {
19
+ range <- ANSWER_RANGE[[question$type]]
20
+ if (length(answers) < range[1] || length(answers) > range[2]) {
21
+ problems <- c(problems, stringr::str_glue(
22
+ "A {question$type} question needs {range[1]} to {range[2]} answers."
23
+ ))
24
+ }
25
+ }
26
+ if (nchar(question$instructions) > MAX_INSTRUCTIONS) {
27
+ problems <- c(problems, stringr::str_glue(
28
+ "The instructions can have {MAX_INSTRUCTIONS} characters at most."
29
+ ))
30
+ }
31
+ if (any(nchar(answers) > MAX_DESCRIPTION)) {
32
+ problems <- c(problems, stringr::str_glue(
33
+ "A description can have {MAX_DESCRIPTION} characters at most."
34
+ ))
35
+ }
36
+ keys <- stringr::str_to_lower(stringr::str_squish(names(answers)))
37
+ if (anyDuplicated(keys) > 0 || any(keys == "")) {
38
+ problems <- c(problems, "The answer names must be unique and not empty.")
39
+ }
40
+ as.character(problems)
41
+ }
42
+
43
+ # The question of the decisions package that decide() needs
44
+ as_decisions_question <- function(question) {
45
+ answers <- as.list(question$answers)
46
+ switch(question$type,
47
+ noul = decisions::question_noul(
48
+ question$instructions,
49
+ true = if (nzchar(answers$true)) answers$true,
50
+ false = if (nzchar(answers$false)) answers$false
51
+ ),
52
+ choice = decisions::question_choice(question$instructions, unlist(answers)),
53
+ score = decisions::question_score(question$instructions, unlist(answers))
54
+ )
55
+ }
56
+
57
+ # The answer that counts as the target of the metrics
58
+ target_answer <- function(question, pick = NULL) {
59
+ answers <- names(question$answers)
60
+ if (question$type == "noul") {
61
+ "true"
62
+ } else if (!is.null(pick) && pick %in% answers) {
63
+ pick
64
+ } else {
65
+ dplyr::last(answers)
66
+ }
67
+ }
68
+
69
+ # One hash for what decides the answers, so that a saved run can be matched
70
+ spec_fingerprint <- function(spec) {
71
+ rlang::hash(list(
72
+ spec$mode, spec$question$type, spec$question$instructions,
73
+ spec$question$answers, MODEL,
74
+ purrr::map(spec$references, \(paths) list(basename(paths), file.size(paths)))
75
+ ))
76
+ }
R/spending.R ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Spending: the usage totals, the limits, and the stores that keep the totals.
2
+ # A usage store is list(read = function() <tibble>, write = function(usage)).
3
+ # The tibble has one row for each UTC date: date, calls, input_tokens, cost.
4
+ # The usage token is read only here. It is never in a message or a warning.
5
+
6
+ # A usage with no rows
7
+ empty_usage <- function() {
8
+ tibble::tibble(
9
+ date = character(), calls = integer(),
10
+ input_tokens = double(), cost = double()
11
+ )
12
+ }
13
+
14
+ # UTC date (YYYY-MM-DD) of a time
15
+ utc_date <- function(now) format(now, "%Y-%m-%d", tz = "UTC")
16
+
17
+ # Add a chunk to the row of the UTC date of `now`
18
+ usage_add <- function(usage, now, calls, tokens, cost) {
19
+ day <- utc_date(now)
20
+ add <- tibble::tibble(
21
+ date = day, calls = as.integer(calls),
22
+ input_tokens = as.numeric(tokens), cost = as.numeric(cost)
23
+ )
24
+ dplyr::bind_rows(usage, add) |>
25
+ dplyr::summarise(
26
+ dplyr::across(c(calls, input_tokens, cost), sum), .by = date
27
+ ) |>
28
+ dplyr::arrange(date)
29
+ }
30
+
31
+ # For each date, the larger totals of two usages (the totals only grow)
32
+ usage_merge <- function(a, b) {
33
+ dplyr::bind_rows(a, b) |>
34
+ dplyr::summarise(
35
+ dplyr::across(c(calls, input_tokens, cost), max), .by = date
36
+ ) |>
37
+ dplyr::arrange(date)
38
+ }
39
+
40
+ # Read the latest totals, add the chunk, write, and return the new usage.
41
+ # Each session adds to the latest totals, so it never writes a stale copy.
42
+ usage_record <- function(store, now, calls, tokens, cost) {
43
+ usage <- store$read() |>
44
+ usage_add(now, calls, tokens, cost)
45
+ store$write(usage)
46
+ usage
47
+ }
48
+
49
+ # Seconds to wait for Hugging Face (a stalled call would block every visitor)
50
+ USAGE_TIMEOUT <- 10
51
+
52
+ # Parse the text of usage.csv; NULL when the columns, a date or a total is bad
53
+ parse_usage <- function(text) {
54
+ usage <- tryCatch(
55
+ suppressWarnings(readr::read_csv(
56
+ I(text),
57
+ col_types = readr::cols(
58
+ date = "c", calls = "i", input_tokens = "d", cost = "d"
59
+ ),
60
+ na = character(), show_col_types = FALSE
61
+ )),
62
+ error = \(e) NULL
63
+ )
64
+ ok <- !is.null(usage) &&
65
+ all(c("date", "calls", "input_tokens", "cost") %in% names(usage)) &&
66
+ all(grepl("^[0-9]{4}-[0-9]{2}-[0-9]{2}$", usage$date)) &&
67
+ !anyNA(usage[c("calls", "input_tokens", "cost")])
68
+ if (!ok) return(NULL)
69
+ usage[c("date", "calls", "input_tokens", "cost")]
70
+ }
71
+
72
+ # Store in memory (tests and the local mock mode)
73
+ usage_store_memory <- function(usage = empty_usage()) {
74
+ list(
75
+ read = function() usage,
76
+ write = function(new) {
77
+ usage <<- new
78
+ invisible(TRUE)
79
+ }
80
+ )
81
+ }
82
+
83
+ # Store in a private Hugging Face dataset (the file usage.csv).
84
+ # The cache holds the last totals written or read: if a request fails, the app
85
+ # goes on with the cache.
86
+ usage_store_hf <- function(repo = USAGE_REPO,
87
+ token = Sys.getenv(USAGE_TOKEN_VAR)) {
88
+ cache <- empty_usage()
89
+ auth <- paste("Bearer", token)
90
+
91
+ # Send a request; NULL when it fails. The text of a failure has no token.
92
+ send <- function(req, what) {
93
+ resp <- tryCatch(
94
+ req |>
95
+ httr2::req_headers(Authorization = auth) |>
96
+ httr2::req_timeout(USAGE_TIMEOUT) |>
97
+ httr2::req_error(is_error = \(r) FALSE) |>
98
+ httr2::req_perform(),
99
+ error = \(e) NULL
100
+ )
101
+ if (is.null(resp)) {
102
+ warning("usage store: ", what, " failed (no response)", call. = FALSE)
103
+ }
104
+ resp
105
+ }
106
+
107
+ read <- function() {
108
+ url <- paste0("https://huggingface.co/datasets/", repo, "/resolve/main/usage.csv")
109
+ resp <- send(httr2::request(url), "read")
110
+ if (is.null(resp)) return(cache)
111
+ status <- httr2::resp_status(resp)
112
+ if (status == 404) return(cache)
113
+ if (status >= 400) {
114
+ warning("usage store: read failed (HTTP ", status, ")", call. = FALSE)
115
+ return(cache)
116
+ }
117
+ remote <- parse_usage(httr2::resp_body_string(resp))
118
+ if (is.null(remote)) {
119
+ warning("usage store: read failed (bad file)", call. = FALSE)
120
+ return(cache)
121
+ }
122
+ cache <<- usage_merge(remote, cache)
123
+ cache
124
+ }
125
+
126
+ write <- function(usage) {
127
+ cache <<- usage_merge(cache, usage)
128
+ csv <- readr::format_csv(cache)
129
+ lines <- list(
130
+ list(key = "header", value = list(summary = paste("usage", utc_date(Sys.time())))),
131
+ list(key = "file", value = list(
132
+ path = "usage.csv",
133
+ content = jsonlite::base64_enc(charToRaw(csv)),
134
+ encoding = "base64"
135
+ ))
136
+ ) |>
137
+ purrr::map_chr(\(x) as.character(jsonlite::toJSON(x, auto_unbox = TRUE)))
138
+ body <- paste0(paste(lines, collapse = "\n"), "\n")
139
+ url <- paste0("https://huggingface.co/api/datasets/", repo, "/commit/main")
140
+ resp <- send(
141
+ httr2::request(url) |> httr2::req_body_raw(body, "application/x-ndjson"),
142
+ "write"
143
+ )
144
+ if (is.null(resp)) return(invisible(FALSE))
145
+ status <- httr2::resp_status(resp)
146
+ if (status >= 400) {
147
+ warning("usage store: write failed (HTTP ", status, ")", call. = FALSE)
148
+ return(invisible(FALSE))
149
+ }
150
+ invisible(TRUE)
151
+ }
152
+
153
+ list(read = read, write = write)
154
+ }
155
+
156
+ # Money left (USD) in the session, the UTC day and the UTC month of `now`
157
+ spend_status <- function(session_cost, usage, now = Sys.time()) {
158
+ day <- utc_date(now)
159
+ spent <- function(rows) sum(usage$cost[rows])
160
+ list(
161
+ session_left = LIMIT_SESSION - session_cost,
162
+ day_left = LIMIT_DAY - spent(usage$date == day),
163
+ month_left = LIMIT_MONTH - spent(substr(usage$date, 1, 7) == substr(day, 1, 7))
164
+ )
165
+ }
166
+
167
+ # Is a run with this estimated cost allowed? The first limit that fails wins.
168
+ # An NA amount fails the limit, so the check fails closed.
169
+ run_allowed <- function(estimate, status) {
170
+ fits <- \(left) isTRUE(estimate <= left)
171
+ reason <- dplyr::case_when(
172
+ !fits(status$session_left) ~ "session",
173
+ !fits(status$day_left) ~ "day",
174
+ !fits(status$month_left) ~ "month",
175
+ .default = NA_character_
176
+ )
177
+ list(ok = is.na(reason), reason = reason)
178
+ }
179
+
180
+ # The text for a refused run (the three texts of the design, section 6)
181
+ limit_message <- function(reason) {
182
+ c(
183
+ session = "The limit of this visit is reached.",
184
+ day = "The limit of today is reached. Try again tomorrow.",
185
+ month = "The limit of this month is reached."
186
+ )[[reason]]
187
+ }
R/status.R ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Uptime record. A GitHub workflow adds a row to status/status.csv twice a day.
2
+ # The app reads this file from GitHub and shows the days in service (chip) and
3
+ # the last checks (Record tab).
4
+
5
+ # The columns of status.csv
6
+ STATUS_COLUMNS <- c("time_utc", "http_status", "seconds", "answered", "app_version")
7
+
8
+ # Read the status file. A failed read gives an empty tibble: the chip then
9
+ # shows "new" and the app does not crash. All columns are read as text, so
10
+ # that an odd value cannot stop the read.
11
+ read_status <- function(url = STATUS_URL,
12
+ read_fn = \(u) readr::read_csv(
13
+ u, col_types = readr::cols(.default = "c")
14
+ )) {
15
+ empty <- tibble::tibble(
16
+ time_utc = character(), http_status = character(), seconds = character(),
17
+ answered = character(), app_version = character()
18
+ )
19
+ tryCatch(
20
+ read_fn(url),
21
+ error = \(e) empty
22
+ )
23
+ }
24
+
25
+ # Days in service, checks answered, checks made, and the last 60 checks.
26
+ # A row with a time that cannot be read is ignored. A check counts as answered
27
+ # only when its value is true (in any case).
28
+ status_summary <- function(status, now = Sys.time()) {
29
+ checks <- status |>
30
+ dplyr::mutate(
31
+ time = as.POSIXct(
32
+ as.character(.data$time_utc), format = "%Y-%m-%dT%H:%M:%SZ", tz = "UTC"
33
+ ),
34
+ ok = stringr::str_to_lower(as.character(.data$answered)) %in% "true"
35
+ ) |>
36
+ dplyr::filter(!is.na(.data$time)) |>
37
+ dplyr::arrange(.data$time)
38
+ first_ok <- checks |>
39
+ dplyr::filter(.data$ok) |>
40
+ dplyr::slice_head(n = 1)
41
+ days <- if (nrow(first_ok) == 0) {
42
+ 0
43
+ } else {
44
+ max(0, floor(as.numeric(difftime(now, first_ok$time, units = "days"))))
45
+ }
46
+ list(
47
+ days = days,
48
+ answered = sum(checks$ok),
49
+ checks = nrow(checks),
50
+ last = checks |>
51
+ dplyr::select(-"time", -"ok") |>
52
+ utils::tail(60)
53
+ )
54
+ }
55
+
56
+ # Short text for the chip: "in service 37 days", "in service 1 day" or "new"
57
+ status_chip <- function(summary) {
58
+ if (summary$days < 1) {
59
+ return("new")
60
+ }
61
+ stringr::str_glue(
62
+ "in service {summary$days} {if (summary$days == 1) 'day' else 'days'}"
63
+ ) |>
64
+ as.character()
65
+ }
R/ui.R ADDED
@@ -0,0 +1,406 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # User interface: the page, its parts, and small helpers that build the
2
+ # dynamic inputs and outputs. The look is in www/theme.css, the behaviour in
3
+ # the browser (tabs, Help switch, menu, video, Copy) in www/app.js.
4
+
5
+ # The address of the video ("" for none). The page reads it through this
6
+ # function, so that a test can replace it.
7
+ video_url <- function() VIDEO_URL
8
+
9
+ # The embed address of a YouTube video, on youtube-nocookie.com ("" if the
10
+ # address holds no video id)
11
+ video_embed <- function(url) {
12
+ id <- stringr::str_match(url, "(?:v=|youtu\\.be/|/embed/)([A-Za-z0-9_-]{11})")[, 2]
13
+ if (is.na(id)) "" else paste0("https://www.youtube-nocookie.com/embed/", id)
14
+ }
15
+
16
+ # The data sources of the sidebar
17
+ SOURCE_CHOICES <- c(
18
+ "Sample \u00b7 woven textiles (images, labelled)" = "textiles",
19
+ "Sample \u00b7 injury reports (texts, labelled)" = "injuries",
20
+ "Your own data" = "upload"
21
+ )
22
+
23
+ # The sources of the data that go with a mode
24
+ source_choices <- function(mode) {
25
+ SOURCE_CHOICES[SOURCE_CHOICES %in% c(if (mode == "image") "textiles" else "injuries", "upload")]
26
+ }
27
+
28
+ # The file types of the image uploads
29
+ IMAGE_ACCEPT <- paste0(".", IMAGE_EXTENSIONS)
30
+
31
+ # --- Small parts ------------------------------------------------------------
32
+
33
+ # Text for screen readers only
34
+ sr <- function(text) shiny::span(class = "sr-only", text)
35
+
36
+ # One help line of the sidebar, and one Help note of a card. The Help switch
37
+ # hides both (class "help").
38
+ help_line <- function(text) shiny::p(class = "help help-line", text)
39
+ note_box <- function(...) shiny::div(class = "howto help", shiny::strong("Help."), " ", ...)
40
+
41
+ # Radio buttons that look like a segmented control
42
+ seg_radio <- function(id, label, choices, selected = NULL, class = "seg") {
43
+ shiny::div(
44
+ class = class,
45
+ shiny::radioButtons(id, sr(label), choices, selected = selected, inline = TRUE)
46
+ )
47
+ }
48
+
49
+ # A file input. The read-only box that shows the chosen file gets a name for
50
+ # screen readers (Shiny gives it none) and no tab stop (it does nothing).
51
+ upload_input <- function(id, label, accept, multiple = FALSE, button = "Browse") {
52
+ input <- shiny::fileInput(id, label, multiple = multiple, accept = accept, buttonLabel = button)
53
+ htmltools::tagQuery(input)$
54
+ find("input.form-control")$
55
+ addAttrs(`aria-label` = "Chosen file", tabindex = "-1")$
56
+ allTags()
57
+ }
58
+
59
+ # Small icons for the buttons of the R code card
60
+ icon_svg <- function(path) {
61
+ htmltools::HTML(sprintf(
62
+ '<svg class="ico" aria-hidden="true" focusable="false" viewBox="0 0 16 16" width="14" height="14"><path d="%s" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>',
63
+ path
64
+ ))
65
+ }
66
+ ICON_COPY <- "M5 5h8v8H5zM3 11V3h8"
67
+ ICON_DOWNLOAD <- "M8 2v8M4.5 6.5 8 10l3.5-3.5M3 13h10"
68
+
69
+ # The links of the page open in a new tab: the app runs in a frame on
70
+ # Hugging Face, and the visitor keeps the session
71
+ ext_link <- function(text, href) shiny::a(href = href, target = "_blank", rel = "noopener", text)
72
+
73
+ # --- Top bar ----------------------------------------------------------------
74
+
75
+ tab_button <- function(id, label, selected = FALSE) {
76
+ shiny::tags$button(
77
+ type = "button", role = "tab", id = paste0("tab-", id),
78
+ `aria-controls` = paste0("panel-", id), `aria-selected` = tolower(selected),
79
+ tabindex = if (selected) "0" else "-1", label
80
+ )
81
+ }
82
+
83
+ help_switch <- function() {
84
+ shiny::tags$label(
85
+ class = "switch", `for` = "help_on",
86
+ shiny::tags$input(id = "help_on", type = "checkbox", role = "switch", checked = NA),
87
+ shiny::span(class = "track", `aria-hidden` = "true"),
88
+ "Help"
89
+ )
90
+ }
91
+
92
+ samples_menu <- function() {
93
+ shiny::tags$details(
94
+ id = "samples_menu", class = "menu",
95
+ shiny::tags$summary(class = "btn btn-red", "Samples"),
96
+ shiny::div(
97
+ class = "menu-list",
98
+ shiny::actionButton("sample_textiles", "Woven textiles (images)", class = "menu-item"),
99
+ shiny::actionButton("sample_injuries", "Injury reports (texts)", class = "menu-item")
100
+ )
101
+ )
102
+ }
103
+
104
+ top_bar <- function(video) {
105
+ shiny::tags$header(
106
+ class = "topbar",
107
+ shiny::div(
108
+ class = "brand",
109
+ shiny::img(src = "logo.svg", alt = "", class = "logo", width = "28", height = "32"),
110
+ shiny::h1(class = "name", "decisions"),
111
+ shiny::span(class = "tag", "Decision models")
112
+ ),
113
+ shiny::tags$nav(
114
+ class = "tabs", `aria-label` = "Pages",
115
+ shiny::div(
116
+ role = "tablist",
117
+ tab_button("build", "Build", TRUE), tab_button("about", "About"),
118
+ tab_button("record", "Record")
119
+ )
120
+ ),
121
+ shiny::div(
122
+ class = "topbar-right",
123
+ help_switch(),
124
+ shiny::span(
125
+ class = "chip", shiny::span(class = "dot", `aria-hidden` = "true", "\u25cf"), " ",
126
+ shiny::textOutput("status_chip", inline = TRUE)
127
+ ),
128
+ if (nzchar(video)) {
129
+ shiny::tags$button(
130
+ id = "open_video", type = "button", class = "btn btn-quiet",
131
+ `aria-haspopup` = "dialog", "Video"
132
+ )
133
+ },
134
+ shiny::div(
135
+ class = "import-btn",
136
+ upload_input("import_file", NULL, c(".zip", ".json"), button = "Import")
137
+ ),
138
+ samples_menu()
139
+ )
140
+ )
141
+ }
142
+
143
+ # --- Build tab: sidebar -----------------------------------------------------
144
+
145
+ # A numbered group of the sidebar, such as "01 - Data"
146
+ group <- function(num, title, ...) {
147
+ shiny::div(class = "group", shiny::h2(class = "eyebrow", shiny::tags$b(num), " \u00b7 ", title), ...)
148
+ }
149
+
150
+ sidebar <- function() {
151
+ textiles <- sample_question("textiles")
152
+ shiny::tags$aside(
153
+ class = "card sidebar", `aria-label` = "Setup",
154
+ group(
155
+ "01", "Data",
156
+ seg_radio("mode", "Kind of data", c(Images = "image", Text = "text")),
157
+ shiny::selectInput("source", sr("Source"), SOURCE_CHOICES, selectize = FALSE),
158
+ shiny::conditionalPanel(
159
+ "input.source == 'upload' && input.mode == 'image'",
160
+ upload_input("upload_images", "Images or zip file", c(IMAGE_ACCEPT, ".zip"), multiple = TRUE)
161
+ ),
162
+ shiny::conditionalPanel(
163
+ "input.source == 'upload' && input.mode == 'text'",
164
+ shiny::textAreaInput("paste_text", "Texts", rows = 4),
165
+ upload_input("upload_csv", "CSV file", ".csv"),
166
+ shiny::selectInput("csv_text_col", "Text column", character(), selectize = FALSE),
167
+ shiny::selectInput("csv_label_col", "Label column", character(), selectize = FALSE)
168
+ ),
169
+ help_line(help_text("data"))
170
+ ),
171
+ group(
172
+ "02", "Question",
173
+ seg_radio(
174
+ "qtype", "Type of question",
175
+ c("Yes / no" = "noul", Choice = "choice", Score = "score"), selected = textiles$type
176
+ ),
177
+ shiny::textAreaInput("instructions", "Instructions", textiles$instructions, rows = 2),
178
+ shiny::uiOutput("answers_ui"),
179
+ help_line(help_text("question"))
180
+ ),
181
+ shiny::conditionalPanel(
182
+ "input.mode == 'image'",
183
+ group(
184
+ "03", "References",
185
+ shiny::conditionalPanel("input.qtype != 'score'", help_line(help_text("references"))),
186
+ shiny::conditionalPanel("input.qtype == 'score'", help_line(help_text("references_score"))),
187
+ shiny::uiOutput("references_ui")
188
+ )
189
+ ),
190
+ shiny::div(
191
+ class = "run-row",
192
+ shiny::actionButton("try5", "Try on 5", class = "btn-ghost"),
193
+ shiny::actionButton(
194
+ "run_all", shiny::tagList("Run all ", shiny::textOutput("run_estimate", inline = TRUE)),
195
+ class = "btn-red"
196
+ )
197
+ ),
198
+ shiny::div(class = "stop-row", shiny::actionButton("stop", "Stop", class = "btn-ghost")),
199
+ htmltools::tagAppendChild(shiny::uiOutput("spend_meter"), spend_meter_ui(0)),
200
+ shiny::p(class = "privacy", help_text("privacy")),
201
+ shiny::div(class = "limits", role = "status", shiny::textOutput("limits_text", inline = TRUE))
202
+ )
203
+ }
204
+
205
+ # --- Build tab: cards -------------------------------------------------------
206
+
207
+ intro_card <- function() {
208
+ step <- \(n, name, text) {
209
+ shiny::tags$li(
210
+ shiny::span(class = "num", `aria-hidden` = "true", n),
211
+ shiny::span(shiny::strong(name), " \u00b7 ", text)
212
+ )
213
+ }
214
+ shiny::tags$section(
215
+ id = "intro", class = "card intro", `aria-label` = "Intro",
216
+ shiny::div(
217
+ shiny::p(class = "intro-lead", "Ask a model a question about each image or text."),
218
+ shiny::p(class = "intro-help", help_text("intro"), " ", shiny::actionLink("intro_hide", "Hide"))
219
+ ),
220
+ shiny::tags$ol(
221
+ class = "steps",
222
+ step("1", "Question", "a question, its answers and a few examples"),
223
+ step(
224
+ "2", "Answers",
225
+ "an answer and a probability for each input; with labels, how often Jev is right"
226
+ ),
227
+ step("3", "R code", "the same run in R, to copy or download")
228
+ )
229
+ )
230
+ }
231
+
232
+ # A region that scrolls sideways; it takes keyboard focus, so that the arrow
233
+ # keys can scroll it
234
+ scroll_region <- function(label, ...) {
235
+ shiny::div(class = "table-wrap", tabindex = "0", role = "region", `aria-label` = label, ...)
236
+ }
237
+
238
+ answers_card <- function() {
239
+ shiny::tags$section(
240
+ class = "card answers-card", `aria-labelledby` = "answers_title",
241
+ shiny::div(
242
+ class = "hd",
243
+ shiny::h2(
244
+ id = "answers_title", class = "card-title", tabindex = "-1",
245
+ shiny::span(class = "eyebrow-num", `aria-hidden` = "true", "1"), "Answers"
246
+ ),
247
+ shiny::div(
248
+ class = "hd-tools",
249
+ shiny::selectInput("target", "Target", character(), selectize = FALSE, width = "auto"),
250
+ seg_radio("view", "View", c(Gallery = "gallery", Table = "table"), class = "seg vtabs")
251
+ )
252
+ ),
253
+ shiny::div(
254
+ class = "bd",
255
+ shiny::uiOutput("stats"),
256
+ note_box(shiny::span(id = "help_answers", class = "shiny-text-output", help_text("answers"))),
257
+ shiny::conditionalPanel("input.view == 'gallery'", shiny::uiOutput("gallery")),
258
+ shiny::conditionalPanel(
259
+ "input.view == 'table'",
260
+ scroll_region("Answers table", shiny::tableOutput("table"))
261
+ ),
262
+ shiny::div(
263
+ class = "card-foot",
264
+ shiny::downloadButton("download_answers", "Answers .csv", class = "btn-quiet sm", icon = NULL)
265
+ )
266
+ )
267
+ )
268
+ }
269
+
270
+ # Server: shiny::outputOptions(output, id, suspendWhenHidden = FALSE) for "code", "bundle_files", "code_summary"
271
+ code_card <- function() {
272
+ code <- htmltools::tagAppendAttributes(
273
+ shiny::verbatimTextOutput("code", placeholder = FALSE),
274
+ class = "code", tabindex = "0", role = "region", `aria-label` = "R code of this run"
275
+ )
276
+ shiny::tags$section(
277
+ class = "card code-card", `aria-labelledby` = "code_title",
278
+ # A heading for screen readers (the visible title is the summary line)
279
+ shiny::h2(id = "code_title", class = "sr-only", "R code"),
280
+ # The summary holds only the summary line; the buttons sit outside it
281
+ shiny::tags$details(
282
+ class = "code-details",
283
+ shiny::tags$summary(
284
+ class = "code-summary",
285
+ shiny::span(class = "eyebrow-num", `aria-hidden` = "true", "2"),
286
+ shiny::span(id = "code_summary", class = "shiny-text-output", "R code")
287
+ ),
288
+ shiny::div(class = "bd", note_box(help_text("code")), code, shiny::uiOutput("bundle_files"))
289
+ ),
290
+ shiny::div(
291
+ class = "code-actions",
292
+ shiny::tags$button(
293
+ type = "button", class = "btn btn-quiet sm copy-btn", `data-target` = "code",
294
+ icon_svg(ICON_COPY), shiny::span(class = "copy-label", "Copy")
295
+ ),
296
+ shiny::downloadButton(
297
+ "download_code", shiny::tagList(icon_svg(ICON_DOWNLOAD), "Download .R"),
298
+ class = "btn-quiet sm", icon = NULL
299
+ ),
300
+ shiny::downloadButton(
301
+ "download_bundle", shiny::tagList(icon_svg(ICON_DOWNLOAD), "Bundle .zip"),
302
+ class = "btn-red sm", icon = NULL
303
+ )
304
+ ),
305
+ shiny::div(id = "copy_status", class = "sr-only", role = "status")
306
+ )
307
+ }
308
+
309
+ build_panel <- function() {
310
+ shiny::div(
311
+ class = "build", sidebar(),
312
+ shiny::div(class = "cards", intro_card(), answers_card(), code_card())
313
+ )
314
+ }
315
+
316
+ # --- About and Record tabs --------------------------------------------------
317
+
318
+ about_panel <- function() {
319
+ # The About text is Markdown; its links open in a new tab
320
+ html <- shiny::markdown(help_text("about")) |>
321
+ as.character() |>
322
+ stringr::str_replace_all("<a href=", '<a target="_blank" rel="noopener" href=')
323
+ shiny::div(class = "page card", shiny::div(class = "bd prose", htmltools::HTML(html)))
324
+ }
325
+
326
+ record_panel <- function() {
327
+ shiny::div(
328
+ class = "page card",
329
+ shiny::div(
330
+ class = "bd prose",
331
+ shiny::h2("Record"),
332
+ shiny::p(help_text("record")),
333
+ scroll_region("Record table", shiny::tableOutput("record_table")),
334
+ shiny::p(
335
+ ext_link("Status file", LINKS$status), " \u00b7 ",
336
+ ext_link("Package record", LINKS$package_record), " \u00b7 ",
337
+ ext_link("Project record", LINKS$project_record)
338
+ )
339
+ )
340
+ )
341
+ }
342
+
343
+ tab_panel <- function(id, content, selected = FALSE) {
344
+ shiny::tags$section(
345
+ id = paste0("panel-", id), role = "tabpanel", `aria-labelledby` = paste0("tab-", id),
346
+ hidden = if (!selected) NA, content
347
+ )
348
+ }
349
+
350
+ # --- Footer and video ---------------------------------------------------------
351
+
352
+ footer_bar <- function() {
353
+ shiny::tags$footer(
354
+ class = "foot",
355
+ shiny::span(sprintf(
356
+ "decisions-space %s \u00b7 decisions %s \u00b7 %s", APP_VERSION, decisions_version(), MODEL
357
+ )),
358
+ shiny::span(
359
+ AUTHORS, " \u00b7 ", ext_link("Source", LINKS$source), " \u00b7 ",
360
+ ext_link("MIT", LINKS$license)
361
+ )
362
+ )
363
+ }
364
+
365
+ # The video dialog (only when there is a video). The iframe gets its address
366
+ # from data-src when the dialog opens, so nothing loads before a click.
367
+ video_dialog <- function(url) {
368
+ shiny::tags$dialog(
369
+ id = "video_dialog", class = "video-dialog", `aria-labelledby` = "video_title",
370
+ shiny::div(
371
+ class = "video-head",
372
+ shiny::h2(id = "video_title", "Video"),
373
+ shiny::tags$button(id = "close_video", type = "button", class = "btn btn-quiet sm", "Close")
374
+ ),
375
+ shiny::tags$iframe(
376
+ id = "video_frame", class = "video-frame", title = "Video of the decisions app",
377
+ `data-src` = video_embed(url), allow = "autoplay; encrypted-media; picture-in-picture; fullscreen",
378
+ referrerpolicy = "strict-origin-when-cross-origin"
379
+ )
380
+ )
381
+ }
382
+
383
+ # --- The page -----------------------------------------------------------------
384
+
385
+ # The page. Shiny passes the HTTP request; the page does not use it.
386
+ app_ui <- function(request) {
387
+ video <- video_url()
388
+ shiny::bootstrapPage(
389
+ title = "decisions", lang = "en",
390
+ shiny::tags$head(
391
+ shiny::tags$meta(name = "app-version", content = APP_VERSION),
392
+ shiny::tags$link(rel = "icon", type = "image/png", href = "logo.png"),
393
+ shiny::tags$link(rel = "stylesheet", href = "theme.css")
394
+ ),
395
+ top_bar(video),
396
+ shiny::tags$main(
397
+ id = "main",
398
+ tab_panel("build", build_panel(), selected = TRUE),
399
+ tab_panel("about", about_panel()),
400
+ tab_panel("record", record_panel())
401
+ ),
402
+ footer_bar(),
403
+ if (nzchar(video)) video_dialog(video),
404
+ shiny::tags$script(src = "app.js")
405
+ )
406
+ }
README.md CHANGED
@@ -1,10 +1,90 @@
1
  ---
2
- title: Decisions
3
- emoji: 🏆
4
- colorFrom: pink
5
- colorTo: pink
6
  sdk: docker
 
7
  pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: decisions
3
+ emoji: 🔎
4
+ colorFrom: red
5
+ colorTo: yellow
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ license: mit
10
+ short_description: Ask Jev a question about each image or text in a set.
11
  ---
12
 
13
+ # decisions
14
+
15
+ ## App
16
+
17
+ This app asks Jev a question about each image or text in a set. Jev is a decision model of TypeSafe. For each input, the app shows the answer and the probability of each answer. It also gives the R code of the run and a bundle with the setup and the answers. A visitor needs no key and no account.
18
+
19
+ The app uses the R package [decisions](https://github.com/fmegahed/decisions) (version 0.1.0). Two samples run at no cost: textile images and injury reports. Their answers are saved.
20
+
21
+ Open the app: <https://huggingface.co/spaces/fmegahed/decisions>
22
+
23
+ ## Privacy
24
+
25
+ Your images, texts and references go to TypeSafe (Jev). Do not upload confidential data. The app keeps no input after your session. It stores only the daily totals of calls, tokens and cost. The bundle and the logs hold no key.
26
+
27
+ ## Limits
28
+
29
+ The demo pays for every call. The limits protect its credit.
30
+
31
+ - Each session can spend USD 0.25. Each day (UTC) can spend USD 2. Each month (UTC) can spend USD 20.
32
+ - A file can have 5 MB. A zip file can have 50 MB.
33
+ - A run can have 100 images or 200 texts.
34
+ - The app sends 10 inputs in each chunk.
35
+ - Jev costs USD 0.042 for each million input tokens.
36
+
37
+ Edit `R/config.R` to change these values. The help text of the app reads them from this file.
38
+
39
+ ## Docker
40
+
41
+ The Space runs this Docker image. You can build it and run it yourself.
42
+
43
+ ```sh
44
+ docker build -t decisions-space .
45
+ docker run -p 7860:7860 -e HF_JEV_API=... decisions-space
46
+ ```
47
+
48
+ Then open <http://localhost:7860>. Replace `...` with a TypeSafe key.
49
+
50
+ - `HF_JEV_API` is the TypeSafe key. Without it, the app opens, but a run shows "The demo is not available right now." The samples still show their saved answers.
51
+ - `HF_USAGE_TOKEN` is a Hugging Face token that can write to the private dataset `fmegahed/decisions-space-usage`. The app keeps its daily totals there. Without it, the app cannot reach the dataset. It then keeps the totals in memory, writes a warning to the log, and loses the totals when the app restarts.
52
+
53
+ The app never writes a key to a file or to the log.
54
+
55
+ ## Layout
56
+
57
+ - `app.R`: starts the app.
58
+ - `R/`: the app code. `config.R` holds the model, the limits and the links.
59
+ - `www/`: the style, the script, the logo and the fonts.
60
+ - `Dockerfile`: the image of the Space.
61
+ - `tests/`: the tests. `tests/live_smoke.R` makes two paid calls and is not part of the suite.
62
+ - `status/status.csv`: one row for each visit of the status workflow.
63
+ - `.github/workflows/`: `tests.yml` runs the tests and builds the image. `deploy.yml` uploads the app files to the Space. `status.yml` visits the app twice a day.
64
+
65
+ ## Tests
66
+
67
+ Run the tests from the repository root:
68
+
69
+ ```sh
70
+ Rscript tests/testthat.R
71
+ ```
72
+
73
+ The tests use a mock in place of Jev, so they need no key and make no paid call. Set `SPACE_MOCK=1` to start the app with the same mock, for example `SPACE_MOCK=1 Rscript -e 'shiny::runApp()'`. This mode is for local checks only. The Dockerfile never sets it.
74
+
75
+ The accessibility test needs Chrome (shinytest2 and axe-core). If chromote does not find Chrome, set the variable `CHROMOTE_CHROME` to the path of Chrome.
76
+
77
+ ## License
78
+
79
+ MIT. See `LICENSE`.
80
+
81
+ ## Authors
82
+
83
+ Fadel M. Megahed (Miami University) and Ying-Ju (Tessa) Chen (University of Dayton).
84
+
85
+ ## Citation
86
+
87
+ If you use the package or the app, cite both:
88
+
89
+ - Megahed, F. M., & Chen, Y.-J. (2026). *decisions: Typed decisions from decision models* (R package version 0.1.0). https://github.com/fmegahed/decisions
90
+ - Megahed, F. M., & Chen, Y.-J. (2026). *decisions Space* (Version 1.0.0) [Web application]. Hugging Face. https://huggingface.co/spaces/fmegahed/decisions
app.R ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # App logic lives in R/ and is sourced here in a fixed order
2
+ # (R/_disable_autoload.R stops Shiny from sourcing the directory a second time).
3
+ # APP_ROOT is the app folder; the mock mode finds the mock of the tests there.
4
+ APP_ROOT <- normalizePath(".")
5
+ APP_FILES <- c("config.R", "spec.R", "samples.R", "data.R", "runs.R", "spending.R", "answers.R", "code.R", "bundle.R", "status.R", "help.R", "render.R", "ui.R", "server.R")
6
+ for (r_file in APP_FILES) {
7
+ source(file.path("R", r_file), local = TRUE)
8
+ }
9
+
10
+ # Uploads larger than this are rejected by Shiny
11
+ options(shiny.maxRequestSize = MAX_ZIP_MB * 1024^2)
12
+
13
+ # Two background R processes run the calls to Jev, so a run does not block the
14
+ # app. They load the same code; the key reaches them only as the environment
15
+ # variable that they inherit.
16
+ mirai::daemons(2)
17
+ mirai::everywhere(
18
+ for (f in files) source(f),
19
+ .args = list(files = normalizePath(file.path("R", APP_FILES)))
20
+ )
21
+ shiny::onStop(\() mirai::daemons(0))
22
+
23
+ # One set of dependencies for all sessions: the stop for the day after a key
24
+ # or credit error holds for every visitor (R/server.R)
25
+ deps <- app_deps()
26
+
27
+ shiny::shinyApp(app_ui, \(input, output, session) app_server(input, output, session, deps))
www/app.js ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Client-side behaviour: the tabs, the Help switch, the intro, the Samples
2
+ // menu, the video dialog, the Copy button, the colours of the R code and the
3
+ // run state. The script sits at the end of the page, so the elements exist.
4
+ // Every access to browser storage is in try/catch (a private window or a
5
+ // blocked site can refuse it).
6
+ (function() {
7
+ 'use strict';
8
+
9
+ function readStore(store, key) {
10
+ try { return window[store].getItem(key); } catch (e) { return null; }
11
+ }
12
+ function writeStore(store, key, value) {
13
+ try { window[store].setItem(key, value); } catch (e) { /* storage refused */ }
14
+ }
15
+ function byId(id) { return document.getElementById(id); }
16
+
17
+ // ---- Tabs: Build, About, Record ----------------------------------------
18
+
19
+ var TABS = ['build', 'about', 'record'];
20
+
21
+ function showTab(name, moveFocus) {
22
+ TABS.forEach(function(t) {
23
+ var tab = byId('tab-' + t);
24
+ var panel = byId('panel-' + t);
25
+ if (!tab || !panel) return;
26
+ var on = (t === name);
27
+ tab.setAttribute('aria-selected', on ? 'true' : 'false');
28
+ tab.setAttribute('tabindex', on ? '0' : '-1');
29
+ panel.hidden = !on;
30
+ if (on && moveFocus) tab.focus();
31
+ });
32
+ if (window.Shiny && Shiny.setInputValue) Shiny.setInputValue('tab', name);
33
+ }
34
+
35
+ function onTabKey(event) {
36
+ var index = TABS.indexOf(event.currentTarget.id.replace('tab-', ''));
37
+ var next = null;
38
+ if (event.key === 'ArrowRight') next = TABS[(index + 1) % TABS.length];
39
+ else if (event.key === 'ArrowLeft') next = TABS[(index + TABS.length - 1) % TABS.length];
40
+ else if (event.key === 'Home') next = TABS[0];
41
+ else if (event.key === 'End') next = TABS[TABS.length - 1];
42
+ if (next !== null) {
43
+ event.preventDefault();
44
+ showTab(next, true);
45
+ }
46
+ }
47
+
48
+ TABS.forEach(function(t) {
49
+ var tab = byId('tab-' + t);
50
+ if (!tab) return;
51
+ tab.addEventListener('click', function() { showTab(t, false); });
52
+ tab.addEventListener('keydown', onTabKey);
53
+ });
54
+
55
+ // ---- Help switch: hides the help lines and notes; kept for the session --
56
+
57
+ var helpSwitch = byId('help_on');
58
+ function applyHelp() { document.body.classList.toggle('help-off', !helpSwitch.checked); }
59
+ if (helpSwitch) {
60
+ if (readStore('sessionStorage', 'decisions-help') === 'off') helpSwitch.checked = false;
61
+ applyHelp();
62
+ helpSwitch.addEventListener('change', function() {
63
+ applyHelp();
64
+ writeStore('sessionStorage', 'decisions-help', helpSwitch.checked ? 'on' : 'off');
65
+ });
66
+ }
67
+
68
+ // ---- Intro: open on the first visit; Hide closes it for this browser ----
69
+
70
+ var intro = byId('intro');
71
+ if (intro && readStore('localStorage', 'decisions-intro') === 'hidden') intro.hidden = true;
72
+ var introHide = byId('intro_hide');
73
+ if (introHide) {
74
+ introHide.addEventListener('click', function(event) {
75
+ event.preventDefault();
76
+ intro.hidden = true;
77
+ writeStore('localStorage', 'decisions-intro', 'hidden');
78
+ var title = byId('answers_title');
79
+ if (title) title.focus();
80
+ });
81
+ }
82
+
83
+ // ---- Samples menu: closes on a choice, on Escape and on a click outside --
84
+
85
+ function closeMenu(menu, moveFocus) {
86
+ if (!menu.open) return;
87
+ menu.open = false;
88
+ if (moveFocus) menu.querySelector('summary').focus();
89
+ }
90
+
91
+ document.addEventListener('click', function(event) {
92
+ document.querySelectorAll('details.menu[open]').forEach(function(menu) {
93
+ if (!menu.contains(event.target)) closeMenu(menu, false);
94
+ else if (event.target.closest('.menu-item')) closeMenu(menu, true);
95
+ });
96
+ });
97
+
98
+ document.addEventListener('keydown', function(event) {
99
+ if (event.key !== 'Escape') return;
100
+ document.querySelectorAll('details.menu[open]').forEach(function(menu) {
101
+ closeMenu(menu, menu.contains(document.activeElement));
102
+ });
103
+ });
104
+
105
+ // ---- Video dialog (only when the page has one) ---------------------------
106
+ // The embed address is set when the dialog opens and cleared when it
107
+ // closes, so nothing loads before a click and the video stops on close.
108
+ // Escape closes the dialog; Tab stays in the dialog.
109
+
110
+ var dialog = byId('video_dialog');
111
+ var openVideo = byId('open_video');
112
+ if (dialog && openVideo && dialog.showModal) {
113
+ var frame = byId('video_frame');
114
+ var closeButton = byId('close_video');
115
+ var lastFocused = null;
116
+
117
+ openVideo.addEventListener('click', function() {
118
+ lastFocused = document.activeElement;
119
+ frame.setAttribute('src', frame.getAttribute('data-src'));
120
+ dialog.showModal();
121
+ closeButton.focus();
122
+ });
123
+ closeButton.addEventListener('click', function() { dialog.close(); });
124
+ dialog.addEventListener('click', function(event) {
125
+ if (event.target === dialog) dialog.close();
126
+ });
127
+ dialog.addEventListener('close', function() {
128
+ frame.setAttribute('src', 'about:blank');
129
+ if (lastFocused && lastFocused.focus) lastFocused.focus();
130
+ });
131
+ dialog.addEventListener('keydown', function(event) {
132
+ if (event.key !== 'Tab') return;
133
+ if (event.shiftKey && document.activeElement === closeButton) {
134
+ event.preventDefault();
135
+ frame.focus();
136
+ } else if (!event.shiftKey && document.activeElement === frame) {
137
+ event.preventDefault();
138
+ closeButton.focus();
139
+ }
140
+ });
141
+ }
142
+
143
+ // ---- Copy button of the R code -------------------------------------------
144
+ // The code output sits in the R code card, which is closed when the page
145
+ // opens. Shiny renders a hidden output only when it shows, so the code can
146
+ // be empty. Then Copy opens the card and copies when the code arrives (see
147
+ // the shiny:value handler below), or says that there is nothing to copy
148
+ // yet. Copy never says "Copied" for an empty text.
149
+
150
+ var pendingCopy = null;
151
+ var pendingTimer = null;
152
+
153
+ // Show a message on the button and in the live region for 2 seconds
154
+ function sayCopy(button, message) {
155
+ var label = button.querySelector('.copy-label');
156
+ var status = byId('copy_status');
157
+ if (label) label.textContent = message;
158
+ if (status) status.textContent = message;
159
+ window.setTimeout(function() {
160
+ if (label) label.textContent = 'Copy';
161
+ if (status) status.textContent = '';
162
+ }, 2000);
163
+ }
164
+
165
+ function writeCopy(button, text) {
166
+ if (navigator.clipboard && navigator.clipboard.writeText) {
167
+ navigator.clipboard.writeText(text).then(
168
+ function() { sayCopy(button, 'Copied'); },
169
+ function() { sayCopy(button, 'Copy failed. Select the code and copy it.'); }
170
+ );
171
+ } else {
172
+ sayCopy(button, 'Copy is not available. Select the code and copy it.');
173
+ }
174
+ }
175
+
176
+ function copyCode(button) {
177
+ var target = byId(button.getAttribute('data-target'));
178
+ if (!target) return;
179
+ var text = target.textContent;
180
+ if (!text) {
181
+ var details = target.closest('details');
182
+ if (details && !details.open) {
183
+ // Open the card; the copy waits for the code, at most 5 seconds
184
+ pendingCopy = button;
185
+ details.open = true;
186
+ window.clearTimeout(pendingTimer);
187
+ pendingTimer = window.setTimeout(function() {
188
+ if (pendingCopy !== button) return;
189
+ pendingCopy = null;
190
+ sayCopy(button, 'Nothing to copy yet.');
191
+ }, 5000);
192
+ } else {
193
+ sayCopy(button, 'Nothing to copy yet.');
194
+ }
195
+ return;
196
+ }
197
+ writeCopy(button, text);
198
+ }
199
+
200
+ document.addEventListener('click', function(event) {
201
+ var button = event.target.closest ? event.target.closest('.copy-btn') : null;
202
+ if (button) copyCode(button);
203
+ });
204
+
205
+ // ---- Colours of the R code -----------------------------------------------
206
+ // The server sends the code as plain text. The text is escaped first, then
207
+ // comments, strings and function names get a span. Copy reads textContent,
208
+ // so it copies the plain code.
209
+
210
+ var TOKEN = /(#[^\n]*)|("(?:[^"\\\n]|\\.)*")|([A-Za-z.][A-Za-z0-9._]*)(?=\()/g;
211
+
212
+ function escapeHtml(text) {
213
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
214
+ }
215
+
216
+ function highlightR(code) {
217
+ var html = '';
218
+ var last = 0;
219
+ var match;
220
+ TOKEN.lastIndex = 0;
221
+ while ((match = TOKEN.exec(code)) !== null) {
222
+ html += escapeHtml(code.slice(last, match.index));
223
+ var kind = match[1] ? 'c' : (match[2] ? 's' : 'f');
224
+ html += '<span class="' + kind + '">' + escapeHtml(match[0]) + '</span>';
225
+ last = TOKEN.lastIndex;
226
+ }
227
+ return html + escapeHtml(code.slice(last));
228
+ }
229
+
230
+ if (window.jQuery) {
231
+ window.jQuery(document).on('shiny:value', function(event) {
232
+ if (event.name !== 'code' || typeof event.value !== 'string') return;
233
+ event.preventDefault();
234
+ byId('code').innerHTML = highlightR(event.value);
235
+ // A Copy that waited for the code runs now
236
+ if (pendingCopy) {
237
+ var button = pendingCopy;
238
+ pendingCopy = null;
239
+ window.clearTimeout(pendingTimer);
240
+ if (event.value) writeCopy(button, event.value);
241
+ else sayCopy(button, 'Nothing to copy yet.');
242
+ }
243
+ });
244
+ }
245
+
246
+ // ---- Run state -------------------------------------------------------------
247
+ // The server sends session$sendCustomMessage("run-state", TRUE) when a run
248
+ // starts and FALSE when it ends: Stop replaces Try on 5 and Run all.
249
+
250
+ if (window.Shiny && Shiny.addCustomMessageHandler) {
251
+ Shiny.addCustomMessageHandler('run-state', function(running) {
252
+ document.body.classList.toggle('is-running', !!running);
253
+ });
254
+ }
255
+ })();
www/fonts/OFL-ibm-plex-mono.txt ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+
5
+ This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
www/fonts/OFL-source-sans-3.txt ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Copyright 2010-2024 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+
5
+ This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
www/fonts/OFL-source-serif-4.txt ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Copyright 2014 - 2023 Adobe (http://www.adobe.com/), with Reserved Font Name ‘Source’. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+
5
+ This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
www/fonts/ibm-plex-mono-latin-400-normal.woff2 ADDED
Binary file (14.7 kB). View file
 
www/fonts/ibm-plex-mono-latin-600-normal.woff2 ADDED
Binary file (15.6 kB). View file
 
www/fonts/source-sans-3-latin-400-normal.woff2 ADDED
Binary file (15.7 kB). View file
 
www/fonts/source-sans-3-latin-600-normal.woff2 ADDED
Binary file (15.7 kB). View file
 
www/fonts/source-sans-3-latin-700-normal.woff2 ADDED
Binary file (15.6 kB). View file
 
www/fonts/source-serif-4-latin-700-normal.woff2 ADDED
Binary file (21.7 kB). View file
 
www/logo.png ADDED
www/logo.svg ADDED
www/theme.css ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Theme of the decisions Space: the Miami palette (no tints), WCAG 2.2 AA.
2
+ Measured contrast: muted text #5f5a52 on white 6.84:1, on #FAF9F7 6.49:1;
3
+ Dark Tan #70685C on white 5.49:1 (never small text on Light Tan);
4
+ Miami Red #C41230 on white 6.04:1; code on #fbfaf7: text #22262b 14.58:1,
5
+ comments #5f5a52 6.55:1, functions #3E5468 7.53:1, strings #7a3e00 7.99:1. */
6
+
7
+ /* Fonts: Latin subsets (woff2), SIL Open Font License 1.1, served from www/fonts.
8
+ Source Serif 4 and Source Sans 3: @fontsource 5.3.1; IBM Plex Mono: @fontsource
9
+ 5.3.0 (cdn.jsdelivr.net/npm/@fontsource/<family>@<version>/files/). The license
10
+ texts come from adobe-fonts/source-serif, adobe-fonts/source-sans and IBM/plex. */
11
+ @font-face { font-family: "Source Serif 4"; font-style: normal; font-weight: 700; font-display: swap;
12
+ src: url("fonts/source-serif-4-latin-700-normal.woff2") format("woff2"); }
13
+ @font-face { font-family: "Source Sans 3"; font-style: normal; font-weight: 400; font-display: swap;
14
+ src: url("fonts/source-sans-3-latin-400-normal.woff2") format("woff2"); }
15
+ @font-face { font-family: "Source Sans 3"; font-style: normal; font-weight: 600; font-display: swap;
16
+ src: url("fonts/source-sans-3-latin-600-normal.woff2") format("woff2"); }
17
+ @font-face { font-family: "Source Sans 3"; font-style: normal; font-weight: 700; font-display: swap;
18
+ src: url("fonts/source-sans-3-latin-700-normal.woff2") format("woff2"); }
19
+ @font-face { font-family: "IBM Plex Mono"; font-style: normal; font-weight: 400; font-display: swap;
20
+ src: url("fonts/ibm-plex-mono-latin-400-normal.woff2") format("woff2"); }
21
+ @font-face { font-family: "IBM Plex Mono"; font-style: normal; font-weight: 600; font-display: swap;
22
+ src: url("fonts/ibm-plex-mono-latin-600-normal.woff2") format("woff2"); }
23
+
24
+ :root {
25
+ --red: #C41230; --tan: #EDECE2; --tan2: #CCC9B8; --tan3: #70685C;
26
+ --warm: #FAF9F7; --slate: #3E5468; --ink: #1b1b1b; --muted: #5f5a52;
27
+ --code-bg: #fbfaf7; --code-text: #22262b; --code-c: #5f5a52; --code-f: #3E5468; --code-s: #7a3e00;
28
+ --serif: "Source Serif 4", Georgia, serif;
29
+ --sans: "Source Sans 3", system-ui, -apple-system, "Segoe UI", sans-serif;
30
+ --mono: "IBM Plex Mono", Consolas, "Courier New", monospace;
31
+ }
32
+
33
+ /* --- Base ------------------------------------------------------------------ */
34
+ html, body { background: var(--warm); }
35
+ body { margin: 0; font-family: var(--sans); font-size: 14px; line-height: 1.45; color: var(--ink); }
36
+ [hidden] { display: none !important; }
37
+ a { color: var(--red); text-decoration: underline; }
38
+ a:hover, a:focus { color: var(--red); text-decoration: underline; }
39
+ h1, h2, h3 { color: var(--ink); }
40
+ .mono { font-family: var(--mono); }
41
+ ::placeholder { color: var(--muted); opacity: 1; }
42
+
43
+ /* Focus: one visible ring on every control (3px Slate Blue) */
44
+ a:focus-visible, button:focus-visible, summary:focus-visible, input:focus-visible,
45
+ select:focus-visible, textarea:focus-visible, [tabindex]:focus-visible, .btn:focus-visible {
46
+ outline: 3px solid var(--slate) !important; outline-offset: 2px !important; box-shadow: none;
47
+ }
48
+ .btn:focus:not(:focus-visible), a:focus:not(:focus-visible) { outline: none; }
49
+ .form-control:focus { border-color: var(--slate); box-shadow: none; }
50
+ .btn-file:focus-within { outline: 3px solid var(--slate); outline-offset: 2px; }
51
+
52
+ /* --- Buttons ----------------------------------------------------------------- */
53
+ .btn { font-family: var(--sans); font-weight: 600; font-size: 13.5px; border-radius: 6px;
54
+ padding: 7px 12px; display: inline-flex; align-items: center; justify-content: center; gap: 6px;
55
+ white-space: nowrap; text-decoration: none; }
56
+ .btn:focus { text-decoration: none; }
57
+ .btn:hover { text-decoration: underline; }
58
+ .btn.sm { padding: 4px 10px; font-size: 12.5px; }
59
+ .btn.btn-red, .btn.btn-red:hover, .btn.btn-red:focus, .btn.btn-red:active {
60
+ background: var(--red); border: 1px solid var(--red); color: #fff; }
61
+ .btn.btn-ghost, .btn.btn-ghost:focus { background: #fff; border: 1px solid var(--red); color: var(--red); }
62
+ .btn.btn-ghost:hover, .btn.btn-ghost:active { background: var(--tan); border-color: var(--red); color: var(--red); }
63
+ .btn.btn-quiet, .btn.btn-quiet:focus, .btn-file.btn { background: #fff; border: 1px solid var(--tan2); color: var(--ink); }
64
+ .btn.btn-quiet:hover, .btn.btn-quiet:active, .btn-file.btn:hover { background: var(--tan); border-color: var(--tan2); color: var(--ink); }
65
+ .btn.btn-icon { padding: 2px 8px; font-size: 16px; line-height: 1.2; }
66
+ .btn .ico { flex: none; }
67
+
68
+ /* --- Top bar ------------------------------------------------------------------ */
69
+ .topbar { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px; padding: 10px 18px;
70
+ background: #fff; border-bottom: 1px solid var(--tan2); }
71
+ .brand { display: flex; align-items: center; gap: 8px; }
72
+ .brand .logo { height: 32px; width: auto; }
73
+ .brand .name { margin: 0; font-family: var(--serif); font-weight: 700; font-size: 20px; line-height: 1; }
74
+ .brand .tag { font-family: var(--mono); font-size: 10.5px; letter-spacing: .14em; text-transform: uppercase; color: var(--tan3); }
75
+ .tabs [role="tablist"] { display: flex; gap: 4px; }
76
+ .tabs [role="tab"] { font-family: var(--mono); font-size: 12px; letter-spacing: .1em; text-transform: uppercase;
77
+ padding: 6px 10px; border: 0; border-radius: 6px; background: transparent; color: var(--muted); }
78
+ .tabs [role="tab"]:hover { color: var(--ink); text-decoration: underline; }
79
+ .tabs [role="tab"][aria-selected="true"] { background: var(--tan); color: var(--ink); font-weight: 600; }
80
+ .topbar-right { margin-left: auto; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
81
+
82
+ /* Help switch: a real checkbox with the role switch */
83
+ .switch { position: relative; display: inline-flex; align-items: center; gap: 6px; margin: 0;
84
+ font-size: 13px; font-weight: 400; color: var(--muted); cursor: pointer; }
85
+ .switch input { position: absolute; left: 0; top: 50%; width: 30px; height: 16px; margin: -8px 0 0; opacity: 0; cursor: pointer; }
86
+ .switch .track { position: relative; display: inline-block; width: 30px; height: 16px; border-radius: 8px;
87
+ background: #fff; border: 1px solid var(--tan3); }
88
+ .switch .track::after { content: ""; position: absolute; left: 2px; top: 2px; width: 10px; height: 10px;
89
+ border-radius: 5px; background: var(--tan3); transition: left .15s; }
90
+ .switch input:checked + .track { background: var(--slate); border-color: var(--slate); }
91
+ .switch input:checked + .track::after { left: 16px; background: #fff; }
92
+ .switch input:focus-visible + .track { outline: 3px solid var(--slate); outline-offset: 2px; }
93
+
94
+ .chip { font-family: var(--mono); font-size: 11.5px; color: var(--slate); background: #fff;
95
+ border: 1px solid var(--tan2); padding: 3px 9px; border-radius: 999px; white-space: nowrap; }
96
+ /* An empty output must keep its size: Shiny does not render an output with display: none */
97
+ .chip:has(.shiny-text-output:empty) { visibility: hidden; }
98
+
99
+ /* Import: a file input that looks like one button */
100
+ .import-btn .form-group { margin: 0; width: auto !important; }
101
+ .import-btn .control-label, .import-btn .form-control, .import-btn .progress { display: none; }
102
+ .import-btn .input-group { display: block; }
103
+ .import-btn .input-group-btn { display: inline-block; width: auto; font-size: inherit; }
104
+ .import-btn .btn-file { border-radius: 6px !important; }
105
+
106
+ /* Samples menu */
107
+ .menu { position: relative; }
108
+ .menu > summary { list-style: none; cursor: pointer; }
109
+ .menu > summary::-webkit-details-marker { display: none; }
110
+ .menu > summary::after { content: ""; display: inline-block; margin-left: 2px; border: 4px solid transparent;
111
+ border-top-color: currentColor; transform: translateY(2px); }
112
+ .menu-list { position: absolute; right: 0; top: calc(100% + 4px); z-index: 20; min-width: 220px; padding: 6px;
113
+ display: flex; flex-direction: column; background: #fff; border: 1px solid var(--tan2); border-radius: 8px;
114
+ box-shadow: 0 4px 12px rgba(0, 0, 0, .08); }
115
+ .menu-list .btn.menu-item { justify-content: flex-start; background: #fff; border: 0; color: var(--ink); font-weight: 400; }
116
+ .menu-list .btn.menu-item:hover { background: var(--tan); }
117
+
118
+ /* --- Layout -------------------------------------------------------------------- */
119
+ .build { display: grid; grid-template-columns: 320px minmax(0, 1fr); gap: 14px; padding: 14px; align-items: start; }
120
+ .cards { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
121
+ .page { max-width: 880px; margin: 14px auto; }
122
+ /* A narrow screen puts the sidebar above the cards (reflow at 320 CSS pixels) */
123
+ @media (max-width: 899px) {
124
+ .build { grid-template-columns: minmax(0, 1fr); }
125
+ .page { margin: 14px; }
126
+ }
127
+
128
+ .card { background: #fff; border: 1px solid var(--tan); border-radius: 10px; box-shadow: 0 1px 2px rgba(0, 0, 0, .04); }
129
+ .card .hd { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 12px 16px 10px; border-bottom: 1px solid var(--tan); }
130
+ .card .bd { padding: 12px 16px; }
131
+ .card-title { margin: 0; display: flex; align-items: baseline; gap: 8px; font-family: var(--serif); font-weight: 700; font-size: 18px; }
132
+ .eyebrow-num { font-family: var(--mono); font-size: 12px; font-weight: 600; color: var(--red); }
133
+ .hd-tools { margin-left: auto; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
134
+ .hd-tools .form-group { display: flex; align-items: center; gap: 6px; margin: 0; }
135
+ .hd-tools .control-label { margin: 0; font-family: var(--mono); font-size: 11.5px; font-weight: 400;
136
+ letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
137
+ .card-foot { margin-top: 12px; }
138
+
139
+ /* --- Sidebar -------------------------------------------------------------------- */
140
+ .sidebar { padding: 14px 16px; display: flex; flex-direction: column; gap: 14px; }
141
+ .sidebar .shiny-input-container:not(.shiny-input-container-inline) { width: 100%; }
142
+ .sidebar .form-group { margin-bottom: 6px; }
143
+ .sidebar .control-label { font-size: 12.5px; font-weight: 600; margin-bottom: 3px; }
144
+ /* A label for screen readers only takes no space */
145
+ .control-label:has(> .sr-only:only-child) { position: absolute; margin: 0; }
146
+ .sidebar .shiny-file-input-progress { height: 6px; margin: 2px 0 4px; }
147
+ .sidebar .shiny-file-input-progress .progress-bar { font-size: 0; }
148
+ .ref-row .form-group { margin-bottom: 0; }
149
+ .eyebrow { margin: 0 0 6px; font-family: var(--mono); font-size: 11.5px; font-weight: 400; letter-spacing: .12em;
150
+ text-transform: uppercase; color: var(--tan3); }
151
+ .eyebrow b { color: var(--red); font-weight: 600; }
152
+ .help { color: var(--muted); font-size: 13px; }
153
+ .help-line { margin: 4px 0 0; }
154
+ body.help-off .help { display: none !important; }
155
+
156
+ .form-control { border: 1px solid var(--tan3); border-radius: 6px; box-shadow: none; color: var(--ink);
157
+ font-size: 13.5px; height: auto; padding: 6px 8px; }
158
+ textarea.form-control { resize: vertical; }
159
+ .form-control[readonly] { background: #fff; }
160
+
161
+ /* Segmented controls: native radio buttons, styled */
162
+ .seg .form-group { margin: 0; }
163
+ .seg .shiny-options-group { display: flex; border: 1px solid var(--tan3); border-radius: 6px; background: #fff; }
164
+ .seg .radio-inline { flex: 1; position: relative; margin: 0; padding: 0; border-right: 1px solid var(--tan3); font-weight: 400; }
165
+ .seg .radio-inline + .radio-inline { margin-left: 0; }
166
+ .seg .radio-inline:last-child { border-right: 0; }
167
+ .seg .radio-inline input[type="radio"] { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
168
+ .seg .radio-inline span { display: block; padding: 5px 6px; text-align: center; font-size: 13px; color: var(--muted); cursor: pointer; }
169
+ .seg .radio-inline:first-child span { border-radius: 5px 0 0 5px; }
170
+ .seg .radio-inline:last-child span { border-radius: 0 5px 5px 0; }
171
+ .seg input:checked + span { background: var(--ink); color: #fff; font-weight: 600; }
172
+ .seg input:focus-visible + span { position: relative; z-index: 1; outline: 3px solid var(--slate); outline-offset: 2px; }
173
+ .vtabs .shiny-options-group { border: 0; gap: 6px; }
174
+ .vtabs .radio-inline { border: 0; flex: none; }
175
+ .vtabs .radio-inline span { font-family: var(--mono); font-size: 11.5px; letter-spacing: .06em; text-transform: uppercase;
176
+ padding: 5px 9px; border: 1px solid var(--tan3); border-radius: 6px !important; }
177
+ .vtabs input:checked + span { border-color: var(--ink); }
178
+
179
+ /* Answers and references of the question */
180
+ .answer-head, .answer-row { display: grid; grid-template-columns: 88px minmax(0, 1fr) auto; gap: 6px; align-items: start; }
181
+ .answer-head { margin-top: 8px; font-size: 11.5px; color: var(--muted); }
182
+ .answer-row { margin-top: 6px; }
183
+ .answer-row .form-group { margin: 0; }
184
+ .answer-row input.form-control, .answer-row .k { font-family: var(--mono); font-size: 12px; background: var(--tan); text-align: center; padding: 5px 4px; }
185
+ .answer-row .k { display: block; border-radius: 4px; }
186
+ .answer-row textarea.form-control { font-size: 13px; padding: 5px 7px; }
187
+ #answer_add { margin-top: 8px; }
188
+ .ref-row { display: grid; grid-template-columns: 88px minmax(0, 1fr); gap: 6px; align-items: start; margin-top: 8px; }
189
+ .ref-row .k-plain { font-family: var(--mono); font-size: 12px; padding-top: 4px; word-break: break-word; }
190
+ .thumbs { display: flex; gap: 4px; margin-bottom: 4px; }
191
+ .thumbs img { width: 42px; height: 42px; object-fit: cover; border-radius: 4px; }
192
+
193
+ /* Run, spending and privacy */
194
+ .run-row, .stop-row { display: flex; gap: 8px; }
195
+ .run-row .btn { flex: 1; padding: 9px 8px; }
196
+ .run-row #run_all { flex: 1.4; }
197
+ .stop-row { display: none; }
198
+ .stop-row .btn { flex: 1; }
199
+ body.is-running .stop-row { display: flex; }
200
+ body.is-running .run-row { display: none; }
201
+ .spend-row { display: flex; justify-content: space-between; font-size: 12.5px; }
202
+ .meter { height: 6px; margin: 4px 0 2px; background: var(--tan); border-radius: 3px; overflow: hidden; }
203
+ .meter i, .bar i { display: block; height: 100%; background: var(--slate); }
204
+ .privacy { margin: 0; font-size: 12.5px; color: var(--muted); }
205
+ .limits { font-size: 13px; font-weight: 600; color: var(--red); }
206
+
207
+ /* --- Cards -------------------------------------------------------------------- */
208
+ .intro { display: grid; grid-template-columns: 1.2fr 1fr; gap: 22px; align-items: center; padding: 16px 18px; }
209
+ .intro-lead { margin: 0; font-family: var(--serif); font-weight: 700; font-size: 21px; line-height: 1.2; }
210
+ .intro-help { margin: 6px 0 0; font-size: 13.5px; color: var(--muted); }
211
+ .steps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; font-size: 13.5px; }
212
+ .steps li { display: flex; gap: 8px; align-items: flex-start; }
213
+ .num { flex: none; display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px;
214
+ border-radius: 11px; background: var(--red); color: #fff; font-weight: 700; font-size: 12px; }
215
+ @media (max-width: 899px) { .intro { grid-template-columns: minmax(0, 1fr); gap: 12px; } }
216
+
217
+ .stats { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 22px; margin: 0 0 10px; }
218
+ .stats .v { font-family: var(--serif); font-size: 24px; font-weight: 700; }
219
+ .stats .l { margin-left: 4px; font-size: 12.5px; color: var(--muted); }
220
+ .howto { margin: 0 0 10px; padding: 6px 10px; border-left: 3px solid var(--tan2); background: var(--warm); font-size: 13px; }
221
+ .howto strong { color: var(--ink); }
222
+
223
+ .gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 10px; }
224
+ .tile { margin: 0; }
225
+ .tile img { display: block; width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 5px; background: var(--tan); }
226
+ .tile .p { display: flex; align-items: center; gap: 6px; margin-top: 4px; font-family: var(--mono); font-size: 12px; }
227
+ .tile .bar { flex: 1; height: 4px; background: var(--tan); border-radius: 2px; overflow: hidden; }
228
+ .tile .t { font-size: 12px; color: var(--muted); }
229
+
230
+ .table-wrap { max-width: 100%; overflow-x: auto; }
231
+ .table-wrap table { margin: 0; font-size: 13px; }
232
+ .table-wrap th { font-family: var(--mono); font-size: 11.5px; font-weight: 600; white-space: nowrap; }
233
+
234
+ /* R code card: the summary line and the buttons share the first row */
235
+ .code-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; }
236
+ .code-details { grid-column: 1 / -1; grid-row: 1; min-width: 0; }
237
+ .code-actions { grid-column: 2; grid-row: 1; align-self: start; position: relative; z-index: 1;
238
+ display: flex; flex-wrap: wrap; gap: 8px; padding: 11px 16px; }
239
+ .code-summary { display: flex; align-items: center; gap: 8px; padding: 14px 340px 14px 16px; cursor: pointer;
240
+ list-style: none; font-family: var(--serif); font-weight: 700; font-size: 17px; }
241
+ .code-summary::-webkit-details-marker { display: none; }
242
+ .code-summary::before { content: "\203A"; display: inline-block; font-family: var(--mono); color: var(--red); transition: transform .15s; }
243
+ .code-details[open] > .code-summary { border-bottom: 1px solid var(--tan); }
244
+ .code-details[open] > .code-summary::before { transform: rotate(90deg); }
245
+ @media (max-width: 760px) {
246
+ .code-summary { padding-right: 16px; }
247
+ .code-actions { grid-row: 2; padding-top: 0; }
248
+ }
249
+ pre.code { margin: 0 0 12px; padding: 10px 12px; background: var(--code-bg); border: 1px solid var(--tan); border-radius: 8px;
250
+ font-family: var(--mono); font-size: 13px; line-height: 1.6; color: var(--code-text);
251
+ white-space: pre; word-break: normal; overflow-wrap: normal; overflow-x: auto; }
252
+ .code .c { color: var(--code-c); }
253
+ .code .f { color: var(--code-f); font-weight: 600; }
254
+ .code .s { color: var(--code-s); }
255
+ .files { list-style: none; margin: 8px 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; }
256
+ .file { padding: 8px 10px; background: var(--warm); border: 1px solid var(--tan); border-radius: 8px; }
257
+ .file .n { font-family: var(--mono); font-size: 12px; font-weight: 600; word-break: break-all; }
258
+ .file .d { margin-top: 3px; font-size: 12.5px; color: var(--muted); }
259
+
260
+ /* --- About and Record ----------------------------------------------------------- */
261
+ .prose h2 { margin: 18px 0 6px; font-family: var(--serif); font-weight: 700; font-size: 18px; }
262
+ .prose h2:first-child { margin-top: 4px; }
263
+ .prose p, .prose li { max-width: 72ch; }
264
+
265
+ /* --- Footer and video -------------------------------------------------------------- */
266
+ .foot { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 12px; padding: 10px 18px;
267
+ background: #fff; border-top: 1px solid var(--tan2); font-size: 12.5px; color: var(--muted); }
268
+ .video-dialog { width: min(880px, 94vw); padding: 0; border: 0; border-radius: 10px; background: #fff; }
269
+ .video-dialog::backdrop { background: rgba(27, 27, 27, .7); }
270
+ .video-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 14px; }
271
+ .video-head h2 { margin: 0; font-family: var(--serif); font-weight: 700; font-size: 18px; }
272
+ .video-frame { display: block; width: 100%; aspect-ratio: 16 / 9; border: 0; }