Spaces:
Paused
Paused
| # TSA Plumber API - Bridge between Shiny app and Claude Agent SDK | |
| # Provides REST endpoints for agent communication | |
| library(plumber) | |
| library(jsonlite) | |
| # Source the Java bridge | |
| source("../R/global.R") | |
| source("../R/java_bridge/tsa_bridge.R") | |
| source("../R/java_bridge/trial_bridge.R") | |
| # Global state (in production, use session-based storage) | |
| api_state <- new.env() | |
| api_state$meta_analysis <- NULL | |
| api_state$results <- NULL | |
| api_state$boundaries <- list() | |
| #* @apiTitle TSA Agent API | |
| #* @apiDescription REST API for Claude Agent SDK to interact with TSA Java engine | |
| #* Health check | |
| #* @get /health | |
| function() { | |
| list( | |
| status = "ok", | |
| timestamp = Sys.time(), | |
| java_initialized = exists("TSA_JAVA_INITIALIZED") && TSA_JAVA_INITIALIZED | |
| ) | |
| } | |
| #* Initialize Java engine | |
| #* @post /init | |
| function() { | |
| tryCatch({ | |
| init_tsa_java() | |
| list(success = TRUE, message = "Java engine initialized") | |
| }, error = function(e) { | |
| list(success = FALSE, message = e$message) | |
| }) | |
| } | |
| #* Get current analysis state | |
| #* @get /analysis/state | |
| function() { | |
| if (is.null(api_state$meta_analysis)) { | |
| return(list( | |
| has_analysis = FALSE, | |
| message = "No analysis loaded" | |
| )) | |
| } | |
| list( | |
| has_analysis = TRUE, | |
| results = api_state$results, | |
| num_boundaries = length(api_state$boundaries) | |
| ) | |
| } | |
| #* Create new meta-analysis | |
| #* @post /analysis/create | |
| #* @param name:str Analysis name | |
| #* @param group1:str Intervention group name | |
| #* @param group2:str Control group name | |
| #* @param trial_type:str Trial type (Dichotomous or Continuous) | |
| #* @param effect_model:str Effect model (Fixed, RandomDL, etc.) | |
| #* @param effect_measure:str Effect measure (OddsRatio, RelativeRisk, etc.) | |
| function(name = "New Analysis", | |
| group1 = "Treatment", | |
| group2 = "Control", | |
| trial_type = "Dichotomous", | |
| effect_model = "RandomDL", | |
| effect_measure = "OddsRatio") { | |
| tryCatch({ | |
| api_state$meta_analysis <- create_meta_analysis( | |
| name = name, | |
| group1 = group1, | |
| group2 = group2, | |
| trial_type = trial_type, | |
| effect_model = effect_model, | |
| effect_measure = effect_measure | |
| ) | |
| api_state$results <- get_meta_analysis_results(api_state$meta_analysis) | |
| list( | |
| success = TRUE, | |
| message = paste("Created analysis:", name), | |
| results = api_state$results | |
| ) | |
| }, error = function(e) { | |
| list(success = FALSE, message = e$message) | |
| }) | |
| } | |
| #* Add a dichotomous trial | |
| #* @post /trials/add/dichotomous | |
| #* @param year:int Publication year | |
| #* @param study:str Study identifier | |
| #* @param int_events:int Intervention group events | |
| #* @param int_total:int Intervention group total | |
| #* @param ctrl_events:int Control group events | |
| #* @param ctrl_total:int Control group total | |
| #* @param low_risk_bias:bool Low risk of bias flag | |
| #* @param comment:str Optional comment | |
| function(year, study, int_events, int_total, ctrl_events, ctrl_total, | |
| low_risk_bias = TRUE, comment = "") { | |
| if (is.null(api_state$meta_analysis)) { | |
| return(list(success = FALSE, message = "No analysis loaded. Create one first.")) | |
| } | |
| tryCatch({ | |
| add_dichotomous_trial( | |
| ma = api_state$meta_analysis, | |
| year = as.integer(year), | |
| study = study, | |
| int_events = as.integer(int_events), | |
| int_total = as.integer(int_total), | |
| ctrl_events = as.integer(ctrl_events), | |
| ctrl_total = as.integer(ctrl_total), | |
| low_risk_bias = as.logical(low_risk_bias), | |
| comment = comment | |
| ) | |
| api_state$results <- get_meta_analysis_results(api_state$meta_analysis) | |
| list( | |
| success = TRUE, | |
| message = paste("Added trial:", study, year), | |
| results = api_state$results | |
| ) | |
| }, error = function(e) { | |
| list(success = FALSE, message = e$message) | |
| }) | |
| } | |
| #* Add a continuous trial | |
| #* @post /trials/add/continuous | |
| #* @param year:int Publication year | |
| #* @param study:str Study identifier | |
| #* @param int_n:int Intervention sample size | |
| #* @param int_mean:dbl Intervention mean | |
| #* @param int_sd:dbl Intervention standard deviation | |
| #* @param ctrl_n:int Control sample size | |
| #* @param ctrl_mean:dbl Control mean | |
| #* @param ctrl_sd:dbl Control standard deviation | |
| #* @param low_risk_bias:bool Low risk of bias flag | |
| function(year, study, int_n, int_mean, int_sd, ctrl_n, ctrl_mean, ctrl_sd, | |
| low_risk_bias = TRUE, comment = "") { | |
| if (is.null(api_state$meta_analysis)) { | |
| return(list(success = FALSE, message = "No analysis loaded. Create one first.")) | |
| } | |
| tryCatch({ | |
| add_continuous_trial( | |
| ma = api_state$meta_analysis, | |
| year = as.integer(year), | |
| study = study, | |
| int_n = as.integer(int_n), | |
| int_mean = as.double(int_mean), | |
| int_sd = as.double(int_sd), | |
| ctrl_n = as.integer(ctrl_n), | |
| ctrl_mean = as.double(ctrl_mean), | |
| ctrl_sd = as.double(ctrl_sd), | |
| low_risk_bias = as.logical(low_risk_bias), | |
| comment = comment | |
| ) | |
| api_state$results <- get_meta_analysis_results(api_state$meta_analysis) | |
| list( | |
| success = TRUE, | |
| message = paste("Added trial:", study, year), | |
| results = api_state$results | |
| ) | |
| }, error = function(e) { | |
| list(success = FALSE, message = e$message) | |
| }) | |
| } | |
| #* Get all trials | |
| #* @get /trials | |
| function() { | |
| if (is.null(api_state$meta_analysis)) { | |
| return(list(success = FALSE, message = "No analysis loaded")) | |
| } | |
| tryCatch({ | |
| trials_df <- get_trials_as_df(api_state$meta_analysis) | |
| list( | |
| success = TRUE, | |
| count = nrow(trials_df), | |
| trials = trials_df | |
| ) | |
| }, error = function(e) { | |
| list(success = FALSE, message = e$message) | |
| }) | |
| } | |
| #* Remove a trial by index | |
| #* @delete /trials/:index | |
| function(index) { | |
| if (is.null(api_state$meta_analysis)) { | |
| return(list(success = FALSE, message = "No analysis loaded")) | |
| } | |
| tryCatch({ | |
| remove_trial_by_index(api_state$meta_analysis, as.integer(index)) | |
| api_state$results <- get_meta_analysis_results(api_state$meta_analysis) | |
| list( | |
| success = TRUE, | |
| message = paste("Removed trial at index", index), | |
| results = api_state$results | |
| ) | |
| }, error = function(e) { | |
| list(success = FALSE, message = e$message) | |
| }) | |
| } | |
| #* Get meta-analysis results | |
| #* @get /results | |
| function() { | |
| if (is.null(api_state$meta_analysis)) { | |
| return(list(success = FALSE, message = "No analysis loaded")) | |
| } | |
| api_state$results <- get_meta_analysis_results(api_state$meta_analysis) | |
| list( | |
| success = TRUE, | |
| results = api_state$results | |
| ) | |
| } | |
| #* Get confidence intervals at multiple levels | |
| #* @get /results/confidence-intervals | |
| function() { | |
| if (is.null(api_state$meta_analysis)) { | |
| return(list(success = FALSE, message = "No analysis loaded")) | |
| } | |
| tryCatch({ | |
| ci_90 <- get_confidence_interval(api_state$meta_analysis, 0.90) | |
| ci_95 <- get_confidence_interval(api_state$meta_analysis, 0.95) | |
| ci_99 <- get_confidence_interval(api_state$meta_analysis, 0.99) | |
| list( | |
| success = TRUE, | |
| intervals = list( | |
| `90%` = ci_90, | |
| `95%` = ci_95, | |
| `99%` = ci_99 | |
| ) | |
| ) | |
| }, error = function(e) { | |
| list(success = FALSE, message = e$message) | |
| }) | |
| } | |
| #* Calculate TSA boundary | |
| #* @post /boundary/calculate | |
| #* @param alpha:dbl Type I error rate (default 0.05) | |
| #* @param beta:dbl Type II error rate (default 0.20) | |
| #* @param spending_fn:str Alpha spending function (OBrienFleming or Pocock) | |
| #* @param p_int:dbl Expected intervention event rate | |
| #* @param p_ctrl:dbl Expected control event rate | |
| #* @param het_correction:str Heterogeneity correction (none, variance, user_defined) | |
| #* @param het_value:dbl User-defined heterogeneity value | |
| function(alpha = 0.05, beta = 0.20, spending_fn = "OBrienFleming", | |
| p_int = 0.10, p_ctrl = 0.15, het_correction = "variance", | |
| het_value = 0.25) { | |
| if (is.null(api_state$meta_analysis) || is.null(api_state$results)) { | |
| return(list(success = FALSE, message = "No analysis loaded")) | |
| } | |
| tryCatch({ | |
| z_alpha <- qnorm(1 - as.double(alpha) / 2) | |
| z_beta <- qnorm(1 - as.double(beta)) | |
| p_int <- as.double(p_int) | |
| p_ctrl <- as.double(p_ctrl) | |
| # OIS calculation | |
| p_star <- (p_int + p_ctrl) / 2 | |
| ois <- 4 * (z_alpha + z_beta)^2 * (p_star * (1 - p_star)) / (p_ctrl - p_int)^2 | |
| # Apply heterogeneity correction | |
| if (het_correction == "variance") { | |
| i2 <- api_state$results$i_squared / 100 | |
| het_correction_factor <- 1 / (1 - i2) | |
| ois <- ois * het_correction_factor | |
| } else if (het_correction == "user_defined") { | |
| het_correction_factor <- 1 / (1 - as.double(het_value)) | |
| ois <- ois * het_correction_factor | |
| } | |
| # Generate boundary points | |
| t_seq <- seq(0.01, 1, by = 0.01) | |
| if (spending_fn == "OBrienFleming") { | |
| alpha_spent <- 2 * (1 - pnorm(z_alpha / sqrt(t_seq))) | |
| } else { | |
| alpha_spent <- as.double(alpha) * t_seq | |
| } | |
| z_upper <- qnorm(1 - alpha_spent / 2) | |
| z_lower <- -z_upper | |
| boundary_points <- data.frame( | |
| t = t_seq, | |
| info = t_seq * ois, | |
| z_upper = z_upper, | |
| z_lower = z_lower, | |
| alpha_spent = alpha_spent | |
| ) | |
| # Store boundary | |
| boundary_name <- paste0("Boundary_", length(api_state$boundaries) + 1) | |
| api_state$boundaries[[boundary_name]] <- list( | |
| type = spending_fn, | |
| alpha = alpha, | |
| beta = beta, | |
| ois = ois, | |
| points = boundary_points | |
| ) | |
| list( | |
| success = TRUE, | |
| name = boundary_name, | |
| ois = round(ois), | |
| current_info = api_state$results$total_patients, | |
| info_fraction = api_state$results$total_patients / ois, | |
| boundary_points = boundary_points[c(25, 50, 75, 100), ] # Key points | |
| ) | |
| }, error = function(e) { | |
| list(success = FALSE, message = e$message) | |
| }) | |
| } | |
| #* Get all boundaries | |
| #* @get /boundaries | |
| function() { | |
| list( | |
| success = TRUE, | |
| count = length(api_state$boundaries), | |
| boundaries = lapply(api_state$boundaries, function(b) { | |
| list(type = b$type, alpha = b$alpha, beta = b$beta, ois = b$ois) | |
| }) | |
| ) | |
| } | |
| #* Load .TSA file | |
| #* @post /file/load | |
| #* @param filepath:str Path to .TSA file | |
| function(filepath) { | |
| tryCatch({ | |
| api_state$meta_analysis <- load_tsa_file(filepath) | |
| api_state$results <- get_meta_analysis_results(api_state$meta_analysis) | |
| list( | |
| success = TRUE, | |
| message = paste("Loaded:", filepath), | |
| results = api_state$results | |
| ) | |
| }, error = function(e) { | |
| list(success = FALSE, message = e$message) | |
| }) | |
| } | |
| #* Save .TSA file | |
| #* @post /file/save | |
| #* @param filepath:str Path to save .TSA file | |
| function(filepath) { | |
| if (is.null(api_state$meta_analysis)) { | |
| return(list(success = FALSE, message = "No analysis to save")) | |
| } | |
| tryCatch({ | |
| save_tsa_file(api_state$meta_analysis, filepath) | |
| list(success = TRUE, message = paste("Saved to:", filepath)) | |
| }, error = function(e) { | |
| list(success = FALSE, message = e$message) | |
| }) | |
| } | |
| #* Explain a statistical concept | |
| #* @get /explain/:concept | |
| function(concept) { | |
| explanations <- list( | |
| "heterogeneity" = list( | |
| title = "Heterogeneity in Meta-Analysis", | |
| content = "Heterogeneity refers to the variability in study outcomes beyond what we'd expect from chance. Key metrics include: Q statistic (tests if variance exceeds expected), I² (percentage of variability due to heterogeneity: <25% low, 25-75% moderate, >75% high), and τ² (estimated between-study variance)." | |
| ), | |
| "i_squared" = list( | |
| title = "I-squared (I²) Statistic", | |
| content = "I² describes the percentage of variability in effect estimates that is due to heterogeneity rather than sampling error. Formula: I² = max(0, (Q - df)/Q × 100%). Interpretation: 0-25% (low), 25-75% (moderate), >75% (high heterogeneity)." | |
| ), | |
| "ois" = list( | |
| title = "Optimal Information Size (OIS)", | |
| content = "OIS is the meta-analysis equivalent of a single trial's sample size calculation. It represents the number of participants needed for a reliable conclusion. OIS depends on: alpha (type I error), beta (type II error), expected effect size, and heterogeneity adjustment." | |
| ), | |
| "alpha_spending" = list( | |
| title = "Alpha Spending Functions", | |
| content = "Alpha spending controls type I error across multiple interim analyses. O'Brien-Fleming: Very conservative early (barely spends alpha), preserves power for final analysis. Pocock: Uniform spending (constant alpha at each look), allows earlier stopping but wider final boundaries." | |
| ), | |
| "effect_models" = list( | |
| title = "Effect Models", | |
| content = "Fixed Effect: Assumes one true effect; differences due to sampling error only. Random Effects (DerSimonian-Laird): Allows true effect to vary across studies; accounts for heterogeneity. Use random effects when heterogeneity is expected or I² > 25%." | |
| ), | |
| "z_curve" = list( | |
| title = "Z-Curve in TSA", | |
| content = "The Z-curve shows the cumulative test statistic (Z-score) as evidence accumulates from each trial. If the Z-curve crosses the upper boundary, there's strong evidence of effect. If it crosses the lower/futility boundary, evidence suggests no meaningful effect. Within boundaries means more data needed." | |
| ), | |
| "funnel_plot" = list( | |
| title = "Funnel Plot", | |
| content = "A funnel plot helps assess publication bias. Studies are plotted with effect size (x-axis) against precision (y-axis). A symmetric funnel suggests no publication bias. Asymmetry (missing studies in bottom-left) may indicate unpublished negative results or small-study effects." | |
| ), | |
| "odds_ratio" = list( | |
| title = "Odds Ratio (OR)", | |
| content = "The odds ratio compares the odds of an event in treatment vs control. OR = (a/c) / (b/d) where a,b = events, c,d = non-events. OR < 1 means treatment reduces odds; OR > 1 means treatment increases odds; OR = 1 means no difference. 95% CI not crossing 1 indicates statistical significance." | |
| ), | |
| "relative_risk" = list( | |
| title = "Relative Risk (RR)", | |
| content = "Relative risk compares the probability of an event in treatment vs control. RR = (a/(a+c)) / (b/(b+d)). RR < 1 means treatment reduces risk; RR > 1 means treatment increases risk. RR is more intuitive than OR but cannot be used in case-control studies." | |
| ) | |
| ) | |
| concept_lower <- tolower(gsub("[_-]", "_", concept)) | |
| if (concept_lower %in% names(explanations)) { | |
| list(success = TRUE, explanation = explanations[[concept_lower]]) | |
| } else { | |
| list( | |
| success = FALSE, | |
| message = paste("Concept not found:", concept), | |
| available = names(explanations) | |
| ) | |
| } | |
| } | |
| # Run the API | |
| # pr <- plumber::pr("plumber.R") | |
| # pr %>% pr_run(port = 8000) | |