Spaces:
Running
Running
| # 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) | |
| ) | |
| } | |