fmegahed's picture
Add the University of Cambridge logo (version 3.0.1)
1a911d5
Raw History Blame Contribute Delete
41.9 kB
library(shiny)
library(ellmer)
library(pdftools)
library(magick)
library(base64enc)
library(jsonlite)
# App logic lives in R/ and is sourced here in a fixed order
# (R/_disable_autoload.R stops Shiny from sourcing the directory a second time)
for (r_file in c("config.R", "fields.R", "sources.R", "extraction.R", "snippets.R", "help.R")) {
source(file.path("R", r_file), local = TRUE)
}
# Uploads larger than this are rejected by Shiny
options(shiny.maxRequestSize = MAX_UPLOAD_MB * 1024^2)
# Model used for every call and named in every piece of UI text
EXTRACTION_MODEL = get_extraction_model()
APP_VERSION = paste0("Version ", APP_VERSION_NUMBER, " | ", APP_VERSION_DATE)
PAPER_TITLE = "What Quality Engineers Need to Know About Generative AI: Structured Data Extraction"
# Field templates (one JSON file each in config/templates)
FIELD_TEMPLATES = load_templates(file.path("config", "templates"))
# Fields shown at start-up and loaded with the demo data
demo_fields = function() {
FIELD_TEMPLATES[["recall_notice"]]$fields[seq_len(NUM_EXAMPLE_FIELDS)]
}
# One input tab button (the panels are shown and hidden by www/app.js)
input_tab = function(id, label, icon_name, selected = FALSE) {
shiny::tags$button(
id = paste0("tab-", id),
type = "button",
class = if (selected) "input-tab active" else "input-tab",
role = "tab",
`aria-selected` = if (selected) "true" else "false",
`aria-controls` = paste0("panel-", id),
tabindex = if (selected) "0" else "-1",
shiny::icon(icon_name),
paste0(" ", label)
)
}
# Container of one input tab's content
input_panel = function(id, ..., selected = FALSE) {
shiny::div(
id = paste0("panel-", id),
class = "input-panel",
role = "tabpanel",
`aria-labelledby` = paste0("tab-", id),
style = if (selected) NULL else "display: none;",
...
)
}
# Heading followed by its "?" help button
heading_with_help = function(heading, topic) {
shiny::div(class = "heading-with-help", heading, help_button(topic))
}
# Define UI for the app
ui = shiny::fluidPage(
lang = "en",
# Add Miami theme CSS and favicon
shiny::tags$head(
shiny::tags$link(rel = "icon", type = "image/svg+xml", href = "favicon.svg"),
shiny::tags$link(rel = "stylesheet", type = "text/css", href = "miami-theme.css"),
shiny::tags$title("AI-Powered Text Extraction Tool")
),
# Custom header with Miami branding
shiny::div(
class = "app-header",
shiny::div(
class = "header-content",
shiny::div(
class = "header-left",
shiny::tags$h1("AI-Powered Text Extraction Tool"),
shiny::p(class = "subtitle", "Extract Structured Data from Text, Documents, and Images")
),
shiny::div(
class = "header-right",
shiny::p(APP_VERSION, " | ", changes_link()),
shiny::p(
shiny::tags$strong("Authors: "),
"Fadel M. Megahed, Ying-Ju Chen, Yamin Dahwich, Arthur Carvalho, L. Allison Jones-Farmer, Ibrahim Yousif, and Inez M. Zwetsloot"
),
shiny::p(
shiny::tags$strong("Contact: "),
shiny::tags$a(
href = "mailto:fmegahed@miamioh.edu",
style = "color: #EFDB72;",
"fmegahed@miamioh.edu"
)
)
)
)
),
# University logos section
shiny::div(
class = "logo-container",
shiny::tags$img(
src = "miami-logo.png",
alt = "Miami University Logo",
style = "height: 55px;"
),
shiny::div(class = "logo-divider"),
shiny::tags$img(
src = "university-of-dayton-vector-logo.png",
alt = "University of Dayton Logo",
style = "height: 50px;"
),
shiny::div(class = "logo-divider"),
shiny::tags$img(
src = "university-of-cambridge-logo.png",
alt = "University of Cambridge Logo",
style = "height: 38px;"
),
shiny::div(class = "logo-divider"),
shiny::tags$img(
src = "uva-compacte-logo.png",
alt = "University of Amsterdam Logo",
style = "height: 50px;"
),
shiny::div(
style = "margin-left: auto; font-size: 0.85em; color: #666;",
shiny::p(
style = "margin: 0;",
"A collaboration between",
shiny::tags$strong("Miami University,"),
"the",
shiny::tags$strong("University of Dayton,"),
"the",
shiny::tags$strong("University of Cambridge,"),
"and the",
shiny::tags$strong("University of Amsterdam")
)
)
),
# How to use section
shiny::div(
class = "how-to-use",
shiny::div(
style = "display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;",
shiny::tags$h4(
style = "margin: 0;",
shiny::icon("circle-info"),
" How to Use This App"
),
shiny::tags$button(
id = "open-video-modal",
type = "button",
class = "btn btn-video-tutorial",
`aria-haspopup` = "dialog",
shiny::icon("play-circle"),
" Watch Video Tutorial"
)
),
shiny::tags$h5(
style = "color: #C41230; margin-top: 0;",
shiny::icon("bolt"),
" Quick Demo"
),
shiny::p(
"Click the ",
shiny::tags$strong("'Load Demo Data'"),
" button to load two sample NHTSA recall notices with pre-configured extraction fields. ",
"Then click ",
shiny::tags$strong("'Extract Data'"),
" to see the model extract structured information from the text. ",
"You can also add more fields, change field labels and types, and modify descriptions. ",
shiny::tags$em(
"(Note: The field description is the instruction the model receives. The label names the column in the results.)"
)
),
shiny::tags$h5(
style = "color: #C41230; margin-top: 15px;",
shiny::icon("file-import"),
" Input Methods"
),
shiny::p(
"Choose from six input methods to provide your source content. Only the tab that is open when you click 'Extract Data' is used."
),
shiny::tags$ul(
style = "margin-bottom: 10px;",
shiny::tags$li(
shiny::tags$strong("Demo Data:"),
" Try the app with pre-loaded NHTSA vehicle recall examples."
),
shiny::tags$li(
shiny::tags$strong("Paste Text:"),
" Directly paste text content. Separate multiple items with double line breaks."
),
shiny::tags$li(
shiny::tags$strong("Text File:"),
" Upload .txt, .csv (single column), or .md files."
),
shiny::tags$li(
shiny::tags$strong("Readable PDF:"),
" Upload a machine-readable PDF. Text is extracted automatically."
),
shiny::tags$li(
shiny::tags$strong("Scanned PDF:"),
" Upload scanned/image-based PDFs. Pages are converted to images for vision processing."
),
shiny::tags$li(
shiny::tags$strong("Upload Images:"),
" Upload images (PNG, JPEG, WebP, GIF) for vision-based extraction."
)
),
shiny::div(
style = "font-size: 0.9em; color: #666;",
shiny::icon("info-circle"),
" ",
shiny::tags$em("Notes:"),
shiny::tags$ul(
style = "margin: 0.25em 0 0 1.25em; padding: 0; line-height: 1.3;",
shiny::tags$li(
style = "margin: 0; padding: 0;",
sprintf(
"Readable PDF and Scanned PDF are limited to the first %d pages, and Upload Images to %d images, in this free app.",
MAX_PAGES_OR_IMAGES, MAX_PAGES_OR_IMAGES
)
),
shiny::tags$li(
style = "margin: 0; padding: 0;",
sprintf(
"Any uploaded file must be at most %d MB, to keep the app responsive and avoid upload timeouts.",
MAX_UPLOAD_MB
)
),
shiny::tags$li(
style = "margin: 0; padding: 0;",
"The '?' buttons next to each part of the app explain what it does and what is sent to the model."
)
)
),
shiny::tags$h5(
style = "color: #C41230; margin-top: 15px;",
shiny::icon("edit"),
" Custom Fields"
),
shiny::p(
"Customize your extraction by adjusting:"
),
shiny::tags$ul(
style = "margin-bottom: 10px;",
shiny::tags$li(
"The ", shiny::tags$strong("number of fields"), " to extract (up to ", MAX_FIELDS, ")"
),
shiny::tags$li("Your own ", shiny::tags$strong("field labels"), " (the column names of the results)"),
shiny::tags$li("Detailed ", shiny::tags$strong("field descriptions"), " to guide the model"),
shiny::tags$li(
"The ", shiny::tags$strong("field type"), " (text, number, yes/no, or list of text)"
)
),
shiny::p(
"You can start from a ",
shiny::tags$strong("template"),
" 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."
),
shiny::p(
shiny::icon("lightbulb"),
" ",
shiny::tags$em(
"Specific field descriptions give the model less room for interpretation. Always check the extracted values against the source documents."
)
)
),
# Video Modal
shiny::div(
id = "video-modal",
class = "video-modal",
role = "dialog",
`aria-modal` = "true",
`aria-labelledby` = "video-modal-title",
shiny::div(
class = "video-modal-content",
shiny::tags$button(
id = "close-video-modal",
type = "button",
class = "video-modal-close",
`aria-label` = "Close video tutorial",
shiny::HTML("×")
),
shiny::tags$h4(
id = "video-modal-title",
style = "color: #C41230; margin-top: 0; margin-bottom: 15px;",
shiny::icon("play-circle"),
" Video Tutorial"
),
shiny::div(
class = "video-modal-wrapper",
# The video address is set by www/app.js when the dialog opens
shiny::tags$iframe(
id = "tutorial-video",
title = "Video tutorial",
src = "",
allow = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",
allowfullscreen = NA
)
)
)
),
# JavaScript for the input tabs, the video dialog and the copy buttons
shiny::tags$script(src = "app.js"),
shiny::sidebarLayout(
shiny::sidebarPanel(
width = 4,
class = "sidebar-panel",
heading_with_help(
shiny::tags$h4(
id = "input-heading",
style = "color: #C41230; margin-top: 0; margin-bottom: 0;",
shiny::icon("file-alt"),
" Input Configuration"
),
"source"
),
# Input method tabs (3 rows of 2 tabs)
shiny::div(
class = "input-tabs-container",
role = "tablist",
`aria-labelledby` = "input-heading",
shiny::div(
class = "input-tabs",
role = "presentation",
input_tab("demo", "Demo Data", "flask", selected = TRUE),
input_tab("paste", "Paste Text", "paste")
),
shiny::div(
class = "input-tabs",
role = "presentation",
input_tab("textfile", "Text File", "file-lines"),
input_tab("pdf", "Readable PDF", "file-pdf")
),
shiny::div(
class = "input-tabs",
role = "presentation",
input_tab("scanned", "Scanned PDF", "file-image"),
input_tab("image", "Upload Images", "images")
)
),
# Demo data panel
input_panel(
"demo",
selected = TRUE,
shiny::p(
"Try the app with pre-loaded NHTSA vehicle recall data. Click the button below to load sample text and pre-configured extraction fields."
),
shiny::actionButton(
"load_example",
shiny::tags$span(shiny::icon("play"), " Load Demo Data"),
class = "btn-primary",
style = "margin-top: 10px;"
),
shiny::uiOutput("demo_preview_ui")
),
# Paste text panel
input_panel(
"paste",
shiny::textAreaInput(
"input_text",
shiny::tags$span(class = "sr-only", "Text to extract from"),
rows = 8,
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."
)
),
# Text file upload panel
input_panel(
"textfile",
shiny::fileInput(
"text_file",
shiny::tags$span(class = "sr-only", "Text file"),
accept = c(".txt", ".csv", ".md"),
placeholder = "No file selected",
buttonLabel = shiny::tags$span(shiny::icon("upload"), " Browse...")
),
shiny::helpText(
shiny::icon("info-circle"),
" Upload a text file (.txt, .csv, .md). Content will be extracted automatically."
),
shiny::div(
class = "file-format-note",
shiny::tags$strong("File format guidelines:"),
shiny::tags$ul(
style = "margin: 5px 0 0 0; padding-left: 20px; font-size: 0.85em;",
shiny::tags$li(".txt: Plain text, separate items with double line breaks"),
shiny::tags$li(".csv: Single column of text entries (one per row, first row is a header)"),
shiny::tags$li(".md: Markdown file treated as plain text, split at double line breaks")
)
),
shiny::uiOutput("textfile_preview_ui")
),
# PDF upload panel (machine-readable)
input_panel(
"pdf",
shiny::fileInput(
"pdf_file",
shiny::tags$span(class = "sr-only", "Readable PDF file"),
accept = ".pdf",
placeholder = "No file selected",
buttonLabel = shiny::tags$span(shiny::icon("upload"), " Browse...")
),
shiny::helpText(
shiny::icon("info-circle"),
" Upload a machine-readable PDF. Text will be extracted automatically and sent as one item."
),
shiny::div(
class = "limit-note",
shiny::icon("exclamation-circle"),
shiny::tags$em(limit_note("pages"))
),
shiny::uiOutput("pdf_preview_ui")
),
# Image upload panel
input_panel(
"image",
shiny::fileInput(
"image_file",
shiny::tags$span(class = "sr-only", "Image files"),
accept = c(".png", ".jpg", ".jpeg", ".webp", ".gif"),
multiple = TRUE,
placeholder = "No file selected",
buttonLabel = shiny::tags$span(shiny::icon("upload"), " Browse...")
),
shiny::helpText(
shiny::icon("info-circle"),
" Upload image(s) containing text to extract. Supports PNG, JPEG, WebP, and GIF."
),
shiny::div(
class = "limit-note",
shiny::icon("exclamation-circle"),
shiny::tags$em(limit_note("images"))
),
shiny::uiOutput("image_preview_ui")
),
# Scanned PDF panel
input_panel(
"scanned",
shiny::fileInput(
"scanned_pdf_file",
shiny::tags$span(class = "sr-only", "Scanned PDF file"),
accept = ".pdf",
placeholder = "No file selected",
buttonLabel = shiny::tags$span(shiny::icon("upload"), " Browse...")
),
shiny::helpText(
shiny::icon("info-circle"),
" Upload a scanned/image-based PDF. Pages will be converted to images for vision processing."
),
shiny::div(
class = "limit-note",
shiny::icon("exclamation-circle"),
shiny::tags$em(limit_note("pages"))
),
shiny::uiOutput("scanned_pdf_preview_ui")
),
shiny::hr(class = "field-separator"),
heading_with_help(
shiny::tags$h4(
style = "color: #C41230; margin-top: 0; margin-bottom: 0;",
shiny::icon("tags"),
" Field Definitions"
),
"fields"
),
shiny::div(
class = "template-row",
shiny::selectInput(
"template_id",
"Start from a template:",
choices = template_choices(FIELD_TEMPLATES),
selectize = FALSE
),
shiny::actionButton("apply_template", "Use template", class = "btn-info")
),
shiny::uiOutput("template_description_ui"),
shiny::numericInput(
"num_fields",
shiny::tags$span(
shiny::icon("list-ol"),
" Number of Fields to Extract:"
),
value = NUM_EXAMPLE_FIELDS,
min = 1,
max = MAX_FIELDS
),
shiny::helpText(
shiny::icon("info-circle"),
" 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.')."
),
shiny::uiOutput("fields_ui"),
shiny::div(
class = "fields-file-row",
shiny::downloadButton("download_fields", "Save fields (JSON)", class = "btn-info"),
shiny::fileInput(
"fields_upload",
"Load fields (JSON):",
accept = c(".json", "application/json"),
placeholder = "No file selected",
buttonLabel = shiny::tags$span(shiny::icon("upload"), " Browse...")
)
),
shiny::hr(class = "field-separator"),
shiny::div(
class = "heading-with-help",
shiny::actionButton(
"extract_btn",
shiny::tags$span(shiny::icon("magic"), " Extract Data"),
class = "btn-primary"
),
help_button("extract")
)
),
shiny::mainPanel(
width = 8,
class = "main-panel",
shiny::div(
class = "info-card",
shiny::div(
class = "heading-with-help section-heading",
shiny::tags$h3(
style = "color: #C41230; font-weight: 600; margin: 0;",
shiny::icon("table"),
" Structured Data Extracted Using AI"
),
help_button("results")
),
shiny::uiOutput("results_status_ui"),
shiny::div(class = "results-table-wrapper", shiny::tableOutput("extracted_table")),
shiny::uiOutput("download_btn_ui")
),
shiny::div(
class = "info-card",
shiny::tags$h3(
class = "section-heading",
shiny::icon("code"),
" Reproduce in Code"
),
shiny::p(
"The code below runs the same extraction, with your current field definitions and the model ",
shiny::tags$code(EXTRACTION_MODEL),
", on your own computer and your own data. It needs your own ",
MODEL_PROVIDER,
" API key."
),
shiny::uiOutput("snippet_status_ui"),
shiny::tags$details(
class = "code-details",
shiny::tags$summary("R code (ellmer package)"),
shiny::tags$button(
type = "button",
class = "btn btn-info copy-btn",
`data-target` = "snippet_r",
"Copy R code"
),
shiny::verbatimTextOutput("snippet_r")
),
shiny::tags$details(
class = "code-details",
shiny::tags$summary("Python code (chatlas package)"),
shiny::tags$button(
type = "button",
class = "btn btn-info copy-btn",
`data-target` = "snippet_python",
"Copy Python code"
),
shiny::verbatimTextOutput("snippet_python")
)
),
shiny::div(
class = "tips-section",
shiny::tags$h4(shiny::icon("lightbulb"), " Tips for Better Results"),
shiny::tags$ul(
shiny::tags$li("Use ", shiny::tags$strong("specific field descriptions"), " and say what to return when the information is absent"),
shiny::tags$li("Start with ", shiny::tags$strong("more fields"), " and remove unnecessary ones later"),
shiny::tags$li("If results are inaccurate, try ", shiny::tags$strong("rephrasing"), " your field descriptions"),
shiny::tags$li("For ", shiny::tags$strong("multiple text blocks"), ", separate each with a double line break"),
shiny::tags$li("Each text block should contain ", shiny::tags$strong("complete information"), " for all fields you want to extract"),
shiny::tags$li(shiny::tags$strong("Check the results"), " against the source documents before you use them")
)
),
shiny::div(
class = "note-section",
shiny::tags$h4(shiny::icon("cog"), " Technical Note"),
shiny::p(
"This app sends its requests to the ",
MODEL_PROVIDER,
" model ",
shiny::tags$code(EXTRACTION_MODEL),
" through ",
shiny::tags$code("chat_openai()"),
" from the ",
shiny::tags$a(
href = "https://ellmer.tidyverse.org/",
target = "_blank",
"ellmer"
),
" package. The app does not provide an option to change the model."
),
shiny::p(
"For ",
shiny::tags$strong("complete privacy"),
", consider using local open-weight models via ",
shiny::tags$a(
href = "https://ellmer.tidyverse.org/reference/chat_ollama.html",
target = "_blank",
shiny::tags$code("chat_ollama()")
),
" from ellmer, which connects to ",
shiny::tags$a(
href = "https://ollama.com/",
target = "_blank",
"Ollama"
),
" for running models locally on your machine."
),
shiny::p(
"ellmer also supports models from other providers. For example, Claude models require ",
shiny::tags$a(
href = "https://ellmer.tidyverse.org/reference/chat_anthropic.html",
target = "_blank",
shiny::tags$code("chat_anthropic()")
),
" and Gemini models require ",
shiny::tags$a(
href = "https://ellmer.tidyverse.org/reference/chat_google_gemini.html",
target = "_blank",
shiny::tags$code("chat_google_gemini()")
),
", each with their respective API keys configured in the R environment. ",
"The code under 'Reproduce in Code' can be adapted by replacing the ",
shiny::tags$code("chat_openai()"),
" call."
)
)
)
),
# Footer
shiny::div(
class = "app-footer",
shiny::p(
"Built with ",
shiny::tags$a(href = "https://shiny.posit.co/", target = "_blank", "Shiny"),
" and ",
shiny::tags$a(href = "https://ellmer.tidyverse.org/", target = "_blank", "ellmer"),
" | ",
"Powered by ",
MODEL_PROVIDER
),
shiny::p(
shiny::tags$em(
paste0(
"Companion app to the paper '",
PAPER_TITLE,
"', in preparation by the app's authors for submission to "
),
shiny::tags$a(
href = "https://www.tandfonline.com/journals/lqen20",
target = "_blank",
"Quality Engineering."
)
)
)
)
)
# Define server logic required to generate dynamic UI and extract data
server = function(input, output, session) {
# ---- State ---------------------------------------------------------------
# What each input tab currently holds. Each tab has its own store, and the
# extraction reads only the store of the active tab.
demo_text = shiny::reactiveVal("")
textfile_items = shiny::reactiveVal(character(0))
pdf_text = shiny::reactiveVal("")
image_files = shiny::reactiveVal(NULL)
scanned_pdf_paths = shiny::reactiveVal(NULL)
# Result of the last extraction: list(table, summary, n_failed, source)
extraction = shiny::reactiveVal(NULL)
# Field definitions. field_state holds the definitions the inputs were built
# from; field_gen is the generation number used in the input ids. The inputs
# are rebuilt only by set_fields(), never by a tab change.
field_state = shiny::reactiveVal(demo_fields())
field_gen = shiny::reactiveVal(0)
# Creates the chat for each item. Tests replace it through the option.
chat_factory = getOption("ste.chat_factory", default = make_chat_factory(EXTRACTION_MODEL))
# ---- Helpers -------------------------------------------------------------
notify = function(message, type = "message", duration = 3) {
icon_name = switch(
type,
error = "exclamation-triangle",
warning = "exclamation-circle",
"check-circle"
)
shiny::showNotification(
shiny::tags$span(shiny::icon(icon_name), " ", message),
type = type,
duration = duration
)
}
# Active input tab (the client reports it through input$input_method)
active_method = shiny::reactive({
method = input$input_method
if (is.null(method) || !(method %in% INPUT_METHODS)) "demo" else method
})
# Field definitions as currently shown, including what the user has typed
current_fields = shiny::reactive({
merge_field_inputs(field_state(), function(id) input[[id]], field_gen())
})
# Replace the field definitions and rebuild their inputs
set_fields = function(fields) {
field_state(fields)
field_gen(shiny::isolate(field_gen()) + 1)
shiny::updateNumericInput(session, "num_fields", value = length(fields))
}
# ---- Input sources -------------------------------------------------------
# Load example data
shiny::observeEvent(input$load_example, {
demo_text(DEMO_TEXT)
set_fields(demo_fields())
notify("Demo data loaded. Click 'Extract Data' to see results.")
})
# Render demo preview
output$demo_preview_ui = shiny::renderUI({
text = demo_text()
if (is.null(text) || nchar(text) == 0) return(NULL)
shiny::div(
class = "pdf-preview",
style = "margin-top: 15px;",
shiny::tags$strong("Loaded Demo Text:"),
shiny::tags$pre(preview_text(text))
)
})
# Process uploaded PDF
shiny::observeEvent(input$pdf_file, {
shiny::req(input$pdf_file)
tryCatch({
extracted = read_pdf_text(input$pdf_file$datapath, MAX_PAGES_OR_IMAGES)
if (extracted$total_pages > extracted$used_pages) {
notify(
sprintf(
"The PDF has %d pages. Only the first %d will be processed (free app limit).",
extracted$total_pages, extracted$used_pages
),
type = "warning",
duration = 5
)
}
pdf_text(extracted$text)
if (nchar(extracted$text) == 0) {
notify(
"No text was found in this PDF. If it is a scan, use the Scanned PDF tab.",
type = "warning",
duration = 8
)
} else {
notify(sprintf("PDF processed. Text of %d page(s) read.", extracted$used_pages))
}
}, error = function(e) {
pdf_text("")
notify(paste("Error reading PDF:", conditionMessage(e)), type = "error", duration = NULL)
})
})
# Render PDF text preview
output$pdf_preview_ui = shiny::renderUI({
text = pdf_text()
if (is.null(text) || nchar(text) == 0) return(NULL)
shiny::div(
class = "pdf-preview",
shiny::tags$strong("Extracted Text Preview:"),
shiny::tags$pre(preview_text(text))
)
})
# Process uploaded text file
shiny::observeEvent(input$text_file, {
shiny::req(input$text_file)
tryCatch({
items = read_text_file_items(input$text_file$datapath, input$text_file$name)
textfile_items(items)
if (length(items) == 0) {
notify("No text was found in this file.", type = "warning", duration = 8)
} else {
notify(sprintf("File processed. %d item(s) found.", length(items)))
}
}, error = function(e) {
textfile_items(character(0))
notify(paste("Error reading file:", conditionMessage(e)), type = "error", duration = NULL)
})
})
# Render text file preview
output$textfile_preview_ui = shiny::renderUI({
items = textfile_items()
if (length(items) == 0) return(NULL)
shiny::div(
class = "pdf-preview",
shiny::tags$strong(sprintf("Extracted Text Preview (%d item(s)):", length(items))),
shiny::tags$pre(preview_text(items))
)
})
# Process uploaded images
shiny::observeEvent(input$image_file, {
shiny::req(input$image_file)
tryCatch({
files = input$image_file
files$mime = vapply(
seq_len(nrow(files)),
function(i) detect_mime(files$datapath[i], files$name[i]),
character(1)
)
not_images = files$name[is.na(files$mime)]
files = files[!is.na(files$mime), , drop = FALSE]
if (length(not_images) > 0) {
notify(
paste("Not a supported image, skipped:", paste(not_images, collapse = ", ")),
type = "warning",
duration = 8
)
}
if (nrow(files) > MAX_PAGES_OR_IMAGES) {
notify(
sprintf(
"Only the first %d images will be processed (free app limit).",
MAX_PAGES_OR_IMAGES
),
type = "warning",
duration = 5
)
files = files[seq_len(MAX_PAGES_OR_IMAGES), , drop = FALSE]
}
image_files(if (nrow(files) > 0) files else NULL)
if (nrow(files) > 0) notify(sprintf("%d image(s) uploaded.", nrow(files)))
}, error = function(e) {
image_files(NULL)
notify(paste("Error uploading images:", conditionMessage(e)), type = "error", duration = NULL)
})
})
# Render image preview
output$image_preview_ui = shiny::renderUI({
files = image_files()
if (is.null(files) || nrow(files) == 0) return(NULL)
shiny::tagList(
shiny::div(
class = "image-preview-grid",
lapply(seq_len(nrow(files)), function(i) {
shiny::div(
class = "image-preview-item",
shiny::tags$img(
src = base64enc::dataURI(file = files$datapath[i], mime = files$mime[i]),
alt = paste0("Image ", i, ": ", files$name[i])
)
)
})
),
shiny::div(
class = "image-preview-count",
paste(nrow(files), "image(s) ready for extraction")
)
)
})
# Process scanned PDF (convert pages to images)
shiny::observeEvent(input$scanned_pdf_file, {
shiny::req(input$scanned_pdf_file)
tryCatch({
shiny::showNotification(
"Converting PDF pages to images...",
type = "message",
duration = NULL,
id = "convert_notif"
)
rendered = render_pdf_pages(input$scanned_pdf_file$datapath, MAX_PAGES_OR_IMAGES)
scanned_pdf_paths(rendered$paths)
shiny::removeNotification(id = "convert_notif")
if (rendered$total_pages > length(rendered$paths)) {
notify(
sprintf(
"The PDF has %d pages. Only the first %d will be processed (free app limit).",
rendered$total_pages, length(rendered$paths)
),
type = "warning",
duration = 5
)
}
notify(sprintf("%d page(s) converted.", length(rendered$paths)))
}, error = function(e) {
scanned_pdf_paths(NULL)
shiny::removeNotification(id = "convert_notif")
notify(paste("Error processing scanned PDF:", conditionMessage(e)), type = "error", duration = NULL)
})
})
# Render scanned PDF preview
output$scanned_pdf_preview_ui = shiny::renderUI({
paths = scanned_pdf_paths()
if (is.null(paths) || length(paths) == 0) return(NULL)
shiny::tagList(
shiny::div(
class = "image-preview-grid",
lapply(seq_along(paths), function(i) {
shiny::div(
class = "image-preview-item",
shiny::tags$img(
src = base64enc::dataURI(file = paths[i], mime = detect_mime(paths[i])),
alt = paste("Page", i)
)
)
})
),
shiny::div(
class = "image-preview-count",
paste(length(paths), "page(s) ready for extraction")
)
)
})
# The previews sit in panels that www/app.js shows and hides
for (preview_id in c("demo_preview_ui", "pdf_preview_ui", "textfile_preview_ui",
"image_preview_ui", "scanned_pdf_preview_ui")) {
shiny::outputOptions(output, preview_id, suspendWhenHidden = FALSE)
}
# What each tab holds right now, in the form resolve_source_items() expects
current_sources = function() {
files = image_files()
list(
demo = demo_text(),
paste = input$input_text,
textfile = textfile_items(),
pdf = pdf_text(),
scanned = scanned_pdf_paths(),
image = if (is.null(files)) NULL else files$datapath
)
}
# ---- Field definitions ---------------------------------------------------
# Change the number of fields, keeping everything the user has entered
shiny::observeEvent(input$num_fields, {
n = clamp_num_fields(input$num_fields, MAX_FIELDS)
if (is.na(n)) return()
if (!isTRUE(n == input$num_fields)) {
shiny::updateNumericInput(session, "num_fields", value = n)
notify(
sprintf("The number of fields must be between 1 and %d.", MAX_FIELDS),
type = "warning",
duration = 5
)
}
fields = current_fields()
if (n == length(fields)) return()
field_state(resize_fields(fields, n))
field_gen(field_gen() + 1)
}, ignoreInit = TRUE)
# Fill the fields from a template
shiny::observeEvent(input$apply_template, {
template = FIELD_TEMPLATES[[input$template_id]]
if (is.null(template)) return()
set_fields(template$fields)
notify(sprintf("Template '%s' loaded. You can edit the fields.", template$name))
})
output$template_description_ui = shiny::renderUI({
template = FIELD_TEMPLATES[[input$template_id]]
if (is.null(template)) return(NULL)
shiny::helpText(sprintf("%s %d fields.", template$description, length(template$fields)))
})
# Restore the fields from an uploaded JSON file
shiny::observeEvent(input$fields_upload, {
shiny::req(input$fields_upload)
loaded = read_fields_json(input$fields_upload$datapath, MAX_FIELDS)
if (!loaded$ok) {
notify(
paste("The field definitions were not loaded.", loaded$error),
type = "error",
duration = NULL
)
return()
}
set_fields(loaded$fields)
notify(sprintf("%d field(s) loaded from the file.", length(loaded$fields)))
})
# Download the current field definitions as JSON
output$download_fields = shiny::downloadHandler(
filename = function() {
paste0("field_definitions_", format(Sys.time(), "%Y%m%d_%H%M%S"), ".json")
},
content = function(file) {
writeLines(fields_to_json(current_fields()), file, useBytes = TRUE)
}
)
# Generate the inputs for each field's label, description and type.
# Depends only on field_gen, so typing and tab changes never rebuild them.
output$fields_ui = shiny::renderUI({
gen = field_gen()
fields = shiny::isolate(field_state())
do.call(shiny::tagList, lapply(seq_along(fields), function(i) {
ids = field_input_ids(gen, i)
field = fields[[i]]
shiny::div(
class = "field-card",
shiny::tags$span(
style = "color: #C41230; font-weight: 600; font-size: 0.9em;",
paste("Field", i)
),
shiny::textInput(ids$label, "Label:", value = field$label),
shiny::textAreaInput(
ids$description,
"Description:",
value = field$description,
rows = 5,
placeholder = "Describe what to extract for this field, and what to return when it is absent"
),
shiny::selectInput(
ids$type,
"Type:",
choices = stats::setNames(names(FIELD_TYPES), FIELD_TYPES),
selected = if (field$type %in% names(FIELD_TYPES)) field$type else "text",
selectize = FALSE
)
)
}))
})
# ---- Help ----------------------------------------------------------------
lapply(HELP_TOPICS, function(topic) {
shiny::observeEvent(input[[paste0("help_", topic)]], {
shiny::showModal(help_modal(topic))
})
})
# ---- Extraction ----------------------------------------------------------
# When the extract button is clicked, perform extraction
shiny::observeEvent(input$extract_btn, {
# Items come from the active tab only
resolved = resolve_source_items(active_method(), current_sources(), MAX_PAGES_OR_IMAGES)
if (!resolved$ok) {
notify(resolved$error, type = "error", duration = 5)
return()
}
checked = validate_fields(current_fields(), MAX_FIELDS)
if (!checked$ok) {
notify(
shiny::tagList(
"Please correct the field definitions:",
shiny::tags$ul(lapply(checked$errors, shiny::tags$li))
),
type = "error",
duration = NULL
)
return()
}
if (length(checked$notes) > 0) {
notify(
shiny::tags$ul(lapply(checked$notes, shiny::tags$li)),
type = "warning",
duration = 10
)
}
if (is.null(getOption("ste.chat_factory")) && !api_key_available()) {
notify(
"The extraction service is not available right now. Please try again later, or contact the authors if the problem continues.",
type = "error",
duration = NULL
)
return()
}
items = resolved$items
source_label = INPUT_METHOD_LABELS[[active_method()]]
custom_type_object = build_type_object(checked$fields)
results = shiny::withProgress(
message = "Extracting",
value = 0,
{
extracted = extract_items(
items,
custom_type_object,
chat_factory,
on_progress = function(i, n, item) {
shiny::setProgress(
value = (i - 1) / n,
detail = sprintf("item %d of %d (source_id: %s)", i, n, item$source_id)
)
}
)
shiny::setProgress(value = 1, detail = "done")
extracted
}
)
results_table = assemble_results(results, checked$fields)
n_failed = sum(results_table$status != "ok")
extraction(list(
table = results_table,
summary = summarize_results(results_table),
n_failed = n_failed,
source = source_label
))
if (n_failed == 0) {
notify("Extraction complete.")
} else {
notify(summarize_results(results_table), type = "warning", duration = 8)
}
})
# Summary line above the table, or the placeholder before the first run
output$results_status_ui = shiny::renderUI({
result = extraction()
if (is.null(result)) {
return(shiny::div(
class = "results-placeholder",
shiny::icon("arrow-left"),
" Configure your extraction fields and click 'Extract Data' to see results here"
))
}
shiny::div(
class = if (result$n_failed > 0) "results-summary has-failures" else "results-summary",
role = "status",
shiny::tags$strong(result$summary),
sprintf(" Source: %s.", result$source),
if (result$n_failed > 0) {
" The status_message column gives the reason for each failure."
},
" Check the values against the source documents before you use them."
)
})
output$extracted_table = shiny::renderTable(
{
result = extraction()
if (is.null(result)) return(NULL)
display_table(result$table)
},
striped = TRUE,
hover = TRUE,
bordered = TRUE
)
# Render download button only when results are available
output$download_btn_ui = shiny::renderUI({
result = extraction()
if (is.null(result) || nrow(result$table) == 0) return(NULL)
shiny::div(
style = "margin-top: 15px;",
shiny::downloadButton(
"download_csv",
"Download as CSV",
class = "btn-info"
)
)
})
# Download handler for CSV export
output$download_csv = shiny::downloadHandler(
filename = function() {
paste0("extracted_data_", format(Sys.time(), "%Y%m%d_%H%M%S"), ".csv")
},
content = function(file) {
result = extraction()
if (!is.null(result)) {
utils::write.csv(result$table, file, row.names = FALSE, na = "")
}
}
)
# ---- Reproduce in code ---------------------------------------------------
# Fields used in the snippets (sanitized, as they would be sent)
snippet_fields = shiny::reactive({
validate_fields(current_fields(), MAX_FIELDS)
})
output$snippet_status_ui = shiny::renderUI({
checked = snippet_fields()
if (checked$ok) return(NULL)
shiny::div(
class = "limit-note",
"The code is shown once the field definitions are valid: ",
paste(checked$errors, collapse = " ")
)
})
output$snippet_r = shiny::renderText({
checked = snippet_fields()
if (!checked$ok) return("")
r_snippet(checked$fields, EXTRACTION_MODEL)
})
output$snippet_python = shiny::renderText({
checked = snippet_fields()
if (!checked$ok) return("")
python_snippet(checked$fields, EXTRACTION_MODEL)
})
# The code blocks sit inside collapsed <details> elements
shiny::outputOptions(output, "snippet_r", suspendWhenHidden = FALSE)
shiny::outputOptions(output, "snippet_python", suspendWhenHidden = FALSE)
}
# Run the Shiny app
shiny::shinyApp(ui = ui, server = server)