fmegahed commited on
Commit
39181ba
·
verified ·
1 Parent(s): 4042a59

Deploy 71faef1 from GitHub

Browse files
Files changed (13) hide show
  1. R/answers.R +10 -8
  2. R/bundle.R +4 -3
  3. R/code.R +45 -17
  4. R/config.R +3 -0
  5. R/help.R +2 -2
  6. R/runs.R +11 -0
  7. R/samples.R +18 -0
  8. R/server.R +44 -16
  9. R/status.R +1 -1
  10. R/ui.R +4 -4
  11. README.md +1 -1
  12. www/app.js +1 -1
  13. www/theme.css +3 -1
R/answers.R CHANGED
@@ -20,7 +20,6 @@ answers_table <- function(spec, answers, target) {
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
@@ -64,17 +63,20 @@ alt_text <- function(row, target) {
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
 
20
  spec$inputs |> dplyr::select(id, file, text, label),
21
  by = "id"
22
  ) |>
 
23
  dplyr::select(
24
  id, file, text, answer, p_target, dplyr::starts_with("p_"),
25
  label, tokens, seconds, problem
 
63
  )
64
  }
65
 
66
+ # What the probability is. A saved run of a sample adds that the app reuses it.
67
+ # The facts of a sample describe its full saved run: they follow only for all
68
+ # its saved answers (`full`, not Try on 5) and its default target.
69
+ help_note <- function(spec, stats, target, saved = FALSE, full = TRUE) {
70
  generic <- sprintf(
71
  "The probability is the raw probability of Jev for %s. It is not calibrated.",
72
  target
73
  )
74
+ saved <- spec$source %in% names(SAMPLE_FACTS_TEXT) && saved
75
+ if (!saved) return(generic)
76
+ facts <- if (full && identical(target, target_answer(spec$question))) {
77
+ SAMPLE_FACTS_TEXT[[spec$source]]
78
+ }
79
+ paste(c(generic, facts, SAVED_RUN_TEXT), collapse = " ")
80
  }
81
 
82
  # The CSV text of the table for the download
R/bundle.R CHANGED
@@ -70,14 +70,15 @@ bundle_readme <- function(spec, refs, name, has_data, date) {
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)]
 
70
  }
71
 
72
  # Write the bundle of a run to `zipfile` and return its path. `table` is the
