fmegahed/structured_text_extraction
1
1library(shiny)2library(ellmer)3library(pdftools)4library(magick)5library(base64enc)6library(jsonlite)7 8# App logic lives in R/ and is sourced here in a fixed order9# (R/_disable_autoload.R stops Shiny from sourcing the directory a second time)10for (r_file in c("config.R", "fields.R", "sources.R", "extraction.R", "snippets.R", "help.R")) {11 source(file.path("R", r_file), local = TRUE)12}13 14# Uploads larger than this are rejected by Shiny15options(shiny.maxRequestSize = MAX_UPLOAD_MB * 1024^2)16 17# Model used for every call and named in every piece of UI text18EXTRACTION_MODEL = get_extraction_model()19 20APP_VERSION = paste0("Version ", APP_VERSION_NUMBER, " | ", APP_VERSION_DATE)21 22PAPER_TITLE = "What Quality Engineers Need to Know About Generative AI: Structured Data Extraction"23 24# Field templates (one JSON file each in config/templates)25FIELD_TEMPLATES = load_templates(file.path("config", "templates"))26 27# Fields shown at start-up and loaded with the demo data28demo_fields = function() {29 FIELD_TEMPLATES[["recall_notice"]]$fields[seq_len(NUM_EXAMPLE_FIELDS)]30}31 32# One input tab button (the panels are shown and hidden by www/app.js)33input_tab = function(id, label, icon_name, selected = FALSE) {34 shiny::tags$button(35 id = paste0("tab-", id),36 type = "button",37 class = if (selected) "input-tab active" else "input-tab",38 role = "tab",39 `aria-selected` = if (selected) "true" else "false",40 `aria-controls` = paste0("panel-", id),41 tabindex = if (selected) "0" else "-1",42 shiny::icon(icon_name),43 paste0(" ", label)44 )45}46 47# Container of one input tab's content48input_panel = function(id, ..., selected = FALSE) {49 shiny::div(50 id = paste0("panel-", id),51 class = "input-panel",52 role = "tabpanel",53 `aria-labelledby` = paste0("tab-", id),54 style = if (selected) NULL else "display: none;",55 ...56 )57}58 59# Heading followed by its "?" help button60heading_with_help = function(heading, topic) {61 shiny::div(class = "heading-with-help", heading, help_button(topic))62}63 64# Define UI for the app65ui = shiny::fluidPage(66 lang = "en",67 # Add Miami theme CSS and favicon68 shiny::tags$head(69 shiny::tags$link(rel = "icon", type = "image/svg+xml", href = "favicon.svg"),70 shiny::tags$link(rel = "stylesheet", type = "text/css", href = "miami-theme.css"),71 shiny::tags$title("AI-Powered Text Extraction Tool")72 ),73 74 # Custom header with Miami branding75 shiny::div(76 class = "app-header",77 shiny::div(78 class = "header-content",79 shiny::div(80 class = "header-left",81 shiny::tags$h1("AI-Powered Text Extraction Tool"),82 shiny::p(class = "subtitle", "Extract Structured Data from Text, Documents, and Images")83 ),84 shiny::div(85 class = "header-right",86 shiny::p(APP_VERSION, " | ", changes_link()),87 shiny::p(88 shiny::tags$strong("Authors: "),89 "Fadel M. Megahed, Ying-Ju Chen, Yamin Dahwich, Arthur Carvalho, L. Allison Jones-Farmer, Ibrahim Yousif, and Inez M. Zwetsloot"90 ),91 shiny::p(92 shiny::tags$strong("Contact: "),93 shiny::tags$a(94 href = "mailto:fmegahed@miamioh.edu",95 style = "color: #EFDB72;",96 "fmegahed@miamioh.edu"97 )98 )99 )100 )101 ),102 103 # University logos section104 shiny::div(105 class = "logo-container",106 shiny::tags$img(107 src = "miami-logo.png",108 alt = "Miami University Logo",109 style = "height: 55px;"110 ),111 shiny::div(class = "logo-divider"),112 shiny::tags$img(113 src = "university-of-dayton-vector-logo.png",114 alt = "University of Dayton Logo",115 style = "height: 50px;"116 ),117 shiny::div(class = "logo-divider"),118 shiny::tags$img(119 src = "university-of-cambridge-logo.png",120 alt = "University of Cambridge Logo",121 style = "height: 38px;"122 ),123 shiny::div(class = "logo-divider"),124 shiny::tags$img(125 src = "uva-compacte-logo.png",126 alt = "University of Amsterdam Logo",127 style = "height: 50px;"128 ),129 shiny::div(130 style = "margin-left: auto; font-size: 0.85em; color: #666;",131 shiny::p(132 style = "margin: 0;",133 "A collaboration between",134 shiny::tags$strong("Miami University,"),135 "the",136 shiny::tags$strong("University of Dayton,"),137 "the",138 shiny::tags$strong("University of Cambridge,"),139 "and the",140 shiny::tags$strong("University of Amsterdam")141 )142 )143 ),144 145 # How to use section146 shiny::div(147 class = "how-to-use",148 shiny::div(149 style = "display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;",150 shiny::tags$h4(151 style = "margin: 0;",152 shiny::icon("circle-info"),153 " How to Use This App"154 ),155 shiny::tags$button(156 id = "open-video-modal",157 type = "button",158 class = "btn btn-video-tutorial",159 `aria-haspopup` = "dialog",160 shiny::icon("play-circle"),161 " Watch Video Tutorial"162 )163 ),164 shiny::tags$h5(165 style = "color: #C41230; margin-top: 0;",166 shiny::icon("bolt"),167 " Quick Demo"168 ),169 shiny::p(170 "Click the ",171 shiny::tags$strong("'Load Demo Data'"),172 " button to load two sample NHTSA recall notices with pre-configured extraction fields. ",173 "Then click ",174 shiny::tags$strong("'Extract Data'"),175 " to see the model extract structured information from the text. ",176 "You can also add more fields, change field labels and types, and modify descriptions. ",177 shiny::tags$em(178 "(Note: The field description is the instruction the model receives. The label names the column in the results.)"179 )180 ),181 shiny::tags$h5(182 style = "color: #C41230; margin-top: 15px;",183 shiny::icon("file-import"),184 " Input Methods"185 ),186 shiny::p(187 "Choose from six input methods to provide your source content. Only the tab that is open when you click 'Extract Data' is used."188 ),189 shiny::tags$ul(190 style = "margin-bottom: 10px;",191 shiny::tags$li(192 shiny::tags$strong("Demo Data:"),193 " Try the app with pre-loaded NHTSA vehicle recall examples."194 ),195 shiny::tags$li(196 shiny::tags$strong("Paste Text:"),197 " Directly paste text content. Separate multiple items with double line breaks."198 ),199 shiny::tags$li(200 shiny::tags$strong("Text File:"),201 " Upload .txt, .csv (single column), or .md files."202 ),203 shiny::tags$li(204 shiny::tags$strong("Readable PDF:"),205 " Upload a machine-readable PDF. Text is extracted automatically."206 ),207 shiny::tags$li(208 shiny::tags$strong("Scanned PDF:"),209 " Upload scanned/image-based PDFs. Pages are converted to images for vision processing."210 ),211 shiny::tags$li(212 shiny::tags$strong("Upload Images:"),213 " Upload images (PNG, JPEG, WebP, GIF) for vision-based extraction."214 )215 ),216 shiny::div(217 style = "font-size: 0.9em; color: #666;",218 shiny::icon("info-circle"),219 " ",220 shiny::tags$em("Notes:"),221 shiny::tags$ul(222 style = "margin: 0.25em 0 0 1.25em; padding: 0; line-height: 1.3;",223 shiny::tags$li(224 style = "margin: 0; padding: 0;",225 sprintf(226 "Readable PDF and Scanned PDF are limited to the first %d pages, and Upload Images to %d images, in this free app.",227 MAX_PAGES_OR_IMAGES, MAX_PAGES_OR_IMAGES228 )229 ),230 shiny::tags$li(231 style = "margin: 0; padding: 0;",232 sprintf(233 "Any uploaded file must be at most %d MB, to keep the app responsive and avoid upload timeouts.",234 MAX_UPLOAD_MB235 )236 ),237 shiny::tags$li(238 style = "margin: 0; padding: 0;",239 "The '?' buttons next to each part of the app explain what it does and what is sent to the model."240 )241 )242 ),243 shiny::tags$h5(244 style = "color: #C41230; margin-top: 15px;",245 shiny::icon("edit"),246 " Custom Fields"247 ),248 shiny::p(249 "Customize your extraction by adjusting:"250 ),251 shiny::tags$ul(252 style = "margin-bottom: 10px;",253 shiny::tags$li(254 "The ", shiny::tags$strong("number of fields"), " to extract (up to ", MAX_FIELDS, ")"255 ),256 shiny::tags$li("Your own ", shiny::tags$strong("field labels"), " (the column names of the results)"),257 shiny::tags$li("Detailed ", shiny::tags$strong("field descriptions"), " to guide the model"),258 shiny::tags$li(259 "The ", shiny::tags$strong("field type"), " (text, number, yes/no, or list of text)"260 )261 ),262 shiny::p(263 "You can start from a ",264 shiny::tags$strong("template"),265 " 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."266 ),267 shiny::p(268 shiny::icon("lightbulb"),269 " ",270 shiny::tags$em(271 "Specific field descriptions give the model less room for interpretation. Always check the extracted values against the source documents."272 )273 )274 ),275 276 # Video Modal277 shiny::div(278 id = "video-modal",279 class = "video-modal",280 role = "dialog",281 `aria-modal` = "true",282 `aria-labelledby` = "video-modal-title",283 shiny::div(284 class = "video-modal-content",285 shiny::tags$button(286 id = "close-video-modal",287 type = "button",288 class = "video-modal-close",289 `aria-label` = "Close video tutorial",290 shiny::HTML("×")291 ),292 shiny::tags$h4(293 id = "video-modal-title",294 style = "color: #C41230; margin-top: 0; margin-bottom: 15px;",295 shiny::icon("play-circle"),296 " Video Tutorial"297 ),298 shiny::div(299 class = "video-modal-wrapper",300 # The video address is set by www/app.js when the dialog opens301 shiny::tags$iframe(302 id = "tutorial-video",303 title = "Video tutorial",304 src = "",305 allow = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture",306 allowfullscreen = NA307 )308 )309 )310 ),311 312 # JavaScript for the input tabs, the video dialog and the copy buttons313 shiny::tags$script(src = "app.js"),314 315 shiny::sidebarLayout(316 shiny::sidebarPanel(317 width = 4,318 class = "sidebar-panel",319 320 heading_with_help(321 shiny::tags$h4(322 id = "input-heading",323 style = "color: #C41230; margin-top: 0; margin-bottom: 0;",324 shiny::icon("file-alt"),325 " Input Configuration"326 ),327 "source"328 ),329 330 # Input method tabs (3 rows of 2 tabs)331 shiny::div(332 class = "input-tabs-container",333 role = "tablist",334 `aria-labelledby` = "input-heading",335 shiny::div(336 class = "input-tabs",337 role = "presentation",338 input_tab("demo", "Demo Data", "flask", selected = TRUE),339 input_tab("paste", "Paste Text", "paste")340 ),341 shiny::div(342 class = "input-tabs",343 role = "presentation",344 input_tab("textfile", "Text File", "file-lines"),345 input_tab("pdf", "Readable PDF", "file-pdf")346 ),347 shiny::div(348 class = "input-tabs",349 role = "presentation",350 input_tab("scanned", "Scanned PDF", "file-image"),351 input_tab("image", "Upload Images", "images")352 )353 ),354 355 # Demo data panel356 input_panel(357 "demo",358 selected = TRUE,359 shiny::p(360 "Try the app with pre-loaded NHTSA vehicle recall data. Click the button below to load sample text and pre-configured extraction fields."361 ),362 shiny::actionButton(363 "load_example",364 shiny::tags$span(shiny::icon("play"), " Load Demo Data"),365 class = "btn-primary",366 style = "margin-top: 10px;"367 ),368 shiny::uiOutput("demo_preview_ui")369 ),370 371 # Paste text panel372 input_panel(373 "paste",374 shiny::textAreaInput(375 "input_text",376 shiny::tags$span(class = "sr-only", "Text to extract from"),377 rows = 8,378 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."379 )380 ),381 382 # Text file upload panel383 input_panel(384 "textfile",385 shiny::fileInput(386 "text_file",387 shiny::tags$span(class = "sr-only", "Text file"),388 accept = c(".txt", ".csv", ".md"),389 placeholder = "No file selected",390 buttonLabel = shiny::tags$span(shiny::icon("upload"), " Browse...")391 ),392 shiny::helpText(393 shiny::icon("info-circle"),394 " Upload a text file (.txt, .csv, .md). Content will be extracted automatically."395 ),396 shiny::div(397 class = "file-format-note",398 shiny::tags$strong("File format guidelines:"),399 shiny::tags$ul(400 style = "margin: 5px 0 0 0; padding-left: 20px; font-size: 0.85em;",401 shiny::tags$li(".txt: Plain text, separate items with double line breaks"),402 shiny::tags$li(".csv: Single column of text entries (one per row, first row is a header)"),403 shiny::tags$li(".md: Markdown file treated as plain text, split at double line breaks")404 )405 ),406 shiny::uiOutput("textfile_preview_ui")407 ),408 409 # PDF upload panel (machine-readable)410 input_panel(411 "pdf",412 shiny::fileInput(413 "pdf_file",414 shiny::tags$span(class = "sr-only", "Readable PDF file"),415 accept = ".pdf",416 placeholder = "No file selected",417 buttonLabel = shiny::tags$span(shiny::icon("upload"), " Browse...")418 ),419 shiny::helpText(420 shiny::icon("info-circle"),421 " Upload a machine-readable PDF. Text will be extracted automatically and sent as one item."422 ),423 shiny::div(424 class = "limit-note",425 shiny::icon("exclamation-circle"),426 shiny::tags$em(limit_note("pages"))427 ),428 shiny::uiOutput("pdf_preview_ui")429 ),430 431 # Image upload panel432 input_panel(433 "image",434 shiny::fileInput(435 "image_file",436 shiny::tags$span(class = "sr-only", "Image files"),437 accept = c(".png", ".jpg", ".jpeg", ".webp", ".gif"),438 multiple = TRUE,439 placeholder = "No file selected",440 buttonLabel = shiny::tags$span(shiny::icon("upload"), " Browse...")441 ),442 shiny::helpText(443 shiny::icon("info-circle"),444 " Upload image(s) containing text to extract. Supports PNG, JPEG, WebP, and GIF."445 ),446 shiny::div(447 class = "limit-note",448 shiny::icon("exclamation-circle"),449 shiny::tags$em(limit_note("images"))450 ),451 shiny::uiOutput("image_preview_ui")452 ),453 454 # Scanned PDF panel455 input_panel(456 "scanned",457 shiny::fileInput(458 "scanned_pdf_file",459 shiny::tags$span(class = "sr-only", "Scanned PDF file"),460 accept = ".pdf",461 placeholder = "No file selected",462 buttonLabel = shiny::tags$span(shiny::icon("upload"), " Browse...")463 ),464 shiny::helpText(465 shiny::icon("info-circle"),466 " Upload a scanned/image-based PDF. Pages will be converted to images for vision processing."467 ),468 shiny::div(469 class = "limit-note",470 shiny::icon("exclamation-circle"),471 shiny::tags$em(limit_note("pages"))472 ),473 shiny::uiOutput("scanned_pdf_preview_ui")474 ),475 476 shiny::hr(class = "field-separator"),477 478 heading_with_help(479 shiny::tags$h4(480 style = "color: #C41230; margin-top: 0; margin-bottom: 0;",481 shiny::icon("tags"),482 " Field Definitions"483 ),484 "fields"485 ),486 487 shiny::div(488 class = "template-row",489 shiny::selectInput(490 "template_id",491 "Start from a template:",492 choices = template_choices(FIELD_TEMPLATES),493 selectize = FALSE494 ),495 shiny::actionButton("apply_template", "Use template", class = "btn-info")496 ),497 shiny::uiOutput("template_description_ui"),498 499 shiny::numericInput(500 "num_fields",501 shiny::tags$span(502 shiny::icon("list-ol"),503 " Number of Fields to Extract:"504 ),505 value = NUM_EXAMPLE_FIELDS,506 min = 1,507 max = MAX_FIELDS508 ),509 510 shiny::helpText(511 shiny::icon("info-circle"),512 " 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.')."513 ),514 515 shiny::uiOutput("fields_ui"),516 517 shiny::div(518 class = "fields-file-row",519 shiny::downloadButton("download_fields", "Save fields (JSON)", class = "btn-info"),520 shiny::fileInput(521 "fields_upload",522 "Load fields (JSON):",523 accept = c(".json", "application/json"),524 placeholder = "No file selected",525 buttonLabel = shiny::tags$span(shiny::icon("upload"), " Browse...")526 )527 ),528 529 shiny::hr(class = "field-separator"),530 531 shiny::div(532 class = "heading-with-help",533 shiny::actionButton(534 "extract_btn",535 shiny::tags$span(shiny::icon("magic"), " Extract Data"),536 class = "btn-primary"537 ),538 help_button("extract")539 )540 ),541 542 shiny::mainPanel(543 width = 8,544 class = "main-panel",545 546 shiny::div(547 class = "info-card",548 shiny::div(549 class = "heading-with-help section-heading",550 shiny::tags$h3(551 style = "color: #C41230; font-weight: 600; margin: 0;",552 shiny::icon("table"),553 " Structured Data Extracted Using AI"554 ),555 help_button("results")556 ),557 shiny::uiOutput("results_status_ui"),558 shiny::div(class = "results-table-wrapper", shiny::tableOutput("extracted_table")),559 shiny::uiOutput("download_btn_ui")560 ),561 562 shiny::div(563 class = "info-card",564 shiny::tags$h3(565 class = "section-heading",566 shiny::icon("code"),567 " Reproduce in Code"568 ),569 shiny::p(570 "The code below runs the same extraction, with your current field definitions and the model ",571 shiny::tags$code(EXTRACTION_MODEL),572 ", on your own computer and your own data. It needs your own ",573 MODEL_PROVIDER,574 " API key."575 ),576 shiny::uiOutput("snippet_status_ui"),577 shiny::tags$details(578 class = "code-details",579 shiny::tags$summary("R code (ellmer package)"),580 shiny::tags$button(581 type = "button",582 class = "btn btn-info copy-btn",583 `data-target` = "snippet_r",584 "Copy R code"585 ),586 shiny::verbatimTextOutput("snippet_r")587 ),588 shiny::tags$details(589 class = "code-details",590 shiny::tags$summary("Python code (chatlas package)"),591 shiny::tags$button(592 type = "button",593 class = "btn btn-info copy-btn",594 `data-target` = "snippet_python",595 "Copy Python code"596 ),597 shiny::verbatimTextOutput("snippet_python")598 )599 ),600 601 shiny::div(602 class = "tips-section",603 shiny::tags$h4(shiny::icon("lightbulb"), " Tips for Better Results"),604 shiny::tags$ul(605 shiny::tags$li("Use ", shiny::tags$strong("specific field descriptions"), " and say what to return when the information is absent"),606 shiny::tags$li("Start with ", shiny::tags$strong("more fields"), " and remove unnecessary ones later"),607 shiny::tags$li("If results are inaccurate, try ", shiny::tags$strong("rephrasing"), " your field descriptions"),608 shiny::tags$li("For ", shiny::tags$strong("multiple text blocks"), ", separate each with a double line break"),609 shiny::tags$li("Each text block should contain ", shiny::tags$strong("complete information"), " for all fields you want to extract"),610 shiny::tags$li(shiny::tags$strong("Check the results"), " against the source documents before you use them")611 )612 ),613 614 shiny::div(615 class = "note-section",616 shiny::tags$h4(shiny::icon("cog"), " Technical Note"),617 shiny::p(618 "This app sends its requests to the ",619 MODEL_PROVIDER,620 " model ",621 shiny::tags$code(EXTRACTION_MODEL),622 " through ",623 shiny::tags$code("chat_openai()"),624 " from the ",625 shiny::tags$a(626 href = "https://ellmer.tidyverse.org/",627 target = "_blank",628 "ellmer"629 ),630 " package. The app does not provide an option to change the model."631 ),632 shiny::p(633 "For ",634 shiny::tags$strong("complete privacy"),635 ", consider using local open-weight models via ",636 shiny::tags$a(637 href = "https://ellmer.tidyverse.org/reference/chat_ollama.html",638 target = "_blank",639 shiny::tags$code("chat_ollama()")640 ),641 " from ellmer, which connects to ",642 shiny::tags$a(643 href = "https://ollama.com/",644 target = "_blank",645 "Ollama"646 ),647 " for running models locally on your machine."648 ),649 shiny::p(650 "ellmer also supports models from other providers. For example, Claude models require ",651 shiny::tags$a(652 href = "https://ellmer.tidyverse.org/reference/chat_anthropic.html",653 target = "_blank",654 shiny::tags$code("chat_anthropic()")655 ),656 " and Gemini models require ",657 shiny::tags$a(658 href = "https://ellmer.tidyverse.org/reference/chat_google_gemini.html",659 target = "_blank",660 shiny::tags$code("chat_google_gemini()")661 ),662 ", each with their respective API keys configured in the R environment. ",663 "The code under 'Reproduce in Code' can be adapted by replacing the ",664 shiny::tags$code("chat_openai()"),665 " call."666 )667 )668 )669 ),670 671 # Footer672 shiny::div(673 class = "app-footer",674 shiny::p(675 "Built with ",676 shiny::tags$a(href = "https://shiny.posit.co/", target = "_blank", "Shiny"),677 " and ",678 shiny::tags$a(href = "https://ellmer.tidyverse.org/", target = "_blank", "ellmer"),679 " | ",680 "Powered by ",681 MODEL_PROVIDER682 ),683 shiny::p(684 shiny::tags$em(685 paste0(686 "Companion app to the paper '",687 PAPER_TITLE,688 "', in preparation by the app's authors for submission to "689 ),690 shiny::tags$a(691 href = "https://www.tandfonline.com/journals/lqen20",692 target = "_blank",693 "Quality Engineering."694 )695 )696 )697 )698)699 700# Define server logic required to generate dynamic UI and extract data701server = function(input, output, session) {702 703 # ---- State ---------------------------------------------------------------704 705 # What each input tab currently holds. Each tab has its own store, and the706 # extraction reads only the store of the active tab.707 demo_text = shiny::reactiveVal("")708 textfile_items = shiny::reactiveVal(character(0))709 pdf_text = shiny::reactiveVal("")710 image_files = shiny::reactiveVal(NULL)711 scanned_pdf_paths = shiny::reactiveVal(NULL)712 713 # Result of the last extraction: list(table, summary, n_failed, source)714 extraction = shiny::reactiveVal(NULL)715 716 # Field definitions. field_state holds the definitions the inputs were built717 # from; field_gen is the generation number used in the input ids. The inputs718 # are rebuilt only by set_fields(), never by a tab change.719 field_state = shiny::reactiveVal(demo_fields())720 field_gen = shiny::reactiveVal(0)721 722 # Creates the chat for each item. Tests replace it through the option.723 chat_factory = getOption("ste.chat_factory", default = make_chat_factory(EXTRACTION_MODEL))724 725 # ---- Helpers -------------------------------------------------------------726 727 notify = function(message, type = "message", duration = 3) {728 icon_name = switch(729 type,730 error = "exclamation-triangle",731 warning = "exclamation-circle",732 "check-circle"733 )734 shiny::showNotification(735 shiny::tags$span(shiny::icon(icon_name), " ", message),736 type = type,737 duration = duration738 )739 }740 741 # Active input tab (the client reports it through input$input_method)742 active_method = shiny::reactive({743 method = input$input_method744 if (is.null(method) || !(method %in% INPUT_METHODS)) "demo" else method745 })746 747 # Field definitions as currently shown, including what the user has typed748 current_fields = shiny::reactive({749 merge_field_inputs(field_state(), function(id) input[[id]], field_gen())750 })751 752 # Replace the field definitions and rebuild their inputs753 set_fields = function(fields) {754 field_state(fields)755 field_gen(shiny::isolate(field_gen()) + 1)756 shiny::updateNumericInput(session, "num_fields", value = length(fields))757 }758 759 # ---- Input sources -------------------------------------------------------760 761 # Load example data762 shiny::observeEvent(input$load_example, {763 demo_text(DEMO_TEXT)764 set_fields(demo_fields())765 notify("Demo data loaded. Click 'Extract Data' to see results.")766 })767 768 # Render demo preview769 output$demo_preview_ui = shiny::renderUI({770 text = demo_text()771 if (is.null(text) || nchar(text) == 0) return(NULL)772 773 shiny::div(774 class = "pdf-preview",775 style = "margin-top: 15px;",776 shiny::tags$strong("Loaded Demo Text:"),777 shiny::tags$pre(preview_text(text))778 )779 })780 781 # Process uploaded PDF782 shiny::observeEvent(input$pdf_file, {783 shiny::req(input$pdf_file)784 785 tryCatch({786 extracted = read_pdf_text(input$pdf_file$datapath, MAX_PAGES_OR_IMAGES)787 788 if (extracted$total_pages > extracted$used_pages) {789 notify(790 sprintf(791 "The PDF has %d pages. Only the first %d will be processed (free app limit).",792 extracted$total_pages, extracted$used_pages793 ),794 type = "warning",795 duration = 5796 )797 }798 799 pdf_text(extracted$text)800 801 if (nchar(extracted$text) == 0) {802 notify(803 "No text was found in this PDF. If it is a scan, use the Scanned PDF tab.",804 type = "warning",805 duration = 8806 )807 } else {808 notify(sprintf("PDF processed. Text of %d page(s) read.", extracted$used_pages))809 }810 }, error = function(e) {811 pdf_text("")812 notify(paste("Error reading PDF:", conditionMessage(e)), type = "error", duration = NULL)813 })814 })815 816 # Render PDF text preview817 output$pdf_preview_ui = shiny::renderUI({818 text = pdf_text()819 if (is.null(text) || nchar(text) == 0) return(NULL)820 821 shiny::div(822 class = "pdf-preview",823 shiny::tags$strong("Extracted Text Preview:"),824 shiny::tags$pre(preview_text(text))825 )826 })827 828 # Process uploaded text file829 shiny::observeEvent(input$text_file, {830 shiny::req(input$text_file)831 832 tryCatch({833 items = read_text_file_items(input$text_file$datapath, input$text_file$name)834 textfile_items(items)835 836 if (length(items) == 0) {837 notify("No text was found in this file.", type = "warning", duration = 8)838 } else {839 notify(sprintf("File processed. %d item(s) found.", length(items)))840 }841 }, error = function(e) {842 textfile_items(character(0))843 notify(paste("Error reading file:", conditionMessage(e)), type = "error", duration = NULL)844 })845 })846 847 # Render text file preview848 output$textfile_preview_ui = shiny::renderUI({849 items = textfile_items()850 if (length(items) == 0) return(NULL)851 852 shiny::div(853 class = "pdf-preview",854 shiny::tags$strong(sprintf("Extracted Text Preview (%d item(s)):", length(items))),855 shiny::tags$pre(preview_text(items))856 )857 })858 859 # Process uploaded images860 shiny::observeEvent(input$image_file, {861 shiny::req(input$image_file)862 863 tryCatch({864 files = input$image_file865 files$mime = vapply(866 seq_len(nrow(files)),867 function(i) detect_mime(files$datapath[i], files$name[i]),868 character(1)869 )870 871 not_images = files$name[is.na(files$mime)]872 files = files[!is.na(files$mime), , drop = FALSE]873 if (length(not_images) > 0) {874 notify(875 paste("Not a supported image, skipped:", paste(not_images, collapse = ", ")),876 type = "warning",877 duration = 8878 )879 }880 881 if (nrow(files) > MAX_PAGES_OR_IMAGES) {882 notify(883 sprintf(884 "Only the first %d images will be processed (free app limit).",885 MAX_PAGES_OR_IMAGES886 ),887 type = "warning",888 duration = 5889 )890 files = files[seq_len(MAX_PAGES_OR_IMAGES), , drop = FALSE]891 }892 893 image_files(if (nrow(files) > 0) files else NULL)894 if (nrow(files) > 0) notify(sprintf("%d image(s) uploaded.", nrow(files)))895 }, error = function(e) {896 image_files(NULL)897 notify(paste("Error uploading images:", conditionMessage(e)), type = "error", duration = NULL)898 })899 })900 901 # Render image preview902 output$image_preview_ui = shiny::renderUI({903 files = image_files()904 if (is.null(files) || nrow(files) == 0) return(NULL)905 906 shiny::tagList(907 shiny::div(908 class = "image-preview-grid",909 lapply(seq_len(nrow(files)), function(i) {910 shiny::div(911 class = "image-preview-item",912 shiny::tags$img(913 src = base64enc::dataURI(file = files$datapath[i], mime = files$mime[i]),914 alt = paste0("Image ", i, ": ", files$name[i])915 )916 )917 })918 ),919 shiny::div(920 class = "image-preview-count",921 paste(nrow(files), "image(s) ready for extraction")922 )923 )924 })925 926 # Process scanned PDF (convert pages to images)927 shiny::observeEvent(input$scanned_pdf_file, {928 shiny::req(input$scanned_pdf_file)929 930 tryCatch({931 shiny::showNotification(932 "Converting PDF pages to images...",933 type = "message",934 duration = NULL,935 id = "convert_notif"936 )937 938 rendered = render_pdf_pages(input$scanned_pdf_file$datapath, MAX_PAGES_OR_IMAGES)939 scanned_pdf_paths(rendered$paths)940 941 shiny::removeNotification(id = "convert_notif")942 if (rendered$total_pages > length(rendered$paths)) {943 notify(944 sprintf(945 "The PDF has %d pages. Only the first %d will be processed (free app limit).",946 rendered$total_pages, length(rendered$paths)947 ),948 type = "warning",949 duration = 5950 )951 }952 notify(sprintf("%d page(s) converted.", length(rendered$paths)))953 }, error = function(e) {954 scanned_pdf_paths(NULL)955 shiny::removeNotification(id = "convert_notif")956 notify(paste("Error processing scanned PDF:", conditionMessage(e)), type = "error", duration = NULL)957 })958 })959 960 # Render scanned PDF preview961 output$scanned_pdf_preview_ui = shiny::renderUI({962 paths = scanned_pdf_paths()963 if (is.null(paths) || length(paths) == 0) return(NULL)964 965 shiny::tagList(966 shiny::div(967 class = "image-preview-grid",968 lapply(seq_along(paths), function(i) {969 shiny::div(970 class = "image-preview-item",971 shiny::tags$img(972 src = base64enc::dataURI(file = paths[i], mime = detect_mime(paths[i])),973 alt = paste("Page", i)974 )975 )976 })977 ),978 shiny::div(979 class = "image-preview-count",980 paste(length(paths), "page(s) ready for extraction")981 )982 )983 })984 985 # The previews sit in panels that www/app.js shows and hides986 for (preview_id in c("demo_preview_ui", "pdf_preview_ui", "textfile_preview_ui",987 "image_preview_ui", "scanned_pdf_preview_ui")) {988 shiny::outputOptions(output, preview_id, suspendWhenHidden = FALSE)989 }990 991 # What each tab holds right now, in the form resolve_source_items() expects992 current_sources = function() {993 files = image_files()994 list(995 demo = demo_text(),996 paste = input$input_text,997 textfile = textfile_items(),998 pdf = pdf_text(),999 scanned = scanned_pdf_paths(),1000 image = if (is.null(files)) NULL else files$datapath1001 )1002 }1003 1004 # ---- Field definitions ---------------------------------------------------1005 1006 # Change the number of fields, keeping everything the user has entered1007 shiny::observeEvent(input$num_fields, {1008 n = clamp_num_fields(input$num_fields, MAX_FIELDS)1009 if (is.na(n)) return()1010 1011 if (!isTRUE(n == input$num_fields)) {1012 shiny::updateNumericInput(session, "num_fields", value = n)1013 notify(1014 sprintf("The number of fields must be between 1 and %d.", MAX_FIELDS),1015 type = "warning",1016 duration = 51017 )1018 }1019 1020 fields = current_fields()1021 if (n == length(fields)) return()1022 field_state(resize_fields(fields, n))1023 field_gen(field_gen() + 1)1024 }, ignoreInit = TRUE)1025 1026 # Fill the fields from a template1027 shiny::observeEvent(input$apply_template, {1028 template = FIELD_TEMPLATES[[input$template_id]]1029 if (is.null(template)) return()1030 set_fields(template$fields)1031 notify(sprintf("Template '%s' loaded. You can edit the fields.", template$name))1032 })1033 1034 output$template_description_ui = shiny::renderUI({1035 template = FIELD_TEMPLATES[[input$template_id]]1036 if (is.null(template)) return(NULL)1037 shiny::helpText(sprintf("%s %d fields.", template$description, length(template$fields)))1038 })1039 1040 # Restore the fields from an uploaded JSON file1041 shiny::observeEvent(input$fields_upload, {1042 shiny::req(input$fields_upload)1043 loaded = read_fields_json(input$fields_upload$datapath, MAX_FIELDS)1044 if (!loaded$ok) {1045 notify(1046 paste("The field definitions were not loaded.", loaded$error),1047 type = "error",1048 duration = NULL1049 )1050 return()1051 }1052 set_fields(loaded$fields)1053 notify(sprintf("%d field(s) loaded from the file.", length(loaded$fields)))1054 })1055 1056 # Download the current field definitions as JSON1057 output$download_fields = shiny::downloadHandler(1058 filename = function() {1059 paste0("field_definitions_", format(Sys.time(), "%Y%m%d_%H%M%S"), ".json")1060 },1061 content = function(file) {1062 writeLines(fields_to_json(current_fields()), file, useBytes = TRUE)1063 }1064 )1065 1066 # Generate the inputs for each field's label, description and type.1067 # Depends only on field_gen, so typing and tab changes never rebuild them.1068 output$fields_ui = shiny::renderUI({1069 gen = field_gen()1070 fields = shiny::isolate(field_state())1071 1072 do.call(shiny::tagList, lapply(seq_along(fields), function(i) {1073 ids = field_input_ids(gen, i)1074 field = fields[[i]]1075 1076 shiny::div(1077 class = "field-card",1078 shiny::tags$span(1079 style = "color: #C41230; font-weight: 600; font-size: 0.9em;",1080 paste("Field", i)1081 ),1082 shiny::textInput(ids$label, "Label:", value = field$label),1083 shiny::textAreaInput(1084 ids$description,1085 "Description:",1086 value = field$description,1087 rows = 5,1088 placeholder = "Describe what to extract for this field, and what to return when it is absent"1089 ),1090 shiny::selectInput(1091 ids$type,1092 "Type:",1093 choices = stats::setNames(names(FIELD_TYPES), FIELD_TYPES),1094 selected = if (field$type %in% names(FIELD_TYPES)) field$type else "text",1095 selectize = FALSE1096 )1097 )1098 }))1099 })1100 1101 # ---- Help ----------------------------------------------------------------1102 1103 lapply(HELP_TOPICS, function(topic) {1104 shiny::observeEvent(input[[paste0("help_", topic)]], {1105 shiny::showModal(help_modal(topic))1106 })1107 })1108 1109 # ---- Extraction ----------------------------------------------------------1110 1111 # When the extract button is clicked, perform extraction1112 shiny::observeEvent(input$extract_btn, {1113 # Items come from the active tab only1114 resolved = resolve_source_items(active_method(), current_sources(), MAX_PAGES_OR_IMAGES)1115 if (!resolved$ok) {1116 notify(resolved$error, type = "error", duration = 5)1117 return()1118 }1119 1120 checked = validate_fields(current_fields(), MAX_FIELDS)1121 if (!checked$ok) {1122 notify(1123 shiny::tagList(1124 "Please correct the field definitions:",1125 shiny::tags$ul(lapply(checked$errors, shiny::tags$li))1126 ),1127 type = "error",1128 duration = NULL1129 )1130 return()1131 }1132 if (length(checked$notes) > 0) {1133 notify(1134 shiny::tags$ul(lapply(checked$notes, shiny::tags$li)),1135 type = "warning",1136 duration = 101137 )1138 }1139 1140 if (is.null(getOption("ste.chat_factory")) && !api_key_available()) {1141 notify(1142 "The extraction service is not available right now. Please try again later, or contact the authors if the problem continues.",1143 type = "error",1144 duration = NULL1145 )1146 return()1147 }1148 1149 items = resolved$items1150 source_label = INPUT_METHOD_LABELS[[active_method()]]1151 custom_type_object = build_type_object(checked$fields)1152 1153 results = shiny::withProgress(1154 message = "Extracting",1155 value = 0,1156 {1157 extracted = extract_items(1158 items,1159 custom_type_object,1160 chat_factory,1161 on_progress = function(i, n, item) {1162 shiny::setProgress(1163 value = (i - 1) / n,1164 detail = sprintf("item %d of %d (source_id: %s)", i, n, item$source_id)1165 )1166 }1167 )1168 shiny::setProgress(value = 1, detail = "done")1169 extracted1170 }1171 )1172 1173 results_table = assemble_results(results, checked$fields)1174 n_failed = sum(results_table$status != "ok")1175 extraction(list(1176 table = results_table,1177 summary = summarize_results(results_table),1178 n_failed = n_failed,1179 source = source_label1180 ))1181 1182 if (n_failed == 0) {1183 notify("Extraction complete.")1184 } else {1185 notify(summarize_results(results_table), type = "warning", duration = 8)1186 }1187 })1188 1189 # Summary line above the table, or the placeholder before the first run1190 output$results_status_ui = shiny::renderUI({1191 result = extraction()1192 if (is.null(result)) {1193 return(shiny::div(1194 class = "results-placeholder",1195 shiny::icon("arrow-left"),1196 " Configure your extraction fields and click 'Extract Data' to see results here"1197 ))1198 }1199 1200 shiny::div(