@ncukondo/search-hub 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mesh-lookup.d.ts","sourceRoot":"","sources":["../../src/query/mesh-lookup.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mCAAmC,CAAC;AACrE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAMnD;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,KAAK,EAAE,OAAO,CAAC;IACf,uDAAuD;IACvD,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB;AAOD;;GAEG;AACH,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA0B;IACtD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAyB;gBAEnC,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,WAAW,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,UAAU,CAAA;KAAE;IAM3F;;;;;;;;;;;;;OAaG;IACG,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;
|
|
1
|
+
{"version":3,"file":"mesh-lookup.d.ts","sourceRoot":"","sources":["../../src/query/mesh-lookup.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mCAAmC,CAAC;AACrE,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAMnD;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,kCAAkC;IAClC,IAAI,EAAE,MAAM,CAAC;IACb,yDAAyD;IACzD,KAAK,EAAE,OAAO,CAAC;IACf,uDAAuD;IACvD,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;CACxB;AAOD;;GAEG;AACH,qBAAa,gBAAgB;IAC3B,OAAO,CAAC,QAAQ,CAAC,WAAW,CAA0B;IACtD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAyB;gBAEnC,OAAO,CAAC,EAAE;QAAE,WAAW,CAAC,EAAE,WAAW,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,UAAU,CAAA;KAAE;IAM3F;;;;;;;;;;;;;OAaG;IACG,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAmMzD;;OAEG;IACG,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;YAQjD,WAAW;CAoC1B"}
|
|
@@ -93,13 +93,76 @@ class MeSHLookupClient {
|
|
|
93
93
|
}
|
|
94
94
|
if (words.length > 1) {
|
|
95
95
|
const firstWord = words[0];
|
|
96
|
+
const restWords = words.slice(1).join(" ").toLowerCase();
|
|
97
|
+
const restWordsPrefix = restWords.slice(0, 4);
|
|
96
98
|
const firstWordResults = await this.fetchLookup(firstWord, "startswith", 25);
|
|
97
99
|
if (firstWordResults.length > 0) {
|
|
98
|
-
const
|
|
100
|
+
const candidates = restWordsPrefix.length >= 4 ? firstWordResults.filter(
|
|
101
|
+
(r) => r.label.toLowerCase().includes(restWordsPrefix)
|
|
102
|
+
) : firstWordResults;
|
|
103
|
+
if (candidates.length > 0) {
|
|
104
|
+
const ranked = candidates.map((s) => ({
|
|
105
|
+
label: s.label,
|
|
106
|
+
distance: levenshteinDistance(
|
|
107
|
+
term.toLowerCase(),
|
|
108
|
+
s.label.toLowerCase()
|
|
109
|
+
)
|
|
110
|
+
})).sort((a, b) => a.distance - b.distance).slice(0, 5).map((s) => s.label);
|
|
111
|
+
const result2 = {
|
|
112
|
+
term,
|
|
113
|
+
found: false,
|
|
114
|
+
suggestions: ranked
|
|
115
|
+
};
|
|
116
|
+
this.cache?.set("mesh", term, result2);
|
|
117
|
+
return result2;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (let len = firstWord.length - 1, iterations = 0; len >= 3 && iterations < 3; len--, iterations++) {
|
|
121
|
+
const truncated = firstWord.slice(0, len);
|
|
122
|
+
const truncResults = await this.fetchLookup(
|
|
123
|
+
truncated,
|
|
124
|
+
"startswith",
|
|
125
|
+
25
|
|
126
|
+
);
|
|
127
|
+
const filtered = truncResults.filter(
|
|
128
|
+
(r) => r.label.toLowerCase().includes(restWordsPrefix)
|
|
129
|
+
);
|
|
130
|
+
if (filtered.length > 0) {
|
|
131
|
+
const ranked = filtered.map((s) => ({
|
|
132
|
+
label: s.label,
|
|
133
|
+
distance: levenshteinDistance(
|
|
134
|
+
term.toLowerCase(),
|
|
135
|
+
s.label.toLowerCase()
|
|
136
|
+
)
|
|
137
|
+
})).sort((a, b) => a.distance - b.distance).slice(0, 5).map((s) => s.label);
|
|
138
|
+
const result2 = {
|
|
139
|
+
term,
|
|
140
|
+
found: false,
|
|
141
|
+
suggestions: ranked
|
|
142
|
+
};
|
|
143
|
+
this.cache?.set("mesh", term, result2);
|
|
144
|
+
return result2;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const lastWord = words[words.length - 1];
|
|
148
|
+
const containsLastResults = await this.fetchLookup(
|
|
149
|
+
lastWord,
|
|
150
|
+
"contains",
|
|
151
|
+
25
|
|
152
|
+
);
|
|
153
|
+
if (containsLastResults.length > 0) {
|
|
154
|
+
const ranked = containsLastResults.map((s) => ({
|
|
99
155
|
label: s.label,
|
|
100
|
-
distance: levenshteinDistance(
|
|
156
|
+
distance: levenshteinDistance(
|
|
157
|
+
term.toLowerCase(),
|
|
158
|
+
s.label.toLowerCase()
|
|
159
|
+
)
|
|
101
160
|
})).sort((a, b) => a.distance - b.distance).slice(0, 5).map((s) => s.label);
|
|
102
|
-
const result2 = {
|
|
161
|
+
const result2 = {
|
|
162
|
+
term,
|
|
163
|
+
found: false,
|
|
164
|
+
suggestions: ranked
|
|
165
|
+
};
|
|
103
166
|
this.cache?.set("mesh", term, result2);
|
|
104
167
|
return result2;
|
|
105
168
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mesh-lookup.js","sources":["../../src/query/mesh-lookup.ts"],"sourcesContent":["/**\n * MeSH Lookup API client.\n *\n * Validates MeSH (Medical Subject Headings) terms against the NLM MeSH Lookup API.\n * No API key required.\n *\n * API docs: https://id.nlm.nih.gov/mesh/lookup/term\n */\n\nimport type { RateLimiter } from '../providers/base/rate-limiter.js';\nimport type { VocabCache } from './vocab-cache.js';\nimport { levenshteinDistance } from '../utils/levenshtein.js';\n\nconst MESH_LOOKUP_BASE_URL = 'https://id.nlm.nih.gov/mesh/lookup/term';\nconst DEFAULT_TIMEOUT_MS = 10_000;\n\n/**\n * Result of a MeSH term lookup.\n */\nexport interface MeSHLookupResult {\n /** The term that was looked up */\n term: string;\n /** Whether the term was found as a valid MeSH heading */\n found: boolean;\n /** Suggested terms if the lookup term was not found */\n suggestions?: string[];\n}\n\ninterface MeSHApiEntry {\n resource: string;\n label: string;\n}\n\n/**\n * Client for the NLM MeSH Lookup API.\n */\nexport class MeSHLookupClient {\n private readonly rateLimiter: RateLimiter | undefined;\n private readonly timeoutMs: number;\n private readonly cache: VocabCache | undefined;\n\n constructor(options?: { rateLimiter?: RateLimiter; timeoutMs?: number; cache?: VocabCache }) {\n this.rateLimiter = options?.rateLimiter;\n this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n this.cache = options?.cache;\n }\n\n /**\n * Look up a single MeSH term.\n *\n * Tries multiple match strategies in order:\n * 1. exact — exact match\n * 2. startsWith (full term) — prefix match\n * 2b. startsWith (truncated) — suffix typo recovery (1-3 chars removed)\n * 2c. startsWith (word1 + word2 prefix) — multi-word progressive prefix (max 3 calls)\n * 3. contains (full term) — substring match\n * 4. startsWith (first word, limit=25) — re-ranked by Levenshtein distance\n *\n * Returns on the first strategy that produces results.\n * Results are cached when a VocabCache is provided.\n */\n async lookupTerm(term: string): Promise<MeSHLookupResult> {\n // Check cache first\n if (this.cache) {\n const cached = this.cache.get('mesh', term);\n if (cached) {\n return cached;\n }\n }\n\n // 1. Try exact match first\n const exactResults = await this.fetchLookup(term, 'exact', 1);\n\n if (exactResults.length > 0) {\n const result: MeSHLookupResult = { term, found: true };\n this.cache?.set('mesh', term, result);\n return result;\n }\n\n // 2. Try startsWith (full term) for suggestions\n const startsWithResults = await this.fetchLookup(term, 'startswith', 5);\n\n if (startsWithResults.length > 0) {\n const result: MeSHLookupResult = {\n term,\n found: false,\n suggestions: startsWithResults.map((s) => s.label),\n };\n this.cache?.set('mesh', term, result);\n return result;\n }\n\n // 2b. Try startsWith with progressively shorter input (handles suffix typos)\n if (term.length > 3) {\n for (let len = term.length - 1; len >= Math.max(term.length - 3, 3); len--) {\n const truncated = term.slice(0, len);\n const truncatedResults = await this.fetchLookup(truncated, 'startswith', 5);\n if (truncatedResults.length > 0) {\n const result: MeSHLookupResult = {\n term,\n found: false,\n suggestions: truncatedResults.map((s) => s.label),\n };\n this.cache?.set('mesh', term, result);\n return result;\n }\n }\n }\n\n // 2c. Multi-word progressive prefix: try word1 + word2.slice(0, N)\n const words = term.split(/\\s+/);\n if (words.length >= 2 && words[1]!.length > 3) {\n const startN = Math.min(words[1]!.length - 4, words[1]!.length - 1);\n const endN = 3;\n let iterations = 0;\n for (let n = startN; n >= endN && iterations < 3; n--, iterations++) {\n const prefix = words[0]! + ' ' + words[1]!.slice(0, n);\n const prefixResults = await this.fetchLookup(prefix, 'startswith', 5);\n if (prefixResults.length > 0) {\n const result: MeSHLookupResult = {\n term,\n found: false,\n suggestions: prefixResults.map((s) => s.label),\n };\n this.cache?.set('mesh', term, result);\n return result;\n }\n }\n }\n\n // 3. Try contains (full term) for typos and variant spellings\n const containsResults = await this.fetchLookup(term, 'contains', 5);\n\n if (containsResults.length > 0) {\n const result: MeSHLookupResult = {\n term,\n found: false,\n suggestions: containsResults.map((s) => s.label),\n };\n this.cache?.set('mesh', term, result);\n return result;\n }\n\n // 4. Try startsWith with first word only (for multi-word terms)\n // Fetch up to 25 results and re-rank by Levenshtein distance\n if (words.length > 1) {\n const firstWord = words[0]!;\n const firstWordResults = await this.fetchLookup(firstWord, 'startswith', 25);\n\n if (firstWordResults.length > 0) {\n const ranked = firstWordResults\n .map((s) => ({\n label: s.label,\n distance: levenshteinDistance(term.toLowerCase(), s.label.toLowerCase()),\n }))\n .sort((a, b) => a.distance - b.distance)\n .slice(0, 5)\n .map((s) => s.label);\n const result: MeSHLookupResult = { term, found: false, suggestions: ranked };\n this.cache?.set('mesh', term, result);\n return result;\n }\n }\n\n const result: MeSHLookupResult = { term, found: false };\n this.cache?.set('mesh', term, result);\n return result;\n }\n\n /**\n * Look up multiple MeSH terms.\n */\n async lookupTerms(terms: string[]): Promise<MeSHLookupResult[]> {\n const results: MeSHLookupResult[] = [];\n for (const term of terms) {\n results.push(await this.lookupTerm(term));\n }\n return results;\n }\n\n private async fetchLookup(\n label: string,\n match: 'exact' | 'startswith' | 'contains',\n limit: number\n ): Promise<MeSHApiEntry[]> {\n if (this.rateLimiter) {\n await this.rateLimiter.acquire();\n }\n\n const params = new URLSearchParams({\n label,\n match,\n limit: String(limit),\n });\n\n const url = `${MESH_LOOKUP_BASE_URL}?${params.toString()}`;\n\n let response: Response;\n try {\n response = await fetch(url, {\n signal: AbortSignal.timeout(this.timeoutMs),\n });\n } catch (error) {\n const message =\n error instanceof Error ? error.message : 'Unknown error';\n throw new Error(`MeSH lookup failed: ${message}`);\n }\n\n if (!response.ok) {\n throw new Error(\n `MeSH lookup failed: HTTP ${response.status} ${response.statusText}`\n );\n }\n\n return (await response.json()) as MeSHApiEntry[];\n }\n}\n"],"names":["result"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"mesh-lookup.js","sources":["../../src/query/mesh-lookup.ts"],"sourcesContent":["/**\n * MeSH Lookup API client.\n *\n * Validates MeSH (Medical Subject Headings) terms against the NLM MeSH Lookup API.\n * No API key required.\n *\n * API docs: https://id.nlm.nih.gov/mesh/lookup/term\n */\n\nimport type { RateLimiter } from '../providers/base/rate-limiter.js';\nimport type { VocabCache } from './vocab-cache.js';\nimport { levenshteinDistance } from '../utils/levenshtein.js';\n\nconst MESH_LOOKUP_BASE_URL = 'https://id.nlm.nih.gov/mesh/lookup/term';\nconst DEFAULT_TIMEOUT_MS = 10_000;\n\n/**\n * Result of a MeSH term lookup.\n */\nexport interface MeSHLookupResult {\n /** The term that was looked up */\n term: string;\n /** Whether the term was found as a valid MeSH heading */\n found: boolean;\n /** Suggested terms if the lookup term was not found */\n suggestions?: string[];\n}\n\ninterface MeSHApiEntry {\n resource: string;\n label: string;\n}\n\n/**\n * Client for the NLM MeSH Lookup API.\n */\nexport class MeSHLookupClient {\n private readonly rateLimiter: RateLimiter | undefined;\n private readonly timeoutMs: number;\n private readonly cache: VocabCache | undefined;\n\n constructor(options?: { rateLimiter?: RateLimiter; timeoutMs?: number; cache?: VocabCache }) {\n this.rateLimiter = options?.rateLimiter;\n this.timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;\n this.cache = options?.cache;\n }\n\n /**\n * Look up a single MeSH term.\n *\n * Tries multiple match strategies in order:\n * 1. exact — exact match\n * 2. startsWith (full term) — prefix match\n * 2b. startsWith (truncated) — suffix typo recovery (1-3 chars removed)\n * 2c. startsWith (word1 + word2 prefix) — multi-word progressive prefix (max 3 calls)\n * 3. contains (full term) — substring match\n * 4. startsWith (first word, limit=25) — re-ranked by Levenshtein distance\n *\n * Returns on the first strategy that produces results.\n * Results are cached when a VocabCache is provided.\n */\n async lookupTerm(term: string): Promise<MeSHLookupResult> {\n // Check cache first\n if (this.cache) {\n const cached = this.cache.get('mesh', term);\n if (cached) {\n return cached;\n }\n }\n\n // 1. Try exact match first\n const exactResults = await this.fetchLookup(term, 'exact', 1);\n\n if (exactResults.length > 0) {\n const result: MeSHLookupResult = { term, found: true };\n this.cache?.set('mesh', term, result);\n return result;\n }\n\n // 2. Try startsWith (full term) for suggestions\n const startsWithResults = await this.fetchLookup(term, 'startswith', 5);\n\n if (startsWithResults.length > 0) {\n const result: MeSHLookupResult = {\n term,\n found: false,\n suggestions: startsWithResults.map((s) => s.label),\n };\n this.cache?.set('mesh', term, result);\n return result;\n }\n\n // 2b. Try startsWith with progressively shorter input (handles suffix typos)\n if (term.length > 3) {\n for (let len = term.length - 1; len >= Math.max(term.length - 3, 3); len--) {\n const truncated = term.slice(0, len);\n const truncatedResults = await this.fetchLookup(truncated, 'startswith', 5);\n if (truncatedResults.length > 0) {\n const result: MeSHLookupResult = {\n term,\n found: false,\n suggestions: truncatedResults.map((s) => s.label),\n };\n this.cache?.set('mesh', term, result);\n return result;\n }\n }\n }\n\n // 2c. Multi-word progressive prefix: try word1 + word2.slice(0, N)\n const words = term.split(/\\s+/);\n if (words.length >= 2 && words[1]!.length > 3) {\n const startN = Math.min(words[1]!.length - 4, words[1]!.length - 1);\n const endN = 3;\n let iterations = 0;\n for (let n = startN; n >= endN && iterations < 3; n--, iterations++) {\n const prefix = words[0]! + ' ' + words[1]!.slice(0, n);\n const prefixResults = await this.fetchLookup(prefix, 'startswith', 5);\n if (prefixResults.length > 0) {\n const result: MeSHLookupResult = {\n term,\n found: false,\n suggestions: prefixResults.map((s) => s.label),\n };\n this.cache?.set('mesh', term, result);\n return result;\n }\n }\n }\n\n // 3. Try contains (full term) for typos and variant spellings\n const containsResults = await this.fetchLookup(term, 'contains', 5);\n\n if (containsResults.length > 0) {\n const result: MeSHLookupResult = {\n term,\n found: false,\n suggestions: containsResults.map((s) => s.label),\n };\n this.cache?.set('mesh', term, result);\n return result;\n }\n\n // 4. Try startsWith with first word only (for multi-word terms)\n // Fetch up to 25 results, filter by rest words, and re-rank by Levenshtein distance\n if (words.length > 1) {\n const firstWord = words[0]!;\n const restWords = words.slice(1).join(' ').toLowerCase();\n const restWordsPrefix = restWords.slice(0, 4);\n const firstWordResults = await this.fetchLookup(firstWord, 'startswith', 25);\n\n if (firstWordResults.length > 0) {\n // Filter by rest words when prefix is long enough to be meaningful\n const candidates =\n restWordsPrefix.length >= 4\n ? firstWordResults.filter((r) =>\n r.label.toLowerCase().includes(restWordsPrefix)\n )\n : firstWordResults;\n\n if (candidates.length > 0) {\n const ranked = candidates\n .map((s) => ({\n label: s.label,\n distance: levenshteinDistance(\n term.toLowerCase(),\n s.label.toLowerCase()\n ),\n }))\n .sort((a, b) => a.distance - b.distance)\n .slice(0, 5)\n .map((s) => s.label);\n const result: MeSHLookupResult = {\n term,\n found: false,\n suggestions: ranked,\n };\n this.cache?.set('mesh', term, result);\n return result;\n }\n // No relevant results after filtering — fall through to step 4b\n }\n\n // 4b. Truncated first word startsWith + rest word filter\n // Handle first-word typos by progressively shortening the first word\n for (\n let len = firstWord.length - 1, iterations = 0;\n len >= 3 && iterations < 3;\n len--, iterations++\n ) {\n const truncated = firstWord.slice(0, len);\n const truncResults = await this.fetchLookup(\n truncated,\n 'startswith',\n 25\n );\n const filtered = truncResults.filter((r) =>\n r.label.toLowerCase().includes(restWordsPrefix)\n );\n if (filtered.length > 0) {\n const ranked = filtered\n .map((s) => ({\n label: s.label,\n distance: levenshteinDistance(\n term.toLowerCase(),\n s.label.toLowerCase()\n ),\n }))\n .sort((a, b) => a.distance - b.distance)\n .slice(0, 5)\n .map((s) => s.label);\n const result: MeSHLookupResult = {\n term,\n found: false,\n suggestions: ranked,\n };\n this.cache?.set('mesh', term, result);\n return result;\n }\n }\n\n // 4c. Contains last word + Levenshtein re-ranking\n // Final fallback for severely misspelled first words\n const lastWord = words[words.length - 1]!;\n const containsLastResults = await this.fetchLookup(\n lastWord,\n 'contains',\n 25\n );\n if (containsLastResults.length > 0) {\n const ranked = containsLastResults\n .map((s) => ({\n label: s.label,\n distance: levenshteinDistance(\n term.toLowerCase(),\n s.label.toLowerCase()\n ),\n }))\n .sort((a, b) => a.distance - b.distance)\n .slice(0, 5)\n .map((s) => s.label);\n const result: MeSHLookupResult = {\n term,\n found: false,\n suggestions: ranked,\n };\n this.cache?.set('mesh', term, result);\n return result;\n }\n }\n\n const result: MeSHLookupResult = { term, found: false };\n this.cache?.set('mesh', term, result);\n return result;\n }\n\n /**\n * Look up multiple MeSH terms.\n */\n async lookupTerms(terms: string[]): Promise<MeSHLookupResult[]> {\n const results: MeSHLookupResult[] = [];\n for (const term of terms) {\n results.push(await this.lookupTerm(term));\n }\n return results;\n }\n\n private async fetchLookup(\n label: string,\n match: 'exact' | 'startswith' | 'contains',\n limit: number\n ): Promise<MeSHApiEntry[]> {\n if (this.rateLimiter) {\n await this.rateLimiter.acquire();\n }\n\n const params = new URLSearchParams({\n label,\n match,\n limit: String(limit),\n });\n\n const url = `${MESH_LOOKUP_BASE_URL}?${params.toString()}`;\n\n let response: Response;\n try {\n response = await fetch(url, {\n signal: AbortSignal.timeout(this.timeoutMs),\n });\n } catch (error) {\n const message =\n error instanceof Error ? error.message : 'Unknown error';\n throw new Error(`MeSH lookup failed: ${message}`);\n }\n\n if (!response.ok) {\n throw new Error(\n `MeSH lookup failed: HTTP ${response.status} ${response.statusText}`\n );\n }\n\n return (await response.json()) as MeSHApiEntry[];\n }\n}\n"],"names":["result"],"mappings":"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"}
|