Team Ai
Apppublic

fmegahed/structured_text_extraction

sourceHugging Facemitupdated 1d agoView on Hugging Face
1likes
app.R1287 linesDownload Raw Back to root
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(

Showing the first 1,200 of 1287 lines. Download the file for the rest.