1 Introduction

In this practical, we will automate the extraction of patient data from unstructured Microsoft Word (.docx) clinical notes.

*Thanks to Dr. Anisa Ahmad from the Department of Medical Education for sharing the PBL triggers for USM MD program as example clinical notes with us to demonstrate the use of LLM in this workshop.

We will use Google’s Gemini LLM to parse the free text and structure it into a clean, analyzable dataset.

First, let’s load the required packages.

library(tidyllm)
library(jsonlite)
library(purrr)
library(dplyr)
library(officer) # to read .docx

2 Setting up the API

We need to authenticate our session with Google’s API. Here, we read the API key from a local file namely “api” to keep it secure and out of the script. Make sure you already obtained API from Google’s AI Studio using your Google account.

# API
Sys.setenv(GOOGLE_API_KEY = readLines("api"))

3 Download clinical notes

Download clinical notes here: text.zip. Extract the folder namely “text”. It contains 13 clinical note files.

list.files(path = "text")
#>  [1] "Note 01.docx" "Note 02.docx" "Note 03.docx" "Note 04.docx" "Note 20.docx"
#>  [6] "Note 22.docx" "Note 23.docx" "Note 24.docx" "Note 25.docx" "Note 26.docx"
#> [11] "Note 28.docx" "Note 29.docx" "Note 47.docx"

4 Defining the JSON Schema

To prevent the LLM from outputting messy data, we define a strict JSON schema using tidyllm_schema(). This helps LLM understand what we meant by the variable names.

When designing more complex clinical schemas, you can use these four field helpers in tidydllmto guide the LLM’s data extraction:

  • field_chr("description"): For extracting unstructured text (e.g., medical history notes, specific complaint descriptions).
  • field_fct("description", .levels = c(...)): Ideal for binary flags (yes/no), severity ratings (mild, moderate, severe), or specific categorical attributes.
  • field_dbl("description"): For forcing numbers (e.g., lab results, weight, dosages).
  • field_lgl("description"): For clean boolean TRUE/FALSE outcomes.

Define the schema,

patient_schema <- tidyllm_schema(
  name = "PatientHistory", # This is the schema name, don't use space in the name
  
  patient_name = field_chr(
    "Patient's name"
  ),
  
    age = field_dbl(
    "Patient's age in years. IMPORTANT: If the age is given in months, you must calculate and output it as a fraction of a year (e.g., 6 months is 0.5)."
  ),
  
  gender = field_fct(
    "Gender of the patient.",
    .levels = c("male","female")
  ),
  
  symptoms = field_chr(
    "List of current clinical symptoms reported by the patient.", 
    .vector = TRUE
  ),
  
    pe = field_chr(
    "List of physical examination findings for the patient. If this information is not available in the text, output exactly 'NA'.", 
    .vector = TRUE
  ),
  
  ix_blood = field_chr(
    "List of blood investigation results for the patient. If this information is not available in the text, output exactly 'NA'.", 
    .vector = TRUE
  ),
  
  ix_imaging = field_chr(
    "List of imaging results for the patient. If this information is not available in the text, output exactly 'NA'.", 
    .vector = TRUE
  ),
  
  diagnosis = field_chr(
    "Final diagnosis. If this information is not available in the text, output exactly 'NA'."
  )
)

5 Processing the Documents

We will now specify all .docx files in our target directory. For each file, the officer package will extract the raw text. We then pass this text into our tidyllm pipeline, enforcing our strict schema, and append the minified JSON result into a JSON Lines (.jsonl) file.

Get the list of all .docx files in the target directory for LLM reference

folder_path <- "text" # folder name, make sure your R file in in the same directory
docx_files <- list.files(path = folder_path, pattern = "\\.docx$", full.names = TRUE)
docx_files
#>  [1] "text/Note 01.docx" "text/Note 02.docx" "text/Note 03.docx"
#>  [4] "text/Note 04.docx" "text/Note 20.docx" "text/Note 22.docx"
#>  [7] "text/Note 23.docx" "text/Note 24.docx" "text/Note 25.docx"
#> [10] "text/Note 26.docx" "text/Note 28.docx" "text/Note 29.docx"
#> [13] "text/Note 47.docx"

