Deploy 7a572e1 from GitHub
Browse files- .dockerignore +10 -0
- Dockerfile +31 -0
- LICENSE +21 -0
- R/_disable_autoload.R +3 -0
- R/answers.R +112 -0
- R/bundle.R +235 -0
- R/code.R +258 -0
- R/config.R +74 -0
- R/data.R +326 -0
- R/help.R +140 -0
- R/render.R +164 -0
- R/runs.R +148 -0
- R/samples.R +109 -0
- R/server.R +589 -0
- R/spec.R +76 -0
- R/spending.R +187 -0
- R/status.R +65 -0
- R/ui.R +406 -0
- README.md +85 -5
- app.R +27 -0
- www/app.js +255 -0
- www/fonts/OFL-ibm-plex-mono.txt +93 -0
- www/fonts/OFL-source-sans-3.txt +93 -0
- www/fonts/OFL-source-serif-4.txt +93 -0
- www/fonts/ibm-plex-mono-latin-400-normal.woff2 +0 -0
- www/fonts/ibm-plex-mono-latin-600-normal.woff2 +0 -0
- www/fonts/source-sans-3-latin-400-normal.woff2 +0 -0
- www/fonts/source-sans-3-latin-600-normal.woff2 +0 -0
- www/fonts/source-sans-3-latin-700-normal.woff2 +0 -0
- www/fonts/source-serif-4-latin-700-normal.woff2 +0 -0
- www/logo.png +0 -0
- www/logo.svg +39 -0
- www/theme.css +272 -0
.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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
| 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; }
|