Rework extraction app for practitioner use (v3.0.0)
Browse filesMove 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 +2 -0
- R/config.R +55 -0
- R/extraction.R +156 -0
- R/fields.R +303 -0
- R/help.R +112 -0
- R/snippets.R +128 -0
- R/sources.R +185 -0
- app.R +654 -647
- config/templates/corrective_action_8d.json +53 -0
- config/templates/customer_complaint.json +48 -0
- config/templates/nonconformance_note.json +48 -0
- config/templates/recall_notice.json +48 -0
- www/app.js +142 -0
- www/miami-theme.css +158 -7
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 |
-
#
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
| 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(
|
| 37 |
shiny::p(
|
| 38 |
shiny::tags$strong("Authors: "),
|
| 39 |
-
"Fadel M. Megahed, Ying-Ju
|
| 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
|
| 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
|
| 116 |
-
"You can also add more fields, change field labels, and modify descriptions. ",
|
| 117 |
shiny::tags$em(
|
| 118 |
-
"(Note:
|
| 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
|
| 154 |
)
|
| 155 |
),
|
| 156 |
-
shiny::
|
| 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:
|
| 163 |
-
shiny::tags$li(
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
),
|
| 166 |
-
shiny::tags$li(
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
)
|
| 169 |
)
|
| 170 |
),
|
|
@@ -178,15 +244,25 @@ ui = shiny::fluidPage(
|
|
| 178 |
),
|
| 179 |
shiny::tags$ul(
|
| 180 |
style = "margin-bottom: 10px;",
|
| 181 |
-
shiny::tags$li(
|
| 182 |
-
|
| 183 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
),
|
| 185 |
shiny::p(
|
| 186 |
shiny::icon("lightbulb"),
|
| 187 |
" ",
|
| 188 |
shiny::tags$em(
|
| 189 |
-
"
|
| 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$
|
| 201 |
id = "close-video-modal",
|
|
|
|
| 202 |
class = "video-modal-close",
|
|
|
|
| 203 |
shiny::HTML("×")
|
| 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 |
-
#
|
| 213 |
shiny::tags$iframe(
|
| 214 |
id = "tutorial-video",
|
| 215 |
-
|
|
|
|
| 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
|
| 224 |
-
shiny::tags$script(
|
| 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 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
),
|
| 286 |
|
| 287 |
-
# Input method tabs (3 rows
|
| 288 |
shiny::div(
|
| 289 |
class = "input-tabs-container",
|
|
|
|
|
|
|
| 290 |
shiny::div(
|
| 291 |
class = "input-tabs",
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 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 |
-
|
| 310 |
-
|
| 311 |
-
|
| 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 |
-
|
| 327 |
-
|
| 328 |
-
|
| 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 |
-
|
| 345 |
-
|
| 346 |
-
|
| 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 |
-
|
| 361 |
-
|
| 362 |
-
class = "input-panel",
|
| 363 |
-
style = "display: none;",
|
| 364 |
shiny::textAreaInput(
|
| 365 |
"input_text",
|
| 366 |
-
|
| 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.
|
| 369 |
)
|
| 370 |
),
|
| 371 |
|
| 372 |
# Text file upload panel
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
class = "input-panel",
|
| 376 |
-
style = "display: none;",
|
| 377 |
shiny::fileInput(
|
| 378 |
"text_file",
|
| 379 |
-
|
| 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 |
-
|
| 403 |
-
|
| 404 |
-
class = "input-panel",
|
| 405 |
-
style = "display: none;",
|
| 406 |
shiny::fileInput(
|
| 407 |
"pdf_file",
|
| 408 |
-
|
| 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 = "
|
| 419 |
shiny::icon("exclamation-circle"),
|
| 420 |
-
shiny::tags$em(
|
| 421 |
),
|
| 422 |
shiny::uiOutput("pdf_preview_ui")
|
| 423 |
),
|
| 424 |
|
| 425 |
# Image upload panel
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
class = "input-panel",
|
| 429 |
-
style = "display: none;",
|
| 430 |
shiny::fileInput(
|
| 431 |
"image_file",
|
| 432 |
-
|
| 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 = "
|
| 444 |
shiny::icon("exclamation-circle"),
|
| 445 |
-
shiny::tags$em(
|
| 446 |
),
|
| 447 |
shiny::uiOutput("image_preview_ui")
|
| 448 |
),
|
| 449 |
|
| 450 |
# Scanned PDF panel
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
class = "input-panel",
|
| 454 |
-
style = "display: none;",
|
| 455 |
shiny::fileInput(
|
| 456 |
"scanned_pdf_file",
|
| 457 |
-
|
| 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
|
| 465 |
),
|
| 466 |
shiny::div(
|
| 467 |
-
class = "
|
| 468 |
shiny::icon("exclamation-circle"),
|
| 469 |
-
shiny::tags$em(
|
| 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 =
|
| 483 |
min = 1,
|
| 484 |
-
max =
|
| 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., '
|
| 490 |
),
|
| 491 |
|
| 492 |
-
shiny::
|
| 493 |
|
| 494 |
-
shiny::
|
| 495 |
-
|
| 496 |
-
shiny::
|
| 497 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
),
|
| 499 |
|
| 500 |
-
shiny::
|
| 501 |
|
| 502 |
shiny::div(
|
| 503 |
-
|
| 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("
|
| 521 |
-
"
|
| 522 |
),
|
| 523 |
-
shiny::
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
|
|
|
| 528 |
),
|
| 529 |
-
shiny::
|
| 530 |
-
shiny::
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
"
|
| 550 |
-
|
| 551 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
"
|
| 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 |
-
|
| 599 |
-
|
| 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
|
|
|
|
| 619 |
),
|
| 620 |
shiny::p(
|
| 621 |
shiny::tags$em(
|
| 622 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
| 640 |
demo_text = shiny::reactiveVal("")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 641 |
|
| 642 |
-
#
|
| 643 |
-
|
| 644 |
|
| 645 |
-
#
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
shiny::updateTextAreaInput(session, "input_text", value = "")
|
| 654 |
-
}
|
| 655 |
-
}, ignoreInit = TRUE)
|
| 656 |
|
| 657 |
-
#
|
| 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 |
-
|
| 662 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 663 |
|
| 664 |
-
|
| 665 |
-
|
|
|
|
|
|
|
|
|
|
| 666 |
|
| 667 |
-
|
| 668 |
-
|
|
|
|
|
|
|
| 669 |
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 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 |
-
|
| 705 |
-
|
| 706 |
-
extracted
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
|
| 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 |
-
|
| 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 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
|
|
|
| 737 |
}, error = function(e) {
|
| 738 |
-
|
| 739 |
-
|
| 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 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 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 |
-
|
| 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 |
-
|
| 806 |
-
|
| 807 |
-
type = "error",
|
| 808 |
-
duration = NULL
|
| 809 |
-
)
|
| 810 |
})
|
| 811 |
})
|
| 812 |
|
| 813 |
# Render text file preview
|
| 814 |
output$textfile_preview_ui = shiny::renderUI({
|
| 815 |
-
|
| 816 |
-
if (
|
| 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 |
-
|
| 842 |
-
|
|
|
|
|
|
|
|
|
|
| 843 |
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
|
|
|
|
|
|
| 847 |
type = "warning",
|
| 848 |
-
duration =
|
| 849 |
)
|
| 850 |
}
|
| 851 |
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 855 |
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
type = "message",
|
| 859 |
-
duration = 3
|
| 860 |
-
)
|
| 861 |
}, error = function(e) {
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
type = "error",
|
| 865 |
-
duration = NULL
|
| 866 |
-
)
|
| 867 |
})
|
| 868 |
})
|
| 869 |
|
| 870 |
# Render image preview
|
| 871 |
output$image_preview_ui = shiny::renderUI({
|
| 872 |
-
|
| 873 |
-
if (is.null(
|
| 874 |
|
| 875 |
shiny::tagList(
|
| 876 |
shiny::div(
|
| 877 |
class = "image-preview-grid",
|
| 878 |
-
lapply(
|
| 879 |
shiny::div(
|
| 880 |
class = "image-preview-item",
|
| 881 |
shiny::tags$img(
|
| 882 |
-
src = base64enc::dataURI(file =
|
| 883 |
-
alt =
|
| 884 |
)
|
| 885 |
)
|
| 886 |
})
|
| 887 |
),
|
| 888 |
shiny::div(
|
| 889 |
class = "image-preview-count",
|
| 890 |
-
paste(
|
| 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 |
-
|
| 922 |
-
|
| 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 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 939 |
}, error = function(e) {
|
|
|
|
| 940 |
shiny::removeNotification(id = "convert_notif")
|
| 941 |
-
|
| 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 =
|
| 962 |
alt = paste("Page", i)
|
| 963 |
)
|
| 964 |
)
|
|
@@ -971,279 +976,248 @@ server = function(input, output, session) {
|
|
| 971 |
)
|
| 972 |
})
|
| 973 |
|
| 974 |
-
#
|
| 975 |
-
|
| 976 |
-
|
| 977 |
-
|
| 978 |
-
|
| 979 |
-
|
| 980 |
-
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
|
| 990 |
-
|
| 991 |
-
|
| 992 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 993 |
)
|
| 994 |
-
} else {
|
| 995 |
-
# Generic placeholders for non-demo modes
|
| 996 |
-
example_labels = character(0)
|
| 997 |
-
example_descs = character(0)
|
| 998 |
}
|
| 999 |
|
| 1000 |
-
fields =
|
| 1001 |
-
|
| 1002 |
-
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
|
| 1006 |
-
|
| 1007 |
-
|
| 1008 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1009 |
|
| 1010 |
shiny::div(
|
| 1011 |
-
|
| 1012 |
shiny::tags$span(
|
| 1013 |
style = "color: #C41230; font-weight: 600; font-size: 0.9em;",
|
| 1014 |
paste("Field", i)
|
| 1015 |
),
|
| 1016 |
-
shiny::textInput(
|
| 1017 |
-
|
| 1018 |
-
|
| 1019 |
-
value = default_label
|
| 1020 |
-
),
|
| 1021 |
-
shiny::textInput(
|
| 1022 |
-
paste0("field_desc_", i),
|
| 1023 |
"Description:",
|
| 1024 |
-
value =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1025 |
)
|
| 1026 |
)
|
| 1027 |
-
})
|
| 1028 |
-
do.call(shiny::tagList, fields)
|
| 1029 |
})
|
| 1030 |
|
| 1031 |
-
#
|
| 1032 |
-
|
| 1033 |
-
|
| 1034 |
-
|
| 1035 |
-
|
| 1036 |
-
|
| 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 |
-
#
|
| 1052 |
-
|
| 1053 |
-
if (
|
| 1054 |
-
|
| 1055 |
-
|
| 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 |
-
|
| 1107 |
-
if (!
|
| 1108 |
-
|
| 1109 |
-
shiny::
|
| 1110 |
-
|
| 1111 |
-
|
| 1112 |
-
error_msg
|
| 1113 |
),
|
| 1114 |
type = "error",
|
| 1115 |
-
duration =
|
| 1116 |
)
|
| 1117 |
return()
|
| 1118 |
}
|
| 1119 |
-
|
| 1120 |
-
|
| 1121 |
-
|
| 1122 |
-
|
| 1123 |
-
|
| 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 |
-
|
| 1145 |
-
|
| 1146 |
-
|
| 1147 |
-
|
| 1148 |
-
|
| 1149 |
-
|
| 1150 |
-
|
|
|
|
| 1151 |
|
| 1152 |
-
|
| 1153 |
-
|
| 1154 |
-
|
| 1155 |
-
|
| 1156 |
-
|
| 1157 |
-
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
-
|
| 1164 |
-
|
| 1165 |
-
|
| 1166 |
-
|
| 1167 |
-
|
| 1168 |
-
|
| 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 |
-
|
| 1211 |
-
|
| 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 |
-
|
| 1219 |
-
|
| 1220 |
-
|
| 1221 |
-
|
| 1222 |
-
|
| 1223 |
-
|
| 1224 |
-
|
| 1225 |
-
|
| 1226 |
-
|
| 1227 |
-
|
| 1228 |
-
|
| 1229 |
-
|
| 1230 |
-
|
| 1231 |
-
|
| 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 |
-
|
| 1246 |
-
if (is.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 |
-
|
| 1265 |
-
if (!is.null(
|
| 1266 |
-
utils::write.csv(
|
| 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("×")
|
| 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:
|
| 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:
|
|
|
|
| 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 |
-
/*
|
| 528 |
-
.
|
| 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 {
|