tsa-shiny-agent / api /plumber.R
mmrech's picture
Initial deployment of TSA Shiny Agent
026774a verified
# 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 () Statistic",
content = "I² describes the percentage of variability in effect estimates that is due to heterogeneity rather than sampling error. Formula:= 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)