We then specify the output file in JSON Lines .jsonl format

output_file <- "pt_data_full.jsonl"

Clear the output file before starting a fresh run. Otherwise, with multiple run, new data will be added to the existing file

if (file.exists(output_file)) file.remove(output_file)

Process using a for loop over the files

for(file_path in docx_files) {
  
  # Extract text from the current .docx file
  doc <- read_docx(file_path)
  doc_content <- docx_summary(doc)
  
  # Combine all the extracted paragraphs into one long string
  note <- paste(doc_content$text, collapse = "\n")
  
  # Combine LLM prompt as instruction + note (the text from .docx)
  full_prompt <- paste("Extract relevant information from patient's note according to the schema.\n\nPatient Note:\n", note)
  
  # We use tryCatch to prevent a single failing document from stopping the whole loop
  tryCatch({
    
    # If you want to use local LLM via Ollama:
    # result <- llm_message(full_prompt) |> 
    #   chat(
    #     .provider = ollama(.ollama_server = "http://127.0.0.1:11434/"),
    #     .model = "llama3.2:3b", 
    #     .json_schema = patient_schema
    #   )
    
    # We use Gemini model
    result <- llm_message(full_prompt) |> 
      chat(
        .provider = gemini(),
        # .model = "gemini-3.5-flash", # longer response time, limited token number for free tier
        .model = "gemini-3.1-flash-lite", # does the job well
        .json_schema = patient_schema # out schema R object we defined earlier
      )
    
    # Extract the textual JSON reply from the LLM message object
    json_string <- get_reply(result)
    
    # Minify and append to .jsonl
    # minify removes all indentation/whitespace
    compact_json <- minify(json_string)
    cat(compact_json, "\n", file = output_file, append = TRUE, sep = "")
    
    # Print success message, this is important for use to know where it went wrong
    cat("Successfully processed:", basename(file_path), "\n")
    
  }, error = function(e) { 
    # Print error message, when it failed to process the file
    cat("Error processing file:", basename(file_path), "\n", conditionMessage(e), "\n")
  })
}

6 Data Import and Inspection

Once the pipeline has finished processing all documents, we stream the generated .jsonl file back into R as a tidy data frame. Finally, we can inspect the overall structure and walk through individual patient records.

if (file.exists("pt_data_full.jsonl")) { # check if we indeed have the file
  patient_data <- stream_in(file(output_file)) # Read the jsonl file
  glimpse(patient_data) # Glimpse into data
} else {
  print("File not found.")
}
#> opening file input connection.
#>  Found 13 records... Imported 13 records. Simplifying...
#> closing file input connection.
#> Rows: 13
#> Columns: 8
#> $ patient_name <chr> "Ramasamy", "Danial", "Bella", "Amy", "Ryan", "Suhaimi", …
#> $ age          <dbl> 45.000, 0.500, 56.000, 35.000, 8.000, 45.000, 27.000, 70.…
#> $ gender       <chr> "male", "male", "female", "female", "male", "male", "fema…
#> $ symptoms     <list> <"heavy alcohol consumption", "smoker", "bedridden", "ri…
#> $ pe           <list> <"cardiomegaly", "hepatomegaly">, NA, NA, NA, <"pale", "…
#> $ ix_blood     <list> NA, NA, NA, "HIV reactive", <"TWBC 80 x10^9/l", "Hb 7.8g…
#> $ ix_imaging   <list> NA, NA, NA, NA, NA, "KUB radiograph: bilateral staghorn …
#> $ diagnosis    <chr> "NA", "beta thalassemia major", "breast cancer", "HIV inf…

Let’s look at individual patient’s data

