|
|
library(shiny) |
|
|
library(ellmer) |
|
|
library(purrr) |
|
|
library(pdftools) |
|
|
library(magick) |
|
|
library(base64enc) |
|
|
|
|
|
|
|
|
MAX_IMAGE_PAGES = 5 |
|
|
|
|
|
num_example_fields = 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ui = shiny::fluidPage( |
|
|
|
|
|
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") |
|
|
), |
|
|
|
|
|
|
|
|
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("Version 2.0.1 | January 2026"), |
|
|
shiny::p( |
|
|
shiny::tags$strong("Authors: "), |
|
|
"Fadel M. Megahed, Ying-Ju (Tessa) Chen, 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" |
|
|
) |
|
|
) |
|
|
) |
|
|
) |
|
|
), |
|
|
|
|
|
|
|
|
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 = "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,"), |
|
|
"and the", |
|
|
shiny::tags$strong("University of Amsterdam") |
|
|
) |
|
|
) |
|
|
), |
|
|
|
|
|
|
|
|
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", |
|
|
class = "btn btn-video-tutorial", |
|
|
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 Examples'"), |
|
|
" button to load two sample NHTSA recall notices with pre-configured extraction fields. ", |
|
|
"Then click ", |
|
|
shiny::tags$strong("'Extract Data'"), |
|
|
" to see the AI extract structured information from the text. ", |
|
|
"You can also add more fields, change field labels, and modify descriptions. ", |
|
|
shiny::tags$em( |
|
|
"(Note: Field labels only affect how data is stored and displayed, not extraction performance.)" |
|
|
) |
|
|
), |
|
|
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:" |
|
|
), |
|
|
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 AI vision-based extraction." |
|
|
) |
|
|
), |
|
|
shiny::p( |
|
|
style = "font-size: 0.9em; color: #666;", |
|
|
shiny::icon("info-circle"), |
|
|
" ", |
|
|
shiny::tags$em("Notes:"), |
|
|
shiny::tags$ul( |
|
|
style = "margin: -0.5em 0 0 1.25em; padding: 0; line-height: 1.2;", |
|
|
shiny::tags$li(style = "margin: 0; padding: 0;", |
|
|
"PDF, Image, and Scanned PDF modes are limited to 5 pages/items in this free demo." |
|
|
), |
|
|
shiny::tags$li(style = "margin: 0; padding: 0;", |
|
|
"Any uploaded file must be less than 5 MB (Shiny default for this free app) to keep the demo responsive and avoid upload timeouts." |
|
|
) |
|
|
) |
|
|
), |
|
|
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"), |
|
|
shiny::tags$li("Your own ", shiny::tags$strong("field labels"), " (for storage/display only)"), |
|
|
shiny::tags$li("Detailed ", shiny::tags$strong("field descriptions"), " to guide the AI") |
|
|
), |
|
|
shiny::p( |
|
|
shiny::icon("lightbulb"), |
|
|
" ", |
|
|
shiny::tags$em( |
|
|
"The more specific your field descriptions, the better the extraction results." |
|
|
) |
|
|
) |
|
|
), |
|
|
|
|
|
|
|
|
shiny::div( |
|
|
id = "video-modal", |
|
|
class = "video-modal", |
|
|
shiny::div( |
|
|
class = "video-modal-content", |
|
|
shiny::tags$span( |
|
|
id = "close-video-modal", |
|
|
class = "video-modal-close", |
|
|
shiny::HTML("×") |
|
|
), |
|
|
shiny::tags$h4( |
|
|
style = "color: #C41230; margin-top: 0; margin-bottom: 15px;", |
|
|
shiny::icon("play-circle"), |
|
|
" Video Tutorial" |
|
|
), |
|
|
shiny::div( |
|
|
class = "video-modal-wrapper", |
|
|
|
|
|
shiny::tags$iframe( |
|
|
id = "tutorial-video", |
|
|
src = "https://www.youtube.com/embed/y6Uit4Drf9w", |
|
|
allow = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture", |
|
|
allowfullscreen = NA |
|
|
) |
|
|
) |
|
|
) |
|
|
), |
|
|
|
|
|
|
|
|
shiny::tags$script(shiny::HTML(" |
|
|
var videoUrl = 'https://www.youtube.com/embed/y6Uit4Drf9w'; |
|
|
var modal = document.getElementById('video-modal'); |
|
|
var openBtn = document.getElementById('open-video-modal'); |
|
|
var closeBtn = document.getElementById('close-video-modal'); |
|
|
var videoIframe = document.getElementById('tutorial-video'); |
|
|
|
|
|
openBtn.onclick = function() { |
|
|
modal.style.display = 'flex'; |
|
|
videoIframe.src = videoUrl; |
|
|
} |
|
|
|
|
|
closeBtn.onclick = function() { |
|
|
modal.style.display = 'none'; |
|
|
videoIframe.src = ''; |
|
|
} |
|
|
|
|
|
window.onclick = function(event) { |
|
|
if (event.target == modal) { |
|
|
modal.style.display = 'none'; |
|
|
videoIframe.src = ''; |
|
|
} |
|
|
} |
|
|
|
|
|
document.addEventListener('keydown', function(event) { |
|
|
if (event.key === 'Escape' && modal.style.display === 'flex') { |
|
|
modal.style.display = 'none'; |
|
|
videoIframe.src = ''; |
|
|
} |
|
|
}); |
|
|
|
|
|
// Input tab switching |
|
|
function switchInputTab(tab) { |
|
|
var tabs = ['demo', 'paste', 'textfile', 'pdf', 'scanned', 'image']; |
|
|
tabs.forEach(function(t) { |
|
|
var tabEl = document.getElementById('tab-' + t); |
|
|
var panelEl = document.getElementById('panel-' + t); |
|
|
if (t === tab) { |
|
|
tabEl.classList.add('active'); |
|
|
panelEl.style.display = 'block'; |
|
|
} else { |
|
|
tabEl.classList.remove('active'); |
|
|
panelEl.style.display = 'none'; |
|
|
} |
|
|
}); |
|
|
// Update Shiny with the current input method |
|
|
Shiny.setInputValue('input_method', tab); |
|
|
} |
|
|
// Initialize input method |
|
|
Shiny.setInputValue('input_method', 'demo'); |
|
|
")), |
|
|
|
|
|
shiny::sidebarLayout( |
|
|
shiny::sidebarPanel( |
|
|
width = 4, |
|
|
class = "sidebar-panel", |
|
|
|
|
|
shiny::tags$h4( |
|
|
style = "color: #C41230; margin-top: 0; margin-bottom: 15px;", |
|
|
shiny::icon("file-alt"), |
|
|
" Input Configuration" |
|
|
), |
|
|
|
|
|
|
|
|
shiny::div( |
|
|
class = "input-tabs-container", |
|
|
shiny::div( |
|
|
class = "input-tabs", |
|
|
shiny::tags$button( |
|
|
id = "tab-demo", |
|
|
class = "input-tab active", |
|
|
onclick = "switchInputTab('demo')", |
|
|
shiny::icon("flask"), |
|
|
" Demo Data" |
|
|
), |
|
|
shiny::tags$button( |
|
|
id = "tab-paste", |
|
|
class = "input-tab", |
|
|
onclick = "switchInputTab('paste')", |
|
|
shiny::icon("paste"), |
|
|
" Paste Text" |
|
|
) |
|
|
), |
|
|
shiny::div( |
|
|
class = "input-tabs", |
|
|
shiny::tags$button( |
|
|
id = "tab-textfile", |
|
|
class = "input-tab", |
|
|
onclick = "switchInputTab('textfile')", |
|
|
shiny::icon("file-lines"), |
|
|
" Text File" |
|
|
), |
|
|
shiny::tags$button( |
|
|
id = "tab-pdf", |
|
|
class = "input-tab", |
|
|
onclick = "switchInputTab('pdf')", |
|
|
shiny::icon("file-pdf"), |
|
|
" Readable PDF" |
|
|
) |
|
|
), |
|
|
shiny::div( |
|
|
class = "input-tabs", |
|
|
shiny::tags$button( |
|
|
id = "tab-scanned", |
|
|
class = "input-tab", |
|
|
onclick = "switchInputTab('scanned')", |
|
|
shiny::icon("file-image"), |
|
|
" Scanned PDF" |
|
|
), |
|
|
shiny::tags$button( |
|
|
id = "tab-image", |
|
|
class = "input-tab", |
|
|
onclick = "switchInputTab('image')", |
|
|
shiny::icon("images"), |
|
|
" Upload Images" |
|
|
) |
|
|
) |
|
|
), |
|
|
|
|
|
|
|
|
shiny::div( |
|
|
id = "panel-demo", |
|
|
class = "input-panel", |
|
|
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") |
|
|
), |
|
|
|
|
|
|
|
|
shiny::div( |
|
|
id = "panel-paste", |
|
|
class = "input-panel", |
|
|
style = "display: none;", |
|
|
shiny::textAreaInput( |
|
|
"input_text", |
|
|
NULL, |
|
|
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. 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." |
|
|
) |
|
|
), |
|
|
|
|
|
|
|
|
shiny::div( |
|
|
id = "panel-textfile", |
|
|
class = "input-panel", |
|
|
style = "display: none;", |
|
|
shiny::fileInput( |
|
|
"text_file", |
|
|
NULL, |
|
|
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)"), |
|
|
shiny::tags$li(".md: Markdown file treated as plain text") |
|
|
) |
|
|
), |
|
|
shiny::uiOutput("textfile_preview_ui") |
|
|
), |
|
|
|
|
|
|
|
|
shiny::div( |
|
|
id = "panel-pdf", |
|
|
class = "input-panel", |
|
|
style = "display: none;", |
|
|
shiny::fileInput( |
|
|
"pdf_file", |
|
|
NULL, |
|
|
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." |
|
|
), |
|
|
shiny::div( |
|
|
class = "image-limit-note", |
|
|
shiny::icon("exclamation-circle"), |
|
|
shiny::tags$em(paste(" Free app limit: Only the first", MAX_IMAGE_PAGES, "pages will be processed.")) |
|
|
), |
|
|
shiny::uiOutput("pdf_preview_ui") |
|
|
), |
|
|
|
|
|
|
|
|
shiny::div( |
|
|
id = "panel-image", |
|
|
class = "input-panel", |
|
|
style = "display: none;", |
|
|
shiny::fileInput( |
|
|
"image_file", |
|
|
NULL, |
|
|
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 = "image-limit-note", |
|
|
shiny::icon("exclamation-circle"), |
|
|
shiny::tags$em(paste(" Free app limit: Maximum", MAX_IMAGE_PAGES, "images will be processed.")) |
|
|
), |
|
|
shiny::uiOutput("image_preview_ui") |
|
|
), |
|
|
|
|
|
|
|
|
shiny::div( |
|
|
id = "panel-scanned", |
|
|
class = "input-panel", |
|
|
style = "display: none;", |
|
|
shiny::fileInput( |
|
|
"scanned_pdf_file", |
|
|
NULL, |
|
|
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 AI vision processing." |
|
|
), |
|
|
shiny::div( |
|
|
class = "image-limit-note", |
|
|
shiny::icon("exclamation-circle"), |
|
|
shiny::tags$em(paste(" Free app limit: Only the first", MAX_IMAGE_PAGES, "pages will be processed.")) |
|
|
), |
|
|
shiny::uiOutput("scanned_pdf_preview_ui") |
|
|
), |
|
|
|
|
|
shiny::hr(class = "field-separator"), |
|
|
|
|
|
shiny::numericInput( |
|
|
"num_fields", |
|
|
shiny::tags$span( |
|
|
shiny::icon("list-ol"), |
|
|
" Number of Fields to Extract:" |
|
|
), |
|
|
value = num_example_fields, |
|
|
min = 1, |
|
|
max = 10 |
|
|
), |
|
|
|
|
|
shiny::helpText( |
|
|
shiny::icon("info-circle"), |
|
|
" Define each field with a clear label (e.g., 'manufacturer') and description (e.g., 'The name of the company recalling the vehicles')." |
|
|
), |
|
|
|
|
|
shiny::hr(class = "field-separator"), |
|
|
|
|
|
shiny::tags$h5( |
|
|
style = "color: #C41230; margin-bottom: 10px;", |
|
|
shiny::icon("tags"), |
|
|
" Field Definitions" |
|
|
), |
|
|
|
|
|
shiny::uiOutput("fields_ui"), |
|
|
|
|
|
shiny::div( |
|
|
style = "margin-top: 20px;", |
|
|
shiny::actionButton( |
|
|
"extract_btn", |
|
|
shiny::tags$span(shiny::icon("magic"), " Extract Data"), |
|
|
class = "btn-primary" |
|
|
) |
|
|
) |
|
|
), |
|
|
|
|
|
shiny::mainPanel( |
|
|
width = 8, |
|
|
class = "main-panel", |
|
|
|
|
|
shiny::div( |
|
|
class = "info-card", |
|
|
shiny::tags$h3( |
|
|
class = "section-heading", |
|
|
shiny::icon("table"), |
|
|
" Structured Data Extracted Using AI" |
|
|
), |
|
|
shiny::div( |
|
|
class = "results-placeholder", |
|
|
id = "results-placeholder", |
|
|
shiny::icon("arrow-left"), |
|
|
" Configure your extraction fields and click 'Extract Data' to see results here" |
|
|
), |
|
|
shiny::tableOutput("extracted_table"), |
|
|
shiny::uiOutput("download_btn_ui") |
|
|
), |
|
|
|
|
|
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"), " to guide the AI accurately"), |
|
|
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::div( |
|
|
class = "note-section", |
|
|
shiny::tags$h4(shiny::icon("cog"), " Technical Note"), |
|
|
shiny::p( |
|
|
"To ensure timely results (since this is hosted on a CPU), we utilize ", |
|
|
shiny::tags$code("gpt-5-mini-2025-08-07"), |
|
|
" for this demo." |
|
|
), |
|
|
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 the ", |
|
|
shiny::tags$a( |
|
|
href = "https://ellmer.tidyverse.org/", |
|
|
target = "_blank", |
|
|
"ellmer" |
|
|
), |
|
|
" library, which connects to ", |
|
|
shiny::tags$a( |
|
|
href = "https://ollama.com/", |
|
|
target = "_blank", |
|
|
"Ollama" |
|
|
), |
|
|
" for running models locally on your machine." |
|
|
), |
|
|
shiny::p( |
|
|
"Alternatively, for higher accuracy requirements, users can leverage more performant closed models (e.g., ", |
|
|
shiny::tags$em("gpt-5.2-2025-12-11"), |
|
|
", ", |
|
|
shiny::tags$em("claude-opus-4-5-20251101"), |
|
|
", or ", |
|
|
shiny::tags$em("gemini-3-pro-preview"), |
|
|
") depending on the application needs. ", |
|
|
"Note that 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." |
|
|
), |
|
|
shiny::p( |
|
|
shiny::tags$em( |
|
|
"This demo uses ", |
|
|
shiny::tags$code("chat_openai()"), |
|
|
" only and does not provide an option to change the gpt-5-mini-2025-08-07 model." |
|
|
) |
|
|
) |
|
|
) |
|
|
) |
|
|
), |
|
|
|
|
|
|
|
|
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 OpenAI" |
|
|
), |
|
|
shiny::p( |
|
|
shiny::tags$em( |
|
|
"Companion app to: 'What Should Quality Engineers Know about Generative AI', submitted by the app's authors to ", |
|
|
shiny::tags$a( |
|
|
href = "https://www.tandfonline.com/journals/lqen20", |
|
|
target = "_blank", |
|
|
"Quality Engineering." |
|
|
) |
|
|
) |
|
|
), |
|
|
|
|
|
|
|
|
|
|
|
) |
|
|
) |
|
|
|
|
|
|
|
|
server = function(input, output, session) { |
|
|
|
|
|
|
|
|
demo_text = shiny::reactiveVal("") |
|
|
|
|
|
|
|
|
extracted_results = shiny::reactiveVal(NULL) |
|
|
|
|
|
|
|
|
shiny::observeEvent(input$input_method, { |
|
|
|
|
|
extracted_results(NULL) |
|
|
output$extracted_table = shiny::renderTable(NULL) |
|
|
|
|
|
|
|
|
if (input$input_method == "paste") { |
|
|
shiny::updateTextAreaInput(session, "input_text", value = "") |
|
|
} |
|
|
}, ignoreInit = TRUE) |
|
|
|
|
|
|
|
|
shiny::observeEvent(input$load_example, { |
|
|
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." |
|
|
|
|
|
|
|
|
demo_text(example_text) |
|
|
|
|
|
|
|
|
shiny::updateTextAreaInput(session, "input_text", value = example_text) |
|
|
|
|
|
|
|
|
shiny::updateNumericInput(session, "num_fields", value = num_example_fields) |
|
|
|
|
|
shiny::showNotification( |
|
|
"Demo data loaded! Click 'Extract Data' to see results.", |
|
|
type = "message", |
|
|
duration = 3 |
|
|
) |
|
|
}) |
|
|
|
|
|
|
|
|
output$demo_preview_ui = shiny::renderUI({ |
|
|
text = demo_text() |
|
|
if (is.null(text) || nchar(text) == 0) return(NULL) |
|
|
|
|
|
preview_text = if (nchar(text) > 500) { |
|
|
paste0(substr(text, 1, 500), "...") |
|
|
} else { |
|
|
text |
|
|
} |
|
|
|
|
|
shiny::div( |
|
|
class = "pdf-preview", |
|
|
style = "margin-top: 15px;", |
|
|
shiny::tags$strong("Loaded Demo Text:"), |
|
|
shiny::tags$pre(preview_text) |
|
|
) |
|
|
}) |
|
|
|
|
|
|
|
|
pdf_text = shiny::reactiveVal("") |
|
|
|
|
|
|
|
|
shiny::observeEvent(input$pdf_file, { |
|
|
shiny::req(input$pdf_file) |
|
|
|
|
|
tryCatch({ |
|
|
|
|
|
pdf_path = input$pdf_file$datapath |
|
|
extracted = pdftools::pdf_text(pdf_path) |
|
|
|
|
|
|
|
|
total_pages = length(extracted) |
|
|
if (total_pages > MAX_IMAGE_PAGES) { |
|
|
extracted = extracted[1:MAX_IMAGE_PAGES] |
|
|
shiny::showNotification( |
|
|
paste("Only the first", MAX_IMAGE_PAGES, "pages will be processed (free app limit)."), |
|
|
type = "warning", |
|
|
duration = 5 |
|
|
) |
|
|
} |
|
|
|
|
|
|
|
|
combined_text = paste(extracted, collapse = "\n\n") |
|
|
|
|
|
|
|
|
combined_text = gsub("\\s+", " ", combined_text) |
|
|
combined_text = trimws(combined_text) |
|
|
|
|
|
|
|
|
pdf_text(combined_text) |
|
|
|
|
|
|
|
|
shiny::updateTextAreaInput(session, "input_text", value = combined_text) |
|
|
|
|
|
shiny::showNotification( |
|
|
paste("PDF processed successfully!", length(extracted), "page(s) extracted."), |
|
|
type = "message", |
|
|
duration = 3 |
|
|
) |
|
|
}, error = function(e) { |
|
|
shiny::showNotification( |
|
|
paste("Error reading PDF:", e$message), |
|
|
type = "error", |
|
|
duration = NULL |
|
|
) |
|
|
}) |
|
|
}) |
|
|
|
|
|
|
|
|
output$pdf_preview_ui = shiny::renderUI({ |
|
|
text = pdf_text() |
|
|
if (is.null(text) || nchar(text) == 0) return(NULL) |
|
|
|
|
|
preview_text = if (nchar(text) > 500) { |
|
|
paste0(substr(text, 1, 500), "...") |
|
|
} else { |
|
|
text |
|
|
} |
|
|
|
|
|
shiny::div( |
|
|
class = "pdf-preview", |
|
|
shiny::tags$strong("Extracted Text Preview:"), |
|
|
shiny::tags$pre(preview_text) |
|
|
) |
|
|
}) |
|
|
|
|
|
|
|
|
textfile_text = shiny::reactiveVal("") |
|
|
|
|
|
|
|
|
shiny::observeEvent(input$text_file, { |
|
|
shiny::req(input$text_file) |
|
|
|
|
|
tryCatch({ |
|
|
file_path = input$text_file$datapath |
|
|
file_name = input$text_file$name |
|
|
file_ext = tolower(tools::file_ext(file_name)) |
|
|
|
|
|
extracted_text = "" |
|
|
|
|
|
if (file_ext == "csv") { |
|
|
|
|
|
csv_data = utils::read.csv(file_path, header = TRUE, stringsAsFactors = FALSE) |
|
|
if (ncol(csv_data) >= 1) { |
|
|
|
|
|
extracted_text = paste(csv_data[[1]], collapse = "\n\n") |
|
|
} |
|
|
} else { |
|
|
|
|
|
extracted_text = paste(readLines(file_path, warn = FALSE), collapse = "\n") |
|
|
} |
|
|
|
|
|
|
|
|
extracted_text = trimws(extracted_text) |
|
|
|
|
|
|
|
|
textfile_text(extracted_text) |
|
|
|
|
|
|
|
|
shiny::updateTextAreaInput(session, "input_text", value = extracted_text) |
|
|
|
|
|
shiny::showNotification( |
|
|
paste("File processed successfully!"), |
|
|
type = "message", |
|
|
duration = 3 |
|
|
) |
|
|
}, error = function(e) { |
|
|
shiny::showNotification( |
|
|
paste("Error reading file:", e$message), |
|
|
type = "error", |
|
|
duration = NULL |
|
|
) |
|
|
}) |
|
|
}) |
|
|
|
|
|
|
|
|
output$textfile_preview_ui = shiny::renderUI({ |
|
|
text = textfile_text() |
|
|
if (is.null(text) || nchar(text) == 0) return(NULL) |
|
|
|
|
|
preview_text = if (nchar(text) > 500) { |
|
|
paste0(substr(text, 1, 500), "...") |
|
|
} else { |
|
|
text |
|
|
} |
|
|
|
|
|
shiny::div( |
|
|
class = "pdf-preview", |
|
|
shiny::tags$strong("Extracted Text Preview:"), |
|
|
shiny::tags$pre(preview_text) |
|
|
) |
|
|
}) |
|
|
|
|
|
|
|
|
image_paths = shiny::reactiveVal(NULL) |
|
|
scanned_pdf_paths = shiny::reactiveVal(NULL) |
|
|
|
|
|
|
|
|
shiny::observeEvent(input$image_file, { |
|
|
shiny::req(input$image_file) |
|
|
|
|
|
tryCatch({ |
|
|
files = input$image_file |
|
|
|
|
|
n_files = min(nrow(files), MAX_IMAGE_PAGES) |
|
|
|
|
|
if (nrow(files) > MAX_IMAGE_PAGES) { |
|
|
shiny::showNotification( |
|
|
paste("Only the first", MAX_IMAGE_PAGES, "images will be processed (free app limit)."), |
|
|
type = "warning", |
|
|
duration = 5 |
|
|
) |
|
|
} |
|
|
|
|
|
|
|
|
paths = files$datapath[1:n_files] |
|
|
image_paths(paths) |
|
|
|
|
|
shiny::showNotification( |
|
|
paste(n_files, "image(s) uploaded successfully!"), |
|
|
type = "message", |
|
|
duration = 3 |
|
|
) |
|
|
}, error = function(e) { |
|
|
shiny::showNotification( |
|
|
paste("Error uploading images:", e$message), |
|
|
type = "error", |
|
|
duration = NULL |
|
|
) |
|
|
}) |
|
|
}) |
|
|
|
|
|
|
|
|
output$image_preview_ui = shiny::renderUI({ |
|
|
paths = image_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 = "image/png"), |
|
|
alt = paste("Image", i) |
|
|
) |
|
|
) |
|
|
}) |
|
|
), |
|
|
shiny::div( |
|
|
class = "image-preview-count", |
|
|
paste(length(paths), "image(s) ready for extraction") |
|
|
) |
|
|
) |
|
|
}) |
|
|
|
|
|
|
|
|
shiny::observeEvent(input$scanned_pdf_file, { |
|
|
shiny::req(input$scanned_pdf_file) |
|
|
|
|
|
tryCatch({ |
|
|
pdf_path = input$scanned_pdf_file$datapath |
|
|
|
|
|
|
|
|
pdf_info = pdftools::pdf_info(pdf_path) |
|
|
n_pages = min(pdf_info$pages, MAX_IMAGE_PAGES) |
|
|
|
|
|
if (pdf_info$pages > MAX_IMAGE_PAGES) { |
|
|
shiny::showNotification( |
|
|
paste("Only the first", MAX_IMAGE_PAGES, "pages will be processed (free app limit)."), |
|
|
type = "warning", |
|
|
duration = 5 |
|
|
) |
|
|
} |
|
|
|
|
|
shiny::showNotification( |
|
|
"Converting PDF pages to images...", |
|
|
type = "message", |
|
|
duration = NULL, |
|
|
id = "convert_notif" |
|
|
) |
|
|
|
|
|
|
|
|
pdf_images = magick::image_read_pdf(pdf_path, pages = 1:n_pages, density = 150) |
|
|
|
|
|
|
|
|
temp_paths = sapply(1:n_pages, function(i) { |
|
|
temp_file = tempfile(fileext = ".png") |
|
|
magick::image_write(pdf_images[i], temp_file, format = "png") |
|
|
temp_file |
|
|
}) |
|
|
|
|
|
scanned_pdf_paths(temp_paths) |
|
|
|
|
|
shiny::removeNotification(id = "convert_notif") |
|
|
shiny::showNotification( |
|
|
paste(n_pages, "page(s) converted successfully!"), |
|
|
type = "message", |
|
|
duration = 3 |
|
|
) |
|
|
}, error = function(e) { |
|
|
shiny::removeNotification(id = "convert_notif") |
|
|
shiny::showNotification( |
|
|
paste("Error processing scanned PDF:", e$message), |
|
|
type = "error", |
|
|
duration = NULL |
|
|
) |
|
|
}) |
|
|
}) |
|
|
|
|
|
|
|
|
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 = "image/png"), |
|
|
alt = paste("Page", i) |
|
|
) |
|
|
) |
|
|
}) |
|
|
), |
|
|
shiny::div( |
|
|
class = "image-preview-count", |
|
|
paste(length(paths), "page(s) ready for extraction") |
|
|
) |
|
|
) |
|
|
}) |
|
|
|
|
|
|
|
|
output$fields_ui = shiny::renderUI({ |
|
|
n = input$num_fields |
|
|
input_method = input$input_method |
|
|
if (is.null(n) || n < 1) return(NULL) |
|
|
if (is.null(input_method)) input_method = "demo" |
|
|
|
|
|
|
|
|
if (input_method == "demo") { |
|
|
example_labels = c("manufacturer", "defect_summary", "models", "model_years", "component", "fmvss_number", "root_cause", "risk") |
|
|
example_descs = c( |
|
|
"The name of the company recalling the vehicles.", |
|
|
"Summary of the main defect.", |
|
|
"List of affected vehicle models.", |
|
|
"List of model years affected.", |
|
|
"The part or system affected by the defect.", |
|
|
"The FMVSS number mentioned, if any.", |
|
|
"The root cause of the defect.", |
|
|
"The risk or consequence posed by the defect." |
|
|
) |
|
|
} else { |
|
|
|
|
|
example_labels = character(0) |
|
|
example_descs = character(0) |
|
|
} |
|
|
|
|
|
fields = purrr::map(1:n, function(i) { |
|
|
|
|
|
if (input_method == "demo" && i <= length(example_labels)) { |
|
|
default_label = example_labels[i] |
|
|
default_desc = example_descs[i] |
|
|
} else { |
|
|
default_label = paste0("field_", i) |
|
|
default_desc = "Describe what to extract for this field" |
|
|
} |
|
|
|
|
|
shiny::div( |
|
|
style = "background-color: #EDECE2; padding: 12px; border-radius: 6px; margin-bottom: 12px;", |
|
|
shiny::tags$span( |
|
|
style = "color: #C41230; font-weight: 600; font-size: 0.9em;", |
|
|
paste("Field", i) |
|
|
), |
|
|
shiny::textInput( |
|
|
paste0("field_label_", i), |
|
|
"Label:", |
|
|
value = default_label |
|
|
), |
|
|
shiny::textInput( |
|
|
paste0("field_desc_", i), |
|
|
"Description:", |
|
|
value = default_desc |
|
|
) |
|
|
) |
|
|
}) |
|
|
do.call(shiny::tagList, fields) |
|
|
}) |
|
|
|
|
|
|
|
|
create_type_object = shiny::reactive({ |
|
|
n = input$num_fields |
|
|
if (is.null(n) || n < 1) return(NULL) |
|
|
|
|
|
|
|
|
type_list = list() |
|
|
for (i in 1:n) { |
|
|
label = input[[paste0("field_label_", i)]] |
|
|
desc = input[[paste0("field_desc_", i)]] |
|
|
if (!is.null(label) && label != "") { |
|
|
type_list[[label]] = ellmer::type_string(desc, required = FALSE) |
|
|
} |
|
|
} |
|
|
|
|
|
do.call(ellmer::type_object, type_list) |
|
|
}) |
|
|
|
|
|
|
|
|
shiny::observeEvent(input$extract_btn, { |
|
|
|
|
|
input_method = input$input_method |
|
|
if (is.null(input_method)) input_method = "demo" |
|
|
|
|
|
|
|
|
has_data = FALSE |
|
|
error_msg = "" |
|
|
|
|
|
|
|
|
field_reminder = " Also, make sure to update the field labels and descriptions for your data." |
|
|
|
|
|
if (input_method == "demo") { |
|
|
|
|
|
if (is.null(demo_text()) || nchar(demo_text()) == 0) { |
|
|
error_msg = "Please click 'Load Demo Data' first to load the sample data." |
|
|
} else { |
|
|
has_data = TRUE |
|
|
} |
|
|
} else if (input_method == "paste") { |
|
|
|
|
|
if (is.null(input$input_text) || nchar(trimws(input$input_text)) == 0) { |
|
|
error_msg = paste0("Please paste your text content in the text area.", field_reminder) |
|
|
} else { |
|
|
has_data = TRUE |
|
|
} |
|
|
} else if (input_method == "textfile") { |
|
|
|
|
|
if (is.null(textfile_text()) || nchar(textfile_text()) == 0) { |
|
|
error_msg = paste0("Please upload a text file (.txt, .csv, or .md) first.", field_reminder) |
|
|
} else { |
|
|
has_data = TRUE |
|
|
} |
|
|
} else if (input_method == "pdf") { |
|
|
|
|
|
if (is.null(pdf_text()) || nchar(pdf_text()) == 0) { |
|
|
error_msg = paste0("Please upload a readable PDF file first.", field_reminder) |
|
|
} else { |
|
|
has_data = TRUE |
|
|
} |
|
|
} else if (input_method == "image") { |
|
|
|
|
|
if (is.null(image_paths()) || length(image_paths()) == 0) { |
|
|
error_msg = paste0("Please upload one or more image files first.", field_reminder) |
|
|
} else { |
|
|
has_data = TRUE |
|
|
} |
|
|
} else if (input_method == "scanned") { |
|
|
|
|
|
if (is.null(scanned_pdf_paths()) || length(scanned_pdf_paths()) == 0) { |
|
|
error_msg = paste0("Please upload a scanned PDF file first.", field_reminder) |
|
|
} else { |
|
|
has_data = TRUE |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (!has_data) { |
|
|
shiny::showNotification( |
|
|
shiny::tags$span( |
|
|
shiny::icon("exclamation-triangle"), |
|
|
" ", |
|
|
error_msg |
|
|
), |
|
|
type = "error", |
|
|
duration = 5 |
|
|
) |
|
|
return() |
|
|
} |
|
|
|
|
|
|
|
|
shiny::showNotification( |
|
|
shiny::tags$span( |
|
|
shiny::icon("spinner", class = "fa-spin"), |
|
|
" Processing extraction request..." |
|
|
), |
|
|
type = "message", |
|
|
duration = NULL, |
|
|
id = "extract_notif" |
|
|
) |
|
|
|
|
|
custom_type_object = create_type_object() |
|
|
|
|
|
|
|
|
tryCatch({ |
|
|
|
|
|
if (Sys.getenv("OPENAI_API_KEY") == "") { |
|
|
stop("OpenAI API key not found. Please set the OPENAI_API_KEY environment variable.") |
|
|
} |
|
|
|
|
|
chat = ellmer::chat_openai( |
|
|
model = "gpt-5-mini-2025-08-07", |
|
|
) |
|
|
|
|
|
all_results = list() |
|
|
|
|
|
if (input_method %in% c("demo", "paste", "pdf", "textfile")) { |
|
|
|
|
|
|
|
|
text_blocks = unlist(strsplit(input$input_text, "\n\n")) |
|
|
text_blocks = text_blocks[text_blocks != ""] |
|
|
|
|
|
|
|
|
for (i in seq_along(text_blocks)) { |
|
|
result = chat$chat_structured(text_blocks[i], type = custom_type_object) |
|
|
if (is.list(result)) { |
|
|
result$source_id = i |
|
|
all_results[[i]] = result |
|
|
} |
|
|
} |
|
|
|
|
|
} else if (input_method == "image") { |
|
|
|
|
|
paths = image_paths() |
|
|
|
|
|
for (i in seq_along(paths)) { |
|
|
|
|
|
image_content = ellmer::content_image_file(paths[i], resize = "high") |
|
|
result = chat$chat_structured(image_content, type = custom_type_object) |
|
|
if (is.list(result)) { |
|
|
result$source_id = paste("Image", i) |
|
|
all_results[[i]] = result |
|
|
} |
|
|
} |
|
|
|
|
|
} else if (input_method == "scanned") { |
|
|
|
|
|
paths = scanned_pdf_paths() |
|
|
|
|
|
for (i in seq_along(paths)) { |
|
|
|
|
|
image_content = ellmer::content_image_file(paths[i], resize = "high") |
|
|
result = chat$chat_structured(image_content, type = custom_type_object) |
|
|
if (is.list(result)) { |
|
|
result$source_id = paste("Page", i) |
|
|
all_results[[i]] = result |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (length(all_results) > 0) { |
|
|
combined_results = do.call(rbind, lapply(all_results, function(x) { |
|
|
|
|
|
as.data.frame(x) |
|
|
})) |
|
|
|
|
|
|
|
|
extracted_results(combined_results) |
|
|
|
|
|
|
|
|
output$extracted_table = shiny::renderTable( |
|
|
{ |
|
|
combined_results |
|
|
}, |
|
|
rownames = TRUE, |
|
|
striped = TRUE, |
|
|
hover = TRUE, |
|
|
bordered = TRUE |
|
|
) |
|
|
} else { |
|
|
|
|
|
extracted_results(NULL) |
|
|
output$extracted_table = shiny::renderTable({ |
|
|
data.frame(Message = "No valid data could be extracted. Please check your input and field definitions.") |
|
|
}) |
|
|
} |
|
|
|
|
|
|
|
|
shiny::removeNotification(id = "extract_notif") |
|
|
shiny::showNotification( |
|
|
shiny::tags$span( |
|
|
shiny::icon("check-circle"), |
|
|
" Extraction complete!" |
|
|
), |
|
|
type = "message", |
|
|
duration = 3 |
|
|
) |
|
|
}, error = function(e) { |
|
|
|
|
|
shiny::removeNotification(id = "extract_notif") |
|
|
shiny::showNotification( |
|
|
shiny::tags$span( |
|
|
shiny::icon("exclamation-triangle"), |
|
|
" Error: ", |
|
|
e$message |
|
|
), |
|
|
type = "error", |
|
|
duration = NULL |
|
|
) |
|
|
}) |
|
|
}) |
|
|
|
|
|
|
|
|
output$download_btn_ui = shiny::renderUI({ |
|
|
results = extracted_results() |
|
|
if (is.null(results) || nrow(results) == 0) return(NULL) |
|
|
|
|
|
shiny::div( |
|
|
style = "margin-top: 15px;", |
|
|
shiny::downloadButton( |
|
|
"download_csv", |
|
|
"Download as CSV", |
|
|
class = "btn-info" |
|
|
) |
|
|
) |
|
|
}) |
|
|
|
|
|
|
|
|
output$download_csv = shiny::downloadHandler( |
|
|
filename = function() { |
|
|
paste0("extracted_data_", format(Sys.time(), "%Y%m%d_%H%M%S"), ".csv") |
|
|
}, |
|
|
content = function(file) { |
|
|
results = extracted_results() |
|
|
if (!is.null(results)) { |
|
|
utils::write.csv(results, file, row.names = FALSE) |
|
|
} |
|
|
} |
|
|
) |
|
|
} |
|
|
|
|
|
|
|
|
shiny::shinyApp(ui = ui, server = server) |
|
|
|