fmegahed Claude Opus 5.5 commited on
Commit
bff537e
·
1 Parent(s): 651a8f3

Rework extraction app for practitioner use (v3.0.0)

Browse files

Move logic into testable functions under R/, read only the active input
tab, keep field definitions across tab and field-count changes, validate
and sanitize field labels, extract each item with a new chat and isolate
failures, add typed fields, templates, JSON save/load of field
definitions, generated R and Python code, help dialogs, per-item progress
and status, and keyboard-accessible tabs and dialogs. The model id is set
in one place (R/config.R, overridable with EXTRACTION_MODEL).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

R/_disable_autoload.R ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # The presence of this file tells Shiny not to source the files in R/ on its own.
2
+ # app.R sources them explicitly, in a fixed order.
R/config.R ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Configuration: the single place for the model id and the app limits.
2
+ # Everything a user reads about the model or the limits is generated from these
3
+ # values (see R/help.R and app.R), so the text cannot drift from the behaviour.
4
+
5
+ # Default model used for extraction. This is the ONLY place a model id may be
6
+ # written. Override it without editing code by setting the environment variable
7
+ # EXTRACTION_MODEL (tests/testthat/test-no-model-literals.R enforces this).
8
+ DEFAULT_EXTRACTION_MODEL = "gpt-6-luna"
9
+
10
+ # Model id used for every call and every piece of UI text
11
+ get_extraction_model = function() {
12
+ model = trimws(Sys.getenv("EXTRACTION_MODEL", unset = ""))
13
+ if (nchar(model) == 0) DEFAULT_EXTRACTION_MODEL else model
14
+ }
15
+
16
+ # Name of the provider behind ellmer::chat_openai(), used in UI text
17
+ MODEL_PROVIDER = "OpenAI"
18
+
19
+ # Maximum number of PDF pages (readable or scanned) or images per extraction
20
+ MAX_PAGES_OR_IMAGES = 5
21
+
22
+ # Maximum size of one uploaded file, in megabytes
23
+ MAX_UPLOAD_MB = 5
24
+
25
+ # Maximum number of fields per extraction
26
+ MAX_FIELDS = 10
27
+
28
+ # Number of fields shown when the app starts and when the demo data are loaded
29
+ NUM_EXAMPLE_FIELDS = 2
30
+
31
+ # Maximum number of characters in a field label
32
+ MAX_LABEL_CHARS = 64
33
+
34
+ # Column names the app adds to the results, so they cannot be field labels
35
+ RESERVED_LABELS = c("source_id", "status", "status_message")
36
+
37
+ # Separator used when a "list of text" field is written to the table and the CSV
38
+ LIST_SEPARATOR = "; "
39
+
40
+ # Zero-argument function returning the API key (the form ellmer expects)
41
+ get_openai_api_key = function() {
42
+ Sys.getenv("OPENAI_API_KEY")
43
+ }
44
+
45
+ # All values the help text and the code snippets are generated from
46
+ app_config = function() {
47
+ list(
48
+ model = get_extraction_model(),
49
+ provider = MODEL_PROVIDER,
50
+ max_pages_or_images = MAX_PAGES_OR_IMAGES,
51
+ max_upload_mb = MAX_UPLOAD_MB,
52
+ max_fields = MAX_FIELDS,
53
+ list_separator = LIST_SEPARATOR
54
+ )
55
+ }
R/extraction.R ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Extraction: one model call per item, with failures isolated per item, and
2
+ # assembly of the results into a data frame.
3
+
4
+ # TRUE when the app has an API key to call the model with
5
+ api_key_available = function() {
6
+ nchar(get_openai_api_key()) > 0
7
+ }
8
+
9
+ # Returns a zero-argument function that creates a new chat each time it is
10
+ # called. Tests pass their own factory to avoid calling the model.
11
+ make_chat_factory = function(model = get_extraction_model()) {
12
+ function() {
13
+ ellmer::chat_openai(model = model, credentials = get_openai_api_key)
14
+ }
15
+ }
16
+
17
+ # What is sent to the model for one item
18
+ item_content = function(item) {
19
+ if (identical(item$kind, "image")) {
20
+ # The type is read from the file content, so a JPEG saved as .png still works.
21
+ # Resizing needs the magick package.
22
+ mime = detect_mime(item$path)
23
+ ellmer::content_image_file(
24
+ item$path,
25
+ content_type = if (is.na(mime)) "auto" else mime,
26
+ resize = "high"
27
+ )
28
+ } else {
29
+ item$text
30
+ }
31
+ }
32
+
33
+ # One-line error message suitable for the results table
34
+ clean_error_message = function(e, max_chars = 300) {
35
+ message = gsub("\\s+", " ", trimws(conditionMessage(e)))
36
+ if (nchar(message) == 0) message = "Unknown error."
37
+ if (nchar(message) > max_chars) message = paste0(substr(message, 1, max_chars), "...")
38
+ message
39
+ }
40
+
41
+ # Extract one item with a new chat, so that no earlier item is in its context
42
+ extract_item = function(item, type, chat_factory, content_fn = item_content) {
43
+ chat = chat_factory()
44
+ chat$chat_structured(content_fn(item), type = type)
45
+ }
46
+
47
+ # Extract every item. A failure on one item is recorded and the loop goes on.
48
+ #
49
+ # on_progress optional function(i, n, item) called before item i is processed
50
+ # Returns a list with one entry per item:
51
+ # list(source_id, status = "ok" or "failed", message, data)
52
+ extract_items = function(items, type, chat_factory, on_progress = NULL,
53
+ content_fn = item_content) {
54
+ n = length(items)
55
+ lapply(seq_along(items), function(i) {
56
+ item = items[[i]]
57
+ if (is.function(on_progress)) on_progress(i, n, item)
58
+ tryCatch({
59
+ data = extract_item(item, type, chat_factory, content_fn = content_fn)
60
+ if (!is.list(data)) stop("The model returned no structured data.")
61
+ list(source_id = item$source_id, status = "ok", message = "", data = data)
62
+ }, error = function(e) {
63
+ list(
64
+ source_id = item$source_id,
65
+ status = "failed",
66
+ message = clean_error_message(e),
67
+ data = NULL
68
+ )
69
+ })
70
+ })
71
+ }
72
+
73
+ # Convert one returned value to a single cell of the given field type.
74
+ # A missing value becomes NA; a list of text is joined with the separator.
75
+ coerce_value = function(value, type, list_separator = LIST_SEPARATOR) {
76
+ flat = unlist(value, use.names = FALSE)
77
+ missing_value = is.null(flat) || length(flat) == 0 || all(is.na(flat))
78
+
79
+ if (type == "number") {
80
+ if (missing_value) return(NA_real_)
81
+ return(suppressWarnings(as.numeric(flat[1])))
82
+ }
83
+ if (type == "yes_no") {
84
+ if (missing_value) return(NA)
85
+ if (is.logical(flat)) return(flat[1])
86
+ answer = tolower(trimws(as.character(flat[1])))
87
+ if (answer %in% c("true", "yes", "y", "1")) return(TRUE)
88
+ if (answer %in% c("false", "no", "n", "0")) return(FALSE)
89
+ return(NA)
90
+ }
91
+ if (missing_value) {
92
+ # An empty list that was returned is an empty cell, not a missing value
93
+ if (type == "list" && !is.null(value) && length(value) == 0) return("")
94
+ return(NA_character_)
95
+ }
96
+ flat = as.character(flat[!is.na(flat)])
97
+ paste(flat, collapse = list_separator)
98
+ }
99
+
100
+ # Combine the per-item results into one data frame with the columns
101
+ # source_id, one column per field, status and status_message.
102
+ # Items that failed, and fields the model did not return, are NA.
103
+ assemble_results = function(results, fields, list_separator = LIST_SEPARATOR) {
104
+ columns = list(
105
+ source_id = vapply(results, function(r) as.character(r$source_id), character(1))
106
+ )
107
+ for (field in fields) {
108
+ cells = lapply(results, function(r) {
109
+ value = if (identical(r$status, "ok")) r$data[[field$label]] else NULL
110
+ coerce_value(value, field$type, list_separator = list_separator)
111
+ })
112
+ columns[[field$label]] = switch(
113
+ field$type,
114
+ number = vapply(cells, as.numeric, numeric(1)),
115
+ yes_no = vapply(cells, as.logical, logical(1)),
116
+ vapply(cells, as.character, character(1))
117
+ )
118
+ }
119
+ columns$status = vapply(results, function(r) as.character(r$status), character(1))
120
+ columns$status_message = vapply(results, function(r) as.character(r$message), character(1))
121
+ as.data.frame(columns, stringsAsFactors = FALSE, check.names = FALSE)
122
+ }
123
+
124
+ # Version of the results for on-screen display: every column as text, with
125
+ # empty cells for missing values and numbers printed without padding zeros.
126
+ # The CSV download uses the typed table, not this one.
127
+ display_table = function(results_table) {
128
+ shown = lapply(results_table, function(column) {
129
+ text = if (is.numeric(column)) {
130
+ vapply(column, function(x) {
131
+ if (is.na(x)) NA_character_ else format(x, scientific = FALSE, drop0trailing = TRUE, trim = TRUE)
132
+ }, character(1))
133
+ } else {
134
+ as.character(column)
135
+ }
136
+ text[is.na(text)] = ""
137
+ text
138
+ })
139
+ as.data.frame(shown, stringsAsFactors = FALSE, check.names = FALSE)
140
+ }
141
+
142
+ # One line describing the outcome, for example
143
+ # "7 of 8 items extracted; 1 failed (source_id: 3)."
144
+ summarize_results = function(results_table) {
145
+ n = nrow(results_table)
146
+ failed = results_table$source_id[results_table$status != "ok"]
147
+ n_ok = n - length(failed)
148
+ item_word = if (n == 1) "item" else "items"
149
+ if (length(failed) == 0) {
150
+ return(sprintf("%d of %d %s extracted.", n_ok, n, item_word))
151
+ }
152
+ sprintf(
153
+ "%d of %d %s extracted; %d failed (source_id: %s).",
154
+ n_ok, n, item_word, length(failed), paste(failed, collapse = ", ")
155
+ )
156
+ }
R/fields.R ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Field definitions: creation, validation, sanitizing, JSON import/export,
2
+ # templates, and conversion to an ellmer type.
3
+ #
4
+ # A field is a list with three character elements:
5
+ # label column name in the results (and property name in the schema)
6
+ # description instruction the model receives for the field
7
+ # type one of names(FIELD_TYPES)
8
+
9
+ # Field types offered in the UI (value = label shown to the user)
10
+ FIELD_TYPES = c(
11
+ text = "Text",
12
+ number = "Number",
13
+ yes_no = "Yes / No",
14
+ list = "List of text"
15
+ )
16
+
17
+ # Name used inside saved field-definition files
18
+ FIELDS_FILE_FORMAT = "structured_text_extraction_fields"
19
+
20
+ new_field = function(label = "", description = "", type = "text") {
21
+ list(
22
+ label = as.character(label),
23
+ description = as.character(description),
24
+ type = as.character(type)
25
+ )
26
+ }
27
+
28
+ # First "field_<k>" label that is not already used
29
+ next_default_label = function(existing_labels) {
30
+ k = 1
31
+ while (paste0("field_", k) %in% existing_labels) k = k + 1
32
+ paste0("field_", k)
33
+ }
34
+
35
+ # Grow or shrink a list of fields to n entries, keeping the existing entries
36
+ resize_fields = function(fields, n) {
37
+ n = as.integer(n)
38
+ if (is.na(n) || n < 1) return(fields)
39
+ if (length(fields) > n) return(fields[seq_len(n)])
40
+ while (length(fields) < n) {
41
+ labels = vapply(fields, function(f) f$label, character(1))
42
+ # Prefer the label that matches the field's position
43
+ label = paste0("field_", length(fields) + 1)
44
+ if (label %in% labels) label = next_default_label(labels)
45
+ fields[[length(fields) + 1]] = new_field(label)
46
+ }
47
+ fields
48
+ }
49
+
50
+ # Bring a user-supplied number of fields into the allowed range.
51
+ # Returns NA when the value is not a usable number.
52
+ clamp_num_fields = function(n, max_fields = MAX_FIELDS) {
53
+ n = suppressWarnings(as.numeric(n))
54
+ if (length(n) != 1 || is.na(n)) return(NA_integer_)
55
+ as.integer(min(max(round(n), 1), max_fields))
56
+ }
57
+
58
+ # Ids of the three inputs of field i. The generation number changes every
59
+ # time the app rebuilds the field inputs, so values typed into an earlier set
60
+ # of inputs can never overwrite a newly loaded template or file.
61
+ field_input_ids = function(gen, i) {
62
+ list(
63
+ label = paste0("field_label_", gen, "_", i),
64
+ description = paste0("field_desc_", gen, "_", i),
65
+ type = paste0("field_type_", gen, "_", i)
66
+ )
67
+ }
68
+
69
+ # Overlay what the user has typed on the stored field definitions.
70
+ # get_input is a function(id) returning the input value or NULL.
71
+ merge_field_inputs = function(fields, get_input, gen = 0) {
72
+ lapply(seq_along(fields), function(i) {
73
+ ids = field_input_ids(gen, i)
74
+ field = fields[[i]]
75
+ for (part in names(ids)) {
76
+ value = get_input(ids[[part]])
77
+ if (!is.null(value) && length(value) == 1 && !is.na(value)) {
78
+ field[[part]] = as.character(value)
79
+ }
80
+ }
81
+ field
82
+ })
83
+ }
84
+
85
+ # Make a label safe to use as a JSON property name and a column name:
86
+ # letters, digits and underscores only, not starting with a digit.
87
+ sanitize_label = function(label, max_chars = MAX_LABEL_CHARS) {
88
+ label = trimws(as.character(label))
89
+ label = gsub("[^A-Za-z0-9_]+", "_", label)
90
+ label = gsub("^_+|_+$", "", label)
91
+ if (nchar(label) > 0 && grepl("^[0-9]", label)) label = paste0("field_", label)
92
+ substr(label, 1, max_chars)
93
+ }
94
+
95
+ # Check a list of fields. Returns
96
+ # ok TRUE when the fields can be used for an extraction
97
+ # errors problems that block the extraction
98
+ # notes things the user should know that do not block it
99
+ # fields the fields with sanitized labels and trimmed descriptions
100
+ validate_fields = function(fields, max_fields = MAX_FIELDS, reserved = RESERVED_LABELS) {
101
+ errors = character(0)
102
+ notes = character(0)
103
+
104
+ if (!is.list(fields) || length(fields) == 0) {
105
+ return(list(ok = FALSE, errors = "Define at least one field.", notes = notes, fields = list()))
106
+ }
107
+ if (length(fields) > max_fields) {
108
+ errors = c(errors, sprintf(
109
+ "You defined %d fields. The maximum is %d.", length(fields), max_fields
110
+ ))
111
+ }
112
+
113
+ clean = list()
114
+ for (i in seq_along(fields)) {
115
+ field = fields[[i]]
116
+ raw_label = if (is.null(field$label)) "" else trimws(as.character(field$label)[1])
117
+ if (is.na(raw_label)) raw_label = ""
118
+ description = if (is.null(field$description)) "" else trimws(as.character(field$description)[1])
119
+ if (is.na(description)) description = ""
120
+ type = if (is.null(field$type)) "text" else as.character(field$type)[1]
121
+
122
+ if (nchar(raw_label) == 0) {
123
+ errors = c(errors, sprintf("Field %d has no label.", i))
124
+ next
125
+ }
126
+ label = sanitize_label(raw_label)
127
+ if (nchar(label) == 0) {
128
+ errors = c(errors, sprintf(
129
+ "The label of field %d ('%s') has no letters or digits.", i, raw_label
130
+ ))
131
+ next
132
+ }
133
+ if (label != raw_label) {
134
+ notes = c(notes, sprintf(
135
+ "The label of field %d ('%s') was changed to '%s' (letters, digits and underscores only).",
136
+ i, raw_label, label
137
+ ))
138
+ }
139
+ if (tolower(label) %in% tolower(reserved)) {
140
+ errors = c(errors, sprintf(
141
+ "The label '%s' (field %d) is used by the app for its own column. Choose another label.",
142
+ label, i
143
+ ))
144
+ }
145
+ if (is.na(type) || !(type %in% names(FIELD_TYPES))) {
146
+ errors = c(errors, sprintf(
147
+ "Field %d has an unknown type ('%s'). Allowed types: %s.",
148
+ i, type, paste(names(FIELD_TYPES), collapse = ", ")
149
+ ))
150
+ type = "text"
151
+ }
152
+ if (nchar(description) == 0) {
153
+ notes = c(notes, sprintf(
154
+ "Field %d ('%s') has no description, so the model only sees its label.", i, label
155
+ ))
156
+ }
157
+ clean[[length(clean) + 1]] = new_field(label, description, type)
158
+ }
159
+
160
+ labels = tolower(vapply(clean, function(f) f$label, character(1)))
161
+ duplicated_labels = unique(labels[duplicated(labels)])
162
+ for (dup in duplicated_labels) {
163
+ errors = c(errors, sprintf(
164
+ "The label '%s' is used by more than one field. Labels must be unique.", dup
165
+ ))
166
+ }
167
+
168
+ list(ok = length(errors) == 0, errors = errors, notes = notes, fields = clean)
169
+ }
170
+
171
+ # ellmer type for one field. All fields are optional so that the model can
172
+ # return nothing when a document does not contain the information.
173
+ field_to_type = function(field) {
174
+ description = if (nchar(field$description) > 0) field$description else NULL
175
+ switch(
176
+ field$type,
177
+ text = ellmer::type_string(description = description, required = FALSE),
178
+ number = ellmer::type_number(description = description, required = FALSE),
179
+ yes_no = ellmer::type_boolean(description = description, required = FALSE),
180
+ list = ellmer::type_array(
181
+ items = ellmer::type_string(),
182
+ description = description,
183
+ required = FALSE
184
+ ),
185
+ stop("Unknown field type: ", field$type)
186
+ )
187
+ }
188
+
189
+ # Build the ellmer type_object for a list of validated fields
190
+ build_type_object = function(fields) {
191
+ if (length(fields) == 0) stop("At least one field is required.")
192
+ type_list = lapply(fields, field_to_type)
193
+ names(type_list) = vapply(fields, function(f) f$label, character(1))
194
+ do.call(ellmer::type_object, type_list)
195
+ }
196
+
197
+ # ---- JSON export and import -------------------------------------------------
198
+
199
+ fields_to_json = function(fields) {
200
+ payload = list(
201
+ format = FIELDS_FILE_FORMAT,
202
+ version = 1,
203
+ fields = lapply(fields, function(f) {
204
+ list(label = f$label, description = f$description, type = f$type)
205
+ })
206
+ )
207
+ as.character(jsonlite::toJSON(payload, auto_unbox = TRUE, pretty = TRUE))
208
+ }
209
+
210
+ # Parse field definitions from JSON text. Accepts the format written by
211
+ # fields_to_json(), a template file, or a bare array of fields.
212
+ # Returns list(ok, error, fields).
213
+ fields_from_json = function(json_text, max_fields = MAX_FIELDS) {
214
+ fail = function(message) list(ok = FALSE, error = message, fields = list())
215
+
216
+ json_text = paste(json_text, collapse = "\n")
217
+ if (!validUTF8(json_text)) return(fail("The file is not a text file with JSON content."))
218
+ if (nchar(trimws(json_text)) == 0) return(fail("The file is empty."))
219
+
220
+ parsed = tryCatch(
221
+ jsonlite::fromJSON(json_text, simplifyVector = FALSE),
222
+ error = function(e) NULL
223
+ )
224
+ if (is.null(parsed)) return(fail("The file is not valid JSON."))
225
+
226
+ raw_fields = if (is.list(parsed) && !is.null(names(parsed))) parsed$fields else parsed
227
+ if (!is.list(raw_fields) || length(raw_fields) == 0 || !is.null(names(raw_fields))) {
228
+ return(fail("The file does not contain a list of fields (expected a \"fields\" array)."))
229
+ }
230
+
231
+ is_text = function(x) is.character(x) && length(x) == 1 && !is.na(x)
232
+ fields = list()
233
+ for (i in seq_along(raw_fields)) {
234
+ entry = raw_fields[[i]]
235
+ if (!is.list(entry) || is.null(names(entry))) {
236
+ return(fail(sprintf("Field %d is not an object with a label, description and type.", i)))
237
+ }
238
+ if (!is_text(entry$label)) {
239
+ return(fail(sprintf("Field %d has no text label.", i)))
240
+ }
241
+ description = if (is.null(entry$description)) "" else entry$description
242
+ if (!is_text(description)) {
243
+ return(fail(sprintf("The description of field %d is not text.", i)))
244
+ }
245
+ type = if (is.null(entry$type)) "text" else entry$type
246
+ if (!is_text(type) || !(type %in% names(FIELD_TYPES))) {
247
+ return(fail(sprintf(
248
+ "Field %d has an unknown type. Allowed types: %s.",
249
+ i, paste(names(FIELD_TYPES), collapse = ", ")
250
+ )))
251
+ }
252
+ fields[[i]] = new_field(entry$label, description, type)
253
+ }
254
+
255
+ checked = validate_fields(fields, max_fields = max_fields)
256
+ if (!checked$ok) return(fail(paste(checked$errors, collapse = " ")))
257
+
258
+ list(ok = TRUE, error = "", fields = checked$fields)
259
+ }
260
+
261
+ read_fields_json = function(path, max_fields = MAX_FIELDS) {
262
+ json_text = tryCatch(
263
+ suppressWarnings(readLines(path, warn = FALSE, encoding = "UTF-8")),
264
+ error = function(e) NULL
265
+ )
266
+ if (is.null(json_text)) {
267
+ return(list(ok = FALSE, error = "The file could not be read.", fields = list()))
268
+ }
269
+ fields_from_json(json_text, max_fields = max_fields)
270
+ }
271
+
272
+ # ---- Templates --------------------------------------------------------------
273
+
274
+ # Read the field templates (one JSON file each) from a directory.
275
+ # Returns a list named by template id, in the order given by each file's
276
+ # "order" value.
277
+ load_templates = function(dir = file.path("config", "templates")) {
278
+ files = sort(list.files(dir, pattern = "\\.json$", full.names = TRUE))
279
+ templates = lapply(files, function(path) {
280
+ raw = jsonlite::fromJSON(
281
+ paste(readLines(path, warn = FALSE, encoding = "UTF-8"), collapse = "\n"),
282
+ simplifyVector = FALSE
283
+ )
284
+ list(
285
+ id = raw$id,
286
+ name = raw$name,
287
+ description = raw$description,
288
+ order = if (is.null(raw$order)) Inf else raw$order,
289
+ fields = lapply(raw$fields, function(f) new_field(f$label, f$description, f$type))
290
+ )
291
+ })
292
+ if (length(templates) == 0) return(list())
293
+ templates = templates[order(vapply(templates, function(t) as.numeric(t$order), numeric(1)))]
294
+ names(templates) = vapply(templates, function(t) t$id, character(1))
295
+ templates
296
+ }
297
+
298
+ # Choices for the template selector (name shown = template id)
299
+ template_choices = function(templates) {
300
+ ids = names(templates)
301
+ names(ids) = vapply(templates, function(t) t$name, character(1))
302
+ ids
303
+ }
R/help.R ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Help text for the "?" buttons. Every number and name in the text comes from
2
+ # app_config(), the same values that drive the app's behaviour.
3
+
4
+ HELP_TOPICS = c("source", "fields", "extract", "results")
5
+
6
+ # Returns list(title, paragraphs) for one help topic
7
+ help_content = function(topic, cfg = app_config()) {
8
+ reserved = paste(RESERVED_LABELS, collapse = ", ")
9
+ type_names = paste(tolower(FIELD_TYPES), collapse = ", ")
10
+
11
+ switch(
12
+ topic,
13
+ source = list(
14
+ title = "Input source",
15
+ paragraphs = c(
16
+ "Choose one input source. Only the source on the tab that is open when you click Extract Data is used. Content loaded on the other tabs is kept, but it is not sent.",
17
+ "Demo Data and Paste Text: the text is split into items at blank lines, and each item is sent to the model as a separate request. Text File: a .txt or .md file is split in the same way. A .csv file gives one item per row of its first column, and its first row is treated as a header.",
18
+ sprintf(
19
+ "Readable PDF: the text of the first %d pages is read from the file and sent as a single item. Scanned PDF: each of the first %d pages is converted to an image and sent as one item. Upload Images: each image is one item, up to %d images.",
20
+ cfg$max_pages_or_images, cfg$max_pages_or_images, cfg$max_pages_or_images
21
+ ),
22
+ sprintf("Each uploaded file can be at most %d MB.", cfg$max_upload_mb),
23
+ sprintf(
24
+ "When you click Extract Data, the content of the active source and your field definitions are sent to the %s model %s. Do not submit confidential or proprietary records to this hosted app.",
25
+ cfg$provider, cfg$model
26
+ )
27
+ )
28
+ ),
29
+ fields = list(
30
+ title = "Field definitions",
31
+ paragraphs = c(
32
+ sprintf(
33
+ "Each field becomes one column of the results. You can define up to %d fields.",
34
+ cfg$max_fields
35
+ ),
36
+ sprintf(
37
+ "The label is the column name. Labels may contain letters, digits and underscores, and any other character is replaced with an underscore. Labels must be unique and cannot be one of the names the app uses for its own columns (%s).",
38
+ reserved
39
+ ),
40
+ "The description is the instruction the model receives for that field. The app adds no instructions of its own, so your descriptions are the prompt. State what to return, in what format, and what to return when the document does not contain the information.",
41
+ sprintf(
42
+ "The type sets the data type of the column (%s). The values of a list are joined with '%s' in the table and in the CSV file.",
43
+ type_names, cfg$list_separator
44
+ ),
45
+ "A template fills the fields with a starting set for a common quality document, and you can edit the fields afterwards. Save fields downloads the current definitions as a JSON file. Load fields restores them from such a file."
46
+ )
47
+ ),
48
+ extract = list(
49
+ title = "Extract Data",
50
+ paragraphs = c(
51
+ sprintf(
52
+ "Extract Data sends one request per item to the %s model %s. A request contains the item (text or an image) and the field definitions (labels, descriptions and types), which the model must follow as its output structure.",
53
+ cfg$provider, cfg$model
54
+ ),
55
+ "Each item is sent in a new conversation, so one item cannot influence the values extracted from another.",
56
+ "Items are processed one after another, and a progress bar shows the item being processed. If an item fails, the remaining items are still processed and the failure is recorded in the results.",
57
+ sprintf(
58
+ "Limits per run: %d PDF pages or images, %d fields, and %d MB per uploaded file.",
59
+ cfg$max_pages_or_images, cfg$max_fields, cfg$max_upload_mb
60
+ ),
61
+ "The model can return different values when the same item is submitted again."
62
+ )
63
+ ),
64
+ results = list(
65
+ title = "Results",
66
+ paragraphs = c(
67
+ "The table has one row per item. The source_id column identifies the item (its position in the text, or the image or page number). The status column is ok or failed, and status_message gives the reason for a failure.",
68
+ "An empty cell means that the model returned no value for that field.",
69
+ sprintf(
70
+ "Check the results against the source documents before you use them. The %s model %s can misread a document or return a value that the document does not contain. A status of ok means that the request completed. It does not mean that the values are correct.",
71
+ cfg$provider, cfg$model
72
+ ),
73
+ "Download as CSV saves the table with the source_id, status and status_message columns. Reproduce in code shows R and Python code that runs the same extraction, with your fields and the same model, on your own data."
74
+ )
75
+ ),
76
+ stop("Unknown help topic: ", topic)
77
+ )
78
+ }
79
+
80
+ # Small "?" button that opens the help for a topic
81
+ help_button = function(topic) {
82
+ content = help_content(topic)
83
+ shiny::actionButton(
84
+ paste0("help_", topic),
85
+ "?",
86
+ class = "help-btn",
87
+ title = paste("How this works:", content$title),
88
+ `aria-label` = paste("Help:", content$title),
89
+ `aria-haspopup` = "dialog"
90
+ )
91
+ }
92
+
93
+ # Modal dialog with the help for a topic
94
+ help_modal = function(topic, cfg = app_config()) {
95
+ content = help_content(topic, cfg)
96
+ shiny::modalDialog(
97
+ title = content$title,
98
+ lapply(content$paragraphs, shiny::tags$p),
99
+ easyClose = TRUE,
100
+ footer = shiny::modalButton("Close")
101
+ )
102
+ }
103
+
104
+ # Sentences about the limits, used in the page text
105
+ limit_note = function(kind, max_items = MAX_PAGES_OR_IMAGES) {
106
+ switch(
107
+ kind,
108
+ pages = sprintf(" Free app limit: only the first %d pages will be processed.", max_items),
109
+ images = sprintf(" Free app limit: a maximum of %d images will be processed.", max_items),
110
+ stop("Unknown limit kind: ", kind)
111
+ )
112
+ }
R/snippets.R ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # "Reproduce in code": R (ellmer) and Python (chatlas) snippets generated from
2
+ # the current field definitions and the configured model.
3
+
4
+ # Names that cannot be used as attribute names of a Python (pydantic) class
5
+ PYTHON_RESERVED_NAMES = c(
6
+ "False", "None", "True", "and", "as", "assert", "async", "await", "break",
7
+ "class", "continue", "def", "del", "elif", "else", "except", "finally",
8
+ "for", "from", "global", "if", "import", "in", "is", "lambda", "nonlocal",
9
+ "not", "or", "pass", "raise", "return", "try", "while", "with", "yield",
10
+ "copy", "dict", "json", "schema", "validate", "construct"
11
+ )
12
+
13
+ # R string literal
14
+ r_string = function(x) {
15
+ encodeString(enc2utf8(x), quote = "\"")
16
+ }
17
+
18
+ # Python string literal (a JSON string is a valid Python string)
19
+ python_string = function(x) {
20
+ as.character(jsonlite::toJSON(enc2utf8(x), auto_unbox = TRUE))
21
+ }
22
+
23
+ r_field_line = function(field) {
24
+ description = r_string(field$description)
25
+ type_call = switch(
26
+ field$type,
27
+ text = sprintf("type_string(%s, required = FALSE)", description),
28
+ number = sprintf("type_number(%s, required = FALSE)", description),
29
+ yes_no = sprintf("type_boolean(%s, required = FALSE)", description),
30
+ list = sprintf(
31
+ "type_array(items = type_string(), description = %s, required = FALSE)",
32
+ description
33
+ ),
34
+ stop("Unknown field type: ", field$type)
35
+ )
36
+ sprintf(" %s = %s", field$label, type_call)
37
+ }
38
+
39
+ # R code that runs the same extraction with ellmer
40
+ r_snippet = function(fields, model = get_extraction_model()) {
41
+ field_lines = paste(vapply(fields, r_field_line, character(1)), collapse = ",\n")
42
+ paste(c(
43
+ "# Structured extraction with ellmer (https://ellmer.tidyverse.org/)",
44
+ "# install.packages(\"ellmer\")",
45
+ "# The API key is read from the OPENAI_API_KEY environment variable.",
46
+ "library(ellmer)",
47
+ "",
48
+ "# One entry per field. The description is the instruction the model receives.",
49
+ "extraction_type = type_object(",
50
+ field_lines,
51
+ ")",
52
+ "",
53
+ "extract_one = function(content) {",
54
+ " # A new chat per document keeps earlier documents out of the context",
55
+ sprintf(" chat = chat_openai(model = %s)", r_string(model)),
56
+ " chat$chat_structured(content, type = extraction_type)",
57
+ "}",
58
+ "",
59
+ "# One document given as text",
60
+ "result = extract_one(\"Paste the text of one document here.\")",
61
+ "str(result)",
62
+ "",
63
+ "# An image or a PDF file",
64
+ "# result = extract_one(content_image_file(\"scan.png\", resize = \"high\"))",
65
+ "# result = extract_one(content_pdf_file(\"report.pdf\"))",
66
+ "",
67
+ "# Several documents, one call each",
68
+ "# documents = c(\"first document\", \"second document\")",
69
+ "# results = lapply(documents, extract_one)"
70
+ ), collapse = "\n")
71
+ }
72
+
73
+ python_field_line = function(field) {
74
+ annotation = switch(
75
+ field$type,
76
+ text = "str | None",
77
+ number = "float | None",
78
+ yes_no = "bool | None",
79
+ list = "list[str] | None",
80
+ stop("Unknown field type: ", field$type)
81
+ )
82
+ name = field$label
83
+ comment = ""
84
+ if (name %in% PYTHON_RESERVED_NAMES) {
85
+ comment = sprintf(" # '%s' cannot be used as a name in Python", name)
86
+ name = paste0(name, "_")
87
+ }
88
+ sprintf(
89
+ " %s: %s = Field(description=%s)%s",
90
+ name, annotation, python_string(field$description), comment
91
+ )
92
+ }
93
+
94
+ # Python code that runs the same extraction with chatlas
95
+ python_snippet = function(fields, model = get_extraction_model()) {
96
+ field_lines = paste(vapply(fields, python_field_line, character(1)), collapse = "\n")
97
+ paste(c(
98
+ "# Structured extraction with chatlas (https://posit-dev.github.io/chatlas/)",
99
+ "# pip install chatlas",
100
+ "# The API key is read from the OPENAI_API_KEY environment variable.",
101
+ "from chatlas import ChatOpenAI, content_image_file, content_pdf_file",
102
+ "from pydantic import BaseModel, Field",
103
+ "",
104
+ "",
105
+ "# One attribute per field. The description is the instruction the model receives.",
106
+ "class Extraction(BaseModel):",
107
+ field_lines,
108
+ "",
109
+ "",
110
+ "def extract_one(content):",
111
+ " # A new chat per document keeps earlier documents out of the context",
112
+ sprintf(" chat = ChatOpenAI(model=%s)", python_string(model)),
113
+ " return chat.chat_structured(content, data_model=Extraction)",
114
+ "",
115
+ "",
116
+ "# One document given as text",
117
+ "result = extract_one(\"Paste the text of one document here.\")",
118
+ "print(result.model_dump())",
119
+ "",
120
+ "# An image or a PDF file",
121
+ "# result = extract_one(content_image_file(\"scan.png\", resize=\"high\"))",
122
+ "# result = extract_one(content_pdf_file(\"report.pdf\"))",
123
+ "",
124
+ "# Several documents, one call each",
125
+ "# documents = [\"first document\", \"second document\"]",
126
+ "# results = [extract_one(d) for d in documents]"
127
+ ), collapse = "\n")
128
+ }
R/sources.R ADDED
@@ -0,0 +1,185 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Input sources: reading uploaded files and resolving the active tab into the
2
+ # list of items that will be sent to the model.
3
+
4
+ # Ids of the input tabs, in display order
5
+ INPUT_METHODS = c("demo", "paste", "textfile", "pdf", "scanned", "image")
6
+
7
+ # Names of the input tabs as shown to the user
8
+ INPUT_METHOD_LABELS = c(
9
+ demo = "Demo Data",
10
+ paste = "Paste Text",
11
+ textfile = "Text File",
12
+ pdf = "Readable PDF",
13
+ scanned = "Scanned PDF",
14
+ image = "Upload Images"
15
+ )
16
+
17
+ # Two NHTSA recall summaries used by the Demo Data tab
18
+ DEMO_TEXT = "Ford Motor Company (Ford) is recalling certain 2021-2022 Bronco vehicles equipped with rearview camera systems and 8-inch screen displays. The rearview camera image may still be displayed after a backing event has ended. As such, these vehicles fail to comply with the requirements of Federal Motor Vehicle Safety Standard number 111, \"Rear Visibility.\"\n\nHonda (American Honda Motor Co.) is recalling certain 2022-2025 Acura MDX Type-S, 2023-2025 Honda Pilot, and 2021-2025 Acura TLX Type-S vehicles. A software error in the fuel injection electronic control unit (FI-ECU) may cause an engine stall or a loss of power."
19
+
20
+ # Split text into items at blank lines
21
+ split_text_blocks = function(text) {
22
+ if (is.null(text) || length(text) == 0) return(character(0))
23
+ text = paste(text, collapse = "\n")
24
+ text = gsub("\r\n?", "\n", text)
25
+ blocks = trimws(unlist(strsplit(text, "\n[ \t]*\n+")))
26
+ blocks[!is.na(blocks) & nchar(blocks) > 0]
27
+ }
28
+
29
+ # Shorten text for an on-screen preview
30
+ preview_text = function(text, max_chars = 500) {
31
+ text = paste(text, collapse = "\n\n")
32
+ if (nchar(text) > max_chars) paste0(substr(text, 1, max_chars), "...") else text
33
+ }
34
+
35
+ # Read an uploaded text file into a character vector of items.
36
+ # .csv: one item per row of the first column (first row is a header).
37
+ # .txt and .md: items are separated by blank lines.
38
+ read_text_file_items = function(path, name = path) {
39
+ file_ext = tolower(tools::file_ext(name))
40
+ if (file_ext == "csv") {
41
+ csv_data = utils::read.csv(path, header = TRUE, stringsAsFactors = FALSE)
42
+ if (ncol(csv_data) < 1) return(character(0))
43
+ items = trimws(as.character(csv_data[[1]]))
44
+ items[!is.na(items) & nchar(items) > 0]
45
+ } else {
46
+ split_text_blocks(readLines(path, warn = FALSE, encoding = "UTF-8"))
47
+ }
48
+ }
49
+
50
+ # Extract the text of the first max_pages pages of a machine-readable PDF.
51
+ # Returns list(text, total_pages, used_pages); the text is one item.
52
+ read_pdf_text = function(path, max_pages = MAX_PAGES_OR_IMAGES) {
53
+ pages = pdftools::pdf_text(path)
54
+ total_pages = length(pages)
55
+ pages = pages[seq_len(min(total_pages, max_pages))]
56
+ combined_text = paste(pages, collapse = "\n\n")
57
+ combined_text = trimws(gsub("\\s+", " ", combined_text))
58
+ list(text = combined_text, total_pages = total_pages, used_pages = length(pages))
59
+ }
60
+
61
+ # Convert the first max_pages pages of a PDF to PNG files.
62
+ # Returns list(paths, total_pages).
63
+ render_pdf_pages = function(path, max_pages = MAX_PAGES_OR_IMAGES, dpi = 150) {
64
+ total_pages = pdftools::pdf_info(path)$pages
65
+ n_pages = min(total_pages, max_pages)
66
+ out_files = vapply(
67
+ seq_len(n_pages),
68
+ function(i) tempfile(pattern = paste0("page_", i, "_"), fileext = ".png"),
69
+ character(1)
70
+ )
71
+ pdftools::pdf_convert(
72
+ path,
73
+ format = "png",
74
+ pages = seq_len(n_pages),
75
+ filenames = out_files,
76
+ dpi = dpi,
77
+ verbose = FALSE
78
+ )
79
+ list(paths = out_files, total_pages = total_pages)
80
+ }
81
+
82
+ # MIME type of an image, from its first bytes and, failing that, from the
83
+ # extension of its name. Returns NA for anything that is not a supported image.
84
+ detect_mime = function(path, name = path) {
85
+ header = tryCatch(
86
+ suppressWarnings(readBin(path, what = "raw", n = 12)),
87
+ error = function(e) raw(0)
88
+ )
89
+ starts_with = function(bytes) {
90
+ length(header) >= length(bytes) && all(header[seq_along(bytes)] == as.raw(bytes))
91
+ }
92
+ if (starts_with(c(0x89, 0x50, 0x4E, 0x47))) return("image/png")
93
+ if (starts_with(c(0xFF, 0xD8, 0xFF))) return("image/jpeg")
94
+ if (starts_with(c(0x47, 0x49, 0x46, 0x38))) return("image/gif")
95
+ if (starts_with(c(0x52, 0x49, 0x46, 0x46)) && length(header) >= 12 &&
96
+ all(header[9:12] == as.raw(c(0x57, 0x45, 0x42, 0x50)))) {
97
+ return("image/webp")
98
+ }
99
+ switch(
100
+ tolower(tools::file_ext(name)),
101
+ png = "image/png",
102
+ jpg = ,
103
+ jpeg = "image/jpeg",
104
+ gif = "image/gif",
105
+ webp = "image/webp",
106
+ NA_character_
107
+ )
108
+ }
109
+
110
+ # Turn the active input tab into the items to extract from.
111
+ #
112
+ # method id of the active tab (one of INPUT_METHODS)
113
+ # sources list with what each tab currently holds:
114
+ # demo text loaded by the demo button
115
+ # paste text in the paste box
116
+ # textfile character vector of items from the uploaded text file
117
+ # pdf text extracted from the readable PDF
118
+ # scanned paths of the page images of the scanned PDF
119
+ # image paths of the uploaded images
120
+ #
121
+ # Only sources[[method]] is read, so content loaded on another tab can never
122
+ # be extracted by mistake. Returns list(ok, error, items); each item is
123
+ # list(source_id, kind = "text" or "image", text or path).
124
+ resolve_source_items = function(method, sources, max_items = MAX_PAGES_OR_IMAGES) {
125
+ fail = function(message) list(ok = FALSE, error = message, items = list())
126
+ field_reminder = " Also check that the field labels and descriptions match your data."
127
+
128
+ text_items = function(blocks) {
129
+ lapply(seq_along(blocks), function(i) {
130
+ list(source_id = as.character(i), kind = "text", text = blocks[[i]])
131
+ })
132
+ }
133
+ image_items = function(paths, prefix) {
134
+ paths = paths[seq_len(min(length(paths), max_items))]
135
+ lapply(seq_along(paths), function(i) {
136
+ list(source_id = paste(prefix, i), kind = "image", path = paths[[i]])
137
+ })
138
+ }
139
+
140
+ if (is.null(method) || length(method) != 1 || !(method %in% INPUT_METHODS)) {
141
+ return(fail("Unknown input source. Select one of the input tabs."))
142
+ }
143
+
144
+ if (method == "demo") {
145
+ blocks = split_text_blocks(sources$demo)
146
+ if (length(blocks) == 0) {
147
+ return(fail("Please click 'Load Demo Data' first to load the sample data."))
148
+ }
149
+ items = text_items(blocks)
150
+ } else if (method == "paste") {
151
+ blocks = split_text_blocks(sources$paste)
152
+ if (length(blocks) == 0) {
153
+ return(fail(paste0("Please paste your text content in the text area.", field_reminder)))
154
+ }
155
+ items = text_items(blocks)
156
+ } else if (method == "textfile") {
157
+ blocks = sources$textfile
158
+ blocks = blocks[!is.na(blocks) & nchar(trimws(blocks)) > 0]
159
+ if (length(blocks) == 0) {
160
+ return(fail(paste0("Please upload a text file (.txt, .csv, or .md) first.", field_reminder)))
161
+ }
162
+ items = text_items(blocks)
163
+ } else if (method == "pdf") {
164
+ text = sources$pdf
165
+ if (is.null(text) || length(text) == 0 || nchar(trimws(text[1])) == 0) {
166
+ return(fail(paste0("Please upload a readable PDF file first.", field_reminder)))
167
+ }
168
+ # The whole document (up to the page limit) is one item
169
+ items = text_items(trimws(text[1]))
170
+ } else if (method == "image") {
171
+ paths = sources$image
172
+ if (is.null(paths) || length(paths) == 0) {
173
+ return(fail(paste0("Please upload one or more image files first.", field_reminder)))
174
+ }
175
+ items = image_items(paths, "Image")
176
+ } else {
177
+ paths = sources$scanned
178
+ if (is.null(paths) || length(paths) == 0) {
179
+ return(fail(paste0("Please upload a scanned PDF file first.", field_reminder)))
180
+ }
181
+ items = image_items(paths, "Page")
182
+ }
183
+
184
+ list(ok = TRUE, error = "", items = items)
185
+ }
app.R CHANGED
@@ -1,19 +1,69 @@
1
  library(shiny)
