From 38fa60888f03ed12a0c9734a30a79be45bb2ce88 Mon Sep 17 00:00:00 2001 From: Tony Wu Date: Tue, 22 Sep 2026 20:57:32 -0400 Subject: [PATCH 1/5] adjust filter by context to exclude key words --- R/filterSubnetworkByContext.R | 140 +++++++++-- man/filterSubnetworkByContext.Rd | 39 +++- .../testthat/test-filterSubnetworkByContext.R | 217 +++++++++++++++++- vignettes/Filter-By-Context.Rmd | 52 +++++ 4 files changed, 417 insertions(+), 31 deletions(-) diff --git a/R/filterSubnetworkByContext.R b/R/filterSubnetworkByContext.R index 9b4cd6c..0399a81 100644 --- a/R/filterSubnetworkByContext.R +++ b/R/filterSubnetworkByContext.R @@ -30,6 +30,9 @@ #' @param query For \code{method = "tag_count"}: a character vector of tags, #' e.g. \code{c("CHEK1", "DNA damage", "DNA damage repair")}. #' For \code{method = "cosine"}: a single character string. +#' May be \code{NULL} (default) when \code{exclude_keywords} is +#' supplied; abstracts are then not scored (\code{score} is +#' \code{NA}) and only the keyword exclusion is applied. #' @param cutoff Numeric threshold applied to the chosen scoring method. #' \itemize{ #' \item \code{"tag_count"}: integer >= 0; abstracts must @@ -39,14 +42,39 @@ #' must score >= this value. Default \code{0.10}. #' } #' @param method One of \code{"tag_count"} (default) or \code{"cosine"}. +#' @param exclude_keywords Optional character vector of keywords. Abstracts +#' containing any of them (case-insensitive substring match) are removed, +#' regardless of their score. To exclude by keyword only, omit +#' \code{query}. Default \code{NULL} excludes nothing. #' -#' @return A named list with three elements: +#' @return A named list with four elements: #' \item{nodes}{Filtered nodes dataframe (only nodes present in kept edges)} #' \item{edges}{Filtered edges dataframe} #' \item{evidence}{Dataframe with columns: source, target, interaction, site, #' evidenceLink, stmt_hash, text, pmid, score. The \code{score} column #' contains tag counts (integer) or cosine similarities (numeric) depending #' on the method used.} +#' \item{abstracts}{Named character vector mapping each PMID in +#' \code{evidence} to its abstract text.} +#' The \code{evidence} and \code{abstracts} elements can be passed to the +#' same-named arguments of \code{\link{decomposeSubnetworkByTopic}} or +#' \code{\link{decomposeSubnetworkIntoHierarchicalTopics}}, together with the +#' returned list as \code{subnetwork}, so INDRA and PubMed are not queried +#' again. +#' +#' @examples +#' \dontrun{ +#' filtered <- filterSubnetworkByContext( +#' subnetwork$nodes, subnetwork$edges, +#' query = c("DNA damage", "DNA repair"), +#' exclude_keywords = c("review") +#' ) +#' hierarchy <- decomposeSubnetworkIntoHierarchicalTopics( +#' filtered, +#' evidence = filtered$evidence, +#' abstracts = filtered$abstracts +#' ) +#' } #' #' @importFrom text2vec itoken word_tokenizer create_vocabulary prune_vocabulary vocab_vectorizer create_dtm TfIdf fit_transform #' @importFrom stopwords stopwords @@ -55,13 +83,29 @@ #' @export filterSubnetworkByContext <- function(nodes, edges, - query, + query = NULL, cutoff = NULL, - method = c("tag_count", "cosine")) { - + method = c("tag_count", "cosine"), + exclude_keywords = NULL) { + method <- match.arg(method) - if (method == "tag_count") { + if (!is.null(exclude_keywords) && + (!is.character(exclude_keywords) || length(exclude_keywords) < 1 || + any(is.na(exclude_keywords)) || any(!nzchar(exclude_keywords)))) { + stop("`exclude_keywords` must be NULL or a character vector of non-empty keywords.") + } + no_abstracts <- stats::setNames(character(0), character(0)) + + if (is.null(query)) { + if (is.null(exclude_keywords)) { + stop("Supply `query`, `exclude_keywords`, or both.") + } + if (!is.null(cutoff)) { + stop("`cutoff` has no effect without `query`; remove it or supply `query`.") + } + cat("No query: scoring skipped, filtering by `exclude_keywords` only\n") + } else if (method == "tag_count") { if (!is.character(query) || length(query) < 1 || any(is.na(query)) || any(!nzchar(query))) { stop("`query` must be a character vector of tags when method = 'tag_count'.") @@ -95,7 +139,8 @@ filterSubnetworkByContext <- function(nodes, if (nrow(evidence) == 0) { evidence$score <- if (method == "tag_count") integer(0) else numeric(0) warning("No evidence text found - returning unfiltered inputs.") - return(list(nodes = nodes, edges = edges, evidence = evidence)) + return(list(nodes = nodes, edges = edges, evidence = evidence, + abstracts = no_abstracts)) } pmids <- unique(evidence$pmid[!is.na(evidence$pmid) & nchar(evidence$pmid) > 0]) @@ -106,7 +151,8 @@ filterSubnetworkByContext <- function(nodes, rep(NA_real_, nrow(evidence)) } warning("No PMIDs found in evidence - returning unfiltered inputs.") - return(list(nodes = nodes, edges = edges, evidence = evidence)) + return(list(nodes = nodes, edges = edges, evidence = evidence, + abstracts = no_abstracts)) } abstract_list <- .fetch_clean_abstracts_xml(pmids) @@ -116,26 +162,48 @@ filterSubnetworkByContext <- function(nodes, stringsAsFactors = FALSE ) - if (method == "tag_count") { - abstracts_df$score <- .score_by_tag_count(abstracts_df$abstract, query) + if (is.null(query)) { + abstracts_df$score <- if (method == "tag_count") NA_integer_ else NA_real_ + passing <- rep(TRUE, nrow(abstracts_df)) } else { - abstracts_df$score <- .score_by_cosine(query, abstracts_df$abstract) + if (method == "tag_count") { + abstracts_df$score <- .score_by_tag_count(abstracts_df$abstract, query) + } else { + abstracts_df$score <- .score_by_cosine(query, abstracts_df$abstract) + } + passing <- abstracts_df$score >= cutoff } - - passing_pmids <- abstracts_df$pmid[abstracts_df$score >= cutoff] - - cat(sprintf( - "\n%d / %d abstracts passed cutoff (score >= %s)\n", - length(passing_pmids), nrow(abstracts_df), cutoff - )) - + if (!is.null(exclude_keywords)) { + excluded <- .contains_any_keyword(abstracts_df$abstract, + exclude_keywords) + cat(sprintf( + "\n%d / %d abstracts excluded by keyword(s): %s\n", + sum(excluded), nrow(abstracts_df), + paste(exclude_keywords, collapse = ", ") + )) + passing <- passing & !excluded + } + passing_pmids <- abstracts_df$pmid[passing] + + if (is.null(query)) { + cat(sprintf("\n%d / %d abstracts kept\n", + length(passing_pmids), nrow(abstracts_df))) + } else { + cat(sprintf( + "\n%d / %d abstracts passed cutoff (score >= %s)\n", + length(passing_pmids), nrow(abstracts_df), cutoff + )) + } + evidence_scored <- merge( evidence, abstracts_df[, c("pmid", "score")], by = "pmid", all.x = TRUE ) - evidence_scored$score[is.na(evidence_scored$score)] <- 0 + if (!is.null(query)) { + evidence_scored$score[is.na(evidence_scored$score)] <- 0 + } evidence_filtered <- evidence_scored[ evidence_scored$pmid %in% passing_pmids, @@ -144,13 +212,13 @@ filterSubnetworkByContext <- function(nodes, ] surviving_hashes <- unique(evidence_filtered$stmt_hash) - edges_filtered <- edges[edges$stmt_hash %in% surviving_hashes, ] + edges_filtered <- edges[edges$stmt_hash %in% surviving_hashes, , drop = FALSE] surviving_nodes <- union(edges_filtered$source, edges_filtered$target) if (!"id" %in% names(nodes)) { stop("`nodes` must contain an `id` column.") } - nodes_filtered <- nodes[nodes$id %in% surviving_nodes, ] + nodes_filtered <- nodes[nodes$id %in% surviving_nodes, , drop = FALSE] cat(sprintf( "Retained: %d edges (of %d), %d nodes (of %d), %d evidence rows (of %d)\n", @@ -159,14 +227,38 @@ filterSubnetworkByContext <- function(nodes, nrow(evidence_filtered), nrow(evidence_scored) )) + kept_pmids <- unique(evidence_filtered$pmid) + abstracts_kept <- stats::setNames( + abstracts_df$abstract[match(kept_pmids, abstracts_df$pmid)], + kept_pmids + ) + return(list( - nodes = nodes_filtered, - edges = edges_filtered, - evidence = evidence_filtered + nodes = nodes_filtered, + edges = edges_filtered, + evidence = evidence_filtered, + abstracts = abstracts_kept )) } +#' Flag abstracts that contain any of a set of keywords +#' +#' @param abstracts Character vector of abstract texts. +#' @param keywords Character vector of keywords to search for. +#' @return Logical vector, same length as \code{abstracts}; \code{TRUE} when +#' the abstract contains at least one keyword (case-insensitive substring). +#' @keywords internal +#' @noRd +.contains_any_keyword <- function(abstracts, keywords) { + abstracts_lower <- tolower(abstracts) + hits <- lapply(tolower(keywords), function(keyword) { + grepl(keyword, abstracts_lower, fixed = TRUE) + }) + Reduce(`|`, hits, logical(length(abstracts))) +} + + #' Score abstracts by tag count #' #' For each abstract, counts how many tags appear as case-insensitive substrings. diff --git a/man/filterSubnetworkByContext.Rd b/man/filterSubnetworkByContext.Rd index 6700d53..36f5ff0 100644 --- a/man/filterSubnetworkByContext.Rd +++ b/man/filterSubnetworkByContext.Rd @@ -7,9 +7,10 @@ filterSubnetworkByContext( nodes, edges, - query, + query = NULL, cutoff = NULL, - method = c("tag_count", "cosine") + method = c("tag_count", "cosine"), + exclude_keywords = NULL ) } \arguments{ @@ -20,7 +21,10 @@ interaction, site, evidenceLink, stmt_hash.} \item{query}{For \code{method = "tag_count"}: a character vector of tags, e.g. \code{c("CHEK1", "DNA damage", "DNA damage repair")}. -For \code{method = "cosine"}: a single character string.} +For \code{method = "cosine"}: a single character string. +May be \code{NULL} (default) when \code{exclude_keywords} is +supplied; abstracts are then not scored (\code{score} is +\code{NA}) and only the keyword exclusion is applied.} \item{cutoff}{Numeric threshold applied to the chosen scoring method. \itemize{ @@ -32,15 +36,27 @@ For \code{method = "cosine"}: a single character string.} }} \item{method}{One of \code{"tag_count"} (default) or \code{"cosine"}.} + +\item{exclude_keywords}{Optional character vector of keywords. Abstracts +containing any of them (case-insensitive substring match) are removed, +regardless of their score. To exclude by keyword only, omit +\code{query}. Default \code{NULL} excludes nothing.} } \value{ -A named list with three elements: +A named list with four elements: \item{nodes}{Filtered nodes dataframe (only nodes present in kept edges)} \item{edges}{Filtered edges dataframe} \item{evidence}{Dataframe with columns: source, target, interaction, site, evidenceLink, stmt_hash, text, pmid, score. The \code{score} column contains tag counts (integer) or cosine similarities (numeric) depending on the method used.} + \item{abstracts}{Named character vector mapping each PMID in + \code{evidence} to its abstract text.} + The \code{evidence} and \code{abstracts} elements can be passed to the + same-named arguments of \code{\link{decomposeSubnetworkByTopic}} or + \code{\link{decomposeSubnetworkIntoHierarchicalTopics}}, together with the + returned list as \code{subnetwork}, so INDRA and PubMed are not queried + again. } \description{ Fetches PubMed abstracts for evidence PMIDs, scores each abstract against a @@ -72,3 +88,18 @@ Two scoring methods are available, controlled by the \code{method} argument: \strong{Beta feature:} This function is experimental and the API may change without notice in future versions. } +\examples{ +\dontrun{ +filtered <- filterSubnetworkByContext( + subnetwork$nodes, subnetwork$edges, + query = c("DNA damage", "DNA repair"), + exclude_keywords = c("review") +) +hierarchy <- decomposeSubnetworkIntoHierarchicalTopics( + filtered, + evidence = filtered$evidence, + abstracts = filtered$abstracts +) +} + +} diff --git a/tests/testthat/test-filterSubnetworkByContext.R b/tests/testthat/test-filterSubnetworkByContext.R index baa1d5f..24100ca 100644 --- a/tests/testthat/test-filterSubnetworkByContext.R +++ b/tests/testthat/test-filterSubnetworkByContext.R @@ -18,6 +18,27 @@ make_nodes <- function() { ) } +make_mock_evidence <- function() { + data.frame( + source = c("A", "B"), + target = c("B", "C"), + interaction = c("activates", "inhibits"), + site = c("T308", "S473"), + evidenceLink = c("https://example.com/1", "https://example.com/2"), + stmt_hash = c("hash1", "hash2"), + text = c("CHEK1 sentence.", "Lipid sentence."), + pmid = c("11111111", "22222222"), + stringsAsFactors = FALSE + ) +} + +make_mock_abstracts <- function() { + list( + "11111111" = "CHEK1 phosphorylates CDC25A in response to DNA damage.", + "22222222" = "Unrelated text about lipid metabolism and glucose uptake." + ) +} + make_pubmed_xml <- function(pmids) { articles <- vapply(pmids, function(pmid) { sprintf(paste0( @@ -221,7 +242,7 @@ describe("filterSubnetworkByContext", { ) # Structure check - expect_named(result, c("nodes", "edges", "evidence")) + expect_named(result, c("nodes", "edges", "evidence", "abstracts")) # Only the CHEK1/DNA-damage abstract passed the cutoff expect_equal(nrow(result$edges), 1) @@ -235,5 +256,195 @@ describe("filterSubnetworkByContext", { expect_true("score" %in% names(result$evidence)) expect_true(all(result$evidence$score >= 1)) }) - -}) \ No newline at end of file + + test_that("returns the abstracts of the PMIDs in the kept evidence", { + mockery::stub(filterSubnetworkByContext, ".extract_evidence_text", + make_mock_evidence()) + mockery::stub(filterSubnetworkByContext, ".fetch_clean_abstracts_xml", + make_mock_abstracts()) + + result <- filterSubnetworkByContext( + make_nodes(), make_edges(), query = c("CHEK1", "DNA damage") + ) + + expect_type(result$abstracts, "character") + expect_equal(names(result$abstracts), "11111111") + expect_equal(result$abstracts[["11111111"]], + make_mock_abstracts()[["11111111"]]) + }) + + test_that("exclude_keywords drops abstracts containing any keyword", { + mockery::stub(filterSubnetworkByContext, ".extract_evidence_text", + make_mock_evidence()) + mockery::stub(filterSubnetworkByContext, ".fetch_clean_abstracts_xml", + make_mock_abstracts()) + + # cutoff = 0 keeps every abstract, so only the exclusion filters. + result <- filterSubnetworkByContext( + make_nodes(), make_edges(), query = "CHEK1", cutoff = 0, + exclude_keywords = c("apoptosis", "LIPID") + ) + + expect_equal(result$edges$stmt_hash, "hash1") + expect_equal(unique(result$evidence$pmid), "11111111") + expect_equal(names(result$abstracts), "11111111") + expect_false("C" %in% result$nodes$id) + }) + + test_that("exclude_keywords takes precedence over a passing score", { + mockery::stub(filterSubnetworkByContext, ".extract_evidence_text", + make_mock_evidence()) + mockery::stub(filterSubnetworkByContext, ".fetch_clean_abstracts_xml", + make_mock_abstracts()) + + result <- filterSubnetworkByContext( + make_nodes(), make_edges(), query = "CHEK1", cutoff = 1, + exclude_keywords = "cdc25a" + ) + + expect_equal(nrow(result$edges), 0) + expect_equal(nrow(result$evidence), 0) + expect_length(result$abstracts, 0) + }) + + test_that("exclude_keywords works with the cosine method", { + mockery::stub(filterSubnetworkByContext, ".extract_evidence_text", + make_mock_evidence()) + mockery::stub(filterSubnetworkByContext, ".fetch_clean_abstracts_xml", + make_mock_abstracts()) + + result <- filterSubnetworkByContext( + make_nodes(), make_edges(), query = "CHEK1 DNA damage", + cutoff = 0, method = "cosine", exclude_keywords = "glucose" + ) + + expect_equal(result$edges$stmt_hash, "hash1") + }) + + test_that("filters by exclude_keywords alone when query is omitted", { + mockery::stub(filterSubnetworkByContext, ".extract_evidence_text", + make_mock_evidence()) + mockery::stub(filterSubnetworkByContext, ".fetch_clean_abstracts_xml", + make_mock_abstracts()) + + result <- filterSubnetworkByContext( + make_nodes(), make_edges(), exclude_keywords = "lipid" + ) + + expect_equal(result$edges$stmt_hash, "hash1") + expect_equal(names(result$abstracts), "11111111") + expect_true(all(is.na(result$evidence$score))) + }) + + test_that("keeps every abstract when no exclude keyword matches", { + mockery::stub(filterSubnetworkByContext, ".extract_evidence_text", + make_mock_evidence()) + mockery::stub(filterSubnetworkByContext, ".fetch_clean_abstracts_xml", + make_mock_abstracts()) + + result <- filterSubnetworkByContext( + make_nodes(), make_edges(), exclude_keywords = "photosynthesis" + ) + + expect_equal(nrow(result$edges), 2) + expect_setequal(names(result$abstracts), c("11111111", "22222222")) + }) + + test_that("requires query or exclude_keywords, and cutoff needs query", { + expect_error( + filterSubnetworkByContext(make_nodes(), make_edges()), + "Supply `query`, `exclude_keywords`, or both" + ) + expect_error( + filterSubnetworkByContext(make_nodes(), make_edges(), cutoff = 1, + exclude_keywords = "lipid"), + "`cutoff` has no effect without `query`" + ) + }) + + test_that("rejects malformed exclude_keywords", { + for (bad in list(1, NA_character_, "", character(0))) { + expect_error( + filterSubnetworkByContext(make_nodes(), make_edges(), + query = "CHEK1", + exclude_keywords = bad), + "`exclude_keywords` must be NULL" + ) + } + }) + + test_that("returns empty abstracts when no evidence is found", { + mockery::stub(filterSubnetworkByContext, ".extract_evidence_text", + make_mock_evidence()[0, ]) + + result <- suppressWarnings(filterSubnetworkByContext( + make_nodes(), make_edges(), query = "CHEK1" + )) + + expect_named(result, c("nodes", "edges", "evidence", "abstracts")) + expect_length(result$abstracts, 0) + }) + + test_that("output can be decomposed without re-querying INDRA or PubMed", { + themes <- list( + c("kinase", "phosphorylation", "signaling", "cascade", "mapk"), + c("dna", "repair", "damage", "checkpoint", "replication"), + c("immune", "cytokine", "inflammation", "macrophage", "interferon") + ) + edges <- list() + evidence <- list() + abstracts <- list() + for (th in seq_along(themes)) { + pmids <- paste0(th, "000", seq_len(6)) + for (i in seq_along(pmids)) { + abstracts[[pmids[i]]] <- paste( + rep(themes[[th]], times = 2 + i %% 3), collapse = " " + ) + } + for (e in seq_len(8)) { + edge <- data.frame( + source = paste0("G", th, "_", e), + target = paste0("G", th, "_", e + 1), + interaction = "Activation", site = NA_character_, + evidenceLink = "https://example.com", + stmt_hash = paste0("h", th, "_", e), + stringsAsFactors = FALSE + ) + edges[[length(edges) + 1]] <- edge + evidence[[length(evidence) + 1]] <- cbind( + edge, text = "sentence", + pmid = pmids[c(e %% 6 + 1, (e + 2) %% 6 + 1)], + stringsAsFactors = FALSE + ) + } + } + edges <- do.call(rbind, edges) + nodes <- data.frame(id = unique(c(edges$source, edges$target)), + stringsAsFactors = FALSE) + mockery::stub(filterSubnetworkByContext, ".extract_evidence_text", + do.call(rbind, evidence)) + mockery::stub(filterSubnetworkByContext, ".fetch_clean_abstracts_xml", + abstracts) + + filtered <- filterSubnetworkByContext( + nodes, edges, exclude_keywords = "cytokine" + ) + expect_false(any(grepl("^G3_", filtered$edges$source))) + + testthat::local_mocked_bindings( + .extract_evidence_text = function(...) stop("INDRA was queried"), + .fetch_clean_abstracts_xml = function(...) stop("PubMed was queried") + ) + hierarchy <- decomposeSubnetworkIntoHierarchicalTopics( + filtered, max_edges = 8, n_topics = 2, + evidence = filtered$evidence, abstracts = filtered$abstracts + ) + + expect_s3_class(hierarchy, "topicHierarchy") + expect_equal(hierarchy$tree$n_edges[1], nrow(filtered$edges)) + expect_setequal(names(hierarchy$corpus$abstracts), + unique(filtered$evidence$pmid)) + expect_true(any(!hierarchy$tree$is_leaf)) + }) + +}) diff --git a/vignettes/Filter-By-Context.Rmd b/vignettes/Filter-By-Context.Rmd index fbc167a..7db7322 100644 --- a/vignettes/Filter-By-Context.Rmd +++ b/vignettes/Filter-By-Context.Rmd @@ -403,6 +403,58 @@ hist(exploratory$evidence$score, --- +## Excluding abstracts by keyword + +Use `exclude_keywords` to drop abstracts that mention unwanted terms +(case-insensitive substring match). Exclusion is applied after scoring, so an +abstract containing an excluded keyword is removed even if it passes `cutoff`. +It can be combined with `query`: + +```{r exclude-keywords} +filtered_network <- filterSubnetworkByContext( + nodes = subnetwork$nodes, + edges = subnetwork$edges, + query = tags, + cutoff = 2, + exclude_keywords = c("leukemia", "lymphoma") +) +``` + +or used on its own by omitting `query`. Abstracts are then not scored (the +`score` column is `NA`) and every abstract without an excluded keyword is kept: + +```{r exclude-keywords-only} +filtered_network <- filterSubnetworkByContext( + nodes = subnetwork$nodes, + edges = subnetwork$edges, + exclude_keywords = c("leukemia", "lymphoma") +) +``` + +--- + +## Decomposing the filtered network into topics + +The output also contains `abstracts`, a named character vector mapping each +PMID in `evidence` to its abstract. Pass `evidence` and `abstracts` to +`decomposeSubnetworkIntoHierarchicalTopics()` (or +`decomposeSubnetworkByTopic()`) so the INDRA evidence and PubMed abstracts are +not fetched again: + +```{r decompose-filtered} +hierarchy <- decomposeSubnetworkIntoHierarchicalTopics( + filtered_network, + evidence = filtered_network$evidence, + abstracts = filtered_network$abstracts +) +hierarchy +``` + +Only evidence from abstracts that passed the filter is used, so the topics +reflect the filtered literature. + +--- + ## Session Info ```{r session-info, eval=TRUE} From c82a6dac197dedaea19fb51a1ac1837223a005ac Mon Sep 17 00:00:00 2001 From: Tony Wu Date: Wed, 23 Sep 2026 11:26:16 -0400 Subject: [PATCH 2/5] fix filtering by keywords to be whole keywords --- R/filterSubnetworkByContext.R | 13 ++++++---- man/filterSubnetworkByContext.Rd | 6 +++-- .../testthat/test-filterSubnetworkByContext.R | 24 +++++++++++++++++++ vignettes/Filter-By-Context.Rmd | 4 +++- 4 files changed, 40 insertions(+), 7 deletions(-) diff --git a/R/filterSubnetworkByContext.R b/R/filterSubnetworkByContext.R index 0399a81..72e36c2 100644 --- a/R/filterSubnetworkByContext.R +++ b/R/filterSubnetworkByContext.R @@ -43,8 +43,10 @@ #' } #' @param method One of \code{"tag_count"} (default) or \code{"cosine"}. #' @param exclude_keywords Optional character vector of keywords. Abstracts -#' containing any of them (case-insensitive substring match) are removed, -#' regardless of their score. To exclude by keyword only, omit +#' containing any of them as a whole word or phrase (case-insensitive) are +#' removed, regardless of their score. For example, \code{"colon"} matches +#' "colon" and "colon-specific" but not "colonize" or "colons"; list +#' variants such as plurals explicitly. To exclude by keyword only, omit #' \code{query}. Default \code{NULL} excludes nothing. #' #' @return A named list with four elements: @@ -247,13 +249,16 @@ filterSubnetworkByContext <- function(nodes, #' @param abstracts Character vector of abstract texts. #' @param keywords Character vector of keywords to search for. #' @return Logical vector, same length as \code{abstracts}; \code{TRUE} when -#' the abstract contains at least one keyword (case-insensitive substring). +#' the abstract contains at least one keyword as a whole word or phrase +#' (case-insensitive), so \code{"colon"} does not match \code{"colonize"}. #' @keywords internal #' @noRd .contains_any_keyword <- function(abstracts, keywords) { abstracts_lower <- tolower(abstracts) hits <- lapply(tolower(keywords), function(keyword) { - grepl(keyword, abstracts_lower, fixed = TRUE) + escaped <- gsub("([][{}()+*^$|\\\\?.])", "\\\\\\1", keyword) + pattern <- paste0("(? Date: Wed, 23 Sep 2026 11:46:11 -0400 Subject: [PATCH 3/5] fix tag count filtering for colon vs colonize --- R/filterSubnetworkByContext.R | 42 ++++++++++++------- man/filterSubnetworkByContext.Rd | 5 ++- .../testthat/test-filterSubnetworkByContext.R | 14 ++++++- 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/R/filterSubnetworkByContext.R b/R/filterSubnetworkByContext.R index 72e36c2..7a9df36 100644 --- a/R/filterSubnetworkByContext.R +++ b/R/filterSubnetworkByContext.R @@ -8,8 +8,9 @@ #' #' \describe{ #' \item{\code{"tag_count"} (default)}{ -#' Counts how many tags from \code{query} appear as substrings in the -#' abstract (case-insensitive). The score for each abstract is an integer +#' Counts how many tags from \code{query} appear as whole words or phrases +#' in the abstract (case-insensitive), so \code{"colon"} does not match +#' "colony" or "colonize". The score for each abstract is an integer #' in \code{[0, length(query)]}. Set \code{cutoff} to the minimum number of #' tags that must appear - e.g. \code{cutoff = 2} keeps abstracts that #' mention at least 2 of your tags. \code{query} must be a character @@ -254,19 +255,33 @@ filterSubnetworkByContext <- function(nodes, #' @keywords internal #' @noRd .contains_any_keyword <- function(abstracts, keywords) { - abstracts_lower <- tolower(abstracts) - hits <- lapply(tolower(keywords), function(keyword) { - escaped <- gsub("([][{}()+*^$|\\\\?.])", "\\\\\\1", keyword) - pattern <- paste0("(? Date: Wed, 23 Sep 2026 16:33:17 -0400 Subject: [PATCH 4/5] update testthat version --- DESCRIPTION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DESCRIPTION b/DESCRIPTION index e56942e..1519dbd 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -35,7 +35,7 @@ Suggests: BiocStyle, knitr, rmarkdown, - testthat (>= 3.0.0), + testthat (>= 3.1.7), mockery, MSstatsConvert, shiny From 67bbe994242195f374ce75bfeabb15ffea714455 Mon Sep 17 00:00:00 2001 From: Tony Wu Date: Wed, 23 Sep 2026 16:51:35 -0400 Subject: [PATCH 5/5] trim user input of whitespaces --- R/filterSubnetworkByContext.R | 3 ++ .../testthat/test-filterSubnetworkByContext.R | 30 ++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/R/filterSubnetworkByContext.R b/R/filterSubnetworkByContext.R index 7a9df36..f346801 100644 --- a/R/filterSubnetworkByContext.R +++ b/R/filterSubnetworkByContext.R @@ -93,6 +93,9 @@ filterSubnetworkByContext <- function(nodes, method <- match.arg(method) + if (is.character(query)) query <- trimws(query) + if (is.character(exclude_keywords)) exclude_keywords <- trimws(exclude_keywords) + if (!is.null(exclude_keywords) && (!is.character(exclude_keywords) || length(exclude_keywords) < 1 || any(is.na(exclude_keywords)) || any(!nzchar(exclude_keywords)))) { diff --git a/tests/testthat/test-filterSubnetworkByContext.R b/tests/testthat/test-filterSubnetworkByContext.R index 1df7b3a..d65eb6a 100644 --- a/tests/testthat/test-filterSubnetworkByContext.R +++ b/tests/testthat/test-filterSubnetworkByContext.R @@ -399,7 +399,7 @@ describe("filterSubnetworkByContext", { }) test_that("rejects malformed exclude_keywords", { - for (bad in list(1, NA_character_, "", character(0))) { + for (bad in list(1, NA_character_, "", " ", character(0))) { expect_error( filterSubnetworkByContext(make_nodes(), make_edges(), query = "CHEK1", @@ -409,6 +409,34 @@ describe("filterSubnetworkByContext", { } }) + test_that("rejects whitespace-only query terms", { + expect_error( + filterSubnetworkByContext(make_nodes(), make_edges(), + query = c("CHEK1", " ")), + "`query` must be a character vector of tags" + ) + expect_error( + filterSubnetworkByContext(make_nodes(), make_edges(), + query = " ", method = "cosine"), + "`query` must be a single character string" + ) + }) + + test_that("trims padded query and exclude_keywords terms", { + mockery::stub(filterSubnetworkByContext, ".extract_evidence_text", + make_mock_evidence()) + mockery::stub(filterSubnetworkByContext, ".fetch_clean_abstracts_xml", + make_mock_abstracts()) + + result <- filterSubnetworkByContext( + make_nodes(), make_edges(), query = " CHEK1 ", cutoff = 0, + exclude_keywords = " lipid " + ) + + expect_equal(result$edges$stmt_hash, "hash1") + expect_equal(result$evidence$score, 1L) + }) + test_that("returns empty abstracts when no evidence is found", { mockery::stub(filterSubnetworkByContext, ".extract_evidence_text", make_mock_evidence()[0, ])