Download app.R from fmegahed/structured_text_extraction: direct link, hf CLI and curl.
- Browser
- Download file 41.9 kB
-
https://huggingface.co/spaces/fmegahed/structured_text_extraction/resolve/main/app.R
- Command line
-
hf download hf://spaces/fmegahed/structured_text_extraction/app.R
-
curl -L -o app.R https://huggingface.co/spaces/fmegahed/structured_text_extraction/resolve/main/app.R
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) | |