# MCAT API - Adaptive Mental Health Assessment # RESTful API for adaptive testing using IRT (mirt package) library(plumber) library(mirt) library(mirtCAT) library(jsonlite) # ============================================================================== # LOAD MODEL AND SETUP # ============================================================================== # Load the fitted IRT model mod <- readRDS("Fullmodel.rds") # Item ranges for each domain item_ranges <- list( Distress = 1:10, Psychosis = 11:26, Mania = 27:31, Suicidality = 32:36, Alcohol = 37:39, Functioning = 40:44, Anxiety = 45:49 ) # Maximum scores for each domain max_scores <- c( Distress = 50, Psychosis = 16, Mania = 20, Suicidality = 50, Alcohol = 12, Functioning = 40, Anxiety = 20 ) # Domain scoring bands for interpretation domain_cuts <- list( Distress = list(breaks = c(-Inf, 15, 21, 29, Inf), labels = c("Low", "Moderate", "High", "Very high")), Psychosis = list(breaks = c(-Inf, 4, 8, Inf), labels = c("Low", "Possible concern", "Probable concern")), Mania = list(breaks = c(-Inf, 5, 9, 14, Inf), labels = c("No concern", "Mild", "Moderate", "Severe")), Suicidality= list(breaks = c(-Inf, 20, 30, 40, Inf), labels = c("Low", "Moderate", "High", "Very high")), Alcohol = list(breaks = c(-Inf, 0, 2, 4, 7, Inf), labels = c("Abstinent", "Low", "Moderate", "High", "Very high")), Functioning= list(breaks = c(-Inf, 9, 20, 30, Inf), labels = c("None", "Mild impairment", "Moderate impairment", "Severe impairment")), Anxiety = list(breaks = c(-Inf, 4, 9, 14, Inf), labels = c("Minimal", "Mild", "Moderate", "Severe")) ) # Extract model parameters K <- extract.mirt(mod, "K") mins <- mod@Data$mins items <- colnames(mod@Data$data) nfact <- extract.mirt(mod, "nfact") # ============================================================================== # ITEM LABELS (Question Text) # ============================================================================== item_labels <- c( # K10 K10_1 = "In the past four (4) weeks, about how often did you feel tired out for no good reason?", K10_2 = "In the past four (4) weeks, about how often did you feel nervous?", K10_3 = "In the past four (4) weeks, about how often did you feel so nervous that nothing could calm you down?", K10_4 = "In the past four (4) weeks, about how often did you feel hopeless?", K10_5 = "In the past four (4) weeks, about how often did you feel restless or fidgety?", K10_6 = "In the past four (4) weeks, about how often did you feel so restless you could not sit still?", K10_7 = "In the past four (4) weeks, about how often did you feel depressed?", K10_8 = "In the past four (4) weeks, about how often did you feel that everything was an effort?", K10_9 = "In the past four (4) weeks, about how often did you feel so sad that nothing could cheer you up?", K10_10 = "In the past four (4) weeks, about how often did you feel worthless?", # OASIS OASIS_1 = "In the past week, how often have you felt anxious?", OASIS_2 = "In the past week, how intense or severe was your anxiety when it occurred?", OASIS_3 = "In the past week, how often did you avoid things due to anxiety or fear?", OASIS_4 = "In the past week, how much did anxiety interfere with work, school, or home responsibilities?", OASIS_5 = "In the past week, how much did anxiety interfere with your social life and relationships?", # SIDAS SIDAS_1 = "In the past month, how often have you had thoughts about suicide?", SIDAS_2 = "In the past month, how much control have you had over these thoughts?", SIDAS_3 = "In the past month, how close have you come to making a suicide attempt?", SIDAS_4 = "In the past month, to what extent have you felt tormented by thoughts about suicide?", SIDAS_5 = "In the past month, how much have thoughts about suicide interfered with daily activities?", # PQ16 PQ16_1 = "I feel uninterested in the things I used to enjoy.", PQ16_2 = "I often seem to live through events exactly as they happened before (deja vu).", PQ16_3 = "I sometimes smell or taste things that others can't.", PQ16_4 = "I often hear unusual sounds like banging, clicking, or ringing in my ears.", PQ16_5 = "I've been confused about whether something was real or imaginary.", PQ16_6 = "I've seen my own or others' faces change in strange ways.", PQ16_7 = "I get extremely anxious when meeting people for the first time.", PQ16_8 = "I have seen things others apparently can't see.", PQ16_9 = "My thoughts are so strong I can almost hear them.", PQ16_10 = "I see special meanings in ordinary things.", PQ16_11 = "Sometimes I feel I'm not in control of my thoughts.", PQ16_12 = "I get distracted by distant sounds I normally wouldn't notice.", PQ16_13 = "I hear voices or whispers that others can't.", PQ16_14 = "I often feel others have it in for me.", PQ16_15 = "I sense a presence around me when no one's there.", PQ16_16 = "I feel parts of my body have changed or work differently.", # ASRM ASRM_1 = "In the past week, how cheerful have you felt?", ASRM_2 = "In the past week, how confident have you felt?", ASRM_3 = "In the past week, how much sleep have you needed?", ASRM_4 = "In the past week, how fast or pressured has your speech been?", ASRM_5 = "In the past week, how active have you been?", # WSAS WSAS_1 = "Because of my mental health, my ability to work is impaired.", WSAS_2 = "Because of my mental health, my home management is impaired.", WSAS_3 = "Because of my mental health, my social leisure activities are impaired.", WSAS_4 = "Because of my mental health, my private leisure activities are impaired.", WSAS_5 = "Because of my mental health, my ability to form and maintain close relationships is impaired.", # AUDIT-C AUDITC_MOD_1 = "How often do you have a drink containing alcohol?", AUDITC_MOD_2 = "How many drinks do you have on a typical drinking day?", AUDITC_MOD_3 = "How often do you have six or more drinks on one occasion?", AUDITC_MOD_4 = "In the past 3 months, how often has alcohol led to health, social, legal or financial problems?" ) # Function to get option labels for an item get_option_labels <- function(item_name, item_k, item_min) { # Default: just use numeric values option_labels <- as.character(item_min + 0:(item_k - 1)) # K10 items (5-point scale) if (grepl("^K10_", item_name)) { option_labels <- c("None of the time", "A little of the time", "Some of the time", "Most of the time", "All of the time") } # SIDAS items (11-point scales) else if (item_name == "SIDAS_1") { option_labels <- c("Never", "1","2","3","4","5","6","7","8","9","Always") } else if (item_name == "SIDAS_2") { option_labels <- c("Total control", "1","2","3","4","5","6","7","8","9","No control") } else if (item_name == "SIDAS_3") { option_labels <- c("Not at all", "1","2","3","4","5","6","7","8","9","Made an attempt") } else if (item_name == "SIDAS_4") { option_labels <- c("Not at all", "1","2","3","4","5","6","7","8","9","Extremely") } else if (item_name == "SIDAS_5") { option_labels <- c("Not at all", "1","2","3","4","5","6","7","8","9","Extremely") } # PQ16 items (binary) else if (grepl("^PQ16_", item_name)) { option_labels <- c("No", "Yes") } # OASIS items else if (item_name == "OASIS_1") { option_labels <- c( "0 - No anxiety in the past week.", "1 - Infrequent anxiety. Felt anxious a few times.", "2 - Occasional anxiety. Felt anxious as much of the time as not. It was hard to relax.", "3 - Frequent anxiety. Felt anxious most of the time. It was very difficult to relax.", "4 - Constant anxiety. Felt anxious all of the time and never really relaxed." ) } else if (item_name == "OASIS_2") { option_labels <- c( "0 - Little or None: Anxiety was absent or barely noticeable.", "1 - Mild: Anxiety was at a low level. It was possible to relax when I tried. Physical symptoms were only slightly uncomfortable.", "2 - Moderate: Anxiety was distressing at times. It was hard to relax or concentrate, but I could do it if I tried. Physical symptoms were uncomfortable.", "3 - Severe: Anxiety was intense much of the time. It was very difficult to relax or focus on anything else. Physical symptoms were extremely uncomfortable.", "4 - Extreme: Anxiety was overwhelming. It was impossible to relax at all. Physical symptoms were unbearable." ) } else if (item_name == "OASIS_3") { option_labels <- c( "0 - None: I do not avoid places, situations, activities, or things because of fear.", "1 - Infrequent: I avoid something once in a while, but will usually face the situation or object. My lifestyle is not affected.", "2 - Occasional: I have some fear of certain situations, places, or objects, but it is still manageable. My lifestyle has only changed in minor ways. I always or almost always avoid the things I fear when I'm alone, but can handle them if someone comes with me.", "3 - Frequent: I have considerable fear and really try to avoid the things that frighten me. I have made significant changes in my lifestyle to avoid the object, situation, activity, or place.", "4 - All the Time: Avoiding objects, situations, activities, or places has taken over my life. My lifestyle has been extensively affected and I no longer do things that I used to enjoy." ) } else if (item_name == "OASIS_4") { option_labels <- c( "0 - None: No interference at work/home/school from anxiety.", "1 - Mild: My anxiety has caused some interference at work/home/school. Things are more difficult, but everything that needs to be done is still getting done.", "2 - Moderate: My anxiety definitely interferes with tasks. Most things are still getting done, but few things are being done as well as in the past.", "3 - Severe: My anxiety has really changed my ability to get things done. Some tasks are still being done, but many things are not. My performance has definitely suffered.", "4 - Extreme: My anxiety has become incapacitating. I am unable to complete tasks and have faced serious consequences (e.g., eviction, job loss, unpaid bills)." ) } else if (item_name == "OASIS_5") { option_labels <- c( "0 - None: My anxiety doesn't affect my relationships.", "1 - Mild: My anxiety slightly interferes with my relationships. Some of my friendships and other relationships have suffered, but overall my social life is still fulfilling.", "2 - Moderate: I have experienced some interference with my social life, but I still have a few close relationships. I don't spend as much time with others as in the past, but I still socialize sometimes.", "3 - Severe: My friendships and other relationships have suffered a lot because of anxiety. I do not enjoy social activities and socialize very little.", "4 - Extreme: My anxiety has completely disrupted my social activities. All of my relationships have suffered or ended, and my family life is extremely strained." ) } # WSAS items (9-point scale) else if (grepl("^WSAS_", item_name)) { option_labels <- c( "0 - Not at all", "1", "2 - Slightly", "3", "4 - Definitely", "5", "6 - Markedly", "7", "8 - Very severely" ) } # ASRM items else if (item_name == "ASRM_1") { option_labels <- c( "0 - I do not feel happier or more cheerful than usual.", "1 - I occasionally feel happier or more cheerful than usual.", "2 - I often feel happier or more cheerful than usual.", "3 - I feel happier or more cheerful than usual most of the time.", "4 - I feel happier or more cheerful than usual all of the time." ) } else if (item_name == "ASRM_2") { option_labels <- c( "0 - I do not feel more self-confident than usual.", "1 - I occasionally feel more self-confident than usual.", "2 - I often feel more self-confident than usual.", "3 - I feel more self-confident than usual.", "4 - I feel extremely self-confident all of the time." ) } else if (item_name == "ASRM_3") { option_labels <- c( "0 - I do not need less sleep than usual.", "1 - I occasionally need less sleep than usual.", "2 - I often need less sleep than usual.", "3 - I frequently need less sleep than usual.", "4 - I can go all day and night without any sleep and still not feel tired." ) } else if (item_name == "ASRM_4") { option_labels <- c( "0 - I do not talk more than usual.", "1 - I occasionally talk more than usual.", "2 - I often talk more than usual.", "3 - I frequently talk more than usual.", "4 - I talk constantly and cannot be interrupted." ) } else if (item_name == "ASRM_5") { option_labels <- c( "0 - I have not been more active (either socially, sexually, at work, home or school) than usual.", "1 - I have occasionally been more active than usual.", "2 - I have often been more active than usual.", "3 - I have frequently been more active than usual.", "4 - I am constantly active or on the go all the time." ) } # AUDIT-C items else if (item_name == "AUDITC_MOD_1") { option_labels <- c( "0 - Never", "1 - Monthly or less", "2 - 2-4 times a month", "3 - 2-3 times a week", "4 - 4 or more times a week" ) } else if (item_name == "AUDITC_MOD_2") { option_labels <- c( "0 - 1 or 2", "1 - 3 or 4", "2 - 5 or 6", "3 - 7-9", "4 - 10 or more" ) } else if (item_name == "AUDITC_MOD_3") { option_labels <- c( "0 - Never", "1 - Less than monthly", "2 - Monthly", "3 - Weekly", "4 - Daily or almost daily" ) } return(option_labels) } # Adaptive testing parameters delta_thetas <- c(0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05) min_items <- 7 max_items <- 25 # ============================================================================== # SESSION MANAGEMENT # ============================================================================== # In-memory session storage (use Redis in production) sessions <- new.env() # Create a new session create_session <- function() { session_id <- paste0("sess_", as.integer(Sys.time()), "_", sample(1000:9999, 1)) sessions[[session_id]] <- list( created_at = Sys.time(), theta = rep(0, nfact), # Initial ability estimates SE_theta = rep(1, nfact), # Initial standard errors responses = rep(NA, length(items)), # Response vector items_administered = integer(0), # Items already shown completed = FALSE ) return(session_id) } # Get session data get_session <- function(session_id) { if (!exists(session_id, envir = sessions)) { stop("Invalid session ID") } return(sessions[[session_id]]) } # Update session data update_session <- function(session_id, data) { sessions[[session_id]] <- data } # ============================================================================== # ADAPTIVE ITEM SELECTION - Using mirtCAT helper script # ============================================================================== # Select next item by calling the mirtCAT helper script # This ensures exact matching with app.R's algorithm select_next_item <- function(session) { items_administered <- session$items_administered responses <- session$responses # Build JSON object with responses # Format: {"responses": {"item_index": response_value, ...}} if (length(items_administered) == 0) { input_data <- list(responses = list()) } else { response_list <- list() for (i in seq_along(items_administered)) { item_idx <- items_administered[i] response_list[[as.character(item_idx)]] <- responses[item_idx] } input_data <- list(responses = response_list) } responses_json <- toJSON(input_data, auto_unbox = TRUE) # Call the helper script result <- tryCatch({ output <- system2( "Rscript", args = c("mirtcat_helper.R", shQuote(responses_json)), stdout = TRUE, stderr = FALSE # Don't capture stderr to keep JSON clean ) # Parse the JSON output (should be clean JSON now) json_output <- paste(output, collapse = "") # Handle empty output if (nchar(trimws(json_output)) == 0) { return(list(error = "Empty output from helper script")) } fromJSON(json_output) }, error = function(e) { # Log error and return NULL message("Error calling mirtcat_helper.R: ", e$message) list(error = e$message) }) # Check for errors if (!is.null(result$error)) { message("mirtcat_helper.R error: ", result$error) return(NULL) } # Check if assessment is complete if (isTRUE(result$is_complete)) { return(NULL) } # Return the next item index return(result$next_item_index) } # ============================================================================== # ABILITY ESTIMATION # ============================================================================== # Update theta estimates after response update_theta <- function(session, item_idx, response) { # Update response vector session$responses[item_idx] <- response session$items_administered <- c(session$items_administered, item_idx) # Re-estimate theta using Maximum A Posteriori (MAP) administered_items <- session$items_administered # Use full response pattern (with NAs for non-administered items) response_pattern <- session$responses # Use fscores for estimation with SE theta_est <- tryCatch({ fscores(mod, response.pattern = matrix(response_pattern, nrow = 1), method = "MAP", QMC = TRUE, full.scores.SE = TRUE, theta_lim = c(-6, 6)) }, error = function(e) { # Fallback to EAP if MAP fails fscores(mod, response.pattern = matrix(response_pattern, nrow = 1), method = "EAP", QMC = TRUE, full.scores.SE = TRUE) }) # Extract theta (first nfact columns) session$theta <- as.numeric(theta_est[1, 1:nfact]) # Extract SE (next nfact columns if available, otherwise use default) if (ncol(theta_est) >= 2 * nfact) { session$SE_theta <- as.numeric(theta_est[1, (nfact + 1):(2 * nfact)]) } else { # Fallback: use default SE values session$SE_theta <- rep(1, nfact) } return(session) } # ============================================================================== # RESULTS CALCULATION # ============================================================================== # Calculate domain scores with confidence intervals calculate_results <- function(session) { theta <- session$theta SE_theta <- session$SE_theta responses <- session$responses z <- 1.96 # 95% CI results <- lapply(names(item_ranges), function(domain_name) { idx <- which(names(item_ranges) == domain_name) item_indices <- item_ranges[[domain_name]] # Point estimate theta_mat <- matrix(theta, nrow = 1, ncol = nfact) mean_score <- expected.test(mod, Theta = theta_mat, which.items = item_indices) # Upper bound (theta + SE) theta_plus <- theta theta_plus[idx] <- theta_plus[idx] + z * SE_theta[idx] theta_plus_mat <- matrix(theta_plus, nrow = 1, ncol = nfact) upper_score <- expected.test(mod, Theta = theta_plus_mat, which.items = item_indices) # Lower bound (theta - SE) theta_minus <- theta theta_minus[idx] <- theta_minus[idx] - z * SE_theta[idx] theta_minus_mat <- matrix(theta_minus, nrow = 1, ncol = nfact) lower_score <- expected.test(mod, Theta = theta_minus_mat, which.items = item_indices) # Ensure monotonicity l95 <- min(lower_score, upper_score) u95 <- max(lower_score, upper_score) # Clip to valid range max_score <- max_scores[[domain_name]] mean_score <- min(mean_score, max_score) l95 <- max(0, min(l95, max_score)) u95 <- max(0, min(u95, max_score)) # Get severity category cuts <- domain_cuts[[domain_name]] category <- as.character(cut(mean_score, breaks = cuts$breaks, labels = cuts$labels, include.lowest = TRUE, right = TRUE)) # Count items answered in this domain items_answered <- sum(!is.na(responses[item_indices])) list( domain = domain_name, score = round(mean_score, 1), lower_95 = round(l95, 1), upper_95 = round(u95, 1), max_score = max_score, category = category, items_answered = items_answered ) }) return(results) } # ============================================================================== # API ENDPOINTS # ============================================================================== #* @apiTitle MCAT Adaptive Assessment API #* @apiDescription RESTful API for adaptive mental health assessment #* Enable CORS #* @filter cors function(req, res) { res$setHeader("Access-Control-Allow-Origin", "*") res$setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS") res$setHeader("Access-Control-Allow-Headers", "Content-Type") if (req$REQUEST_METHOD == "OPTIONS") { res$status <- 200 return(list()) } plumber::forward() } #* Start a new assessment session #* @post /start function() { session_id <- create_session() list( session_id = session_id, message = "Assessment started", total_items = length(items), min_items = min_items, max_items = max_items ) } #* Get the next question #* @get /next-question function(req, res, session_id = NULL) { # Extract session_id from query parameters if not provided if (is.null(session_id)) { session_id <- req$args$session_id } session <- get_session(session_id) if (session$completed) { return(list( completed = TRUE, message = "Assessment already completed" )) } # Select next item next_item_idx <- select_next_item(session) if (is.null(next_item_idx)) { # Assessment complete session$completed <- TRUE update_session(session_id, session) return(list( completed = TRUE, items_administered = length(session$items_administered), message = "Assessment complete" )) } # Get item details item_name <- items[next_item_idx] item_k <- K[next_item_idx] item_min <- mins[next_item_idx] # Get actual question text question_text <- if (item_name %in% names(item_labels)) { item_labels[[item_name]] } else { item_name # Fallback to item name if no label found } # Get option labels for this item option_labels <- get_option_labels(item_name, item_k, item_min) # Create response options with actual labels options <- list() for (j in 0:(item_k - 1)) { options[[j + 1]] <- list( label = option_labels[j + 1], value = item_min + j ) } list( completed = FALSE, item_index = next_item_idx, item_name = item_name, question_text = question_text, progress = length(session$items_administered) / max_items, items_completed = length(session$items_administered), options = options ) } #* Submit an answer #* @parser json #* @post /submit-answer function(req, res) { # Manually parse the JSON body to avoid parameter naming conflicts body_text <- if (is.raw(req$postBody)) { rawToChar(req$postBody) } else { as.character(req$postBody) } body <- jsonlite::fromJSON(body_text) session_id <- as.character(body$session_id) item_index <- as.integer(body$item_index) response_val <- as.numeric(body$response) session <- get_session(session_id) # Update theta estimates session <- update_theta(session, item_index, response_val) update_session(session_id, session) list( success = TRUE, items_completed = length(session$items_administered), message = "Response recorded" ) } #* Undo last answer (go back to previous question) #* @parser json #* @post /undo-last function(req, res) { body_text <- if (is.raw(req$postBody)) { rawToChar(req$postBody) } else { as.character(req$postBody) } body <- jsonlite::fromJSON(body_text) session_id <- as.character(body$session_id) session <- get_session(session_id) n_administered <- length(session$items_administered) if (n_administered == 0) { return(list( success = FALSE, message = "No items to undo" )) } # Remove the last administered item and clear its response last_item <- session$items_administered[n_administered] session$responses[last_item] <- NA session$items_administered <- session$items_administered[-n_administered] session$completed <- FALSE # Re-estimate theta if there are remaining items, otherwise reset if (length(session$items_administered) > 0) { response_pattern <- session$responses theta_est <- tryCatch({ fscores(mod, response.pattern = matrix(response_pattern, nrow = 1), method = "MAP", QMC = TRUE, full.scores.SE = TRUE, theta_lim = c(-6, 6)) }, error = function(e) { fscores(mod, response.pattern = matrix(response_pattern, nrow = 1), method = "EAP", QMC = TRUE, full.scores.SE = TRUE) }) session$theta <- as.numeric(theta_est[1, 1:nfact]) if (ncol(theta_est) >= 2 * nfact) { session$SE_theta <- as.numeric(theta_est[1, (nfact + 1):(2 * nfact)]) } } else { session$theta <- rep(0, nfact) session$SE_theta <- rep(1, nfact) } update_session(session_id, session) list( success = TRUE, items_completed = length(session$items_administered), message = "Last response removed" ) } #* Get assessment results #* @get /results function(req, res, session_id = NULL) { # Extract session_id from query parameters if not provided if (is.null(session_id)) { session_id <- req$args$session_id } session <- get_session(session_id) if (!session$completed) { return(list( error = "Assessment not yet completed", items_completed = length(session$items_administered), min_items = min_items )) } results <- calculate_results(session) list( session_id = session_id, items_completed = length(session$items_administered), domains = results, timestamp = Sys.time() ) } #* Health check endpoint #* @get /health function() { list( status = "healthy", timestamp = Sys.time(), model_loaded = !is.null(mod), total_items = length(items) ) }