2
  library(ellmer)
3
- library(purrr)
4
  library(pdftools)
5
  library(magick)
6
  library(base64enc)
 
7
 
8
- # Maximum pages for image-based extraction (free app limit)
9
- MAX_IMAGE_PAGES = 5
 
 
 
 
 
 
 
 
 
10
 
11
- num_example_fields = 2
12
 
 
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
 
15
  # Define UI for the app
16
  ui = shiny::fluidPage(
 
17
  # Add Miami theme CSS and favicon
18
  shiny::tags$head(
19
  shiny::tags$link(rel = "icon", type = "image/svg+xml", href = "favicon.svg"),
@@ -33,10 +83,10 @@ ui = shiny::fluidPage(
33
  ),
34
  shiny::div(
35
  class = "header-right",
36
- shiny::p("Version 2.0.1 | January 2026"),
37
  shiny::p(
38
  shiny::tags$strong("Authors: "),
39
- "Fadel M. Megahed, Ying-Ju (Tessa) Chen, Allison Jones-Farmer, Ibrahim Yousif, and Inez M. Zwetsloot"
40
  ),
41
  shiny::p(
42
  shiny::tags$strong("Contact: "),
@@ -78,6 +128,8 @@ ui = shiny::fluidPage(
78
  shiny::tags$strong("Miami University,"),
79
  "the",
80
  shiny::tags$strong("University of Dayton,"),
 
 
81
  "and the",
82
  shiny::tags$strong("University of Amsterdam")
83
  )
@@ -96,7 +148,9 @@ ui = shiny::fluidPage(
96
  ),
97
  shiny::tags$button(
98
  id = "open-video-modal",
 
99
  class = "btn btn-video-tutorial",
 
100
  shiny::icon("play-circle"),
101
  " Watch Video Tutorial"
102
  )
@@ -108,14 +162,14 @@ ui = shiny::fluidPage(
108
  ),
109
  shiny::p(
110
  "Click the ",
111
- shiny::tags$strong("'Load Examples'"),
112
  " button to load two sample NHTSA recall notices with pre-configured extraction fields. ",
113
  "Then click ",
114
  shiny::tags$strong("'Extract Data'"),
115
- " to see the AI extract structured information from the text. ",
116
- "You can also add more fields, change field labels, and modify descriptions. ",
117
  shiny::tags$em(
118
- "(Note: Field labels only affect how data is stored and displayed, not extraction performance.)"
119
  )
120
  ),
121
  shiny::tags$h5(
@@ -124,7 +178,7 @@ ui = shiny::fluidPage(
124
  " Input Methods"
125
  ),
126
  shiny::p(
127
- "Choose from six input methods to provide your source content:"
128
  ),
129
  shiny::tags$ul(
130
  style = "margin-bottom: 10px;",
@@ -150,21 +204,33 @@ ui = shiny::fluidPage(
150
  ),
151
  shiny::tags$li(
152
  shiny::tags$strong("Upload Images:"),
153
- " Upload images (PNG, JPEG, WebP, GIF) for AI vision-based extraction."
154
  )
155
  ),
156
- shiny::p(
157
  style = "font-size: 0.9em; color: #666;",
158
  shiny::icon("info-circle"),
159
  " ",
160
  shiny::tags$em("Notes:"),
161
  shiny::tags$ul(
162
- style = "margin: -0.5em 0 0 1.25em; padding: 0; line-height: 1.2;",
163
- shiny::tags$li(style = "margin: 0; padding: 0;",
164
- "PDF, Image, and Scanned PDF modes are limited to 5 pages/items in this free demo."
 
 
 
 
165
  ),
166
- shiny::tags$li(style = "margin: 0; padding: 0;",
167
- "Any uploaded file must be less than 5 MB (Shiny default for this free app) to keep the demo responsive and avoid upload timeouts."
 
 
 
 
 
 
 
 
168
  )
169
  )
170
  ),
@@ -178,15 +244,25 @@ ui = shiny::fluidPage(
178
  ),
179
  shiny::tags$ul(
180
  style = "margin-bottom: 10px;",
181
- shiny::tags$li("The ", shiny::tags$strong("number of fields"), " to extract"),
182
- shiny::tags$li("Your own ", shiny::tags$strong("field labels"), " (for storage/display only)"),
183
- shiny::tags$li("Detailed ", shiny::tags$strong("field descriptions"), " to guide the AI")
 
 
 
 
 
 
 
 
 
 
184
  ),
185
  shiny::p(
186
  shiny::icon("lightbulb"),
187
  " ",
188
  shiny::tags$em(
189
- "The more specific your field descriptions, the better the extraction results."
190
  )
191
  )
192
  ),
@@ -195,24 +271,31 @@ ui = shiny::fluidPage(
195
  shiny::div(
196
  id = "video-modal",
197
  class = "video-modal",
 
 
 
198
  shiny::div(
199
  class = "video-modal-content",
200
- shiny::tags$span(
201
  id = "close-video-modal",
 
202
  class = "video-modal-close",
 
203
  shiny::HTML("&times;")
204
  ),
205
  shiny::tags$h4(
 
206
  style = "color: #C41230; margin-top: 0; margin-bottom: 15px;",
207
  shiny::icon("play-circle"),
208
  " Video Tutorial"
209
  ),
210
  shiny::div(
211
  class = "video-modal-wrapper",
212
- # Placeholder - replace VIDEO_ID_PLACEHOLDER with actual YouTube video ID
213
  shiny::tags$iframe(
214
  id = "tutorial-video",
215
- src = "https://www.youtube.com/embed/y6Uit4Drf9w",
 
216
  allow = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",
217
  allowfullscreen = NA
218
  )
@@ -220,130 +303,53 @@ ui = shiny::fluidPage(
220
  )
221
  ),
222
 
223
- # JavaScript for video modal
224
- shiny::tags$script(shiny::HTML("
225
- var videoUrl = 'https://www.youtube.com/embed/y6Uit4Drf9w';
226
- var modal = document.getElementById('video-modal');
227
- var openBtn = document.getElementById('open-video-modal');
228
- var closeBtn = document.getElementById('close-video-modal');
229
- var videoIframe = document.getElementById('tutorial-video');
230
-
231
- openBtn.onclick = function() {
232
- modal.style.display = 'flex';
233
- videoIframe.src = videoUrl;
234
- }
235
-
236
- closeBtn.onclick = function() {
237
- modal.style.display = 'none';
238
- videoIframe.src = '';
239
- }
240
-
241
- window.onclick = function(event) {
242
- if (event.target == modal) {
243
- modal.style.display = 'none';
244
- videoIframe.src = '';
245
- }
246
- }
247
-
248
- document.addEventListener('keydown', function(event) {
249
- if (event.key === 'Escape' && modal.style.display === 'flex') {
250
- modal.style.display = 'none';
251
- videoIframe.src = '';
252
- }
253
- });
254
-
255
- // Input tab switching
256
- function switchInputTab(tab) {
257
- var tabs = ['demo', 'paste', 'textfile', 'pdf', 'scanned', 'image'];
258
- tabs.forEach(function(t) {
259
- var tabEl = document.getElementById('tab-' + t);
260
- var panelEl = document.getElementById('panel-' + t);
261
- if (t === tab) {
262
- tabEl.classList.add('active');
263
- panelEl.style.display = 'block';
264
- } else {
265
- tabEl.classList.remove('active');
266
- panelEl.style.display = 'none';
267
- }
268
- });
269
- // Update Shiny with the current input method
270
- Shiny.setInputValue('input_method', tab);
271
- }
272
- // Initialize input method
273
- Shiny.setInputValue('input_method', 'demo');
274
- ")),
275
 
276
  shiny::sidebarLayout(
277
  shiny::sidebarPanel(
278
  width = 4,
279
  class = "sidebar-panel",
280
 
281
- shiny::tags$h4(
282
- style = "color: #C41230; margin-top: 0; margin-bottom: 15px;",
283
- shiny::icon("file-alt"),
284
- " Input Configuration"
 
 
 
 
285
  ),
286
 
287
- # Input method tabs (3 rows for 5 tabs)
288
  shiny::div(
289
  class = "input-tabs-container",
 
 
290
  shiny::div(
291
  class = "input-tabs",
292
- shiny::tags$button(
293
- id = "tab-demo",
294
- class = "input-tab active",
295
- onclick = "switchInputTab('demo')",
296
- shiny::icon("flask"),
297
- " Demo Data"
298
- ),
299
- shiny::tags$button(
300
- id = "tab-paste",
301
- class = "input-tab",
302
- onclick = "switchInputTab('paste')",
303
- shiny::icon("paste"),
304
- " Paste Text"
305
- )
306
  ),
307
  shiny::div(
308
  class = "input-tabs",
309
- shiny::tags$button(
310
- id = "tab-textfile",
311
- class = "input-tab",
312
- onclick = "switchInputTab('textfile')",
313
- shiny::icon("file-lines"),
314
- " Text File"
315
- ),
316
- shiny::tags$button(
317
- id = "tab-pdf",
318
- class = "input-tab",
319
- onclick = "switchInputTab('pdf')",
320
- shiny::icon("file-pdf"),
321
- " Readable PDF"
322
- )
323
  ),
324
  shiny::div(
325
  class = "input-tabs",
326
- shiny::tags$button(
327
- id = "tab-scanned",
328
- class = "input-tab",
329
- onclick = "switchInputTab('scanned')",
330
- shiny::icon("file-image"),
331
- " Scanned PDF"
332
- ),
333
- shiny::tags$button(
334
- id = "tab-image",
335
- class = "input-tab",
336
- onclick = "switchInputTab('image')",
337
- shiny::icon("images"),
338
- " Upload Images"
339
- )
340
  )
341
  ),
342
 
343
  # Demo data panel
344
- shiny::div(
345
- id = "panel-demo",
346
- class = "input-panel",
347
  shiny::p(
348
  "Try the app with pre-loaded NHTSA vehicle recall data. Click the button below to load sample text and pre-configured extraction fields."
349
  ),
@@ -357,26 +363,22 @@ ui = shiny::fluidPage(
357
  ),
358
 
359
  # Paste text panel
360
- shiny::div(
361
- id = "panel-paste",
362
- class = "input-panel",
363
- style = "display: none;",
364
  shiny::textAreaInput(
365
  "input_text",
366
- NULL,
367
  rows = 8,
368
- placeholder = "Paste your text content here.\n\nTo process multiple items at once, separate each block of text with a double line break (press Enter twice).\n\nIMPORTANT: Before extracting, scroll down to configure your extraction fields:\n1. Set the number of fields you need\n2. Update each field label (e.g., 'product_name', 'price')\n3. Write clear descriptions for each field\n\nThen click 'Extract Data' to see results."
369
  )
370
  ),
371
 
372
  # Text file upload panel
373
- shiny::div(
374
- id = "panel-textfile",
375
- class = "input-panel",
376
- style = "display: none;",
377
  shiny::fileInput(
378
  "text_file",
379
- NULL,
380
  accept = c(".txt", ".csv", ".md"),
381
  placeholder = "No file selected",
382
  buttonLabel = shiny::tags$span(shiny::icon("upload"), " Browse...")
@@ -391,45 +393,41 @@ ui = shiny::fluidPage(
391
  shiny::tags$ul(
392
  style = "margin: 5px 0 0 0; padding-left: 20px; font-size: 0.85em;",
393
  shiny::tags$li(".txt: Plain text, separate items with double line breaks"),
394
- shiny::tags$li(".csv: Single column of text entries (one per row)"),
395
- shiny::tags$li(".md: Markdown file treated as plain text")
396
  )
397
  ),
398
  shiny::uiOutput("textfile_preview_ui")
399
  ),
400
 
401
  # PDF upload panel (machine-readable)
402
- shiny::div(
403
- id = "panel-pdf",
404
- class = "input-panel",
405
- style = "display: none;",
406
  shiny::fileInput(
407
  "pdf_file",
408
- NULL,
409
  accept = ".pdf",
410
  placeholder = "No file selected",
411
  buttonLabel = shiny::tags$span(shiny::icon("upload"), " Browse...")
412
  ),
413
  shiny::helpText(
414
  shiny::icon("info-circle"),
415
- " Upload a machine-readable PDF. Text will be extracted automatically."
416
  ),
417
  shiny::div(
418
- class = "image-limit-note",
419
  shiny::icon("exclamation-circle"),
420
- shiny::tags$em(paste(" Free app limit: Only the first", MAX_IMAGE_PAGES, "pages will be processed."))
421
  ),
422
  shiny::uiOutput("pdf_preview_ui")
423
  ),
424
 
425
  # Image upload panel
426
- shiny::div(
427
- id = "panel-image",
428
- class = "input-panel",
429
- style = "display: none;",
430
  shiny::fileInput(
431
  "image_file",
432
- NULL,
433
  accept = c(".png", ".jpg", ".jpeg", ".webp", ".gif"),
434
  multiple = TRUE,
435
  placeholder = "No file selected",
@@ -440,72 +438,98 @@ ui = shiny::fluidPage(
440
  " Upload image(s) containing text to extract. Supports PNG, JPEG, WebP, and GIF."
441
  ),
442
  shiny::div(
443
- class = "image-limit-note",
444
  shiny::icon("exclamation-circle"),
445
- shiny::tags$em(paste(" Free app limit: Maximum", MAX_IMAGE_PAGES, "images will be processed."))
446
  ),
447
  shiny::uiOutput("image_preview_ui")
448
  ),
449
 
450
  # Scanned PDF panel
451
- shiny::div(
452
- id = "panel-scanned",
453
- class = "input-panel",
454
- style = "display: none;",
455
  shiny::fileInput(
456
  "scanned_pdf_file",
457
- NULL,
458
  accept = ".pdf",
459
  placeholder = "No file selected",
460
  buttonLabel = shiny::tags$span(shiny::icon("upload"), " Browse...")
461
  ),
462
  shiny::helpText(
463
  shiny::icon("info-circle"),
464
- " Upload a scanned/image-based PDF. Pages will be converted to images for AI vision processing."
465
  ),
466
  shiny::div(
467
- class = "image-limit-note",
468
  shiny::icon("exclamation-circle"),
469
- shiny::tags$em(paste(" Free app limit: Only the first", MAX_IMAGE_PAGES, "pages will be processed."))
470
  ),
471
  shiny::uiOutput("scanned_pdf_preview_ui")
472
  ),
473
 
474
  shiny::hr(class = "field-separator"),
475
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
476
  shiny::numericInput(
477
  "num_fields",
478
  shiny::tags$span(
479
  shiny::icon("list-ol"),
480
  " Number of Fields to Extract:"
481
  ),
482
- value = num_example_fields,
483
  min = 1,
484
- max = 10
485
  ),
486
 
487
  shiny::helpText(
488
  shiny::icon("info-circle"),
489
- " Define each field with a clear label (e.g., 'manufacturer') and description (e.g., 'The name of the company recalling the vehicles')."
490
  ),
491
 
492
- shiny::hr(class = "field-separator"),
493
 
494
- shiny::tags$h5(
495
- style = "color: #C41230; margin-bottom: 10px;",
496
- shiny::icon("tags"),
497
- " Field Definitions"
 
 
 
 
 
 
498
  ),
499
 
500
- shiny::uiOutput("fields_ui"),
501
 
502
  shiny::div(
503
- style = "margin-top: 20px;",
504
  shiny::actionButton(
505
  "extract_btn",
506
  shiny::tags$span(shiny::icon("magic"), " Extract Data"),
507
  class = "btn-primary"
508
- )
 
509
  )
510
  ),
511
 
@@ -513,32 +537,71 @@ ui = shiny::fluidPage(
513
  width = 8,
514
  class = "main-panel",
515
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
516
  shiny::div(
517
  class = "info-card",
518
  shiny::tags$h3(
519
  class = "section-heading",
520
- shiny::icon("table"),
521
- " Structured Data Extracted Using AI"
522
  ),
523
- shiny::div(
524
- class = "results-placeholder",
525
- id = "results-placeholder",
526
- shiny::icon("arrow-left"),
527
- " Configure your extraction fields and click 'Extract Data' to see results here"
 
528
  ),
529
- shiny::tableOutput("extracted_table"),
530
- shiny::uiOutput("download_btn_ui")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
531
  ),
532
 
533
  shiny::div(
534
  class = "tips-section",
535
  shiny::tags$h4(shiny::icon("lightbulb"), " Tips for Better Results"),
536
  shiny::tags$ul(
537
- shiny::tags$li("Use ", shiny::tags$strong("specific field descriptions"), " to guide the AI accurately"),
538
  shiny::tags$li("Start with ", shiny::tags$strong("more fields"), " and remove unnecessary ones later"),
539
  shiny::tags$li("If results are inaccurate, try ", shiny::tags$strong("rephrasing"), " your field descriptions"),
540
  shiny::tags$li("For ", shiny::tags$strong("multiple text blocks"), ", separate each with a double line break"),
541
- shiny::tags$li("Each text block should contain ", shiny::tags$strong("complete information"), " for all fields you want to extract")
 
542
  )
543
  ),
544
 
@@ -546,9 +609,19 @@ ui = shiny::fluidPage(
546
  class = "note-section",
547
  shiny::tags$h4(shiny::icon("cog"), " Technical Note"),
548
  shiny::p(
549
- "To ensure timely results (since this is hosted on a CPU), we utilize ",
550
- shiny::tags$code("gpt-5-mini-2025-08-07"),
551
- " for this demo."
 
 
 
 
 
 
 
 
 
 
552
  ),
553
  shiny::p(
554
  "For ",
@@ -559,13 +632,7 @@ ui = shiny::fluidPage(
559
  target = "_blank",
560
  shiny::tags$code("chat_ollama()")
561
  ),
562
- " from the ",
563
- shiny::tags$a(
564
- href = "https://ellmer.tidyverse.org/",
565
- target = "_blank",
566
- "ellmer"
567
- ),
568
- " library, which connects to ",
569
  shiny::tags$a(
570
  href = "https://ollama.com/",
571
  target = "_blank",
@@ -574,14 +641,7 @@ ui = shiny::fluidPage(
574
  " for running models locally on your machine."
575
  ),
576
  shiny::p(
577
- "Alternatively, for higher accuracy requirements, users can leverage more performant closed models (e.g., ",
578
- shiny::tags$em("gpt-5.2-2025-12-11"),
579
- ", ",
580
- shiny::tags$em("claude-opus-4-5-20251101"),
581
- ", or ",
582
- shiny::tags$em("gemini-3-pro-preview"),
583
- ") depending on the application needs. ",
584
- "Note that Claude models require ",
585
  shiny::tags$a(
586
  href = "https://ellmer.tidyverse.org/reference/chat_anthropic.html",
587
  target = "_blank",
@@ -593,14 +653,10 @@ ui = shiny::fluidPage(
593
  target = "_blank",
594
  shiny::tags$code("chat_google_gemini()")
595
  ),
596
- ", each with their respective API keys configured in the R environment."
597
- ),
598
- shiny::p(
599
- shiny::tags$em(
600
- "This demo uses ",
601
- shiny::tags$code("chat_openai()"),
602
- " only and does not provide an option to change the gpt-5-mini-2025-08-07 model."
603
- )
604
  )
605
  )
606
  )
@@ -615,63 +671,92 @@ ui = shiny::fluidPage(
615
  " and ",
616
  shiny::tags$a(href = "https://ellmer.tidyverse.org/", target = "_blank", "ellmer"),
617
  " | ",
618
- "Powered by OpenAI"
 
619
  ),
620
  shiny::p(
621
  shiny::tags$em(
622
- "Companion app to: 'What Should Quality Engineers Know about Generative AI', submitted by the app's authors to ",
 
 
 
 
623
  shiny::tags$a(
624
  href = "https://www.tandfonline.com/journals/lqen20",
625
  target = "_blank",
626
  "Quality Engineering."
627
  )
628
  )
629
- ),
630
- # shiny::p(
631
- # "\u00A9 2026 Developed by researchers from Miami University, the University of Dayton, and the University of Amsterdam."
632
- # )
633
  )
634
  )
635
 
636
  # Define server logic required to generate dynamic UI and extract data
637
  server = function(input, output, session) {
638
 
639
- # Reactive value to store demo text
 
 
 
640
  demo_text = shiny::reactiveVal("")
 
 
 
 
641
 
642
- # Reactive value to store extracted results (for table display and CSV download)
643
- extracted_results = shiny::reactiveVal(NULL)
644
 
645
- # Observer to clear data when switching tabs
646
- shiny::observeEvent(input$input_method, {
647
- # Clear the extracted results table
648
- extracted_results(NULL)
649
- output$extracted_table = shiny::renderTable(NULL)
650
 
651
- # Clear the text area when switching to paste tab (so placeholder shows)
652
- if (input$input_method == "paste") {
653
- shiny::updateTextAreaInput(session, "input_text", value = "")
654
- }
655
- }, ignoreInit = TRUE)
656
 
657
- # Load example data
658
- shiny::observeEvent(input$load_example, {
659
- example_text = "Ford Motor Company (Ford) is recalling certain 2021-2022 Bronco vehicles equipped with rearview camera systems and 8-inch screen displays. The rearview camera image may still be displayed after a backing event has ended. As such, these vehicles fail to comply with the requirements of Federal Motor Vehicle Safety Standard number 111, \"Rear Visibility.\"\n\nHonda (American Honda Motor Co.) is recalling certain 2022-2025 Acura MDX Type-S, 2023-2025 Honda Pilot, and 2021-2025 Acura TLX Type-S vehicles. A software error in the fuel injection electronic control unit (FI-ECU) may cause an engine stall or a loss of power."
660
 
661
- # Store demo text
662
- demo_text(example_text)
 
 
 
 
 
 
 
 
 
 
 
663
 
664
- # Also update the text area for extraction
665
- shiny::updateTextAreaInput(session, "input_text", value = example_text)
 
 
 
666
 
667
- # Set up example fields
668
- shiny::updateNumericInput(session, "num_fields", value = num_example_fields)
 
 
669
 
670
- shiny::showNotification(
671
- "Demo data loaded! Click 'Extract Data' to see results.",
672
- type = "message",
673
- duration = 3
674
- )
 
 
 
 
 
 
 
 
 
675
  })
676
 
677
  # Render demo preview
@@ -679,67 +764,46 @@ server = function(input, output, session) {
679
  text = demo_text()
680
  if (is.null(text) || nchar(text) == 0) return(NULL)
681
 
682
- preview_text = if (nchar(text) > 500) {
683
- paste0(substr(text, 1, 500), "...")
684
- } else {
685
- text
686
- }
687
-
688
  shiny::div(
689
  class = "pdf-preview",
690
  style = "margin-top: 15px;",
691
  shiny::tags$strong("Loaded Demo Text:"),
692
- shiny::tags$pre(preview_text)
693
  )
694
  })
695
 
696
- # Reactive value to store PDF extracted text
697
- pdf_text = shiny::reactiveVal("")
698
-
699
  # Process uploaded PDF
700
  shiny::observeEvent(input$pdf_file, {
701
  shiny::req(input$pdf_file)
702
 
703
  tryCatch({
704
- # Extract text from PDF
705
- pdf_path = input$pdf_file$datapath
706
- extracted = pdftools::pdf_text(pdf_path)
707
-
708
- # Limit to MAX_IMAGE_PAGES
709
- total_pages = length(extracted)
710
- if (total_pages > MAX_IMAGE_PAGES) {
711
- extracted = extracted[1:MAX_IMAGE_PAGES]
712
- shiny::showNotification(
713
- paste("Only the first", MAX_IMAGE_PAGES, "pages will be processed (free app limit)."),
714
  type = "warning",
715
  duration = 5
716
  )
717
  }
718
 
719
- # Combine all pages with double line breaks
720
- combined_text = paste(extracted, collapse = "\n\n")
721
-
722
- # Clean up the text (remove excessive whitespace)
723
- combined_text = gsub("\\s+", " ", combined_text)
724
- combined_text = trimws(combined_text)
725
-
726
- # Store the extracted text
727
- pdf_text(combined_text)
728
 
729
- # Update the text area with extracted text
730
- shiny::updateTextAreaInput(session, "input_text", value = combined_text)
731
-
732
- shiny::showNotification(
733
- paste("PDF processed successfully!", length(extracted), "page(s) extracted."),
734
- type = "message",
735
- duration = 3
736
- )
 
737
  }, error = function(e) {
738
- shiny::showNotification(
739
- paste("Error reading PDF:", e$message),
740
- type = "error",
741
- duration = NULL
742
- )
743
  })
744
  })
745
 
@@ -748,169 +812,116 @@ server = function(input, output, session) {
748
  text = pdf_text()
749
  if (is.null(text) || nchar(text) == 0) return(NULL)
750
 
751
- preview_text = if (nchar(text) > 500) {
752
- paste0(substr(text, 1, 500), "...")
753
- } else {
754
- text
755
- }
756
-
757
  shiny::div(
758
  class = "pdf-preview",
759
  shiny::tags$strong("Extracted Text Preview:"),
760
- shiny::tags$pre(preview_text)
761
  )
762
  })
763
 
764
- # Reactive value for text file content
765
- textfile_text = shiny::reactiveVal("")
766
-
767
  # Process uploaded text file
768
  shiny::observeEvent(input$text_file, {
769
  shiny::req(input$text_file)
770
 
771
  tryCatch({
772
- file_path = input$text_file$datapath
773
- file_name = input$text_file$name
774
- file_ext = tolower(tools::file_ext(file_name))
775
-
776
- extracted_text = ""
777
-
778
- if (file_ext == "csv") {
779
- # Read CSV - assume single column or use first column
780
- csv_data = utils::read.csv(file_path, header = TRUE, stringsAsFactors = FALSE)
781
- if (ncol(csv_data) >= 1) {
782
- # Use first column, combine rows with double line breaks
783
- extracted_text = paste(csv_data[[1]], collapse = "\n\n")
784
- }
785
  } else {
786
- # Read txt or md as plain text
787
- extracted_text = paste(readLines(file_path, warn = FALSE), collapse = "\n")
788
  }
789
-
790
- # Clean up the text
791
- extracted_text = trimws(extracted_text)
792
-
793
- # Store the extracted text
794
- textfile_text(extracted_text)
795
-
796
- # Update the text area with extracted text
797
- shiny::updateTextAreaInput(session, "input_text", value = extracted_text)
798
-
799
- shiny::showNotification(
800
- paste("File processed successfully!"),
801
- type = "message",
802
- duration = 3
803
- )
804
  }, error = function(e) {
805
- shiny::showNotification(
806
- paste("Error reading file:", e$message),
807
- type = "error",
808
- duration = NULL
809
- )
810
  })
811
  })
812
 
813
  # Render text file preview
814
  output$textfile_preview_ui = shiny::renderUI({
815
- text = textfile_text()
816
- if (is.null(text) || nchar(text) == 0) return(NULL)
817
-
818
- preview_text = if (nchar(text) > 500) {
819
- paste0(substr(text, 1, 500), "...")
820
- } else {
821
- text
822
- }
823
 
824
  shiny::div(
825
  class = "pdf-preview",
826
- shiny::tags$strong("Extracted Text Preview:"),
827
- shiny::tags$pre(preview_text)
828
  )
829
  })
830
 
831
- # Reactive values to store image paths for vision-based extraction
832
- image_paths = shiny::reactiveVal(NULL)
833
- scanned_pdf_paths = shiny::reactiveVal(NULL)
834
-
835
  # Process uploaded images
836
  shiny::observeEvent(input$image_file, {
837
  shiny::req(input$image_file)
838
 
839
  tryCatch({
840
  files = input$image_file
841
- # Limit to MAX_IMAGE_PAGES
842
- n_files = min(nrow(files), MAX_IMAGE_PAGES)
 
 
 
843
 
844
- if (nrow(files) > MAX_IMAGE_PAGES) {
845
- shiny::showNotification(
846
- paste("Only the first", MAX_IMAGE_PAGES, "images will be processed (free app limit)."),
 
 
847
  type = "warning",
848
- duration = 5
849
  )
850
  }
851
 
852
- # Store paths of uploaded images
853
- paths = files$datapath[1:n_files]
854
- image_paths(paths)
 
 
 
 
 
 
 
 
855
 
856
- shiny::showNotification(
857
- paste(n_files, "image(s) uploaded successfully!"),
858
- type = "message",
859
- duration = 3
860
- )
861
  }, error = function(e) {
862
- shiny::showNotification(
863
- paste("Error uploading images:", e$message),
864
- type = "error",
865
- duration = NULL
866
- )
867
  })
868
  })
869
 
870
  # Render image preview
871
  output$image_preview_ui = shiny::renderUI({
872
- paths = image_paths()
873
- if (is.null(paths) || length(paths) == 0) return(NULL)
874
 
875
  shiny::tagList(
876
  shiny::div(
877
  class = "image-preview-grid",
878
- lapply(seq_along(paths), function(i) {
879
  shiny::div(
880
  class = "image-preview-item",
881
  shiny::tags$img(
882
- src = base64enc::dataURI(file = paths[i], mime = "image/png"),
883
- alt = paste("Image", i)
884
  )
885
  )
886
  })
887
  ),
888
  shiny::div(
889
  class = "image-preview-count",
890
- paste(length(paths), "image(s) ready for extraction")
891
  )
892
  )
893
  })
894
 
895
- # Process scanned PDF (convert to images)
896
  shiny::observeEvent(input$scanned_pdf_file, {
897
  shiny::req(input$scanned_pdf_file)
898
 
899
  tryCatch({
900
- pdf_path = input$scanned_pdf_file$datapath
901
-
902
- # Get number of pages
903
- pdf_info = pdftools::pdf_info(pdf_path)
904
- n_pages = min(pdf_info$pages, MAX_IMAGE_PAGES)
905
-
906
- if (pdf_info$pages > MAX_IMAGE_PAGES) {
907
- shiny::showNotification(
908
- paste("Only the first", MAX_IMAGE_PAGES, "pages will be processed (free app limit)."),
909
- type = "warning",
910
- duration = 5
911
- )
912
- }
913
-
914
  shiny::showNotification(
915
  "Converting PDF pages to images...",
916
  type = "message",
@@ -918,31 +929,25 @@ server = function(input, output, session) {
918
  id = "convert_notif"
919
  )
920
 
921
- # Convert PDF pages to images using magick
922
- pdf_images = magick::image_read_pdf(pdf_path, pages = 1:n_pages, density = 150)
923
-
924
- # Save images to temp files
925
- temp_paths = sapply(1:n_pages, function(i) {
926
- temp_file = tempfile(fileext = ".png")
927
- magick::image_write(pdf_images[i], temp_file, format = "png")
928
- temp_file
929
- })
930
-
931
- scanned_pdf_paths(temp_paths)
932
 
933
  shiny::removeNotification(id = "convert_notif")
934
- shiny::showNotification(
935
- paste(n_pages, "page(s) converted successfully!"),
936
- type = "message",
937
- duration = 3
938
- )
 
 
 
 
 
 
939
  }, error = function(e) {
 
940
  shiny::removeNotification(id = "convert_notif")
941
- shiny::showNotification(
942
- paste("Error processing scanned PDF:", e$message),
943
- type = "error",
944
- duration = NULL
945
- )
946
  })
947
  })
948
 
@@ -958,7 +963,7 @@ server = function(input, output, session) {
958
  shiny::div(
959
  class = "image-preview-item",
960
  shiny::tags$img(
961
- src = base64enc::dataURI(file = paths[i], mime = "image/png"),
962
  alt = paste("Page", i)
963
  )
964
  )
@@ -971,279 +976,248 @@ server = function(input, output, session) {
971
  )
972
  })
973
 
974
- # Dynamically generate UI elements for each field's label and description
975
- output$fields_ui = shiny::renderUI({
976
- n = input$num_fields
977
- input_method = input$input_method
978
- if (is.null(n) || n < 1) return(NULL)
979
- if (is.null(input_method)) input_method = "demo"
980
-
981
- # Use NHTSA example fields only for demo mode
982
- if (input_method == "demo") {
983
- example_labels = c("manufacturer", "defect_summary", "models", "model_years", "component", "fmvss_number", "root_cause", "risk")
984
- example_descs = c(
985
- "The name of the company recalling the vehicles.",
986
- "Summary of the main defect.",
987
- "List of affected vehicle models.",
988
- "List of model years affected.",
989
- "The part or system affected by the defect.",
990
- "The FMVSS number mentioned, if any.",
991
- "The root cause of the defect.",
992
- "The risk or consequence posed by the defect."
 
 
 
 
 
 
 
 
 
 
 
 
 
993
  )
994
- } else {
995
- # Generic placeholders for non-demo modes
996
- example_labels = character(0)
997
- example_descs = character(0)
998
  }
999
 
1000
- fields = purrr::map(1:n, function(i) {
1001
- # Set default values - use examples for demo, generic for others
1002
- if (input_method == "demo" && i <= length(example_labels)) {
1003
- default_label = example_labels[i]
1004
- default_desc = example_descs[i]
1005
- } else {
1006
- default_label = paste0("field_", i)
1007
- default_desc = "Describe what to extract for this field"
1008
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1009
 
1010
  shiny::div(
1011
- style = "background-color: #EDECE2; padding: 12px; border-radius: 6px; margin-bottom: 12px;",
1012
  shiny::tags$span(
1013
  style = "color: #C41230; font-weight: 600; font-size: 0.9em;",
1014
  paste("Field", i)
1015
  ),
1016
- shiny::textInput(
1017
- paste0("field_label_", i),
1018
- "Label:",
1019
- value = default_label
1020
- ),
1021
- shiny::textInput(
1022
- paste0("field_desc_", i),
1023
  "Description:",
1024
- value = default_desc
 
 
 
 
 
 
 
 
 
1025
  )
1026
  )
1027
- })
1028
- do.call(shiny::tagList, fields)
1029
  })
1030
 
1031
- # Build a custom type_object based on user-specified fields
1032
- create_type_object = shiny::reactive({
1033
- n = input$num_fields
1034
- if (is.null(n) || n < 1) return(NULL)
1035
-
1036
- # Build a list of field definitions
1037
- type_list = list()
1038
- for (i in 1:n) {
1039
- label = input[[paste0("field_label_", i)]]
1040
- desc = input[[paste0("field_desc_", i)]]
1041
- if (!is.null(label) && label != "") {
1042
- type_list[[label]] = ellmer::type_string(desc, required = FALSE)
1043
- }
1044
- }
1045
- # Dynamically create the type object
1046
- do.call(ellmer::type_object, type_list)
1047
  })
1048
 
 
 
1049
  # When the extract button is clicked, perform extraction
1050
  shiny::observeEvent(input$extract_btn, {
1051
- # Get the current input method
1052
- input_method = input$input_method
1053
- if (is.null(input_method)) input_method = "demo"
1054
-
1055
- # Validate input based on method with helpful error messages
1056
- has_data = FALSE
1057
- error_msg = ""
1058
-
1059
- # Suffix to add to error messages reminding about field configuration
1060
- field_reminder = " Also, make sure to update the field labels and descriptions for your data."
1061
-
1062
- if (input_method == "demo") {
1063
- # Check if demo data was loaded
1064
- if (is.null(demo_text()) || nchar(demo_text()) == 0) {
1065
- error_msg = "Please click 'Load Demo Data' first to load the sample data."
1066
- } else {
1067
- has_data = TRUE
1068
- }
1069
- } else if (input_method == "paste") {
1070
- # Check if text was pasted
1071
- if (is.null(input$input_text) || nchar(trimws(input$input_text)) == 0) {
1072
- error_msg = paste0("Please paste your text content in the text area.", field_reminder)
1073
- } else {
1074
- has_data = TRUE
1075
- }
1076
- } else if (input_method == "textfile") {
1077
- # Check if text file was uploaded
1078
- if (is.null(textfile_text()) || nchar(textfile_text()) == 0) {
1079
- error_msg = paste0("Please upload a text file (.txt, .csv, or .md) first.", field_reminder)
1080
- } else {
1081
- has_data = TRUE
1082
- }
1083
- } else if (input_method == "pdf") {
1084
- # Check if PDF was uploaded
1085
- if (is.null(pdf_text()) || nchar(pdf_text()) == 0) {
1086
- error_msg = paste0("Please upload a readable PDF file first.", field_reminder)
1087
- } else {
1088
- has_data = TRUE
1089
- }
1090
- } else if (input_method == "image") {
1091
- # Check if images were uploaded
1092
- if (is.null(image_paths()) || length(image_paths()) == 0) {
1093
- error_msg = paste0("Please upload one or more image files first.", field_reminder)
1094
- } else {
1095
- has_data = TRUE
1096
- }
1097
- } else if (input_method == "scanned") {
1098
- # Check if scanned PDF was uploaded
1099
- if (is.null(scanned_pdf_paths()) || length(scanned_pdf_paths()) == 0) {
1100
- error_msg = paste0("Please upload a scanned PDF file first.", field_reminder)
1101
- } else {
1102
- has_data = TRUE
1103
- }
1104
  }
1105
 
1106
- # Show error if no data
1107
- if (!has_data) {
1108
- shiny::showNotification(
1109
- shiny::tags$span(
1110
- shiny::icon("exclamation-triangle"),
1111
- " ",
1112
- error_msg
1113
  ),
1114
  type = "error",
1115
- duration = 5
1116
  )
1117
  return()
1118
  }
1119
-
1120
- # Show processing indicator
1121
- shiny::showNotification(
1122
- shiny::tags$span(
1123
- shiny::icon("spinner", class = "fa-spin"),
1124
- " Processing extraction request..."
1125
- ),
1126
- type = "message",
1127
- duration = NULL,
1128
- id = "extract_notif"
1129
- )
1130
-
1131
- custom_type_object = create_type_object()
1132
-
1133
- # Initialize the chat object using the OpenAI API key from your environment
1134
- tryCatch({
1135
- # Check if API key is available
1136
- if (Sys.getenv("OPENAI_API_KEY") == "") {
1137
- stop("OpenAI API key not found. Please set the OPENAI_API_KEY environment variable.")
1138
- }
1139
-
1140
- chat = ellmer::chat_openai(
1141
- model = "gpt-5-mini-2025-08-07",
1142
  )
 
1143
 
1144
- all_results = list()
1145
-
1146
- if (input_method %in% c("demo", "paste", "pdf", "textfile")) {
1147
- # Text-based extraction
1148
- # Split text by double linebreaks to process multiple entities
1149
- text_blocks = unlist(strsplit(input$input_text, "\n\n"))
1150
- text_blocks = text_blocks[text_blocks != ""] # Remove empty blocks
 
1151
 
1152
- # Process each text block
1153
- for (i in seq_along(text_blocks)) {
1154
- result = chat$chat_structured(text_blocks[i], type = custom_type_object)
1155
- if (is.list(result)) {
1156
- result$source_id = i
1157
- all_results[[i]] = result
1158
- }
1159
- }
1160
-
1161
- } else if (input_method == "image") {
1162
- # Vision-based extraction from uploaded images
1163
- paths = image_paths()
1164
-
1165
- for (i in seq_along(paths)) {
1166
- # Create image content for the model
1167
- image_content = ellmer::content_image_file(paths[i], resize = "high")
1168
- result = chat$chat_structured(image_content, type = custom_type_object)
1169
- if (is.list(result)) {
1170
- result$source_id = paste("Image", i)
1171
- all_results[[i]] = result
1172
- }
1173
- }
1174
-
1175
- } else if (input_method == "scanned") {
1176
- # Vision-based extraction from scanned PDF pages
1177
- paths = scanned_pdf_paths()
1178
-
1179
- for (i in seq_along(paths)) {
1180
- # Create image content for the model
1181
- image_content = ellmer::content_image_file(paths[i], resize = "high")
1182
- result = chat$chat_structured(image_content, type = custom_type_object)
1183
- if (is.list(result)) {
1184
- result$source_id = paste("Page", i)
1185
- all_results[[i]] = result
1186
  }
1187
- }
1188
- }
1189
-
1190
- # Combine all results into a single data frame
1191
- if (length(all_results) > 0) {
1192
- combined_results = do.call(rbind, lapply(all_results, function(x) {
1193
- # Ensure all results have the same columns by converting to data frame
1194
- as.data.frame(x)
1195
- }))
1196
-
1197
- # Store results for CSV download
1198
- extracted_results(combined_results)
1199
-
1200
- # Render the output as a table
1201
- output$extracted_table = shiny::renderTable(
1202
- {
1203
- combined_results
1204
- },
1205
- rownames = TRUE,
1206
- striped = TRUE,
1207
- hover = TRUE,
1208
- bordered = TRUE
1209
  )
1210
- } else {
1211
- # Handle the case when no valid results are returned
1212
- extracted_results(NULL)
1213
- output$extracted_table = shiny::renderTable({
1214
- data.frame(Message = "No valid data could be extracted. Please check your input and field definitions.")
1215
- })
1216
  }
 
1217
 
1218
- # Remove notification
1219
- shiny::removeNotification(id = "extract_notif")
1220
- shiny::showNotification(
1221
- shiny::tags$span(
1222
- shiny::icon("check-circle"),
1223
- " Extraction complete!"
1224
- ),
1225
- type = "message",
1226
- duration = 3
1227
- )
1228
- }, error = function(e) {
1229
- # Handle errors
1230
- shiny::removeNotification(id = "extract_notif")
1231
- shiny::showNotification(
1232
- shiny::tags$span(
1233
- shiny::icon("exclamation-triangle"),
1234
- " Error: ",
1235
- e$message
1236
- ),
1237
- type = "error",
1238
- duration = NULL
1239
- )
1240
- })
1241
  })
1242
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1243
  # Render download button only when results are available
1244
  output$download_btn_ui = shiny::renderUI({
1245
- results = extracted_results()
1246
- if (is.null(results) || nrow(results) == 0) return(NULL)
1247
 
1248
  shiny::div(
1249
  style = "margin-top: 15px;",
@@ -1261,12 +1235,45 @@ server = function(input, output, session) {
1261
  paste0("extracted_data_", format(Sys.time(), "%Y%m%d_%H%M%S"), ".csv")
1262
  },
1263
  content = function(file) {
1264
- results = extracted_results()
1265
- if (!is.null(results)) {
1266
- utils::write.csv(results, file, row.names = FALSE)
1267
  }
1268
  }
1269
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1270
  }
1271
 
1272
  # Run the Shiny app
 
1
  library(shiny)
2
  library(ellmer)
 
3
  library(pdftools)
4
  library(magick)
5
  library(base64enc)
6
+ library(jsonlite)
7
 
8
+ # App logic lives in R/ and is sourced here in a fixed order
9
+ # (R/_disable_autoload.R stops Shiny from sourcing the directory a second time)
10
+ for (r_file in c("config.R", "fields.R", "sources.R", "extraction.R", "snippets.R", "help.R")) {
11
+ source(file.path("R", r_file), local = TRUE)
12
+ }
13
+
14
+ # Uploads larger than this are rejected by Shiny
15
+ options(shiny.maxRequestSize = MAX_UPLOAD_MB * 1024^2)
16
+
17
+ # Model used for every call and named in every piece of UI text
18
+ EXTRACTION_MODEL = get_extraction_model()
19
 
20
+ APP_VERSION = "Version 3.0.0 | October 2026"
21
 
22
+ PAPER_TITLE = "What Quality Engineers Need to Know About Generative AI: Structured Data Extraction"
23
 
24
+ # Field templates (one JSON file each in config/templates)
25
+ FIELD_TEMPLATES = load_templates(file.path("config", "templates"))
26
+
27
+ # Fields shown at start-up and loaded with the demo data
28
+ demo_fields = function() {
29
+ FIELD_TEMPLATES[["recall_notice"]]$fields[seq_len(NUM_EXAMPLE_FIELDS)]
30
+ }
31
+
32
+ # One input tab button (the panels are shown and hidden by www/app.js)
33
+ input_tab = function(id, label, icon_name, selected = FALSE) {
34
+ shiny::tags$button(
35
+ id = paste0("tab-", id),
36
+ type = "button",
37
+ class = if (selected) "input-tab active" else "input-tab",
38
+ role = "tab",
39
+ `aria-selected` = if (selected) "true" else "false",
40
+ `aria-controls` = paste0("panel-", id),
41
+ tabindex = if (selected) "0" else "-1",
42
+ shiny::icon(icon_name),
43
+ paste0(" ", label)
44
+ )
45
+ }
46
+
47
+ # Container of one input tab's content
48
+ input_panel = function(id, ..., selected = FALSE) {
49
+ shiny::div(
50
+ id = paste0("panel-", id),
51
+ class = "input-panel",
52
+ role = "tabpanel",
53
+ `aria-labelledby` = paste0("tab-", id),
54
+ style = if (selected) NULL else "display: none;",
55
+ ...
56
+ )
57
+ }
58
+
59
+ # Heading followed by its "?" help button
60
+ heading_with_help = function(heading, topic) {
61
+ shiny::div(class = "heading-with-help", heading, help_button(topic))
62
+ }
63
 
64
  # Define UI for the app
65
  ui = shiny::fluidPage(
66
+ lang = "en",
67
  # Add Miami theme CSS and favicon
68
  shiny::tags$head(
69
  shiny::tags$link(rel = "icon", type = "image/svg+xml", href = "favicon.svg"),
 
83
  ),
84
  shiny::div(
85
  class = "header-right",
86
+ shiny::p(APP_VERSION),
87
  shiny::p(
88
  shiny::tags$strong("Authors: "),
89
+ "Fadel M. Megahed, Ying-Ju Chen, Yamin Dahwich, Arthur Carvalho, L. Allison Jones-Farmer, Ibrahim Yousif, and Inez M. Zwetsloot"
90
  ),
91
  shiny::p(
92
  shiny::tags$strong("Contact: "),
 
128
  shiny::tags$strong("Miami University,"),
129
  "the",
130
  shiny::tags$strong("University of Dayton,"),
131
+ "the",
132
+ shiny::tags$strong("University of Cambridge,"),
133
  "and the",
134
  shiny::tags$strong("University of Amsterdam")
135
  )
 
148
  ),
149
  shiny::tags$button(
150
  id = "open-video-modal",
151
+ type = "button",
152
  class = "btn btn-video-tutorial",
153
+ `aria-haspopup` = "dialog",
154
  shiny::icon("play-circle"),
155
  " Watch Video Tutorial"
156
  )
 
162
  ),
163
  shiny::p(
164
  "Click the ",
165
+ shiny::tags$strong("'Load Demo Data'"),
166
  " button to load two sample NHTSA recall notices with pre-configured extraction fields. ",
167
  "Then click ",
168
  shiny::tags$strong("'Extract Data'"),
169
+ " to see the model extract structured information from the text. ",
170
+ "You can also add more fields, change field labels and types, and modify descriptions. ",
171
  shiny::tags$em(
172
+ "(Note: The field description is the instruction the model receives. The label names the column in the results.)"
173
  )
174
  ),
175
  shiny::tags$h5(
 
178
  " Input Methods"
179
  ),
180
  shiny::p(
181
+ "Choose from six input methods to provide your source content. Only the tab that is open when you click 'Extract Data' is used."
182
  ),
183
  shiny::tags$ul(
184
  style = "margin-bottom: 10px;",
 
204
  ),
205
  shiny::tags$li(
206
  shiny::tags$strong("Upload Images:"),
207
+ " Upload images (PNG, JPEG, WebP, GIF) for vision-based extraction."
208
  )
209
  ),
210
+ shiny::div(
211
  style = "font-size: 0.9em; color: #666;",
212
  shiny::icon("info-circle"),
213
  " ",
214
  shiny::tags$em("Notes:"),
215
  shiny::tags$ul(
216
+ style = "margin: 0.25em 0 0 1.25em; padding: 0; line-height: 1.3;",
217
+ shiny::tags$li(
218
+ style = "margin: 0; padding: 0;",
219
+ sprintf(
220
+ "Readable PDF and Scanned PDF are limited to the first %d pages, and Upload Images to %d images, in this free app.",
221
+ MAX_PAGES_OR_IMAGES, MAX_PAGES_OR_IMAGES
222
+ )
223
  ),
224
+ shiny::tags$li(
225
+ style = "margin: 0; padding: 0;",
226
+ sprintf(
227
+ "Any uploaded file must be at most %d MB, to keep the app responsive and avoid upload timeouts.",
228
+ MAX_UPLOAD_MB
229
+ )
230
+ ),
231
+ shiny::tags$li(
232
+ style = "margin: 0; padding: 0;",
233
+ "The '?' buttons next to each part of the app explain what it does and what is sent to the model."
234
  )
235
  )
236
  ),
 
244
  ),
245
  shiny::tags$ul(
246
  style = "margin-bottom: 10px;",
247
+ shiny::tags$li(
248
+ "The ", shiny::tags$strong("number of fields"), " to extract (up to ", MAX_FIELDS, ")"
249
+ ),
250
+ shiny::tags$li("Your own ", shiny::tags$strong("field labels"), " (the column names of the results)"),
251
+ shiny::tags$li("Detailed ", shiny::tags$strong("field descriptions"), " to guide the model"),
252
+ shiny::tags$li(
253
+ "The ", shiny::tags$strong("field type"), " (text, number, yes/no, or list of text)"
254
+ )
255
+ ),
256
+ shiny::p(
257
+ "You can start from a ",
258
+ shiny::tags$strong("template"),
259
+ " for a common quality document (recall notice, 8D / corrective action report, customer complaint record, inspection or nonconformance note), and save your field definitions to a file to reuse them later."
260
  ),
261
  shiny::p(
262
  shiny::icon("lightbulb"),
263
  " ",
264
  shiny::tags$em(
265
+ "Specific field descriptions give the model less room for interpretation. Always check the extracted values against the source documents."
266
  )
267
  )
268
  ),
 
271
  shiny::div(
272
  id = "video-modal",
273
  class = "video-modal",
274
+ role = "dialog",
275
+ `aria-modal` = "true",
276
+ `aria-labelledby` = "video-modal-title",
277
  shiny::div(
278
  class = "video-modal-content",
279
+ shiny::tags$button(
280
  id = "close-video-modal",
281
+ type = "button",
282
  class = "video-modal-close",
283
+ `aria-label` = "Close video tutorial",
284
  shiny::HTML("&times;")
285
  ),
286
  shiny::tags$h4(
287
+ id = "video-modal-title",
288
  style = "color: #C41230; margin-top: 0; margin-bottom: 15px;",
289
  shiny::icon("play-circle"),
290
  " Video Tutorial"
291
  ),
292
  shiny::div(
293
  class = "video-modal-wrapper",
294
+ # The video address is set by www/app.js when the dialog opens
295
  shiny::tags$iframe(
296
  id = "tutorial-video",
297
+ title = "Video tutorial",
298
+ src = "",
299
  allow = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",
300
  allowfullscreen = NA
301
  )
 
303
  )
304
  ),
305
 
306
+ # JavaScript for the input tabs, the video dialog and the copy buttons
307
+ shiny::tags$script(src = "app.js"),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
 
309
  shiny::sidebarLayout(
310
  shiny::sidebarPanel(
311
  width = 4,
312
  class = "sidebar-panel",
313
 
314
+ heading_with_help(
315
+ shiny::tags$h4(
316
+ id = "input-heading",
317
+ style = "color: #C41230; margin-top: 0; margin-bottom: 0;",
318
+ shiny::icon("file-alt"),
319
+ " Input Configuration"
320
+ ),
321
+ "source"
322
  ),
323
 
324
+ # Input method tabs (3 rows of 2 tabs)
325
  shiny::div(
326
  class = "input-tabs-container",
327
+ role = "tablist",
328
+ `aria-labelledby` = "input-heading",
329
  shiny::div(
330
  class = "input-tabs",
331
+ role = "presentation",
332
+ input_tab("demo", "Demo Data", "flask", selected = TRUE),
333
+ input_tab("paste", "Paste Text", "paste")
 
 
 
 
 
 
 
 
 
 
 
334
  ),
335
  shiny::div(
336
  class = "input-tabs",
337
+ role = "presentation",
338
+ input_tab("textfile", "Text File", "file-lines"),
339
+ input_tab("pdf", "Readable PDF", "file-pdf")
 
 
 
 
 
 
 
 
 
 
 
340
  ),
341
  shiny::div(
342
  class = "input-tabs",
343
+ role = "presentation",
344
+ input_tab("scanned", "Scanned PDF", "file-image"),
345
+ input_tab("image", "Upload Images", "images")
 
 
 
 
 
 
 
 
 
 
 
346
  )
347
  ),
348
 
349
  # Demo data panel
350
+ input_panel(
351
+ "demo",
352
+ selected = TRUE,
353
  shiny::p(
354
  "Try the app with pre-loaded NHTSA vehicle recall data. Click the button below to load sample text and pre-configured extraction fields."
355
  ),
 
363
  ),
364
 
365
  # Paste text panel
366
+ input_panel(
367
+ "paste",
 
 
368
  shiny::textAreaInput(
369
  "input_text",
370
+ shiny::tags$span(class = "sr-only", "Text to extract from"),
371
  rows = 8,
372
+ placeholder = "Paste your text content here.\n\nTo process multiple items at once, separate each block of text with a double line break (press Enter twice).\n\nIMPORTANT: Before extracting, scroll down to configure your extraction fields:\n1. Choose a template or set the number of fields you need\n2. Update each field label (e.g., 'product_name', 'price')\n3. Write clear descriptions for each field\n\nThen click 'Extract Data' to see results."
373
  )
374
  ),
375
 
376
  # Text file upload panel
377
+ input_panel(
378
+ "textfile",
 
 
379
  shiny::fileInput(
380
  "text_file",
381
+ shiny::tags$span(class = "sr-only", "Text file"),
382
  accept = c(".txt", ".csv", ".md"),
383
  placeholder = "No file selected",
384
  buttonLabel = shiny::tags$span(shiny::icon("upload"), " Browse...")
 
393
  shiny::tags$ul(
394
  style = "margin: 5px 0 0 0; padding-left: 20px; font-size: 0.85em;",
395
  shiny::tags$li(".txt: Plain text, separate items with double line breaks"),
396
+ shiny::tags$li(".csv: Single column of text entries (one per row, first row is a header)"),
397
+ shiny::tags$li(".md: Markdown file treated as plain text, split at double line breaks")
398
  )
399
  ),
400
  shiny::uiOutput("textfile_preview_ui")
401
  ),
402
 
403
  # PDF upload panel (machine-readable)
404
+ input_panel(
405
+ "pdf",
 
 
406
  shiny::fileInput(
407
  "pdf_file",
408
+ shiny::tags$span(class = "sr-only", "Readable PDF file"),
409
  accept = ".pdf",
410
  placeholder = "No file selected",
411
  buttonLabel = shiny::tags$span(shiny::icon("upload"), " Browse...")
412
  ),
413
  shiny::helpText(
414
  shiny::icon("info-circle"),
415
+ " Upload a machine-readable PDF. Text will be extracted automatically and sent as one item."
416
  ),
417
  shiny::div(
418
+ class = "limit-note",
419
  shiny::icon("exclamation-circle"),
420
+ shiny::tags$em(limit_note("pages"))
421
  ),
422
  shiny::uiOutput("pdf_preview_ui")
423
  ),
424
 
425
  # Image upload panel
426
+ input_panel(
427
+ "image",
 
 
428
  shiny::fileInput(
429
  "image_file",
430
+ shiny::tags$span(class = "sr-only", "Image files"),
431
  accept = c(".png", ".jpg", ".jpeg", ".webp", ".gif"),
432
  multiple = TRUE,
433
  placeholder = "No file selected",
 
438
  " Upload image(s) containing text to extract. Supports PNG, JPEG, WebP, and GIF."
439
  ),
440
  shiny::div(
441
+ class = "limit-note",
442
  shiny::icon("exclamation-circle"),
443
+ shiny::tags$em(limit_note("images"))
444
  ),
445
  shiny::uiOutput("image_preview_ui")
446
  ),
447
 
448
  # Scanned PDF panel
449
+ input_panel(
450
+ "scanned",
 
 
451
  shiny::fileInput(
452
  "scanned_pdf_file",
453
+ shiny::tags$span(class = "sr-only", "Scanned PDF file"),
454
  accept = ".pdf",
455
  placeholder = "No file selected",
456
  buttonLabel = shiny::tags$span(shiny::icon("upload"), " Browse...")
457
  ),
458
  shiny::helpText(
459
  shiny::icon("info-circle"),
460
+ " Upload a scanned/image-based PDF. Pages will be converted to images for vision processing."
461
  ),
462
  shiny::div(
463
+ class = "limit-note",
464
  shiny::icon("exclamation-circle"),
465
+ shiny::tags$em(limit_note("pages"))
466
  ),
467
  shiny::uiOutput("scanned_pdf_preview_ui")
468
  ),
469
 
470
  shiny::hr(class = "field-separator"),
471
 
472
+ heading_with_help(
473
+ shiny::tags$h4(
474
+ style = "color: #C41230; margin-top: 0; margin-bottom: 0;",
475
+ shiny::icon("tags"),
476
+ " Field Definitions"
477
+ ),
478
+ "fields"
479
+ ),
480
+
481
+ shiny::div(
482
+ class = "template-row",
483
+ shiny::selectInput(
484
+ "template_id",
485
+ "Start from a template:",
486
+ choices = template_choices(FIELD_TEMPLATES),
487
+ selectize = FALSE
488
+ ),
489
+ shiny::actionButton("apply_template", "Use template", class = "btn-info")
490
+ ),
491
+ shiny::uiOutput("template_description_ui"),
492
+
493
  shiny::numericInput(
494
  "num_fields",
495
  shiny::tags$span(
496
  shiny::icon("list-ol"),
497
  " Number of Fields to Extract:"
498
  ),
499
+ value = NUM_EXAMPLE_FIELDS,
500
  min = 1,
501
+ max = MAX_FIELDS
502
  ),
503
 
504
  shiny::helpText(
505
  shiny::icon("info-circle"),
506
+ " Define each field with a clear label (e.g., 'manufacturer') and description (e.g., 'Name of the company that is recalling the vehicles. Leave empty if no company is named.')."
507
  ),
508
 
509
+ shiny::uiOutput("fields_ui"),
510
 
511
+ shiny::div(
512
+ class = "fields-file-row",
513
+ shiny::downloadButton("download_fields", "Save fields (JSON)", class = "btn-info"),
514
+ shiny::fileInput(
515
+ "fields_upload",
516
+ "Load fields (JSON):",
517
+ accept = c(".json", "application/json"),
518
+ placeholder = "No file selected",
519
+ buttonLabel = shiny::tags$span(shiny::icon("upload"), " Browse...")
520
+ )
521
  ),
522
 
523
+ shiny::hr(class = "field-separator"),
524
 
525
  shiny::div(
526
+ class = "heading-with-help",
527
  shiny::actionButton(
528
  "extract_btn",
529
  shiny::tags$span(shiny::icon("magic"), " Extract Data"),
530
  class = "btn-primary"
531
+ ),
532
+ help_button("extract")
533
  )
534
  ),
535
 
 
537
  width = 8,
538
  class = "main-panel",
539
 
540
+ shiny::div(
541
+ class = "info-card",
542
+ shiny::div(
543
+ class = "heading-with-help section-heading",
544
+ shiny::tags$h3(
545
+ style = "color: #C41230; font-weight: 600; margin: 0;",
546
+ shiny::icon("table"),
547
+ " Structured Data Extracted Using AI"
548
+ ),
549
+ help_button("results")
550
+ ),
551
+ shiny::uiOutput("results_status_ui"),
552
+ shiny::div(class = "results-table-wrapper", shiny::tableOutput("extracted_table")),
553
+ shiny::uiOutput("download_btn_ui")
554
+ ),
555
+
556
  shiny::div(
557
  class = "info-card",
558
  shiny::tags$h3(
559
  class = "section-heading",
560
+ shiny::icon("code"),
561
+ " Reproduce in Code"
562
  ),
563
+ shiny::p(
564
+ "The code below runs the same extraction, with your current field definitions and the model ",
565
+ shiny::tags$code(EXTRACTION_MODEL),
566
+ ", on your own computer and your own data. It needs your own ",
567
+ MODEL_PROVIDER,
568
+ " API key."
569
  ),
570
+ shiny::uiOutput("snippet_status_ui"),
571
+ shiny::tags$details(
572
+ class = "code-details",
573
+ shiny::tags$summary("R code (ellmer package)"),
574
+ shiny::tags$button(
575
+ type = "button",
576
+ class = "btn btn-info copy-btn",
577
+ `data-target` = "snippet_r",
578
+ "Copy R code"
579
+ ),
580
+ shiny::verbatimTextOutput("snippet_r")
581
+ ),
582
+ shiny::tags$details(
583
+ class = "code-details",
584
+ shiny::tags$summary("Python code (chatlas package)"),
585
+ shiny::tags$button(
586
+ type = "button",
587
+ class = "btn btn-info copy-btn",
588
+ `data-target` = "snippet_python",
589
+ "Copy Python code"
590
+ ),
591
+ shiny::verbatimTextOutput("snippet_python")
592
+ )
593
  ),
594
 
595
  shiny::div(
596
  class = "tips-section",
597
  shiny::tags$h4(shiny::icon("lightbulb"), " Tips for Better Results"),
598
  shiny::tags$ul(
599
+ shiny::tags$li("Use ", shiny::tags$strong("specific field descriptions"), " and say what to return when the information is absent"),
600
  shiny::tags$li("Start with ", shiny::tags$strong("more fields"), " and remove unnecessary ones later"),
601
  shiny::tags$li("If results are inaccurate, try ", shiny::tags$strong("rephrasing"), " your field descriptions"),
602
  shiny::tags$li("For ", shiny::tags$strong("multiple text blocks"), ", separate each with a double line break"),
603
+ shiny::tags$li("Each text block should contain ", shiny::tags$strong("complete information"), " for all fields you want to extract"),
604
+ shiny::tags$li(shiny::tags$strong("Check the results"), " against the source documents before you use them")
605
  )
606
  ),
607
 
 
609
  class = "note-section",
610
  shiny::tags$h4(shiny::icon("cog"), " Technical Note"),
611
  shiny::p(
612
+ "This app sends its requests to the ",
613
+ MODEL_PROVIDER,
614
+ " model ",
615
+ shiny::tags$code(EXTRACTION_MODEL),
616
+ " through ",
617
+ shiny::tags$code("chat_openai()"),
618
+ " from the ",
619
+ shiny::tags$a(
620
+ href = "https://ellmer.tidyverse.org/",
621
+ target = "_blank",
622
+ "ellmer"
623
+ ),
624
+ " package. The app does not provide an option to change the model."
625
  ),
626
  shiny::p(
627
  "For ",
 
632
  target = "_blank",
633
  shiny::tags$code("chat_ollama()")
634
  ),
635
+ " from ellmer, which connects to ",
 
 
 
 
 
 
636
  shiny::tags$a(
637
  href = "https://ollama.com/",
638
  target = "_blank",
 
641
  " for running models locally on your machine."
642
  ),
643
  shiny::p(
644
+ "ellmer also supports models from other providers. For example, Claude models require ",
 
 
 
 
 
 
 
645
  shiny::tags$a(
646
  href = "https://ellmer.tidyverse.org/reference/chat_anthropic.html",
647
  target = "_blank",
 
653
  target = "_blank",
654
  shiny::tags$code("chat_google_gemini()")
655
  ),
656
+ ", each with their respective API keys configured in the R environment. ",
657
+ "The code under 'Reproduce in Code' can be adapted by replacing the ",
658
+ shiny::tags$code("chat_openai()"),
659
+ " call."
 
 
 
 
660
  )
661
  )
662
  )
 
671
  " and ",
672
  shiny::tags$a(href = "https://ellmer.tidyverse.org/", target = "_blank", "ellmer"),
673
  " | ",
674
+ "Powered by ",
675
+ MODEL_PROVIDER
676
  ),
677
  shiny::p(
678
  shiny::tags$em(
679
+ paste0(
680
+ "Companion app to the paper '",
681
+ PAPER_TITLE,
682
+ "', in preparation by the app's authors for submission to "
683
+ ),
684
  shiny::tags$a(
685
  href = "https://www.tandfonline.com/journals/lqen20",
686
  target = "_blank",
687
  "Quality Engineering."
688
  )
689
  )
690
+ )
 
 
 
691
  )
692
  )
693
 
694
  # Define server logic required to generate dynamic UI and extract data
695
  server = function(input, output, session) {
696
 
697
+ # ---- State ---------------------------------------------------------------
698
+
699
+ # What each input tab currently holds. Each tab has its own store, and the
700
+ # extraction reads only the store of the active tab.
701
  demo_text = shiny::reactiveVal("")
702
+ textfile_items = shiny::reactiveVal(character(0))
703
+ pdf_text = shiny::reactiveVal("")
704
+ image_files = shiny::reactiveVal(NULL)
705
+ scanned_pdf_paths = shiny::reactiveVal(NULL)
706
 
707
+ # Result of the last extraction: list(table, summary, n_failed, source)
708
+ extraction = shiny::reactiveVal(NULL)
709
 
710
+ # Field definitions. field_state holds the definitions the inputs were built
711
+ # from; field_gen is the generation number used in the input ids. The inputs
712
+ # are rebuilt only by set_fields(), never by a tab change.
713
+ field_state = shiny::reactiveVal(demo_fields())
714
+ field_gen = shiny::reactiveVal(0)
715
 
716
+ # Creates the chat for each item. Tests replace it through the option.
717
+ chat_factory = getOption("ste.chat_factory", default = make_chat_factory(EXTRACTION_MODEL))
 
 
 
718
 
719
+ # ---- Helpers -------------------------------------------------------------
 
 
720
 
721
+ notify = function(message, type = "message", duration = 3) {
722
+ icon_name = switch(
723
+ type,
724
+ error = "exclamation-triangle",
725
+ warning = "exclamation-circle",
726
+ "check-circle"
727
+ )
728
+ shiny::showNotification(
729
+ shiny::tags$span(shiny::icon(icon_name), " ", message),
730
+ type = type,
731
+ duration = duration
732
+ )
733
+ }
734
 
735
+ # Active input tab (the client reports it through input$input_method)
736
+ active_method = shiny::reactive({
737
+ method = input$input_method
738
+ if (is.null(method) || !(method %in% INPUT_METHODS)) "demo" else method
739
+ })
740
 
741
+ # Field definitions as currently shown, including what the user has typed
742
+ current_fields = shiny::reactive({
743
+ merge_field_inputs(field_state(), function(id) input[[id]], field_gen())
744
+ })
745
 
746
+ # Replace the field definitions and rebuild their inputs
747
+ set_fields = function(fields) {
748
+ field_state(fields)
749
+ field_gen(shiny::isolate(field_gen()) + 1)
750
+ shiny::updateNumericInput(session, "num_fields", value = length(fields))
751
+ }
752
+
753
+ # ---- Input sources -------------------------------------------------------
754
+
755
+ # Load example data
756
+ shiny::observeEvent(input$load_example, {
757
+ demo_text(DEMO_TEXT)
758
+ set_fields(demo_fields())
759
+ notify("Demo data loaded. Click 'Extract Data' to see results.")
760
  })
761
 
762
  # Render demo preview
 
764
  text = demo_text()
765
  if (is.null(text) || nchar(text) == 0) return(NULL)
766
 
 
 
 
 
 
 
767
  shiny::div(
768
  class = "pdf-preview",
769
  style = "margin-top: 15px;",
770
  shiny::tags$strong("Loaded Demo Text:"),
771
+ shiny::tags$pre(preview_text(text))
772
  )
773
  })
774
 
 
 
 
775
  # Process uploaded PDF
776
  shiny::observeEvent(input$pdf_file, {
777
  shiny::req(input$pdf_file)
778
 
779
  tryCatch({
780
+ extracted = read_pdf_text(input$pdf_file$datapath, MAX_PAGES_OR_IMAGES)
781
+
782
+ if (extracted$total_pages > extracted$used_pages) {
783
+ notify(
784
+ sprintf(
785
+ "The PDF has %d pages. Only the first %d will be processed (free app limit).",
786
+ extracted$total_pages, extracted$used_pages
787
+ ),
 
 
788
  type = "warning",
789
  duration = 5
790
  )
791
  }
792
 
793
+ pdf_text(extracted$text)
 
 
 
 
 
 
 
 
794
 
795
+ if (nchar(extracted$text) == 0) {
796
+ notify(
797
+ "No text was found in this PDF. If it is a scan, use the Scanned PDF tab.",
798
+ type = "warning",
799
+ duration = 8
800
+ )
801
+ } else {
802
+ notify(sprintf("PDF processed. Text of %d page(s) read.", extracted$used_pages))
803
+ }
804
  }, error = function(e) {
805
+ pdf_text("")
806
+ notify(paste("Error reading PDF:", conditionMessage(e)), type = "error", duration = NULL)
 
 
 
807
  })
808
  })
809
 
 
812
  text = pdf_text()
813
  if (is.null(text) || nchar(text) == 0) return(NULL)
814
 
 
 
 
 
 
 
815
  shiny::div(
816
  class = "pdf-preview",
817
  shiny::tags$strong("Extracted Text Preview:"),
818
+ shiny::tags$pre(preview_text(text))
819
  )
820
  })
821
 
 
 
 
822
  # Process uploaded text file
823
  shiny::observeEvent(input$text_file, {
824
  shiny::req(input$text_file)
825
 
826
  tryCatch({
827
+ items = read_text_file_items(input$text_file$datapath, input$text_file$name)
828
+ textfile_items(items)
829
+
830
+ if (length(items) == 0) {
831
+ notify("No text was found in this file.", type = "warning", duration = 8)
 
 
 
 
 
 
 
 
832
  } else {
833
+ notify(sprintf("File processed. %d item(s) found.", length(items)))
 
834
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
835
  }, error = function(e) {
836
+ textfile_items(character(0))
837
+ notify(paste("Error reading file:", conditionMessage(e)), type = "error", duration = NULL)
 
 
 
838
  })
839
  })
840
 
841
  # Render text file preview
842
  output$textfile_preview_ui = shiny::renderUI({
843
+ items = textfile_items()
844
+ if (length(items) == 0) return(NULL)
 
 
 
 
 
 
845
 
846
  shiny::div(
847
  class = "pdf-preview",
848
+ shiny::tags$strong(sprintf("Extracted Text Preview (%d item(s)):", length(items))),
849
+ shiny::tags$pre(preview_text(items))
850
  )
851
  })
852
 
 
 
 
 
853
  # Process uploaded images
854
  shiny::observeEvent(input$image_file, {
855
  shiny::req(input$image_file)
856
 
857
  tryCatch({
858
  files = input$image_file
859
+ files$mime = vapply(
860
+ seq_len(nrow(files)),
861
+ function(i) detect_mime(files$datapath[i], files$name[i]),
862
+ character(1)
863
+ )
864
 
865
+ not_images = files$name[is.na(files$mime)]
866
+ files = files[!is.na(files$mime), , drop = FALSE]
867
+ if (length(not_images) > 0) {
868
+ notify(
869
+ paste("Not a supported image, skipped:", paste(not_images, collapse = ", ")),
870
  type = "warning",
871
+ duration = 8
872
  )
873
  }
874
 
875
+ if (nrow(files) > MAX_PAGES_OR_IMAGES) {
876
+ notify(
877
+ sprintf(
878
+ "Only the first %d images will be processed (free app limit).",
879
+ MAX_PAGES_OR_IMAGES
880
+ ),
881
+ type = "warning",
882
+ duration = 5
883
+ )
884
+ files = files[seq_len(MAX_PAGES_OR_IMAGES), , drop = FALSE]
885
+ }
886
 
887
+ image_files(if (nrow(files) > 0) files else NULL)
888
+ if (nrow(files) > 0) notify(sprintf("%d image(s) uploaded.", nrow(files)))
 
 
 
889
  }, error = function(e) {
890
+ image_files(NULL)
891
+ notify(paste("Error uploading images:", conditionMessage(e)), type = "error", duration = NULL)
 
 
 
892
  })
893
  })
894
 
895
  # Render image preview
896
  output$image_preview_ui = shiny::renderUI({
897
+ files = image_files()
898
+ if (is.null(files) || nrow(files) == 0) return(NULL)
899
 
900
  shiny::tagList(
901
  shiny::div(
902
  class = "image-preview-grid",
903
+ lapply(seq_len(nrow(files)), function(i) {
904
  shiny::div(
905
  class = "image-preview-item",
906
  shiny::tags$img(
907
+ src = base64enc::dataURI(file = files$datapath[i], mime = files$mime[i]),
908
+ alt = paste0("Image ", i, ": ", files$name[i])
909
  )
910
  )
911
  })
912
  ),
913
  shiny::div(
914
  class = "image-preview-count",
915
+ paste(nrow(files), "image(s) ready for extraction")
916
  )
917
  )
918
  })
919
 
920
+ # Process scanned PDF (convert pages to images)
921
  shiny::observeEvent(input$scanned_pdf_file, {
922
  shiny::req(input$scanned_pdf_file)
923
 
924
  tryCatch({
 
 
 
 
 
 
 
 
 
 
 
 
 
 
925
  shiny::showNotification(
926
  "Converting PDF pages to images...",
927
  type = "message",
 
929
  id = "convert_notif"
930
  )
931
 
932
+ rendered = render_pdf_pages(input$scanned_pdf_file$datapath, MAX_PAGES_OR_IMAGES)
933
+ scanned_pdf_paths(rendered$paths)
 
 
 
 
 
 
 
 
 
934
 
935
  shiny::removeNotification(id = "convert_notif")
936
+ if (rendered$total_pages > length(rendered$paths)) {
937
+ notify(
938
+ sprintf(
939
+ "The PDF has %d pages. Only the first %d will be processed (free app limit).",
940
+ rendered$total_pages, length(rendered$paths)
941
+ ),
942
+ type = "warning",
943
+ duration = 5
944
+ )
945
+ }
946
+ notify(sprintf("%d page(s) converted.", length(rendered$paths)))
947
  }, error = function(e) {
948
+ scanned_pdf_paths(NULL)
949
  shiny::removeNotification(id = "convert_notif")
950
+ notify(paste("Error processing scanned PDF:", conditionMessage(e)), type = "error", duration = NULL)
 
 
 
 
951
  })
952
  })
953
 
 
963
  shiny::div(
964
  class = "image-preview-item",
965
  shiny::tags$img(
966
+ src = base64enc::dataURI(file = paths[i], mime = detect_mime(paths[i])),
967
  alt = paste("Page", i)
968
  )
969
  )
 
976
  )
977
  })
978
 
979
+ # The previews sit in panels that www/app.js shows and hides
980
+ for (preview_id in c("demo_preview_ui", "pdf_preview_ui", "textfile_preview_ui",
981
+ "image_preview_ui", "scanned_pdf_preview_ui")) {
982
+ shiny::outputOptions(output, preview_id, suspendWhenHidden = FALSE)
983
+ }
984
+
985
+ # What each tab holds right now, in the form resolve_source_items() expects
986
+ current_sources = function() {
987
+ files = image_files()
988
+ list(
989
+ demo = demo_text(),
990
+ paste = input$input_text,
991
+ textfile = textfile_items(),
992
+ pdf = pdf_text(),
993
+ scanned = scanned_pdf_paths(),
994
+ image = if (is.null(files)) NULL else files$datapath
995
+ )
996
+ }
997
+
998
+ # ---- Field definitions ---------------------------------------------------
999
+
1000
+ # Change the number of fields, keeping everything the user has entered
1001
+ shiny::observeEvent(input$num_fields, {
1002
+ n = clamp_num_fields(input$num_fields, MAX_FIELDS)
1003
+ if (is.na(n)) return()
1004
+
1005
+ if (!isTRUE(n == input$num_fields)) {
1006
+ shiny::updateNumericInput(session, "num_fields", value = n)
1007
+ notify(
1008
+ sprintf("The number of fields must be between 1 and %d.", MAX_FIELDS),
1009
+ type = "warning",
1010
+ duration = 5
1011
  )
 
 
 
 
1012
  }
1013
 
1014
+ fields = current_fields()
1015
+ if (n == length(fields)) return()
1016
+ field_state(resize_fields(fields, n))
1017
+ field_gen(field_gen() + 1)
1018
+ }, ignoreInit = TRUE)
1019
+
1020
+ # Fill the fields from a template
1021
+ shiny::observeEvent(input$apply_template, {
1022
+ template = FIELD_TEMPLATES[[input$template_id]]
1023
+ if (is.null(template)) return()
1024
+ set_fields(template$fields)
1025
+ notify(sprintf("Template '%s' loaded. You can edit the fields.", template$name))
1026
+ })
1027
+
1028
+ output$template_description_ui = shiny::renderUI({
1029
+ template = FIELD_TEMPLATES[[input$template_id]]
1030
+ if (is.null(template)) return(NULL)
1031
+ shiny::helpText(sprintf("%s %d fields.", template$description, length(template$fields)))
1032
+ })
1033
+
1034
+ # Restore the fields from an uploaded JSON file
1035
+ shiny::observeEvent(input$fields_upload, {
1036
+ shiny::req(input$fields_upload)
1037
+ loaded = read_fields_json(input$fields_upload$datapath, MAX_FIELDS)
1038
+ if (!loaded$ok) {
1039
+ notify(
1040
+ paste("The field definitions were not loaded.", loaded$error),
1041
+ type = "error",
1042
+ duration = NULL
1043
+ )
1044
+ return()
1045
+ }
1046
+ set_fields(loaded$fields)
1047
+ notify(sprintf("%d field(s) loaded from the file.", length(loaded$fields)))
1048
+ })
1049
+
1050
+ # Download the current field definitions as JSON
1051
+ output$download_fields = shiny::downloadHandler(
1052
+ filename = function() {
1053
+ paste0("field_definitions_", format(Sys.time(), "%Y%m%d_%H%M%S"), ".json")
1054
+ },
1055
+ content = function(file) {
1056
+ writeLines(fields_to_json(current_fields()), file, useBytes = TRUE)
1057
+ }
1058
+ )
1059
+
1060
+ # Generate the inputs for each field's label, description and type.
1061
+ # Depends only on field_gen, so typing and tab changes never rebuild them.
1062
+ output$fields_ui = shiny::renderUI({
1063
+ gen = field_gen()
1064
+ fields = shiny::isolate(field_state())
1065
+
1066
+ do.call(shiny::tagList, lapply(seq_along(fields), function(i) {
1067
+ ids = field_input_ids(gen, i)
1068
+ field = fields[[i]]
1069
 
1070
  shiny::div(
1071
+ class = "field-card",
1072
  shiny::tags$span(
1073
  style = "color: #C41230; font-weight: 600; font-size: 0.9em;",
1074
  paste("Field", i)
1075
  ),
1076
+ shiny::textInput(ids$label, "Label:", value = field$label),
1077
+ shiny::textAreaInput(
1078
+ ids$description,
 
 
 
 
1079
  "Description:",
1080
+ value = field$description,
1081
+ rows = 3,
1082
+ placeholder = "Describe what to extract for this field, and what to return when it is absent"
1083
+ ),
1084
+ shiny::selectInput(
1085
+ ids$type,
1086
+ "Type:",
1087
+ choices = stats::setNames(names(FIELD_TYPES), FIELD_TYPES),
1088
+ selected = if (field$type %in% names(FIELD_TYPES)) field$type else "text",
1089
+ selectize = FALSE
1090
  )
1091
  )
1092
+ }))
 
1093
  })
1094
 
1095
+ # ---- Help ----------------------------------------------------------------
1096
+
1097
+ lapply(HELP_TOPICS, function(topic) {
1098
+ shiny::observeEvent(input[[paste0("help_", topic)]], {
1099
+ shiny::showModal(help_modal(topic))
1100
+ })
 
 
 
 
 
 
 
 
 
 
1101
  })
1102
 
1103
+ # ---- Extraction ----------------------------------------------------------
1104
+
1105
  # When the extract button is clicked, perform extraction
1106
  shiny::observeEvent(input$extract_btn, {
1107
+ # Items come from the active tab only
1108
+ resolved = resolve_source_items(active_method(), current_sources(), MAX_PAGES_OR_IMAGES)
1109
+ if (!resolved$ok) {
1110
+ notify(resolved$error, type = "error", duration = 5)
1111
+ return()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1112
  }
1113
 
1114
+ checked = validate_fields(current_fields(), MAX_FIELDS)
1115
+ if (!checked$ok) {
1116
+ notify(
1117
+ shiny::tagList(
1118
+ "Please correct the field definitions:",
1119
+ shiny::tags$ul(lapply(checked$errors, shiny::tags$li))
 
1120
  ),
1121
  type = "error",
1122
+ duration = NULL
1123
  )
1124
  return()
1125
  }
1126
+ if (length(checked$notes) > 0) {
1127
+ notify(
1128
+ shiny::tags$ul(lapply(checked$notes, shiny::tags$li)),
1129
+ type = "warning",
1130
+ duration = 10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1131
  )
1132
+ }
1133
 
1134
+ if (is.null(getOption("ste.chat_factory")) && !api_key_available()) {
1135
+ notify(
1136
+ "The extraction service is not available right now. Please try again later, or contact the authors if the problem continues.",
1137
+ type = "error",
1138
+ duration = NULL
1139
+ )
1140
+ return()
1141
+ }
1142
 
1143
+ items = resolved$items
1144
+ source_label = INPUT_METHOD_LABELS[[active_method()]]
1145
+ custom_type_object = build_type_object(checked$fields)
1146
+
1147
+ results = shiny::withProgress(
1148
+ message = "Extracting",
1149
+ value = 0,
1150
+ {
1151
+ extracted = extract_items(
1152
+ items,
1153
+ custom_type_object,
1154
+ chat_factory,
1155
+ on_progress = function(i, n, item) {
1156
+ shiny::setProgress(
1157
+ value = (i - 1) / n,
1158
+ detail = sprintf("item %d of %d (source_id: %s)", i, n, item$source_id)
1159
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1160
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1161
  )
1162
+ shiny::setProgress(value = 1, detail = "done")
1163
+ extracted
 
 
 
 
1164
  }
1165
+ )
1166
 
1167
+ results_table = assemble_results(results, checked$fields)
1168
+ n_failed = sum(results_table$status != "ok")
1169
+ extraction(list(
1170
+ table = results_table,
1171
+ summary = summarize_results(results_table),
1172
+ n_failed = n_failed,
1173
+ source = source_label
1174
+ ))
1175
+
1176
+ if (n_failed == 0) {
1177
+ notify("Extraction complete.")
1178
+ } else {
1179
+ notify(summarize_results(results_table), type = "warning", duration = 8)
1180
+ }
 
 
 
 
 
 
 
 
 
1181
  })
1182
 
1183
+ # Summary line above the table, or the placeholder before the first run
1184
+ output$results_status_ui = shiny::renderUI({
1185
+ result = extraction()
1186
+ if (is.null(result)) {
1187
+ return(shiny::div(
1188
+ class = "results-placeholder",
1189
+ shiny::icon("arrow-left"),
1190
+ " Configure your extraction fields and click 'Extract Data' to see results here"
1191
+ ))
1192
+ }
1193
+
1194
+ shiny::div(
1195
+ class = if (result$n_failed > 0) "results-summary has-failures" else "results-summary",
1196
+ role = "status",
1197
+ shiny::tags$strong(result$summary),
1198
+ sprintf(" Source: %s.", result$source),
1199
+ if (result$n_failed > 0) {
1200
+ " The status_message column gives the reason for each failure."
1201
+ },
1202
+ " Check the values against the source documents before you use them."
1203
+ )
1204
+ })
1205
+
1206
+ output$extracted_table = shiny::renderTable(
1207
+ {
1208
+ result = extraction()
1209
+ if (is.null(result)) return(NULL)
1210
+ display_table(result$table)
1211
+ },
1212
+ striped = TRUE,
1213
+ hover = TRUE,
1214
+ bordered = TRUE
1215
+ )
1216
+
1217
  # Render download button only when results are available
1218
  output$download_btn_ui = shiny::renderUI({
1219
+ result = extraction()
1220
+ if (is.null(result) || nrow(result$table) == 0) return(NULL)
1221
 
1222
  shiny::div(
1223
  style = "margin-top: 15px;",
 
1235
  paste0("extracted_data_", format(Sys.time(), "%Y%m%d_%H%M%S"), ".csv")
1236
  },
1237
  content = function(file) {
1238
+ result = extraction()
1239
+ if (!is.null(result)) {
1240
+ utils::write.csv(result$table, file, row.names = FALSE, na = "")
1241
  }
1242
  }
1243
  )
1244
+
1245
+ # ---- Reproduce in code ---------------------------------------------------
1246
+
1247
+ # Fields used in the snippets (sanitized, as they would be sent)
1248
+ snippet_fields = shiny::reactive({
1249
+ validate_fields(current_fields(), MAX_FIELDS)
1250
+ })
1251
+
1252
+ output$snippet_status_ui = shiny::renderUI({
1253
+ checked = snippet_fields()
1254
+ if (checked$ok) return(NULL)
1255
+ shiny::div(
1256
+ class = "limit-note",
1257
+ "The code is shown once the field definitions are valid: ",
1258
+ paste(checked$errors, collapse = " ")
1259
+ )
1260
+ })
1261
+
1262
+ output$snippet_r = shiny::renderText({
1263
+ checked = snippet_fields()
1264
+ if (!checked$ok) return("")
1265
+ r_snippet(checked$fields, EXTRACTION_MODEL)
1266
+ })
1267
+
1268
+ output$snippet_python = shiny::renderText({
1269
+ checked = snippet_fields()
1270
+ if (!checked$ok) return("")
1271
+ python_snippet(checked$fields, EXTRACTION_MODEL)
1272
+ })
1273
+
1274
+ # The code blocks sit inside collapsed <details> elements
1275
+ shiny::outputOptions(output, "snippet_r", suspendWhenHidden = FALSE)
1276
+ shiny::outputOptions(output, "snippet_python", suspendWhenHidden = FALSE)
1277
  }
1278
 
1279
  # Run the Shiny app
config/templates/corrective_action_8d.json ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "corrective_action_8d",
3
+ "name": "8D / corrective action report",
4
+ "description": "8D problem-solving report or corrective action report (CAR, SCAR).",
5
+ "order": 2,
6
+ "fields": [
7
+ {
8
+ "label": "report_id",
9
+ "description": "The identifier of the report (for example, an 8D, CAR or SCAR number), exactly as written. Leave empty if the report has no identifier.",
10
+ "type": "text"
11
+ },
12
+ {
13
+ "label": "part_number",
14
+ "description": "The part number or product identifier that the report concerns, exactly as written. If several are listed, return the first one. Leave empty if none is given.",
15
+ "type": "text"
16
+ },
17
+ {
18
+ "label": "problem_description",
19
+ "description": "One or two sentences stating the problem as the report describes it (what failed, where it was found, and how many units, when stated). Use only facts in the report. Leave empty if the report gives no problem description.",
20
+ "type": "text"
21
+ },
22
+ {
23
+ "label": "containment_actions",
24
+ "description": "The interim containment actions taken to protect the customer (for example, sorting stock or quarantining a lot), one entry per action. Do not include permanent corrective actions. Return an empty list if none are stated.",
25
+ "type": "list"
26
+ },
27
+ {
28
+ "label": "root_cause",
29
+ "description": "The root cause of why the problem occurred, as stated in the report. Do not infer a cause. Leave empty if the report states none or says the analysis is still open.",
30
+ "type": "text"
31
+ },
32
+ {
33
+ "label": "escape_point",
34
+ "description": "Why the problem was not detected before it reached the customer (the escape point or detection root cause), as stated in the report. Leave empty if the report does not state it.",
35
+ "type": "text"
36
+ },
37
+ {
38
+ "label": "corrective_actions",
39
+ "description": "The permanent corrective actions chosen or implemented to remove the root cause, one entry per action. Do not include containment actions. Return an empty list if none are stated.",
40
+ "type": "list"
41
+ },
42
+ {
43
+ "label": "preventive_actions",
44
+ "description": "The actions taken to prevent recurrence on this and similar products or processes (for example, updates to the FMEA, control plan or work instructions), one entry per action. Return an empty list if none are stated.",
45
+ "type": "list"
46
+ },
47
+ {
48
+ "label": "effectiveness_verified",
49
+ "description": "True if the report states that the effectiveness of the corrective actions was verified. False if the report states that verification is pending or was not done. Leave empty if the report does not mention verification.",
50
+ "type": "yes_no"
51
+ }
52
+ ]
53
+ }
config/templates/customer_complaint.json ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "customer_complaint",
3
+ "name": "Customer complaint record",
4
+ "description": "Customer complaint, warranty claim or service call record.",
5
+ "order": 3,
6
+ "fields": [
7
+ {
8
+ "label": "complaint_date",
9
+ "description": "The date the complaint was made or received, in YYYY-MM-DD format. If only the month and year are given, return YYYY-MM. Leave empty if no date is stated.",
10
+ "type": "text"
11
+ },
12
+ {
13
+ "label": "product",
14
+ "description": "The product the complaint is about, including the model name or number when given, as written in the record. Leave empty if no product is identified.",
15
+ "type": "text"
16
+ },
17
+ {
18
+ "label": "lot_or_serial",
19
+ "description": "The lot, batch or serial number of the product, exactly as written. Leave empty if none is given.",
20
+ "type": "text"
21
+ },
22
+ {
23
+ "label": "complaint_summary",
24
+ "description": "One or two sentences stating what the customer reported, using only facts in the record. Leave empty if the record does not describe the complaint.",
25
+ "type": "text"
26
+ },
27
+ {
28
+ "label": "failure_mode",
29
+ "description": "The way the product failed, as a short phrase (for example, 'cracked housing', 'will not power on' or 'leaking seal'). Leave empty if the record describes no failure.",
30
+ "type": "text"
31
+ },
32
+ {
33
+ "label": "units_affected",
34
+ "description": "The number of units the customer reports as affected, as a number. Leave empty if the record states no quantity.",
35
+ "type": "number"
36
+ },
37
+ {
38
+ "label": "injury_or_safety_issue",
39
+ "description": "True if the record reports an injury, a fire, or another safety hazard. False if the record states that there was none. Leave empty if the record does not mention safety.",
40
+ "type": "yes_no"
41
+ },
42
+ {
43
+ "label": "customer_request",
44
+ "description": "What the customer asks for (for example, a refund, a replacement, a repair or an explanation), as a short phrase. Leave empty if the customer makes no request.",
45
+ "type": "text"
46
+ }
47
+ ]
48
+ }
config/templates/nonconformance_note.json ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "nonconformance_note",
3
+ "name": "Inspection or nonconformance note",
4
+ "description": "Inspection record, nonconformance report (NCR) or shop-floor defect note.",
5
+ "order": 4,
6
+ "fields": [
7
+ {
8
+ "label": "part_number",
9
+ "description": "The part number of the inspected item, exactly as written. Leave empty if none is given.",
10
+ "type": "text"
11
+ },
12
+ {
13
+ "label": "lot_number",
14
+ "description": "The lot, batch, work order or serial number of the inspected material, exactly as written. Leave empty if none is given.",
15
+ "type": "text"
16
+ },
17
+ {
18
+ "label": "characteristic",
19
+ "description": "The feature or characteristic that was found nonconforming (for example, 'bore diameter', 'surface finish' or 'weld penetration'). If several are reported, return the first one. Leave empty if the note reports no nonconformance.",
20
+ "type": "text"
21
+ },
22
+ {
23
+ "label": "specification",
24
+ "description": "The requirement for that characteristic, with tolerance and units as written (for example, '25.00 +/- 0.05 mm'). Leave empty if the note states no requirement.",
25
+ "type": "text"
26
+ },
27
+ {
28
+ "label": "measured_value",
29
+ "description": "The measured or observed value for that characteristic, with units as written (for example, '25.12 mm'). If several measurements are given, return the one farthest from the requirement. Leave empty if no value is recorded.",
30
+ "type": "text"
31
+ },
32
+ {
33
+ "label": "quantity_inspected",
34
+ "description": "The number of units inspected, as a number. Leave empty if not stated.",
35
+ "type": "number"
36
+ },
37
+ {
38
+ "label": "quantity_nonconforming",
39
+ "description": "The number of units found nonconforming, as a number. Leave empty if not stated.",
40
+ "type": "number"
41
+ },
42
+ {
43
+ "label": "disposition",
44
+ "description": "The disposition of the nonconforming material as one of: use as is, rework, repair, scrap, return to supplier, or hold. If the note uses another term, return it as written. Leave empty if no disposition is recorded.",
45
+ "type": "text"
46
+ }
47
+ ]
48
+ }
config/templates/recall_notice.json ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "id": "recall_notice",
3
+ "name": "Recall notice",
4
+ "description": "Vehicle safety recall notice, such as an NHTSA recall summary.",
5
+ "order": 1,
6
+ "fields": [
7
+ {
8
+ "label": "manufacturer",
9
+ "description": "Name of the company that is recalling the vehicles, as written in the notice. If both a short name and a full name are given, return the full name. Leave empty if no company is named.",
10
+ "type": "text"
11
+ },
12
+ {
13
+ "label": "defect_summary",
14
+ "description": "One or two sentences that describe the defect or noncompliance, using only facts stated in the notice. Leave empty if the notice describes no defect.",
15
+ "type": "text"
16
+ },
17
+ {
18
+ "label": "models",
19
+ "description": "Every affected vehicle model named in the notice, one entry per model, including the make (for example, 'Acura MDX Type-S'). Do not include model years. Return an empty list if no model is named.",
20
+ "type": "list"
21
+ },
22
+ {
23
+ "label": "model_years",
24
+ "description": "The affected model years as written in the notice, one entry per year or year range (for example, '2021-2022'). If several models have different years, give one entry per distinct range. Return an empty list if no model year is stated.",
25
+ "type": "list"
26
+ },
27
+ {
28
+ "label": "component",
29
+ "description": "The part or system affected by the defect (for example, 'rearview camera' or 'fuel injection electronic control unit'). Leave empty if the notice does not name one.",
30
+ "type": "text"
31
+ },
32
+ {
33
+ "label": "fmvss_number",
34
+ "description": "The number of the Federal Motor Vehicle Safety Standard (FMVSS) that the vehicles fail to comply with, digits only (for example, '111'). Leave empty if the notice cites no FMVSS.",
35
+ "type": "text"
36
+ },
37
+ {
38
+ "label": "root_cause",
39
+ "description": "The cause of the defect as stated in the notice (for example, a software error or a supplier manufacturing error). Do not infer a cause. Leave empty if the notice does not state one.",
40
+ "type": "text"
41
+ },
42
+ {
43
+ "label": "risk",
44
+ "description": "The safety risk or consequence described in the notice (for example, an increased risk of a crash). Leave empty if the notice does not state one.",
45
+ "type": "text"
46
+ }
47
+ ]
48
+ }
www/app.js ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Client-side behaviour: input tabs, the video dialog, and the copy buttons.
2
+ (function() {
3
+ var INPUT_TABS = ['demo', 'paste', 'textfile', 'pdf', 'scanned', 'image'];
4
+ var currentTab = 'demo';
5
+
6
+ // ---- Input tabs ----------------------------------------------------------
7
+
8
+ function sendInputMethod() {
9
+ if (window.Shiny && Shiny.setInputValue) {
10
+ Shiny.setInputValue('input_method', currentTab);
11
+ }
12
+ }
13
+
14
+ function switchInputTab(tab, moveFocus) {
15
+ if (INPUT_TABS.indexOf(tab) === -1) return;
16
+ currentTab = tab;
17
+ INPUT_TABS.forEach(function(t) {
18
+ var tabEl = document.getElementById('tab-' + t);
19
+ var panelEl = document.getElementById('panel-' + t);
20
+ if (!tabEl || !panelEl) return;
21
+ var selected = (t === tab);
22
+ tabEl.classList.toggle('active', selected);
23
+ tabEl.setAttribute('aria-selected', selected ? 'true' : 'false');
24
+ tabEl.setAttribute('tabindex', selected ? '0' : '-1');
25
+ panelEl.style.display = selected ? 'block' : 'none';
26
+ if (selected && moveFocus) tabEl.focus();
27
+ });
28
+ sendInputMethod();
29
+ // Let Shiny render outputs in the panel that just became visible
30
+ if (window.jQuery) window.jQuery('#panel-' + tab).trigger('shown');
31
+ }
32
+ window.switchInputTab = switchInputTab;
33
+
34
+ function onTabKeydown(event) {
35
+ var index = INPUT_TABS.indexOf(currentTab);
36
+ var next = null;
37
+ if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
38
+ next = INPUT_TABS[(index + 1) % INPUT_TABS.length];
39
+ } else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
40
+ next = INPUT_TABS[(index + INPUT_TABS.length - 1) % INPUT_TABS.length];
41
+ } else if (event.key === 'Home') {
42
+ next = INPUT_TABS[0];
43
+ } else if (event.key === 'End') {
44
+ next = INPUT_TABS[INPUT_TABS.length - 1];
45
+ }
46
+ if (next !== null) {
47
+ event.preventDefault();
48
+ switchInputTab(next, true);
49
+ }
50
+ }
51
+
52
+ // ---- Video dialog --------------------------------------------------------
53
+
54
+ var videoUrl = 'https://www.youtube.com/embed/y6Uit4Drf9w';
55
+ var lastFocused = null;
56
+
57
+ function videoModal() { return document.getElementById('video-modal'); }
58
+
59
+ function openVideo() {
60
+ var modal = videoModal();
61
+ lastFocused = document.activeElement;
62
+ document.getElementById('tutorial-video').src = videoUrl;
63
+ modal.style.display = 'flex';
64
+ document.getElementById('close-video-modal').focus();
65
+ }
66
+
67
+ function closeVideo() {
68
+ var modal = videoModal();
69
+ if (modal.style.display !== 'flex') return;
70
+ modal.style.display = 'none';
71
+ document.getElementById('tutorial-video').src = '';
72
+ if (lastFocused && lastFocused.focus) lastFocused.focus();
73
+ }
74
+
75
+ // Keep keyboard focus inside the open dialog
76
+ function trapVideoFocus(event) {
77
+ if (event.key !== 'Tab') return;
78
+ var closeBtn = document.getElementById('close-video-modal');
79
+ var iframe = document.getElementById('tutorial-video');
80
+ if (event.shiftKey && document.activeElement === closeBtn) {
81
+ event.preventDefault();
82
+ iframe.focus();
83
+ } else if (!event.shiftKey && document.activeElement === iframe) {
84
+ event.preventDefault();
85
+ closeBtn.focus();
86
+ }
87
+ }
88
+
89
+ // ---- Copy buttons --------------------------------------------------------
90
+
91
+ function copyFrom(button) {
92
+ var target = document.getElementById(button.getAttribute('data-target'));
93
+ if (!target) return;
94
+ var text = target.textContent;
95
+ var label = button.getAttribute('data-label') || button.textContent;
96
+ button.setAttribute('data-label', label);
97
+ function done(message) {
98
+ button.textContent = message;
99
+ window.setTimeout(function() { button.textContent = label; }, 2000);
100
+ }
101
+ if (navigator.clipboard && navigator.clipboard.writeText) {
102
+ navigator.clipboard.writeText(text).then(
103
+ function() { done('Copied'); },
104
+ function() { done('Copy failed. Select the code and copy it manually.'); }
105
+ );
106
+ } else {
107
+ done('Copy is not available. Select the code and copy it manually.');
108
+ }
109
+ }
110
+
111
+ // ---- Wiring --------------------------------------------------------------
112
+
113
+ document.addEventListener('DOMContentLoaded', function() {
114
+ INPUT_TABS.forEach(function(t) {
115
+ var tabEl = document.getElementById('tab-' + t);
116
+ if (!tabEl) return;
117
+ tabEl.addEventListener('click', function() { switchInputTab(t, false); });
118
+ tabEl.addEventListener('keydown', onTabKeydown);
119
+ });
120
+
121
+ var modal = videoModal();
122
+ document.getElementById('open-video-modal').addEventListener('click', openVideo);
123
+ document.getElementById('close-video-modal').addEventListener('click', closeVideo);
124
+ modal.addEventListener('click', function(event) {
125
+ if (event.target === modal) closeVideo();
126
+ });
127
+ modal.addEventListener('keydown', trapVideoFocus);
128
+ document.addEventListener('keydown', function(event) {
129
+ if (event.key === 'Escape') closeVideo();
130
+ });
131
+
132
+ document.addEventListener('click', function(event) {
133
+ var button = event.target.closest ? event.target.closest('.copy-btn') : null;
134
+ if (button) copyFrom(button);
135
+ });
136
+ });
137
+
138
+ // Tell the server which tab is open as soon as the session starts
139
+ if (window.jQuery) {
140
+ window.jQuery(document).on('shiny:connected', sendInputMethod);
141
+ }
142
+ })();
www/miami-theme.css CHANGED
@@ -205,18 +205,22 @@ body {
205
  }
206
 
207
  .video-modal-close {
 
 
 
208
  position: absolute;
209
  top: 10px;
210
  right: 18px;
211
  font-size: 32px;
212
  font-weight: bold;
213
- color: var(--miami-medium-tan);
214
  cursor: pointer;
215
  transition: color 0.2s;
216
  line-height: 1;
217
  }
218
 
219
- .video-modal-close:hover {
 
220
  color: var(--miami-red);
221
  }
222
 
@@ -248,7 +252,8 @@ body {
248
  .form-control:focus {
249
  border-color: var(--miami-red);
250
  box-shadow: 0 0 0 3px rgba(196, 18, 48, 0.15);
251
- outline: none;
 
252
  }
253
 
254
  /* Text area styling */
@@ -510,7 +515,8 @@ hr {
510
  }
511
 
512
  /* File input browse button - make it red */
513
- .input-panel .btn-file {
 
514
  background-color: var(--miami-red);
515
  color: var(--miami-white);
516
  border: none;
@@ -519,13 +525,15 @@ hr {
519
  }
520
 
521
  .input-panel .btn-file:hover,
522
- .input-panel .btn-file:focus {
 
 
523
  background-color: var(--miami-dark-red);
524
  color: var(--miami-white);
525
  }
526
 
527
- /* Image limit note */
528
- .image-limit-note {
529
  background-color: rgba(239, 219, 114, 0.3);
530
  border: 1px solid var(--miami-yellow);
531
  border-radius: 4px;
@@ -577,6 +585,149 @@ hr {
577
  margin-top: 8px;
578
  }
579
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
580
  /* Responsive adjustments */
581
  @media (max-width: 768px) {
582
  .header-content {
 
205
  }
206
 
207
  .video-modal-close {
208
+ background: none;
209
+ border: none;
210
+ padding: 0 4px;
211
  position: absolute;
212
  top: 10px;
213
  right: 18px;
214
  font-size: 32px;
215
  font-weight: bold;
216
+ color: #666;
217
  cursor: pointer;
218
  transition: color 0.2s;
219
  line-height: 1;
220
  }
221
 
222
+ .video-modal-close:hover,
223
+ .video-modal-close:focus {
224
  color: var(--miami-red);
225
  }
226
 
 
252
  .form-control:focus {
253
  border-color: var(--miami-red);
254
  box-shadow: 0 0 0 3px rgba(196, 18, 48, 0.15);
255
+ outline: 2px solid var(--miami-red);
256
+ outline-offset: 1px;
257
  }
258
 
259
  /* Text area styling */
 
515
  }
516
 
517
  /* File input browse button - make it red */
518
+ .input-panel .btn-file,
519
+ .fields-file-row .btn-file {
520
  background-color: var(--miami-red);
521
  color: var(--miami-white);
522
  border: none;
 
525
  }
526
 
527
  .input-panel .btn-file:hover,
528
+ .input-panel .btn-file:focus,
529
+ .fields-file-row .btn-file:hover,
530
+ .fields-file-row .btn-file:focus {
531
  background-color: var(--miami-dark-red);
532
  color: var(--miami-white);
533
  }
534
 
535
+ /* Page and image limit note */
536
+ .limit-note {
537
  background-color: rgba(239, 219, 114, 0.3);
538
  border: 1px solid var(--miami-yellow);
539
  border-radius: 4px;
 
585
  margin-top: 8px;
586
  }
587
 
588
+ /* Visible keyboard focus for every interactive element */
589
+ a:focus-visible,
590
+ button:focus-visible,
591
+ summary:focus-visible,
592
+ select:focus-visible,
593
+ .btn:focus-visible,
594
+ .input-tab:focus-visible,
595
+ [tabindex]:focus-visible {
596
+ outline: 3px solid var(--miami-black);
597
+ outline-offset: 2px;
598
+ }
599
+
600
+ .app-header a:focus-visible {
601
+ outline-color: var(--miami-white);
602
+ }
603
+
604
+ /* Heading (or button) with its "?" help button */
605
+ .heading-with-help {
606
+ display: flex;
607
+ align-items: center;
608
+ gap: 10px;
609
+ margin-bottom: 15px;
610
+ }
611
+
612
+ .heading-with-help.section-heading {
613
+ padding-bottom: 10px;
614
+ }
615
+
616
+ .help-btn {
617
+ flex: none;
618
+ width: 26px;
619
+ height: 26px;
620
+ padding: 0;
621
+ margin: 0;
622
+ border: 1px solid var(--miami-red);
623
+ border-radius: 50%;
624
+ background-color: var(--miami-white);
625
+ color: var(--miami-red);
626
+ font-weight: 700;
627
+ font-size: 0.9em;
628
+ line-height: 1;
629
+ }
630
+
631
+ .help-btn:hover,
632
+ .help-btn:focus {
633
+ background-color: var(--miami-red);
634
+ color: var(--miami-white);
635
+ }
636
+
637
+ .heading-with-help .btn-primary {
638
+ margin: 0;
639
+ }
640
+
641
+ /* Help dialog */
642
+ .modal-title {
643
+ color: var(--miami-red);
644
+ font-weight: 600;
645
+ }
646
+
647
+ .modal-body p {
648
+ line-height: 1.5;
649
+ }
650
+
651
+ /* Field definitions */
652
+ .field-card {
653
+ background-color: var(--miami-light-tan);
654
+ padding: 12px;
655
+ border-radius: 6px;
656
+ margin-bottom: 12px;
657
+ }
658
+
659
+ .field-card textarea.form-control {
660
+ min-height: 70px;
661
+ }
662
+
663
+ .field-card .form-group {
664
+ margin-bottom: 8px;
665
+ }
666
+
667
+ .template-row {
668
+ display: flex;
669
+ align-items: flex-end;
670
+ gap: 10px;
671
+ }
672
+
673
+ .template-row .form-group {
674
+ flex: 1;
675
+ margin-bottom: 0;
676
+ }
677
+
678
+ .template-row .btn {
679
+ margin: 0;
680
+ }
681
+
682
+ .fields-file-row {
683
+ margin-top: 5px;
684
+ }
685
+
686
+ .fields-file-row .shiny-input-container {
687
+ width: 100%;
688
+ margin-top: 12px;
689
+ }
690
+
691
+ /* Results */
692
+ .results-summary {
693
+ border-left: 4px solid var(--miami-medium-tan);
694
+ background-color: var(--miami-light-tan);
695
+ padding: 10px 14px;
696
+ margin-bottom: 15px;
697
+ border-radius: 0 6px 6px 0;
698
+ }
699
+
700
+ .results-summary.has-failures {
701
+ border-left-color: var(--miami-red);
702
+ }
703
+
704
+ .results-table-wrapper {
705
+ overflow-x: auto;
706
+ }
707
+
708
+ /* Reproduce in code */
709
+ .code-details {
710
+ border: 1px solid var(--miami-medium-tan);
711
+ border-radius: 6px;
712
+ padding: 10px 14px;
713
+ margin-top: 10px;
714
+ }
715
+
716
+ .code-details summary {
717
+ cursor: pointer;
718
+ font-weight: 600;
719
+ }
720
+
721
+ .code-details pre {
722
+ margin-top: 10px;
723
+ max-height: 420px;
724
+ overflow: auto;
725
+ white-space: pre;
726
+ font-size: 0.85em;
727
+ background-color: var(--miami-light-tan);
728
+ border: none;
729
+ }
730
+
731
  /* Responsive adjustments */
732
  @media (max-width: 768px) {
733
  .header-content {