diff --git a/CHANGELOG.md b/CHANGELOG.md index 297f031..db505b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file. The format ## [Unreleased] +## [1.1.1] - 2026-09-07 + +### Changed + +- Autocomplete now models the API's flat seven-field suggestion entries, + supports `domainIds` and `includeContext`, and maps the deprecated `domains` + option to `domain_ids`. +- Similarity search now exposes the API's complete option set, pagination and + source-concept metadata, score omission, explanations, and degraded/lower- + bound result indicators. Its documented default algorithm is `semantic`. + +### Fixed + +- `mappings.map()` now types the API's actual per-source result: required + `unmapped_sources` and `summary` fields replace the never-emitted + `failed_mappings` shape. The unsupported `summary` field was removed from + `mappings.get()` results. + ## [1.1.0] - 2026-08-11 ### Added @@ -115,8 +133,8 @@ Standalone: omophubFhirUrl, getApiKey, setApiKey, hasApiKey ``` -[Unreleased]: https://github.com/OMOPHub/omophub-node/compare/v1.1.0...HEAD +[Unreleased]: https://github.com/OMOPHub/omophub-node/compare/v1.1.1...HEAD +[1.1.1]: https://github.com/OMOPHub/omophub-node/compare/v1.1.0...v1.1.1 [1.1.0]: https://github.com/OMOPHub/omophub-node/releases/tag/v1.1.0 [1.0.1]: https://github.com/OMOPHub/omophub-node/releases/tag/v1.0.1 [1.0.0]: https://github.com/OMOPHub/omophub-node/releases/tag/v1.0.0 - diff --git a/README.md b/README.md index e516e37..bbac5d6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Query millions of standardized medical concepts from TypeScript with full type safety** -Access SNOMED CT, ICD-10, RxNorm, LOINC, and 100+ OHDSI ATHENA vocabularies without downloading, installing, or maintaining local databases. +Access SNOMED CT, ICD-10, RxNorm, LOINC, and 120+ OHDSI ATHENA vocabularies without downloading, installing, or maintaining local databases. [![npm version](https://img.shields.io/npm/v/@omophub/omophub-node.svg)](https://www.npmjs.com/package/@omophub/omophub-node) [![Node Version](https://img.shields.io/node/v/@omophub/omophub-node.svg)](https://www.npmjs.com/package/@omophub/omophub-node) @@ -64,6 +64,12 @@ const mapping = await client.mappings.map({ targetVocabulary: 'SNOMED', sourceCodes: [{ vocabulary_id: 'ICD10CM', concept_code: 'E11.9' }], }); +if (mapping.data) { + console.log(mapping.data.summary); + // Every source without a mapping is reported with source_not_found or + // no_mapping_found, rather than being silently dropped. + console.log(mapping.data.unmapped_sources); +} // Navigate concept hierarchy const ancestors = await client.hierarchy.ancestors(201826, { maxLevels: 3 }); @@ -231,6 +237,7 @@ Find concepts similar to a known concept or natural language query: ```ts // Find concepts similar to a known concept +// `algorithm` defaults to 'semantic'; 'lexical' and 'hybrid' are also available. const sim = await client.search.similar({ conceptId: 201826, algorithm: 'hybrid' }); for (const r of sim.data?.similar_concepts ?? []) { console.log(`${r.concept_name} (score: ${r.similarity_score.toFixed(2)})`); diff --git a/e2e/search.test.ts b/e2e/search.test.ts index 0810d8d..69e8292 100644 --- a/e2e/search.test.ts +++ b/e2e/search.test.ts @@ -122,8 +122,8 @@ describe('e2e: client.search.autocomplete', () => { expect(Array.isArray(data?.suggestions)).toBe(true); expect(data?.suggestions.length).toBeGreaterThan(0); const first = data?.suggestions[0]; - expect(typeof first?.suggestion.concept_id).toBe('number'); - expect(typeof first?.suggestion.concept_name).toBe('string'); + expect(typeof first?.concept_id).toBe('number'); + expect(typeof first?.suggestion).toBe('string'); }); runOrSkip('echoes the query field with vocabulary filter applied', async () => { diff --git a/examples/map-between-vocabularies.ts b/examples/map-between-vocabularies.ts index f4c4070..35b06c3 100644 --- a/examples/map-between-vocabularies.ts +++ b/examples/map-between-vocabularies.ts @@ -98,10 +98,12 @@ async function mapConcepts(): Promise { } console.log(`Mapped ${data.mappings.length} concepts to ICD-10-CM`); - if (data.summary) { - console.log( - ` ${data.summary.mapped_concepts ?? '?'}/${data.summary.total_source_concepts ?? '?'} source concepts mapped`, - ); + console.log( + ` ${data.summary.mapped_sources}/${data.summary.requested_sources} source concepts mapped`, + ); + + for (const source of data.unmapped_sources) { + console.log(` Unmapped source ${source.source_concept_id}: ${source.reason}`); } for (const m of data.mappings) { diff --git a/examples/search-concepts.ts b/examples/search-concepts.ts index 5f107d4..5f8ec71 100644 --- a/examples/search-concepts.ts +++ b/examples/search-concepts.ts @@ -100,12 +100,12 @@ async function bulkSemanticSearch(client: OMOPHub): Promise { async function autocompleteExample(client: OMOPHub): Promise { console.log('\n=== Autocomplete ==='); - // Returns `{ query, suggestions: [{ suggestion: Concept, match_score?, match_type? }] }`. + // Returns `{ query, suggestions: [{ suggestion: string, concept_id, ... }] }`. const { data, error } = await client.search.autocomplete('hypert', { pageSize: 5 }); if (error) throw new Error(error.message); console.log(`Suggestions for '${data.query}':`); for (const s of data.suggestions.slice(0, 5)) { - console.log(` [${s.suggestion.vocabulary_id}] ${s.suggestion.concept_name}`); + console.log(` [${s.vocabulary_id}] ${s.suggestion}`); } } diff --git a/package-lock.json b/package-lock.json index 4789841..52c7c6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@omophub/omophub-node", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@omophub/omophub-node", - "version": "1.1.0", + "version": "1.1.1", "license": "MIT", "devDependencies": { "@biomejs/biome": "^2.5.0", diff --git a/package.json b/package.json index 71e053d..fe9b1d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@omophub/omophub-node", - "version": "1.1.0", + "version": "1.1.1", "description": "Official OMOPHub Node.js / TypeScript SDK for the OHDSI medical vocabularies API - search, lookup, map, and navigate concepts across SNOMED, ICD10, RxNorm, LOINC, and more.", "type": "module", "main": "dist/index.js", diff --git a/src/index.ts b/src/index.ts index fa132b7..c0c3134 100644 --- a/src/index.ts +++ b/src/index.ts @@ -88,7 +88,6 @@ export type { ResponseMeta, } from './interfaces.js'; export type { - FailedMapping, GetMappingsOptions, MapConceptsOptions, MapConceptsResult, @@ -98,6 +97,8 @@ export type { MappingsListResult, MappingsSummary, SourceCodeRef, + UnmappedSource, + UnmappedSourceReason, } from './mappings/interfaces/index.js'; export type { GetRelationshipsOptions, @@ -135,6 +136,7 @@ export type { SimilarSearchMetadata, SimilarSearchOptions, SimilarSearchResult, + SimilarSourceConcept, } from './search/interfaces/index.js'; export { __version__ } from './version.js'; export type { diff --git a/src/mappings/interfaces/index.ts b/src/mappings/interfaces/index.ts index f112612..18bc0de 100644 --- a/src/mappings/interfaces/index.ts +++ b/src/mappings/interfaces/index.ts @@ -1,7 +1,6 @@ export type { GetMappingsOptions } from './get-mappings-options.js'; export type { MapConceptsOptions } from './map-concepts-options.js'; export type { - FailedMapping, MapConceptsResult, Mapping, MappingContext, @@ -9,4 +8,6 @@ export type { MappingsListResult, MappingsSummary, SourceCodeRef, + UnmappedSource, + UnmappedSourceReason, } from './mapping.js'; diff --git a/src/mappings/interfaces/mapping.ts b/src/mappings/interfaces/mapping.ts index 5fbe94b..47bcf24 100644 --- a/src/mappings/interfaces/mapping.ts +++ b/src/mappings/interfaces/mapping.ts @@ -26,7 +26,7 @@ export interface MappingContext { * `targetVocabulary` does NOT add the vocabulary/code fields — * measured against production 2026-08-12. Resolve a target's * vocabulary and code with `concepts.get(target_concept_id)`. - * - `mappings.map` (`POST /mappings/map`) additionally returns + * - `mappings.map` (`POST /concepts/map`) additionally returns * `source_*` / `target_*` `vocabulary_id` and `concept_code`. */ export interface Mapping { @@ -50,37 +50,37 @@ export interface Mapping { } export interface MappingsSummary { - total_source_concepts?: number; - total_mappings?: number; - mapped_concepts?: number; - unmapped_concepts?: number; + requested_sources: number; + mapped_sources: number; + unmapped_sources: number; + total_mappings: number; } export interface MappingsListResult { mappings: Mapping[]; - summary?: MappingsSummary; } -/** - * Failed-mapping entry in `MapConceptsResult.failed_mappings`. Discriminated - * by which input variant the failure traces back to — every failed mapping - * carries either a `source_concept_id` (from the `sourceConcepts` input) or - * a `source_code` (from the `sourceCodes` input), never an empty object. - * `reason` is optional, mirroring `BatchConceptResult.failed_concepts` in - * `concepts/interfaces/`. - */ -export type FailedMapping = - | { source_concept_id: number; source_code?: never; reason?: string } +export type UnmappedSourceReason = 'source_not_found' | 'no_mapping_found'; + +/** A submitted source that produced no mappings. */ +export type UnmappedSource = + | { + source_concept_id: number; + vocabulary_id?: never; + concept_code?: never; + reason: UnmappedSourceReason; + } | { - source_concept_id?: never; - source_code: { vocabulary_id: string; concept_code: string }; - reason?: string; + source_concept_id?: number; + vocabulary_id: string; + concept_code: string; + reason: UnmappedSourceReason; }; export interface MapConceptsResult { mappings: Mapping[]; - failed_mappings?: FailedMapping[]; - summary?: MappingsSummary; + unmapped_sources: UnmappedSource[]; + summary: MappingsSummary; } /** diff --git a/src/search/interfaces/autocomplete-options.ts b/src/search/interfaces/autocomplete-options.ts index 960fdeb..3880033 100644 --- a/src/search/interfaces/autocomplete-options.ts +++ b/src/search/interfaces/autocomplete-options.ts @@ -1,6 +1,9 @@ export interface AutocompleteOptions { vocabularyIds?: string[]; + domainIds?: string[]; + /** @deprecated Use `domainIds`. */ domains?: string[]; - /** Maximum number of suggestions. Default 10 at the API; max 100. */ + /** Maximum number of suggestions. Default 10 at the API; max 20. */ pageSize?: number; + includeContext?: boolean; } diff --git a/src/search/interfaces/index.ts b/src/search/interfaces/index.ts index 76e4e9f..f6e67f1 100644 --- a/src/search/interfaces/index.ts +++ b/src/search/interfaces/index.ts @@ -32,4 +32,5 @@ export type { SimilarConcept, SimilarSearchMetadata, SimilarSearchResult, + SimilarSourceConcept, } from './similar-search-result.js'; diff --git a/src/search/interfaces/search-result.ts b/src/search/interfaces/search-result.ts index 43d61cc..590fda3 100644 --- a/src/search/interfaces/search-result.ts +++ b/src/search/interfaces/search-result.ts @@ -34,14 +34,20 @@ export interface SearchResult { search_metadata?: SearchMetadata; } -/** - * One entry in `AutocompleteResult.suggestions`. The server nests the - * concept under a `suggestion` field and may add scoring fields alongside. - */ +/** One concept-name suggestion returned by `GET /search/suggest`. */ export interface AutocompleteEntry { - suggestion: Concept; - match_score?: number; - match_type?: string; + suggestion: string; + concept_id: Concept['concept_id']; + concept_code: Concept['concept_code']; + vocabulary_id: Concept['vocabulary_id']; + domain_id: Concept['domain_id']; + concept_class_id: Concept['concept_class_id']; + standard_concept: Concept['standard_concept']; + context?: { + vocabulary_id: string; + domain_id: string; + concept_class_id: string; + }; } /** diff --git a/src/search/interfaces/similar-search-options.ts b/src/search/interfaces/similar-search-options.ts index 6015cc1..80fc865 100644 --- a/src/search/interfaces/similar-search-options.ts +++ b/src/search/interfaces/similar-search-options.ts @@ -1,15 +1,39 @@ interface SimilarSearchBase { - /** `'semantic'`, `'lexical'`, or `'hybrid'` (default). */ + /** `'semantic'` (the API's default), `'lexical'`, or `'hybrid'`. */ algorithm?: 'semantic' | 'lexical' | 'hybrid'; - /** Similarity floor (0–1). Default 0.7 at the API. */ + /** + * Similarity floor (0–1). Default 0.7 at the API. `0` is a valid value and + * is honoured — it returns every candidate the retrieval produced. + */ similarityThreshold?: number; + /** + * Page of the ranked candidate pool, 1-based. + * + * Every algorithm ranks a bounded pool, so reachable depth is bounded too; + * page while `meta.pagination.has_next` is true rather than comparing `page` + * to `total_pages`, and check `search_metadata.totals_are_lower_bound` + * before treating a total as exact. + */ + page?: number; pageSize?: number; vocabularyIds?: string[]; domainIds?: string[]; + conceptClassIds?: string[]; + /** `'N'` selects non-standard concepts, which OMOP stores as a null column. */ standardConcept?: 'S' | 'C' | 'N'; + /** + * Include invalid/deprecated concepts. Defaults to `false`, and is supported + * only with `algorithm: 'lexical'` — the embedding index holds valid + * concepts only, so the API answers 400 for the other two rather than + * ignoring the filter. + */ includeInvalid?: boolean; + /** Include `similarity_score` on each concept. Default true. */ includeScores?: boolean; + /** Include an `explanation` on each concept. Default false. */ includeExplanations?: boolean; + /** Exclude the reference concept from its own results. Default true. */ + excludeSelf?: boolean; } /** diff --git a/src/search/interfaces/similar-search-result.ts b/src/search/interfaces/similar-search-result.ts index 87eda01..2335d36 100644 --- a/src/search/interfaces/similar-search-result.ts +++ b/src/search/interfaces/similar-search-result.ts @@ -1,7 +1,11 @@ import type { ConceptSummary } from '../../concepts/interfaces/concept.js'; export interface SimilarConcept extends ConceptSummary { - similarity_score: number; + /** + * Absent when the request set `includeScores: false`, which the API honours + * by omitting the key rather than zeroing it. + */ + similarity_score?: number; domain_id?: string; concept_class_id?: string; standard_concept?: 'S' | 'C' | 'N' | null; @@ -10,20 +14,59 @@ export interface SimilarConcept extends ConceptSummary { lexical?: number; hybrid?: number; }; + /** Present when the request set `includeExplanations: true`. */ explanation?: string; + /** + * @deprecated Duplicate of `explanation`, emitted by the API for one release + * so clients that read the old name keep working. Read `explanation`. + */ + similarity_explanation?: string; } export interface SimilarSearchMetadata { original_query: string; algorithm_used: 'semantic' | 'lexical' | 'hybrid'; similarity_threshold: number; + /** + * How many concepts cleared `similarity_threshold` inside the bounded + * retrieval pool — not how many were evaluated. The pool holds up to 500 and + * everything below the threshold is discarded before this is counted. + */ total_candidates: number; results_returned: number; processing_time_ms: number; embedding_latency_ms?: number; + /** + * True when retrieval hit its candidate bound, so `total_candidates` and the + * pagination totals count only what qualified inside the pool that was + * searched, rather than across the whole corpus. Treat them as "at least this + * many". + */ + totals_are_lower_bound?: boolean; + /** + * The algorithm that was asked for, when a fallback served the request + * instead — `hybrid` degrades to `lexical` if the embedding service is + * unavailable. Present only when it differs from `algorithm_used`. + */ + degraded_from?: 'semantic' | 'lexical' | 'hybrid'; + /** The concept the search started from, when `conceptId` was supplied. */ + source_concept_id?: number; +} + +/** The reference concept a similarity search started from. */ +export interface SimilarSourceConcept { + concept_id: number; + concept_name: string; + concept_code?: string; + vocabulary_id?: string; + domain_id?: string; + concept_class_id?: string; + standard_concept?: 'S' | 'C' | 'N' | null; } export interface SimilarSearchResult { similar_concepts: SimilarConcept[]; search_metadata: SimilarSearchMetadata; + /** Returned when the search started from a `conceptId`. */ + source_concept?: SimilarSourceConcept; } diff --git a/src/search/search.ts b/src/search/search.ts index 8e486d1..142b24c 100644 --- a/src/search/search.ts +++ b/src/search/search.ts @@ -191,18 +191,23 @@ export class Search { /** * Lightweight typeahead suggestions. The server returns - * `{ query, suggestions: [{ suggestion: Concept, match_score?, match_type? }] }` + * `{ query, suggestions: [{ suggestion: string, concept_id, ... }] }` * — `query` echoes the input, `suggestions` is the array. */ async autocomplete( query: string, options: AutocompleteOptions & GetOptions = {}, ): Promise> { - const { signal, headers, query: extraQuery, ...flags } = options; + const { signal, headers, query: extraQuery, domains, domainIds, ...flags } = options; return this.client.get('/search/suggest', { signal, headers, - query: { ...flags, ...extraQuery, query }, + query: { + ...flags, + ...(domainIds || domains ? { domainIds: domainIds ?? domains } : {}), + ...extraQuery, + query, + }, }); } diff --git a/src/version.ts b/src/version.ts index 6373d56..8afc8cf 100644 --- a/src/version.ts +++ b/src/version.ts @@ -2,4 +2,4 @@ * SDK version. Bumped in lockstep with package.json. Surfaced in the * User-Agent header so server-side logs can attribute traffic to a release. */ -export const __version__ = '1.1.0'; +export const __version__ = '1.1.1'; diff --git a/test/mappings/mappings.test.ts b/test/mappings/mappings.test.ts index f212f77..ceedbb2 100644 --- a/test/mappings/mappings.test.ts +++ b/test/mappings/mappings.test.ts @@ -278,9 +278,21 @@ describe('client.mappings pagination', () => { describe('client.mappings.map', () => { test('POST /concepts/map with sourceConcepts variant', async () => { const fetchMock = createMockFetch(); - enqueueSuccess(fetchMock, { mappings: [], summary: { total_source_concepts: 2 } }); + enqueueSuccess(fetchMock, { + mappings: [], + unmapped_sources: [ + { source_concept_id: 201826, reason: 'no_mapping_found' }, + { source_concept_id: 1112807, reason: 'source_not_found' }, + ], + summary: { + requested_sources: 2, + mapped_sources: 0, + unmapped_sources: 2, + total_mappings: 0, + }, + }); const client = new OMOPHub('oh_test', { fetch: fetchMock }); - await client.mappings.map({ + const result = await client.mappings.map({ targetVocabulary: 'SNOMED', sourceConcepts: [201826, 1112807], mappingType: 'direct', @@ -296,6 +308,8 @@ describe('client.mappings.map', () => { mapping_type: 'direct', include_invalid: false, }); + expect(result.data?.unmapped_sources).toHaveLength(2); + expect(result.data?.summary.requested_sources).toBe(2); }); test('POST /concepts/map with sourceCodes variant', async () => { diff --git a/test/search/search.test.ts b/test/search/search.test.ts index 2d9b3f4..e299b89 100644 --- a/test/search/search.test.ts +++ b/test/search/search.test.ts @@ -200,20 +200,47 @@ describe('client.search.advanced', () => { describe('client.search.autocomplete', () => { test('hits GET /search/suggest with positional query', async () => { const fetchMock = createMockFetch(); - enqueueSuccess(fetchMock, [{ suggestion: 'diabetes', concept_id: 201826 }]); + enqueueSuccess(fetchMock, { + query: 'diab', + suggestions: [ + { + suggestion: 'Type 2 diabetes mellitus', + concept_id: 201826, + concept_code: '44054006', + vocabulary_id: 'SNOMED', + domain_id: 'Condition', + concept_class_id: 'Clinical Finding', + standard_concept: 'S', + }, + ], + }); const client = new OMOPHub('oh_test', { fetch: fetchMock }); const { data, error } = await client.search.autocomplete('diab', { vocabularyIds: ['SNOMED'], + domainIds: ['Condition'], pageSize: 5, }); expect(error).toBeNull(); - expect(data).toEqual([{ suggestion: 'diabetes', concept_id: 201826 }]); + expect(data?.suggestions[0]?.suggestion).toBe('Type 2 diabetes mellitus'); const { url } = lastCall(fetchMock); expect(url).toContain('/search/suggest'); expect(url).toContain('query=diab'); expect(url).toContain('vocabulary_ids=SNOMED'); + expect(url).toContain('domain_ids=Condition'); expect(url).toContain('page_size=5'); }); + + test('maps the deprecated domains option to domain_ids', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { query: 'diab', suggestions: [] }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + + await client.search.autocomplete('diab', { domains: ['Condition'] }); + + const { url } = lastCall(fetchMock); + expect(url).toContain('domain_ids=Condition'); + expect(url).not.toContain('domains='); + }); }); describe('client.search.semantic', () => { @@ -474,6 +501,85 @@ describe('client.search.similar', () => { expect(body).toEqual({ concept_id: 201826, algorithm: 'hybrid' }); }); + test('forwards every documented similarity option in snake_case', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { + similar_concepts: [], + search_metadata: { + original_query: '201826', + algorithm_used: 'lexical', + similarity_threshold: 0, + total_candidates: 0, + results_returned: 0, + processing_time_ms: 1, + totals_are_lower_bound: false, + }, + }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + await client.search.similar({ + conceptId: 201826, + algorithm: 'lexical', + // 0 is a legitimate threshold, distinct from omitting it. + similarityThreshold: 0, + page: 3, + pageSize: 10, + vocabularyIds: ['SNOMED'], + domainIds: ['Condition'], + conceptClassIds: ['Clinical Finding'], + standardConcept: 'N', + includeInvalid: true, + includeScores: false, + includeExplanations: true, + excludeSelf: false, + }); + const body = JSON.parse(lastCall(fetchMock).init.body as string); + expect(body).toEqual({ + concept_id: 201826, + algorithm: 'lexical', + similarity_threshold: 0, + page: 3, + page_size: 10, + vocabulary_ids: ['SNOMED'], + domain_ids: ['Condition'], + concept_class_ids: ['Clinical Finding'], + standard_concept: 'N', + include_invalid: true, + include_scores: false, + include_explanations: true, + exclude_self: false, + }); + }); + + test('types a scoreless concept, as include_scores=false returns', async () => { + const fetchMock = createMockFetch(); + enqueueSuccess(fetchMock, { + similar_concepts: [ + { + concept_id: 313217, + concept_name: 'Type 1 diabetes mellitus', + vocabulary_id: 'SNOMED', + concept_code: '46635009', + }, + ], + search_metadata: { + original_query: '201826', + algorithm_used: 'semantic', + similarity_threshold: 0.7, + total_candidates: 1, + results_returned: 1, + processing_time_ms: 1, + }, + }); + const client = new OMOPHub('oh_test', { fetch: fetchMock }); + const response = await client.search.similar({ + conceptId: 201826, + includeScores: false, + }); + // `similarity_score` used to be typed as required, which made the + // documented `include_scores: false` response a type error. + expect(response.data?.similar_concepts[0]?.similarity_score).toBeUndefined(); + }); + test('accepts conceptName variant', async () => { const fetchMock = createMockFetch(); enqueueSuccess(fetchMock, {