73
+ # answers table of answers_table(). `rows` are the rows of Try on 5 for the
74
+ # code (NULL for all inputs).
75
+ write_bundle <- function(spec, table, zipfile, date = Sys.Date(), rows = NULL) {
76
  name <- run_name(spec)
77
  refs <- reference_files(spec)
78
  dir <- tempfile("bundle")
79
  dir.create(dir)
80
  on.exit(unlink(dir, recursive = TRUE))
81
+ readr::write_file(paste0(run_code(spec, date, rows), "\n"), file.path(dir, paste0(name, ".R")))
82
  readr::write_file(answers_csv(table), file.path(dir, paste0(name, "-answers.csv")))
83
  # The setup holds the references as the prepared images; the key stays out
84
  references <- if (nrow(refs) > 0) spec$references[unique(refs$answer)]
R/code.R CHANGED
@@ -146,19 +146,33 @@ references_code <- function(refs) {
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) {
@@ -166,12 +180,15 @@ text_code <- function(spec) {
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(
@@ -181,13 +198,18 @@ inputs_code <- function(spec, refs) {
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 {
@@ -220,8 +242,9 @@ question_code <- function(question, var) {
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") {
@@ -232,10 +255,10 @@ run_code <- function(spec, date = Sys.Date()) {
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
  "",
@@ -252,7 +275,12 @@ run_code <- function(spec, date = Sys.Date()) {
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
  }
 
146
  paste0("\nreferences <- list(\n", paste(by_answer$code, collapse = ",\n"), "\n)")
147
  }
148
 
149
+ # The comment above the inputs of Try on 5
150
+ TRY_COMMENT <- "# The inputs of Try on 5 in the app"
151
+
152
+ # The lines that keep the rows of Try on 5: x[c(3, 17), ] for a data frame,
153
+ # x[c(3, 17)] for a vector. Empty with no rows.
154
+ keep_code <- function(var, rows, frame = FALSE) {
155
+ if (length(rows) == 0) return("")
156
+ sprintf(
157
+ "\n%s\n%s <- %s[c(%s)%s]",
158
+ TRY_COMMENT, var, var, paste(rows, collapse = ", "), if (frame) ", " else ""
159
+ )
160
+ }
161
+
162
+ # The lines that define `texts` (or `injuries`). With `rows` (Try on 5), one
163
+ # more line keeps those rows.
164
+ text_code <- function(spec, rows = NULL) {
165
  if (spec$source == "injuries") {
166
+ return(paste0(paste(
167
  "set.seed(2026)",
168
  "# This line needs the package dplyr.",
169
  "injuries <- ohio_injuries |>",
170
  " dplyr::slice_sample(n = 20, by = collapsed_nature_title)",
171
  sep = "\n"
172
+ ), keep_code("injuries", rows, frame = TRUE)))
173
  }
174
  if (is.null(spec$text_column)) {
175
+ return(paste0('texts <- read.csv("data.csv")$text', keep_code("texts", rows)))
176
  }
177
  # read.csv() changes odd column names unless check.names is FALSE
178
  read <- if (make.names(spec$text_column) == spec$text_column) {
 
180
  } else {
181
  'read.csv("data.csv", check.names = FALSE)'
182
  }
183
+ # A CSV file keeps its own row numbers ("Row 3"): the app drops empty texts
184
+ file_rows <- as.integer(stringr::str_extract(spec$inputs$file[rows], "[0-9]+$"))
185
+ paste0("texts <- ", read, "[[", r_string(spec$text_column), "]]", keep_code("texts", file_rows))
186
  }
187
 
188
+ # The lines that define `images` and `references`. With `rows` (Try on 5),
189
+ # the code names only the images of those rows.
190
+ inputs_code <- function(spec, refs, rows = NULL) {
191
+ if (spec$mode == "text") return(text_code(spec, rows))
192
  if (spec$source == "textiles") {
193
  by_answer <- refs |>
194
  dplyr::summarise(
 
198
  ),
199
  .by = answer
200
  )
201
+ images <- if (is.null(rows)) {
202
+ 'images <- file.path(folder, textiles$file[textiles$role == "input"])'
203
+ } else {
204
+ paste0(TRY_COMMENT, "\nimages <- file.path(folder, ", r_vector(spec$inputs$file[rows]), ")")
205
+ }
206
  return(paste0(
207
  'folder <- system.file("extdata", "textiles", package = "decisions")\n',
208
+ images, "\n",
209
  "references <- list(\n", paste(by_answer$code, collapse = ",\n"), "\n)"
210
  ))
211
  }
212
+ inputs <- if (is.null(rows)) spec$inputs else spec$inputs[rows, ]
213
  images <- if (nrow(inputs) == 0) {
214
  'images <- list.files("images", full.names = TRUE)'
215
  } else {
 
242
  )
243
  }
244
 
245
+ # The code of a run, as one string. `date` is the day of the run. `rows` are
246
+ # the rows of Try on 5 (NULL for all inputs).
247
+ run_code <- function(spec, date = Sys.Date(), rows = NULL) {
248
  refs <- reference_files(spec)
249
  var <- question_var(spec)
250
  data_arg <- if (spec$mode == "text") {
 
255
  if (nrow(refs) > 0) data_arg <- paste0(data_arg, ", references = references")
256
  paste(
257
  sprintf("# The run of the decisions Space on %s, in R. Run it with your own key.", format(date)),
258
+ 'library(decisions) # install once: pak::pak("fmegahed/decisions")',
259
  "",
260
  paths_comment(spec, refs),
261
+ inputs_code(spec, refs, rows),
262
  "",
263
  question_code(spec$question, var),
264
  "",
 
275
  )
276
  }
277
 
278
+ # The number of lines of code, for the closed card: the top-level calls, so
279
+ # comments and blank lines do not count. Code that does not parse counts the
280
+ # lines that are not blank and not only a comment.
281
  code_lines <- function(code) {
282
+ calls <- tryCatch(length(parse(text = code, keep.source = FALSE)), error = \(e) NULL)
283
+ if (!is.null(calls)) return(calls)
284
+ lines <- stringr::str_trim(stringr::str_split_1(code, "\n"))
285
+ sum(lines != "" & !startsWith(lines, "#"))
286
  }
R/config.R CHANGED
@@ -11,6 +11,9 @@ APP_DATE <- "24 October 2026"
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
 
 
11
  # (tests/testthat/test-config.R enforces this).
12
  MODEL <- "jev-1.14.0"
13
 
14
+ # Day of the saved runs of the two samples (their answers record MODEL)
15
+ SAVED_RUN_DATE <- "2026-10-23"
16
+
17
  # Commit of the decisions package that the app uses
18
  DECISIONS_SHA <- "b25fc9ffa7908e9a6a2d9833f080de1456c9d50a"
19
 
R/help.R CHANGED
@@ -1,4 +1,4 @@
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.
@@ -48,7 +48,7 @@ help_text <- function(key) {
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.",
 
1
+ # Help: the text of the intro, the help lines, the notes, the Uptime 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.
 
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 run of an unchanged sample shows its saved run, 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.",
R/runs.R CHANGED
@@ -12,6 +12,17 @@ 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"))
 
12
  split(seq_len(n), ceiling(seq_len(n) / size)) |> unname()
13
  }
14
 
15
+ # The rows of Try on 5, sorted: the same rows each time. With labels, the
16
+ # first row of each label in a shuffle comes first, then the other rows of the
17
+ # shuffle. The seed does not change the random state of the session.
18
+ try_rows <- function(inputs, n = 5, seed = 2026) {
19
+ if (nrow(inputs) <= n) return(seq_len(nrow(inputs)))
20
+ shuffled <- withr::with_seed(seed, sample(nrow(inputs)))
21
+ label <- inputs$label[shuffled]
22
+ first <- utils::head(shuffled[!is.na(label) & !duplicated(label)], n)
23
+ sort(c(first, setdiff(shuffled, first))[seq_len(n)])
24
+ }
25
+
26
  # Names of the probability columns of decide(), one for each answer name
27
  pred_column <- function(answers) {
28
  paste0(".pred_", janitor::make_clean_names(answers, case = "none"))
R/samples.R CHANGED
@@ -22,6 +22,9 @@ SAMPLE_FACTS_TEXT <- list(
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,
@@ -107,3 +110,18 @@ sample_data <- function(id) {
107
  list(spec = sample_spec(id), answers = sample_answers(id))
108
  SAMPLE_CACHE[[id]]
109
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  )
23
  )
24
 
25
+ # The sentence of the Help note when the answers on show are a saved run
26
+ SAVED_RUN_TEXT <- "The app reuses this run to save cost. Change the question or a description to ask Jev again."
27
+
28
  # The questions, copied from the vignettes images.Rmd and choosing-a-model.Rmd
29
  sample_question <- function(id) {
30
  switch(id,
 
110
  list(spec = sample_spec(id), answers = sample_answers(id))
111
  SAMPLE_CACHE[[id]]
112
  }
113
+
114
+ # Is the spec the setup of its sample? Then a run shows the saved run and makes
115
+ # no call. Edits of spaces only in the instructions and the descriptions do not
116
+ # count; every other part must be the same.
117
+ sample_unchanged <- function(spec) {
118
+ if (!isTRUE(spec$source %in% c("textiles", "injuries"))) return(FALSE)
119
+ sample <- sample_data(spec$source)$spec
120
+ setup <- \(s) {
121
+ list(
122
+ s$mode, s$source, s$inputs, s$references, s$question$type, names(s$question$answers),
123
+ stringr::str_squish(s$question$instructions), stringr::str_squish(unname(s$question$answers))
124
+ )
125
+ }
126
+ identical(setup(spec), setup(sample))
127
+ }
R/server.R CHANGED
@@ -105,7 +105,8 @@ app_server <- function(input, output, session, deps = app_deps()) {
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())
@@ -172,14 +173,14 @@ app_server <- function(input, output, session, deps = app_deps()) {
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
  }
@@ -351,15 +352,18 @@ app_server <- function(input, output, session, deps = app_deps()) {
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
- allowed <- run_allowed(cost, status)
361
  # No inputs: the buttons stay off, and no limit is the reason
362
- list(ok = allowed$ok && n > 0, reason = allowed$reason, n = n, cost = cost)
363
  })
364
  })
365
  shiny::observe({
@@ -412,13 +416,25 @@ app_server <- function(input, output, session, deps = app_deps()) {
412
  start_run <- function(n) {
413
  if (running()) return()
414
  s <- spec()
 
 
 
 
 
 
 
 
 
 
 
 
 
415
  problems <- run_problems(s)
416
  if (length(problems) > 0) return(notice(problems))
417
  if (!is.null(stop_reason(deps))) return(notice(error_message(stop_reason(deps))))
418
  # The latest totals of all sessions decide, not the copy of this session
419
  latest <- deps$store$read()
420
  usage(latest)
421
- n <- min(n, nrow(s$inputs))
422
  allowed <- run_allowed(
423
  estimate_cost(s, n, mean_for(s)), spend_status(session_cost(), latest, deps$now())
424
  )
@@ -426,12 +442,13 @@ app_server <- function(input, output, session, deps = app_deps()) {
426
  notice(character())
427
  job$spec <- s
428
  job$key <- key()
429
- job$chunks <- chunk_ids(n)
 
430
  job$done <- 0L
431
  job$stop <- FALSE
432
  job$answers <- NULL
433
  # The run writes its answers only while the card holds the answers that it
434
- # found or wrote: a sample loaded during the run keeps its saved answers
435
  job$token <- (job$token %||% 0L) + 1L
436
  results(utils::modifyList(results() %||% list(), list(token = job$token)))
437
  running(TRUE)
@@ -454,7 +471,11 @@ app_server <- function(input, output, session, deps = app_deps()) {
454
  if (!is.null(done$usage)) usage(done$usage)
455
  job$answers <- dplyr::bind_rows(job$answers, done$answers)
456
  shown <- identical(results()$token, job$token)
457
- if (shown) results(list(answers = job$answers, key = job$key, saved = FALSE, token = job$token))
 
 
 
 
458
  # Only calls with tokens count: a mean of 0 would make the next run look free
459
  tokens <- job$answers$.input_tokens
460
  tokens <- tokens[!is.na(tokens) & tokens > 0]
@@ -493,10 +514,12 @@ app_server <- function(input, output, session, deps = app_deps()) {
493
  stats::setNames(srcs, l$answers)
494
  )
495
  })
496
- # A cost below one cent reads "< $0.01", not "$0.00": a run is never free
 
497
  output$run_estimate <- shiny::renderText({
498
  all <- checks()$run_all
499
  if (all$n == 0) return("")
 
500
  cost <- if (all$cost > 0 && all$cost < 0.005) "< $0.01" else sprintf("\u2248 $%.2f", all$cost)
501
  paste(all$n, "\u00b7", cost)
502
  })
@@ -522,7 +545,7 @@ app_server <- function(input, output, session, deps = app_deps()) {
522
  if (is.null(stats())) return(NULL)
523
  r <- results()
524
  note <- if (r$saved) {
525
- paste0("Saved run, ", MODEL)
526
  } else if (running()) {
527
  sprintf("%d of %d", nrow(r$answers), length(unlist(job$chunks)))
528
  }
@@ -536,9 +559,11 @@ app_server <- function(input, output, session, deps = app_deps()) {
536
  )
537
  }
538
  })
 
539
  output$help_answers <- shiny::renderText({
540
  if (is.null(stats())) return(help_text("answers"))
541
- help_note(spec(), stats(), target(), saved = results()$saved)
 
542
  })
543
  output$gallery <- shiny::renderUI({
544
  t <- table()
@@ -558,9 +583,12 @@ app_server <- function(input, output, session, deps = app_deps()) {
558
  # --- Outputs: R code and bundle ---------------------------------------------
559
  run_date <- function() as.Date(deps$now())
560
  zip_name <- function() sprintf("%s-%s.zip", run_name(spec()), format(run_date()))
561
- code <- shiny::reactive(run_code(spec(), run_date()))
 
 
 
562
  output$code_summary <- shiny::renderText(
563
- sprintf("R code of this run \u00b7 %d lines", code_lines(code()))
564
  )
565
  output$code <- shiny::renderText(code())
566
  output$bundle_files <- shiny::renderUI(bundle_files_ui(bundle_names(spec()), zip_name()))
@@ -573,7 +601,7 @@ app_server <- function(input, output, session, deps = app_deps()) {
573
  )
574
  output$download_bundle <- shiny::downloadHandler(
575
  filename = zip_name,
576
- content = \(file) write_bundle(spec(), download_table(), file, run_date())
577
  )
578
 
579
  # --- Outputs: status ----------------------------------------------------------
 
105
  image_upload <- shiny::reactiveVal(NULL)
106
  csv_file <- shiny::reactiveVal(NULL)
107
  text_kind <- shiny::reactiveVal("paste")
108
+ # list(answers, key, saved, token of the run, rows of Try on 5 or NULL for all)
109
+ results <- shiny::reactiveVal(NULL)
110
  mean_tokens <- shiny::reactiveVal(NULL) # list(fingerprint, mean)
111
  session_cost <- shiny::reactiveVal(0)
112
  usage <- shiny::reactiveVal(deps$store$read())
 
173
  rows_version(rows_version() + 1)
174
  }
175
 
176
+ # A sample opens with no answers and makes no call. A run of the unchanged
177
+ # sample then shows its saved run (see start_run()).
178
  load_sample <- function(id, send = c("mode", "source")) {
179
  sample <- sample_data(id)
180
  question(sample$spec$question)
181
  refs(unname(sample$spec$references) %||% list())
182
  data_mode(sample$spec$mode)
183
  data_source(id)
 
184
  notice(character())
185
  show_setup(send)
186
  }
 
352
  m <- mean_tokens()
353
  if (!is.null(m) && identical(m$fingerprint, spec_fingerprint(s))) m$mean
354
  }
355
+ # A run of an unchanged sample shows its saved run: it costs nothing, so no
356
+ # limit stops it
357
  checks <- shiny::reactive({
358
  s <- spec()
359
+ saved <- sample_unchanged(s)
360
  status <- spend_status(session_cost(), usage(), deps$now())
361
  purrr::map(c(try5 = 5, run_all = Inf), \(k) {
362
  n <- min(k, nrow(s$inputs))
363
+ cost <- if (saved) 0 else estimate_cost(s, n, mean_for(s))
364
+ allowed <- if (saved) list(ok = TRUE, reason = NA_character_) else run_allowed(cost, status)
365
  # No inputs: the buttons stay off, and no limit is the reason
366
+ list(ok = allowed$ok && n > 0, reason = allowed$reason, n = n, cost = cost, saved = saved)
367
  })
368
  })
369
  shiny::observe({
 
416
  start_run <- function(n) {
417
  if (running()) return()
418
  s <- spec()
419
+ n <- min(n, nrow(s$inputs))
420
+ # Try on 5 takes the rows of try_rows(); Run all takes all rows (try_rows()
421
+ # gives all rows when n is the number of inputs). The R code repeats the
422
+ # rows of Try on 5 (`subset`).
423
+ rows <- try_rows(s$inputs, n)
424
+ subset <- if (n < nrow(s$inputs)) rows
425
+ # An unchanged sample shows the saved answers of these rows at once: no
426
+ # call, no cost and no write to the usage store
427
+ if (sample_unchanged(s)) {
428
+ notice(character())
429
+ saved <- sample_data(s$source)$answers[rows, ]
430
+ return(results(list(answers = saved, key = key(), saved = TRUE, rows = subset)))
431
+ }
432
  problems <- run_problems(s)
433
  if (length(problems) > 0) return(notice(problems))
434
  if (!is.null(stop_reason(deps))) return(notice(error_message(stop_reason(deps))))
435
  # The latest totals of all sessions decide, not the copy of this session
436
  latest <- deps$store$read()
437
  usage(latest)
 
438
  allowed <- run_allowed(
439
  estimate_cost(s, n, mean_for(s)), spend_status(session_cost(), latest, deps$now())
440
  )
 
442
  notice(character())
443
  job$spec <- s
444
  job$key <- key()
445
+ job$chunks <- purrr::map(chunk_ids(n), \(i) rows[i])
446
+ job$rows <- subset
447
  job$done <- 0L
448
  job$stop <- FALSE
449
  job$answers <- NULL
450
  # The run writes its answers only while the card holds the answers that it
451
+ # found or wrote
452
  job$token <- (job$token %||% 0L) + 1L
453
  results(utils::modifyList(results() %||% list(), list(token = job$token)))
454
  running(TRUE)
 
471
  if (!is.null(done$usage)) usage(done$usage)
472
  job$answers <- dplyr::bind_rows(job$answers, done$answers)
473
  shown <- identical(results()$token, job$token)
474
+ if (shown) {
475
+ results(list(
476
+ answers = job$answers, key = job$key, saved = FALSE, token = job$token, rows = job$rows
477
+ ))
478
+ }
479
  # Only calls with tokens count: a mean of 0 would make the next run look free
480
  tokens <- job$answers$.input_tokens
481
  tokens <- tokens[!is.na(tokens) & tokens > 0]
 
514
  stats::setNames(srcs, l$answers)
515
  )
516
  })
517
+ # A cost below one cent reads "< $0.01", not "$0.00": a run is never free.
518
+ # Only the saved run of an unchanged sample costs nothing.
519
  output$run_estimate <- shiny::renderText({
520
  all <- checks()$run_all
521
  if (all$n == 0) return("")
522
+ if (all$saved) return(paste(all$n, "\u00b7 saved run, no cost"))
523
  cost <- if (all$cost > 0 && all$cost < 0.005) "< $0.01" else sprintf("\u2248 $%.2f", all$cost)
524
  paste(all$n, "\u00b7", cost)
525
  })
 
545
  if (is.null(stats())) return(NULL)
546
  r <- results()
547
  note <- if (r$saved) {
548
+ sprintf("Saved run of %s, %s", SAVED_RUN_DATE, MODEL)
549
  } else if (running()) {
550
  sprintf("%d of %d", nrow(r$answers), length(unlist(job$chunks)))
551
  }
 
559
  )
560
  }
561
  })
562
+ # The facts of a sample describe its full saved run, not Try on 5
563
  output$help_answers <- shiny::renderText({
564
  if (is.null(stats())) return(help_text("answers"))
565
+ r <- results()
566
+ help_note(spec(), stats(), target(), saved = r$saved, full = is.null(r$rows))
567
  })
568
  output$gallery <- shiny::renderUI({
569
  t <- table()
 
583
  # --- Outputs: R code and bundle ---------------------------------------------
584
  run_date <- function() as.Date(deps$now())
585
  zip_name <- function() sprintf("%s-%s.zip", run_name(spec()), format(run_date()))
586
+ # The code repeats the run on show: after Try on 5, the same rows; with no
587
+ # answers on show, or after Run all, all inputs
588
+ code_rows <- shiny::reactive(if (!is.null(table())) results()$rows)
589
+ code <- shiny::reactive(run_code(spec(), run_date(), code_rows()))
590
  output$code_summary <- shiny::renderText(
591
+ sprintf("R code of this run \u00b7 %d lines of code", code_lines(code()))
592
  )
593
  output$code <- shiny::renderText(code())
594
  output$bundle_files <- shiny::renderUI(bundle_files_ui(bundle_names(spec()), zip_name()))
 
601
  )
602
  output$download_bundle <- shiny::downloadHandler(
603
  filename = zip_name,
604
+ content = \(file) write_bundle(spec(), download_table(), file, run_date(), code_rows())
605
  )
606
 
607
  # --- Outputs: status ----------------------------------------------------------
R/status.R CHANGED
@@ -1,6 +1,6 @@
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")
 
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 (Uptime tab).
4
 
5
  # The columns of status.csv
6
  STATUS_COLUMNS <- c("time_utc", "http_status", "seconds", "answered", "app_version")
R/ui.R CHANGED
@@ -115,7 +115,7 @@ top_bar <- function(video) {
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(
@@ -315,7 +315,7 @@ build_panel <- function() {
315
  )
316
  }
317
 
318
- # --- About and Record tabs --------------------------------------------------
319
 
320
  about_panel <- function() {
321
  # The About text is Markdown; its links open in a new tab
@@ -330,9 +330,9 @@ record_panel <- function() {
330
  class = "page card",
331
  shiny::div(
332
  class = "bd prose",
333
- shiny::h2("Record"),
334
  shiny::p(help_text("record")),
335
- scroll_region("Record table", shiny::tableOutput("record_table")),
336
  shiny::p(
337
  ext_link("Status file", LINKS$status), " \u00b7 ",
338
  ext_link("Package record", LINKS$package_record), " \u00b7 ",
 
115
  shiny::div(
116
  role = "tablist",
117
  tab_button("build", "Build", TRUE), tab_button("about", "About"),
118
+ tab_button("record", "Uptime")
119
  )
120
  ),
121
  shiny::div(
 
315
  )
316
  }
317
 
318
+ # --- About and Uptime tabs --------------------------------------------------
319
 
320
  about_panel <- function() {
321
  # The About text is Markdown; its links open in a new tab
 
330
  class = "page card",
331
  shiny::div(
332
  class = "bd prose",
333
+ shiny::h2("Uptime"),
334
  shiny::p(help_text("record")),
335
+ scroll_region("Uptime table", shiny::tableOutput("record_table")),
336
  shiny::p(
337
  ext_link("Status file", LINKS$status), " \u00b7 ",
338
  ext_link("Package record", LINKS$package_record), " \u00b7 ",
README.md CHANGED
@@ -47,7 +47,7 @@ docker run -p 7860:7860 -e HF_JEV_API=... decisions-space
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.
 
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." A run of an unchanged sample still shows its 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.
www/app.js CHANGED
@@ -14,7 +14,7 @@
14
  }
15
  function byId(id) { return document.getElementById(id); }
16
 
17
- // ---- Tabs: Build, About, Record ----------------------------------------
18
 
19
  var TABS = ['build', 'about', 'record'];
20
 
 
14
  }
15
  function byId(id) { return document.getElementById(id); }
16
 
17
+ // ---- Tabs: Build, About, Uptime ----------------------------------------
18
 
19
  var TABS = ['build', 'about', 'record'];
20
 
www/theme.css CHANGED
@@ -231,6 +231,8 @@ body.is-running .run-row { display: none; }
231
  .table-wrap { max-width: 100%; overflow-x: auto; }
232
  .table-wrap table { margin: 0; font-size: 13px; }
233
  .table-wrap th { font-family: var(--mono); font-size: 11.5px; font-weight: 600; white-space: nowrap; }
 
 
234
 
235
  /* R code card: the summary line and the buttons share the first row */
236
  .code-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; }
@@ -258,7 +260,7 @@ pre.code { margin: 0 0 12px; padding: 10px 12px; background: var(--code-bg); bor
258
  .file .n { font-family: var(--mono); font-size: 12px; font-weight: 600; word-break: break-all; }
259
  .file .d { margin-top: 3px; font-size: 12.5px; color: var(--muted); }
260
 
261
- /* --- About and Record ----------------------------------------------------------- */
262
  .prose h2 { margin: 18px 0 6px; font-family: var(--serif); font-weight: 700; font-size: 18px; }
263
  .prose h2:first-child { margin-top: 4px; }
264
  .prose p, .prose li { max-width: 72ch; }
 
231
  .table-wrap { max-width: 100%; overflow-x: auto; }
232
  .table-wrap table { margin: 0; font-size: 13px; }
233
  .table-wrap th { font-family: var(--mono); font-size: 11.5px; font-weight: 600; white-space: nowrap; }
234
+ /* The full text of an input wraps in a column that is wide enough to read */
235
+ #table td:first-child { min-width: 16em; }
236
 
237
  /* R code card: the summary line and the buttons share the first row */
238
  .code-card { display: grid; grid-template-columns: minmax(0, 1fr) auto; }
 
260
  .file .n { font-family: var(--mono); font-size: 12px; font-weight: 600; word-break: break-all; }
261
  .file .d { margin-top: 3px; font-size: 12.5px; color: var(--muted); }
262
 
263
+ /* --- About and Uptime ----------------------------------------------------------- */
264
  .prose h2 { margin: 18px 0 6px; font-family: var(--serif); font-weight: 700; font-size: 18px; }
265
  .prose h2:first-child { margin-top: 4px; }
266
  .prose p, .prose li { max-width: 72ch; }