walk(1:length(docx_files), ~ {
  cat(paste0("## File name: ", docx_files[.x], " ##\n"))
  patient_data[.x, ] |> glimpse()
  cat("\n")
})
#> ## File name: text/Note 01.docx ##
#> Rows: 1
#> Columns: 8
#> $ patient_name <chr> "Ramasamy"
#> $ age          <dbl> 45
#> $ gender       <chr> "male"
#> $ symptoms     <list> <"heavy alcohol consumption", "smoker", "bedridden", "rig…
#> $ pe           <list> <"cardiomegaly", "hepatomegaly">
#> $ ix_blood     <list> NA
#> $ ix_imaging   <list> NA
#> $ diagnosis    <chr> "NA"
#> 
#> ## File name: text/Note 02.docx ##
#> Rows: 1
#> Columns: 8
#> $ patient_name <chr> "Danial"
#> $ age          <dbl> 0.5
#> $ gender       <chr> "male"
#> $ symptoms     <list> <"pale", "not active">
#> $ pe           <list> NA
#> $ ix_blood     <list> NA
#> $ ix_imaging   <list> NA
#> $ diagnosis    <chr> "beta thalassemia major"
#> 
#> ## File name: text/Note 03.docx ##
#> Rows: 1
#> Columns: 8
#> $ patient_name <chr> "Bella"
#> $ age          <dbl> 56
#> $ gender       <chr> "female"
#> $ symptoms     <list> "breast lump"
#> $ pe           <list> NA
#> $ ix_blood     <list> NA
#> $ ix_imaging   <list> NA
#> $ diagnosis    <chr> "breast cancer"
#> 
#> ## File name: text/Note 04.docx ##
#> Rows: 1
#> Columns: 8
#> $ patient_name <chr> "Amy"
#> $ age          <dbl> 35
#> $ gender       <chr> "female"
#> $ symptoms     <list> "severe lethargy"
#> $ pe           <list> NA
#> $ ix_blood     <list> "HIV reactive"
#> $ ix_imaging   <list> NA
#> $ diagnosis    <chr> "HIV infection"
#> 
#> ## File name: text/Note 20.docx ##
#> Rows: 1
#> Columns: 8
#> $ patient_name <chr> "Ryan"
#> $ age          <dbl> 8
#> $ gender       <chr> "male"
#> $ symptoms     <list> <"fever", "spontaneous gum bleeding">
#> $ pe           <list> <"pale", "febrile", "multiple bruises over upper and lowe…
#> $ ix_blood     <list> <"TWBC 80 x10^9/l", "Hb 7.8g/dl", "Platelet 20 x10^9/l", …
#> $ ix_imaging   <list> NA
#> $ diagnosis    <chr> "acute lymphoblastic leukaemia (ALL)"
#> 
#> ## File name: text/Note 22.docx ##
#> Rows: 1
#> Columns: 8
#> $ patient_name <chr> "Suhaimi"
#> $ age          <dbl> 45
#> $ gender       <chr> "male"
#> $ symptoms     <list> <"severe left loin pain", "nausea", "vomiting", "fever", …
#> $ pe           <list> <"alert and conscious", "blood pressure 144/86 mmHg", "pu…
#> $ ix_blood     <list> <"Urine dipstick: microscopic haematuria and leukocytes",…
#> $ ix_imaging   <list> "KUB radiograph: bilateral staghorn calculi, left ureter…
#> $ diagnosis    <chr> "NA"
#> 
#> ## File name: text/Note 23.docx ##
#> Rows: 1
#> Columns: 8
#> $ patient_name <chr> "Fatimah"
#> $ age          <dbl> 27
#> $ gender       <chr> "female"
#> $ symptoms     <list> <"high grade fever", "pain during micturition", "feeling …
#> $ pe           <list> <"temperature 39C", "pulse rate 100 bpm", "blood pressure…
#> $ ix_blood     <list> <"Hb: 11.9 g/dl", "WBC: 17.8 x 109/liter", "Neutrophils: …
#> $ ix_imaging   <list> NA
#> $ diagnosis    <chr> "urinary tract infection"
#> 
#> ## File name: text/Note 24.docx ##
#> Rows: 1
#> Columns: 8
#> $ patient_name <chr> "Ahmed"
#> $ age          <dbl> 70
#> $ gender       <chr> "male"
#> $ symptoms     <list> <"inability to pass urine", "suprapubic discomfort", "noc…
#> $ pe           <list> <"suprapubic mass", "large rubbery mass on the anterior a…
#> $ ix_blood     <list> <"Hb 12.0 g/dL", "TWBC 7 x 10^9/L", "Potassium 5.6 mmol/L…
#> $ ix_imaging   <list> <"KUB plain radiograph: no abnormality", "Ultrasound KUB…
#> $ diagnosis    <chr> "benign prostatic hyperplasia"
#> 
#> ## File name: text/Note 25.docx ##
#> Rows: 1
#> Columns: 8
#> $ patient_name <chr> "Najeeb"
#> $ age          <dbl> 60
#> $ gender       <chr> "male"
#> $ symptoms     <list> <"severe diarrhoea", "nausea", "vomiting", "dark yellow u…
#> $ pe           <list> <"drowsy", "sunken eyes", "dry lips", "reduced skin turgo…
#> $ ix_blood     <list> <"Hb: 15 g/dl", "PCV: 50%", "Sodium: 140 mmol/L", "Potass…
#> $ ix_imaging   <list> NA
#> $ diagnosis    <chr> "acute kidney injury"
#> 
#> ## File name: text/Note 26.docx ##
#> Rows: 1
#> Columns: 8
#> $ patient_name <chr> "Danisha"
#> $ age          <dbl> 0.019
#> $ gender       <chr> "female"
#> $ symptoms     <list> <"midline swelling over the lumbo-sacral spine", "lower l…
#> $ pe           <list> <"generally active", "both lower limbs not moving">
#> $ ix_blood     <list> NA
#> $ ix_imaging   <list> "hydrocephalus"
#> $ diagnosis    <chr> "spina bifida cystica (myelomeningocoele) and hydrocephal…
#> 
#> ## File name: text/Note 28.docx ##
#> Rows: 1
#> Columns: 8
#> $ patient_name <chr> "Salmah"
#> $ age          <dbl> 50
#> $ gender       <chr> "female"
#> $ symptoms     <list> <"difficulty in talking", "difficulty in standing", "asym…
#> $ pe           <list> <"GCS eye opening spontaneous", "unable to produce sponta…
#> $ ix_blood     <list> NA
#> $ ix_imaging   <list> NA
#> $ diagnosis    <chr> "left middle cerebral artery ischemic stroke"
#> 
#> ## File name: text/Note 29.docx ##
#> Rows: 1
#> Columns: 8
#> $ patient_name <chr> "Hashim"
#> $ age          <dbl> 17
#> $ gender       <chr> "male"
#> $ symptoms     <list> <"weakness of both legs", "numbness in legs", "tingling s…
#> $ pe           <list> <"normal vital signs", "normal cranial nerves", "no upper…
#> $ ix_blood     <list> NA
#> $ ix_imaging   <list> "nerve conduction study"
#> $ diagnosis    <chr> "Guillain-Barre Syndrome"
#> 
#> ## File name: text/Note 47.docx ##
#> Rows: 1
#> Columns: 8
#> $ patient_name <chr> "Asyraf"
#> $ age          <dbl> 35
#> $ gender       <chr> "male"
#> $ symptoms     <list> <"severe shortness of breath", "cough">
#> $ pe           <list> <"tachypneic", "afebrile", "normal vital signs", "ronchi …
#> $ ix_blood     <list> NA
#> $ ix_imaging   <list> NA
#> $ diagnosis    <chr> "occupational asthma"