Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Package: omophub
Title: R Client for the 'OMOPHub' Medical Vocabulary API
Version: 1.9.0
Version: 1.9.1
Authors@R: c(
person("Alex", "Chen", email = "alex@omophub.com", role = c("aut", "cre", "cph")),
person("Observational Health Data Science and Informatics", role = c("cph"))
Expand Down Expand Up @@ -32,6 +32,7 @@ Suggests:
webmockr,
knitr,
rmarkdown,
jsonlite,
keyring,
withr
Config/testthat/edition: 3
Expand Down
28 changes: 28 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,31 @@
# omophub 1.9.1

## New Features

* Similarity search now supports pagination and the API's complete filter and
response metadata, including `source_concept` and lower-bound totals.

## Changed

* Autocomplete now uses `domain_ids` and `page_size`, while retaining warned
compatibility aliases for `domains` and `max_suggestions`. Similarity search
now uses the API's `semantic` default algorithm.

## Bug Fixes

* **`include_invalid = FALSE` never reached the server** on
`client$mappings$map()`. The method now sends its documented default
explicitly. Mapping documentation also describes `unmapped_sources`, which
reports every input that was not mapped and why.

* **Autocomplete documentation and tests used an incomplete response shape.**
They now document and exercise all seven fields returned for each suggestion.

## Maintenance

* Declared the test-only `jsonlite` dependency and regenerated package manuals
so their signatures match the current source.

# omophub 1.9.0

## New Features
Expand Down
11 changes: 6 additions & 5 deletions R/mappings.R
Original file line number Diff line number Diff line change
Expand Up @@ -151,10 +151,12 @@ MappingsResource <- R6::R6Class(
#' @param source_codes List of vocabulary/code pairs to map. Each element should be a list
#' with `vocabulary_id` and `concept_code`. Use this OR source_concepts, not both.
#' @param mapping_type Mapping type filter (direct, equivalent, broader, narrower).
#' @param include_invalid Include invalid mappings. Default `FALSE`.
#' @param include_invalid Include invalid mappings. Default `FALSE`; the
#' value is always sent explicitly.
#' @param vocab_release Specific vocabulary release version (e.g., "2025.1"). Default `NULL`.
#'
#' @returns Mapping results with summary.
#' @returns Mapping results with `mappings`, per-input `unmapped_sources`,
#' and a `summary` of requested, mapped, and unmapped sources.
map = function(target_vocabulary,
source_concepts = NULL,
source_codes = NULL,
Expand Down Expand Up @@ -197,9 +199,8 @@ MappingsResource <- R6::R6Class(
if (!is.null(mapping_type)) {
body$mapping_type <- mapping_type
}
if (isTRUE(include_invalid)) {
body$include_invalid <- TRUE
}
checkmate::assert_flag(include_invalid)
body$include_invalid <- include_invalid

query <- list()
if (!is.null(vocab_release)) {
Expand Down
20 changes: 19 additions & 1 deletion R/request.R
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,18 @@ perform_get <- function(base_req, endpoint, query = NULL) {
#' @param endpoint API endpoint path.
#' @param body Named list for JSON body.
#' @param query Named list of query parameters.
#' @param preserve_pagination If `TRUE`, copy `meta$pagination` from the
#' response envelope onto the returned list as a `pagination` element.
#' Paginated POST endpoints carry their pagination in `meta` while the results
#' sit in `data`, so unwrapping to `data` alone leaves the caller with a
#' `page` argument and no way to know whether another page exists. Added as an
#' element rather than changing the return shape, so existing accessors keep
#' working.
#'
#' @returns Parsed JSON response (unwrapped from `data` field if present).
#' @keywords internal
perform_post <- function(base_req, endpoint, body = NULL, query = NULL) {
perform_post <- function(base_req, endpoint, body = NULL, query = NULL,
preserve_pagination = FALSE) {
req <- base_req |>
httr2::req_url_path_append(endpoint) |>
httr2::req_method("POST")
Expand All @@ -119,6 +127,16 @@ perform_post <- function(base_req, endpoint, body = NULL, query = NULL) {

# Unwrap data field if present (matching Python SDK behavior)
if (is.list(resp_body) && "data" %in% names(resp_body)) {
if (isTRUE(preserve_pagination)) {
pagination <- resp_body$meta$pagination
if (!is.null(pagination)) {
data <- resp_body$data
if (is.list(data)) {
data$pagination <- pagination
return(data)
}
}
}
return(resp_body$data)
}
resp_body
Expand Down
91 changes: 73 additions & 18 deletions R/search.R
Original file line number Diff line number Diff line change
Expand Up @@ -199,27 +199,51 @@ SearchResource <- R6::R6Class(
#'
#' @param query Partial query string.
#' @param vocabulary_ids Filter by vocabulary IDs.
#' @param domains Filter by domains.
#' @param max_suggestions Maximum suggestions. Default 10.
#' @param domain_ids Filter by domain IDs.
#' @param page_size Maximum suggestions (1-20). Default 10.
#' @param domains Deprecated alias for `domain_ids`.
#' @param max_suggestions Deprecated alias for `page_size`. Ignored, with a
#' warning, when `page_size` is also supplied.
#'
#' @returns Autocomplete suggestions.
#' @returns A list containing `query` and `suggestions`. Each suggestion is
#' a flat list with `suggestion`, `concept_id`, `concept_code`,
#' `vocabulary_id`, `domain_id`, `concept_class_id`, and
#' `standard_concept`.
autocomplete = function(query,
vocabulary_ids = NULL,
domain_ids = NULL,
page_size = 10,
domains = NULL,
max_suggestions = 10) {
max_suggestions = NULL) {
checkmate::assert_string(query, min.chars = 1)
checkmate::assert_integerish(max_suggestions, lower = 1, len = 1, any.missing = FALSE)
# The canonical argument wins, matching how `domain_ids` beats `domains`
# below and how the Python SDK resolves the same pair. This used to
# overwrite page_size unconditionally, so a caller passing both got the
# deprecated value and no indication that the one they named was ignored.
if (!is.null(max_suggestions)) {
if (missing(page_size)) {
page_size <- max_suggestions
} else {
warning(
"Both `page_size` and the deprecated `max_suggestions` were given; ",
"using `page_size`.",
call. = FALSE
)
}
}
checkmate::assert_integerish(page_size, lower = 1, upper = 20, len = 1, any.missing = FALSE)

params <- list(
query = query,
max_suggestions = as.integer(max_suggestions)
page_size = as.integer(page_size)
)

if (!is.null(vocabulary_ids)) {
params$vocabulary_ids <- join_params(vocabulary_ids)
}
if (!is.null(domains)) {
params$domains <- join_params(domains)
selected_domains <- domain_ids %||% domains
if (!is.null(selected_domains)) {
params$domain_ids <- join_params(selected_domains)
}

perform_get(private$.base_req, "search/suggest", query = params)
Expand Down Expand Up @@ -395,31 +419,49 @@ SearchResource <- R6::R6Class(
#' @param concept_id Concept ID to find similar concepts for.
#' @param concept_name Concept name to find similar concepts for.
#' @param query Natural language query for semantic similarity.
#' @param algorithm One of 'semantic', 'lexical', or 'hybrid' (default).
#' @param algorithm One of 'semantic' (default), 'lexical', or 'hybrid'.
#' @param similarity_threshold Minimum similarity (0.0-1.0). Default 0.7.
#' @param page_size Max results (max 1000). Default 20.
#' `0` is a valid value and is honoured.
#' @param page_size Results per page (max 1000). Default 20.
#' @param vocabulary_ids Filter by vocabulary IDs.
#' @param domain_ids Filter by domain IDs.
#' @param standard_concept Filter by standard concept flag ('S', 'C', or 'N').
#' @param include_invalid Include invalid/deprecated concepts.
#' @param include_scores Include detailed similarity scores.
#' @param include_explanations Include similarity explanations.
#' 'N' selects non-standard concepts, which OMOP stores as a null column.
#' @param include_invalid Include invalid/deprecated concepts. Defaults to
#' FALSE, and supported only with algorithm='lexical' - the embedding
#' index holds valid concepts only, so the API returns 400 for the other
#' two rather than ignoring the filter.
#' @param include_scores Include `similarity_score` on each concept
#' (default TRUE). When FALSE the field is absent.
#' @param include_explanations Include an `explanation` on each concept.
#' @param page Page of the ranked candidate pool (1-based). Default 1.
#' @param concept_class_ids Filter by concept class IDs.
#' @param exclude_self Exclude the reference concept from its own results
#' (default TRUE).
#'
#' @returns List with similar_concepts and search_metadata.
#' @returns List with similar_concepts, search_metadata, a `pagination`
#' element carrying the response envelope's pagination, and, when the
#' search started from a concept_id, source_concept.
#'
#' @note When algorithm='semantic', only single vocabulary/domain filter supported.
#' @note Every algorithm ranks a bounded candidate pool, so the totals can
#' be lower bounds - `search_metadata$totals_are_lower_bound` says when.
#' Page while `has_next` is TRUE rather than comparing page to
#' total_pages.
similar = function(concept_id = NULL,
concept_name = NULL,
query = NULL,
algorithm = "hybrid",
algorithm = "semantic",
similarity_threshold = 0.7,
page_size = 20,
vocabulary_ids = NULL,
domain_ids = NULL,
standard_concept = NULL,
include_invalid = NULL,
include_scores = NULL,
include_explanations = NULL) {
include_explanations = NULL,
page = 1,
concept_class_ids = NULL,
exclude_self = NULL) {
# Validate exactly one of concept_id, concept_name, or query provided
provided <- sum(!is.null(concept_id), !is.null(concept_name), !is.null(query))
if (provided != 1) {
Expand All @@ -430,6 +472,7 @@ SearchResource <- R6::R6Class(

checkmate::assert_choice(algorithm, c("semantic", "lexical", "hybrid"))
checkmate::assert_number(similarity_threshold, lower = 0, upper = 1)
checkmate::assert_integerish(page, lower = 1, len = 1, any.missing = FALSE)
checkmate::assert_integerish(page_size, lower = 1, upper = 1000)
if (!is.null(concept_id)) {
checkmate::assert_integerish(concept_id, len = 1, any.missing = FALSE)
Expand All @@ -449,6 +492,9 @@ SearchResource <- R6::R6Class(
if (!is.null(query)) {
body$query <- query
}
if (page != 1) {
body$page <- as.integer(page)
}
if (page_size != 20) {
body$page_size <- as.integer(page_size)
}
Expand All @@ -458,6 +504,9 @@ SearchResource <- R6::R6Class(
if (!is.null(domain_ids)) {
body$domain_ids <- as.list(domain_ids)
}
if (!is.null(concept_class_ids)) {
body$concept_class_ids <- as.list(concept_class_ids)
}
if (!is.null(standard_concept)) {
checkmate::assert_choice(standard_concept, c("S", "C", "N"))
body$standard_concept <- standard_concept
Expand All @@ -471,8 +520,14 @@ SearchResource <- R6::R6Class(
if (!is.null(include_explanations)) {
body$include_explanations <- include_explanations
}
if (!is.null(exclude_self)) {
body$exclude_self <- exclude_self
}

perform_post(private$.base_req, "search/similar", body = body)
perform_post(
private$.base_req, "search/similar",
body = body, preserve_pagination = TRUE
)
},

#' @description
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ results <- client$search$semantic(
all_results <- client$search$semantic_all("chronic kidney disease", page_size = 50)

# Find concepts similar to a reference concept
# `algorithm` defaults to "semantic"; "lexical" and "hybrid" are also available.
similar <- client$search$similar(concept_id = 201826, algorithm = "hybrid")
for (s in similar$similar_concepts) {
cat(sprintf("%s (score: %.2f)\n", s$concept_name, s$similarity_score))
Expand Down Expand Up @@ -327,6 +328,20 @@ validate_and_map <- function(source_vocab, source_code) {
standard_id <- validate_and_map("ICD10CM", "E11.9")
```

Map several native codes in one request with `client$mappings$map()`. Inputs
that do not produce a mapping are preserved in `unmapped_sources` with a
`source_not_found` or `no_mapping_found` reason.

```r
result <- client$mappings$map(
target_vocabulary = "SNOMED",
source_codes = list(list(vocabulary_id = "ICD10CM", concept_code = "E11.9"))
)

result$summary
result$unmapped_sources
```

### Data Quality Checks

Verify codes exist and are valid:
Expand Down
6 changes: 4 additions & 2 deletions man/MappingsResource.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading