MCAT / api.R
tinazhang128
back button added; mobile friendly
0237ec9
# 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)
)
}