ezmedicationinput 0.1.13 → 0.1.15
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.
- package/README.md +25 -6
- package/dist/maps.js +6 -1
- package/dist/parser.js +217 -5
- package/dist/types.d.ts +30 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -138,9 +138,25 @@ const result = await parseSigAsync("apply to {left temple} nightly", {
|
|
|
138
138
|
system: "http://example.org/custom",
|
|
139
139
|
code: "LTEMP",
|
|
140
140
|
display: "Left temple"
|
|
141
|
-
}
|
|
141
|
+
},
|
|
142
|
+
aliases: ["temporal region, left"],
|
|
143
|
+
text: "Left temple"
|
|
142
144
|
}
|
|
143
145
|
},
|
|
146
|
+
// any overrides that the user explicitly selected
|
|
147
|
+
siteCodeSelections: [
|
|
148
|
+
{
|
|
149
|
+
canonical: "scalp",
|
|
150
|
+
resolution: {
|
|
151
|
+
coding: {
|
|
152
|
+
system: "http://snomed.info/sct",
|
|
153
|
+
code: "39937001",
|
|
154
|
+
display: "Scalp structure"
|
|
155
|
+
},
|
|
156
|
+
text: "Scalp"
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
],
|
|
144
160
|
siteCodeResolvers: async (request) => {
|
|
145
161
|
if (request.canonical === "mole on scalp") {
|
|
146
162
|
return {
|
|
@@ -171,11 +187,13 @@ result.meta.siteLookups;
|
|
|
171
187
|
// → [{ request: { text: "left temple", isProbe: true, ... }, suggestions: [...] }]
|
|
172
188
|
```
|
|
173
189
|
|
|
174
|
-
- `siteCodeMap` lets you supply deterministic overrides for normalized site phrases.
|
|
175
|
-
- `
|
|
176
|
-
- `
|
|
177
|
-
-
|
|
178
|
-
- `
|
|
190
|
+
- `siteCodeMap` lets you supply deterministic overrides for normalized site phrases.
|
|
191
|
+
- Entries accept an `aliases` array so punctuation-heavy variants (e.g., "first bicuspid, left") can resolve to the same coding.
|
|
192
|
+
- `siteCodeResolvers` (sync or async) can call external services to resolve sites on demand.
|
|
193
|
+
- `siteCodeSuggestionResolvers` return candidate codes; their results populate `meta.siteLookups[0].suggestions`.
|
|
194
|
+
- `siteCodeSelections` let callers override the automatic match for a detected phrase or range—helpful when a clinician chooses a bundled SNOMED option over a custom override.
|
|
195
|
+
- Each resolver receives the full `SiteCodeLookupRequest`, including the original input, the cleaned site text, and a `{ start, end }` range you can use to highlight the substring in UI workflows.
|
|
196
|
+
- `parseSigAsync` behaves like `parseSig` but awaits asynchronous resolvers and suggestion providers.
|
|
179
197
|
|
|
180
198
|
#### Site resolver signatures
|
|
181
199
|
|
|
@@ -225,6 +243,7 @@ You can specify the number of times (total count) the medication is supposed to
|
|
|
225
243
|
- `allowHouseholdVolumeUnits`: defaults to `true`; set to `false` to ignore
|
|
226
244
|
teaspoon/tablespoon units during parsing and suggestions.
|
|
227
245
|
- Custom `routeMap`, `unitMap`, `freqMap`, and `whenMap` let you augment the built-in dictionaries without mutating them.
|
|
246
|
+
- `siteCodeSelections` override automatic site resolution for matching phrases or ranges so user-picked suggestions stick when re-parsing a sig.
|
|
228
247
|
|
|
229
248
|
### Next due dose generation
|
|
230
249
|
|
package/dist/maps.js
CHANGED
|
@@ -170,7 +170,12 @@ exports.DEFAULT_ROUTE_SYNONYMS = (() => {
|
|
|
170
170
|
* same logic to ensure consistent lookups.
|
|
171
171
|
*/
|
|
172
172
|
function normalizeBodySiteKey(value) {
|
|
173
|
-
return value
|
|
173
|
+
return value
|
|
174
|
+
.trim()
|
|
175
|
+
.toLowerCase()
|
|
176
|
+
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
|
177
|
+
.replace(/\s+/g, " ")
|
|
178
|
+
.trim();
|
|
174
179
|
}
|
|
175
180
|
const DEFAULT_BODY_SITE_SNOMED_SOURCE = [
|
|
176
181
|
{
|
package/dist/parser.js
CHANGED
|
@@ -25,16 +25,27 @@ function buildCustomSiteHints(map) {
|
|
|
25
25
|
return undefined;
|
|
26
26
|
}
|
|
27
27
|
const hints = new Set();
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
const addPhraseHints = (phrase) => {
|
|
29
|
+
if (!phrase) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const normalized = (0, maps_1.normalizeBodySiteKey)(phrase);
|
|
30
33
|
if (!normalized) {
|
|
31
|
-
|
|
34
|
+
return;
|
|
32
35
|
}
|
|
33
36
|
for (const part of normalized.split(" ")) {
|
|
34
37
|
if (part) {
|
|
35
38
|
hints.add(part);
|
|
36
39
|
}
|
|
37
40
|
}
|
|
41
|
+
};
|
|
42
|
+
for (const [key, definition] of (0, object_1.objectEntries)(map)) {
|
|
43
|
+
addPhraseHints(key);
|
|
44
|
+
if (definition.aliases) {
|
|
45
|
+
for (const alias of definition.aliases) {
|
|
46
|
+
addPhraseHints(alias);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
38
49
|
}
|
|
39
50
|
return hints;
|
|
40
51
|
}
|
|
@@ -175,6 +186,33 @@ const COUNT_CONNECTOR_WORDS = new Set([
|
|
|
175
186
|
"additional",
|
|
176
187
|
"extra"
|
|
177
188
|
]);
|
|
189
|
+
const FREQUENCY_SIMPLE_WORDS = {
|
|
190
|
+
once: 1,
|
|
191
|
+
twice: 2,
|
|
192
|
+
thrice: 3
|
|
193
|
+
};
|
|
194
|
+
const FREQUENCY_NUMBER_WORDS = {
|
|
195
|
+
one: 1,
|
|
196
|
+
two: 2,
|
|
197
|
+
three: 3,
|
|
198
|
+
four: 4,
|
|
199
|
+
five: 5,
|
|
200
|
+
six: 6,
|
|
201
|
+
seven: 7,
|
|
202
|
+
eight: 8,
|
|
203
|
+
nine: 9,
|
|
204
|
+
ten: 10,
|
|
205
|
+
eleven: 11,
|
|
206
|
+
twelve: 12
|
|
207
|
+
};
|
|
208
|
+
const FREQUENCY_TIMES_WORDS = new Set(["time", "times", "x"]);
|
|
209
|
+
const FREQUENCY_CONNECTOR_WORDS = new Set(["per", "a", "an", "each", "every"]);
|
|
210
|
+
const FREQUENCY_ADVERB_UNITS = {
|
|
211
|
+
daily: types_1.FhirPeriodUnit.Day,
|
|
212
|
+
weekly: types_1.FhirPeriodUnit.Week,
|
|
213
|
+
monthly: types_1.FhirPeriodUnit.Month,
|
|
214
|
+
hourly: types_1.FhirPeriodUnit.Hour
|
|
215
|
+
};
|
|
178
216
|
const ROUTE_DESCRIPTOR_FILLER_WORDS = new Set([
|
|
179
217
|
"per",
|
|
180
218
|
"by",
|
|
@@ -569,6 +607,115 @@ function tryParseNumericCadence(internal, tokens, index) {
|
|
|
569
607
|
mark(internal.consumed, unitToken);
|
|
570
608
|
return true;
|
|
571
609
|
}
|
|
610
|
+
function tryParseCountBasedFrequency(internal, tokens, index, options) {
|
|
611
|
+
const token = tokens[index];
|
|
612
|
+
if (internal.consumed.has(token.index)) {
|
|
613
|
+
return false;
|
|
614
|
+
}
|
|
615
|
+
if (internal.frequency !== undefined ||
|
|
616
|
+
internal.frequencyMax !== undefined ||
|
|
617
|
+
internal.period !== undefined ||
|
|
618
|
+
internal.periodMax !== undefined) {
|
|
619
|
+
return false;
|
|
620
|
+
}
|
|
621
|
+
const normalized = normalizeTokenLower(token);
|
|
622
|
+
let value;
|
|
623
|
+
let requiresPeriod = true;
|
|
624
|
+
let requiresCue = true;
|
|
625
|
+
if (/^[0-9]+(?:\.[0-9]+)?$/.test(normalized)) {
|
|
626
|
+
value = parseFloat(token.original);
|
|
627
|
+
}
|
|
628
|
+
else {
|
|
629
|
+
const simple = FREQUENCY_SIMPLE_WORDS[normalized];
|
|
630
|
+
if (simple !== undefined) {
|
|
631
|
+
value = simple;
|
|
632
|
+
requiresPeriod = false;
|
|
633
|
+
requiresCue = false;
|
|
634
|
+
}
|
|
635
|
+
else {
|
|
636
|
+
const wordValue = FREQUENCY_NUMBER_WORDS[normalized];
|
|
637
|
+
if (wordValue === undefined) {
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
value = wordValue;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
if (!Number.isFinite(value) || value === undefined || value <= 0) {
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
const nextToken = tokens[index + 1];
|
|
647
|
+
if (nextToken &&
|
|
648
|
+
!internal.consumed.has(nextToken.index) &&
|
|
649
|
+
normalizeUnit(normalizeTokenLower(nextToken), options)) {
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
const partsToConsume = [];
|
|
653
|
+
let nextIndex = index + 1;
|
|
654
|
+
let periodUnit;
|
|
655
|
+
let sawCue = !requiresCue;
|
|
656
|
+
let sawTimesWord = false;
|
|
657
|
+
let sawConnectorWord = false;
|
|
658
|
+
while (true) {
|
|
659
|
+
const candidate = tokens[nextIndex];
|
|
660
|
+
if (!candidate || internal.consumed.has(candidate.index)) {
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
const lower = normalizeTokenLower(candidate);
|
|
664
|
+
if (FREQUENCY_TIMES_WORDS.has(lower)) {
|
|
665
|
+
partsToConsume.push(candidate);
|
|
666
|
+
sawCue = true;
|
|
667
|
+
sawTimesWord = true;
|
|
668
|
+
nextIndex += 1;
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (FREQUENCY_CONNECTOR_WORDS.has(lower)) {
|
|
672
|
+
partsToConsume.push(candidate);
|
|
673
|
+
sawCue = true;
|
|
674
|
+
sawConnectorWord = true;
|
|
675
|
+
nextIndex += 1;
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
const adverbUnit = mapFrequencyAdverb(lower);
|
|
679
|
+
if (adverbUnit) {
|
|
680
|
+
periodUnit = adverbUnit;
|
|
681
|
+
partsToConsume.push(candidate);
|
|
682
|
+
break;
|
|
683
|
+
}
|
|
684
|
+
const mappedUnit = mapIntervalUnit(lower);
|
|
685
|
+
if (mappedUnit) {
|
|
686
|
+
periodUnit = mappedUnit;
|
|
687
|
+
partsToConsume.push(candidate);
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
break;
|
|
691
|
+
}
|
|
692
|
+
if (!periodUnit) {
|
|
693
|
+
if (requiresPeriod) {
|
|
694
|
+
return false;
|
|
695
|
+
}
|
|
696
|
+
periodUnit = types_1.FhirPeriodUnit.Day;
|
|
697
|
+
}
|
|
698
|
+
if (requiresCue && !sawCue) {
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
internal.frequency = value;
|
|
702
|
+
internal.period = 1;
|
|
703
|
+
internal.periodUnit = periodUnit;
|
|
704
|
+
if (value === 1 && periodUnit === types_1.FhirPeriodUnit.Day && !internal.timingCode) {
|
|
705
|
+
internal.timingCode = "QD";
|
|
706
|
+
}
|
|
707
|
+
let consumeCurrentToken = true;
|
|
708
|
+
if (value === 1 && !sawConnectorWord && sawTimesWord && periodUnit !== types_1.FhirPeriodUnit.Day) {
|
|
709
|
+
consumeCurrentToken = false;
|
|
710
|
+
}
|
|
711
|
+
if (consumeCurrentToken) {
|
|
712
|
+
mark(internal.consumed, token);
|
|
713
|
+
}
|
|
714
|
+
for (const part of partsToConsume) {
|
|
715
|
+
mark(internal.consumed, part);
|
|
716
|
+
}
|
|
717
|
+
return consumeCurrentToken;
|
|
718
|
+
}
|
|
572
719
|
const SITE_UNIT_ROUTE_HINTS = [
|
|
573
720
|
{ pattern: /\beye(s)?\b/i, route: types_1.RouteCode["Ophthalmic route"] },
|
|
574
721
|
{ pattern: /\beyelid(s)?\b/i, route: types_1.RouteCode["Ophthalmic route"] },
|
|
@@ -1233,6 +1380,9 @@ function mapIntervalUnit(token) {
|
|
|
1233
1380
|
}
|
|
1234
1381
|
return undefined;
|
|
1235
1382
|
}
|
|
1383
|
+
function mapFrequencyAdverb(token) {
|
|
1384
|
+
return FREQUENCY_ADVERB_UNITS[token];
|
|
1385
|
+
}
|
|
1236
1386
|
function parseNumericRange(token) {
|
|
1237
1387
|
const rangeMatch = token.match(/^([0-9]+(?:\.[0-9]+)?)-([0-9]+(?:\.[0-9]+)?)$/);
|
|
1238
1388
|
if (!rangeMatch) {
|
|
@@ -1609,6 +1759,9 @@ function parseInternal(input, options) {
|
|
|
1609
1759
|
}
|
|
1610
1760
|
}
|
|
1611
1761
|
// Numeric dose
|
|
1762
|
+
if (tryParseCountBasedFrequency(internal, tokens, i, options)) {
|
|
1763
|
+
continue;
|
|
1764
|
+
}
|
|
1612
1765
|
const rangeValue = parseNumericRange(token.lower);
|
|
1613
1766
|
if (rangeValue) {
|
|
1614
1767
|
if (!internal.doseRange) {
|
|
@@ -1896,8 +2049,9 @@ function runSiteCodingResolutionSync(internal, options) {
|
|
|
1896
2049
|
return;
|
|
1897
2050
|
}
|
|
1898
2051
|
const canonical = request.canonical;
|
|
2052
|
+
const selection = pickSiteSelection(options === null || options === void 0 ? void 0 : options.siteCodeSelections, request);
|
|
1899
2053
|
const customDefinition = lookupBodySiteDefinition(options === null || options === void 0 ? void 0 : options.siteCodeMap, canonical);
|
|
1900
|
-
let resolution = customDefinition;
|
|
2054
|
+
let resolution = selection !== null && selection !== void 0 ? selection : customDefinition;
|
|
1901
2055
|
if (!resolution) {
|
|
1902
2056
|
// Allow synchronous resolver callbacks to claim the site.
|
|
1903
2057
|
for (const resolver of toArray(options === null || options === void 0 ? void 0 : options.siteCodeResolvers)) {
|
|
@@ -1927,6 +2081,9 @@ function runSiteCodingResolutionSync(internal, options) {
|
|
|
1927
2081
|
return;
|
|
1928
2082
|
}
|
|
1929
2083
|
const suggestionMap = new Map();
|
|
2084
|
+
if (selection) {
|
|
2085
|
+
addSuggestionToMap(suggestionMap, definitionToSuggestion(selection));
|
|
2086
|
+
}
|
|
1930
2087
|
if (customDefinition) {
|
|
1931
2088
|
addSuggestionToMap(suggestionMap, definitionToSuggestion(customDefinition));
|
|
1932
2089
|
}
|
|
@@ -1959,8 +2116,9 @@ function runSiteCodingResolutionAsync(internal, options) {
|
|
|
1959
2116
|
return;
|
|
1960
2117
|
}
|
|
1961
2118
|
const canonical = request.canonical;
|
|
2119
|
+
const selection = pickSiteSelection(options === null || options === void 0 ? void 0 : options.siteCodeSelections, request);
|
|
1962
2120
|
const customDefinition = lookupBodySiteDefinition(options === null || options === void 0 ? void 0 : options.siteCodeMap, canonical);
|
|
1963
|
-
let resolution = customDefinition;
|
|
2121
|
+
let resolution = selection !== null && selection !== void 0 ? selection : customDefinition;
|
|
1964
2122
|
if (!resolution) {
|
|
1965
2123
|
// Await asynchronous resolver callbacks (e.g., HTTP terminology services).
|
|
1966
2124
|
for (const resolver of toArray(options === null || options === void 0 ? void 0 : options.siteCodeResolvers)) {
|
|
@@ -1986,6 +2144,9 @@ function runSiteCodingResolutionAsync(internal, options) {
|
|
|
1986
2144
|
return;
|
|
1987
2145
|
}
|
|
1988
2146
|
const suggestionMap = new Map();
|
|
2147
|
+
if (selection) {
|
|
2148
|
+
addSuggestionToMap(suggestionMap, definitionToSuggestion(selection));
|
|
2149
|
+
}
|
|
1989
2150
|
if (customDefinition) {
|
|
1990
2151
|
addSuggestionToMap(suggestionMap, definitionToSuggestion(customDefinition));
|
|
1991
2152
|
}
|
|
@@ -2020,6 +2181,57 @@ function lookupBodySiteDefinition(map, canonical) {
|
|
|
2020
2181
|
if ((0, maps_1.normalizeBodySiteKey)(key) === canonical) {
|
|
2021
2182
|
return definition;
|
|
2022
2183
|
}
|
|
2184
|
+
if (definition.aliases) {
|
|
2185
|
+
for (const alias of definition.aliases) {
|
|
2186
|
+
if ((0, maps_1.normalizeBodySiteKey)(alias) === canonical) {
|
|
2187
|
+
return definition;
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
return undefined;
|
|
2193
|
+
}
|
|
2194
|
+
function pickSiteSelection(selections, request) {
|
|
2195
|
+
if (!selections) {
|
|
2196
|
+
return undefined;
|
|
2197
|
+
}
|
|
2198
|
+
const canonical = request.canonical;
|
|
2199
|
+
const normalizedText = (0, maps_1.normalizeBodySiteKey)(request.text);
|
|
2200
|
+
const requestRange = request.range;
|
|
2201
|
+
for (const selection of toArray(selections)) {
|
|
2202
|
+
if (!selection) {
|
|
2203
|
+
continue;
|
|
2204
|
+
}
|
|
2205
|
+
let matched = false;
|
|
2206
|
+
if (selection.range) {
|
|
2207
|
+
if (!requestRange) {
|
|
2208
|
+
continue;
|
|
2209
|
+
}
|
|
2210
|
+
if (selection.range.start !== requestRange.start ||
|
|
2211
|
+
selection.range.end !== requestRange.end) {
|
|
2212
|
+
continue;
|
|
2213
|
+
}
|
|
2214
|
+
matched = true;
|
|
2215
|
+
}
|
|
2216
|
+
if (selection.canonical) {
|
|
2217
|
+
if ((0, maps_1.normalizeBodySiteKey)(selection.canonical) !== canonical) {
|
|
2218
|
+
continue;
|
|
2219
|
+
}
|
|
2220
|
+
matched = true;
|
|
2221
|
+
}
|
|
2222
|
+
else if (selection.text) {
|
|
2223
|
+
const normalizedSelection = (0, maps_1.normalizeBodySiteKey)(selection.text);
|
|
2224
|
+
if (normalizedSelection !== canonical && normalizedSelection !== normalizedText) {
|
|
2225
|
+
continue;
|
|
2226
|
+
}
|
|
2227
|
+
matched = true;
|
|
2228
|
+
}
|
|
2229
|
+
if (!selection.range && !selection.canonical && !selection.text) {
|
|
2230
|
+
continue;
|
|
2231
|
+
}
|
|
2232
|
+
if (matched) {
|
|
2233
|
+
return selection.resolution;
|
|
2234
|
+
}
|
|
2023
2235
|
}
|
|
2024
2236
|
return undefined;
|
|
2025
2237
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -297,6 +297,12 @@ export interface BodySiteCode {
|
|
|
297
297
|
export interface BodySiteDefinition {
|
|
298
298
|
coding: BodySiteCode;
|
|
299
299
|
text?: string;
|
|
300
|
+
/**
|
|
301
|
+
* Optional phrases that should resolve to the same coding as this entry.
|
|
302
|
+
* Aliases are normalized with the same logic as map keys so callers can
|
|
303
|
+
* provide punctuation-heavy variants such as "first bicuspid, left".
|
|
304
|
+
*/
|
|
305
|
+
aliases?: string[];
|
|
300
306
|
}
|
|
301
307
|
export interface TextRange {
|
|
302
308
|
/** Inclusive start index of the matched substring within the original input. */
|
|
@@ -337,6 +343,24 @@ export interface SiteCodeSuggestion {
|
|
|
337
343
|
export interface SiteCodeSuggestionsResult {
|
|
338
344
|
suggestions: SiteCodeSuggestion[];
|
|
339
345
|
}
|
|
346
|
+
/**
|
|
347
|
+
* Allows callers to override the parser's automatic site resolution for a
|
|
348
|
+
* specific match. Matches can be scoped by the normalized phrase, the original
|
|
349
|
+
* sanitized text, or the exact character range that was detected.
|
|
350
|
+
*/
|
|
351
|
+
export interface SiteCodeSelection {
|
|
352
|
+
/** Canonical key (punctuation stripped, lower-case) that should trigger this selection. */
|
|
353
|
+
canonical?: string;
|
|
354
|
+
/**
|
|
355
|
+
* Human-friendly site text used to match the extracted phrase. It is
|
|
356
|
+
* normalized with the same logic as canonical keys.
|
|
357
|
+
*/
|
|
358
|
+
text?: string;
|
|
359
|
+
/** Exact range of the detected phrase within the input string. */
|
|
360
|
+
range?: TextRange;
|
|
361
|
+
/** Desired coded definition to apply when the selection matches. */
|
|
362
|
+
resolution: SiteCodeResolution;
|
|
363
|
+
}
|
|
340
364
|
/**
|
|
341
365
|
* Site code resolvers can perform deterministic lookups or remote queries with
|
|
342
366
|
* access to the original sig text and extracted site range.
|
|
@@ -390,6 +414,12 @@ export interface ParseOptions extends FormatOptions {
|
|
|
390
414
|
* the default site dictionary (trimmed, lower-cased, collapsing whitespace).
|
|
391
415
|
*/
|
|
392
416
|
siteCodeMap?: Record<string, BodySiteDefinition>;
|
|
417
|
+
/**
|
|
418
|
+
* Explicit selections that override automatic site resolution for matching
|
|
419
|
+
* phrases. Useful when custom dictionaries provide multiple options but a UI
|
|
420
|
+
* workflow needs to pin a particular coding for a given match or range.
|
|
421
|
+
*/
|
|
422
|
+
siteCodeSelections?: SiteCodeSelection | SiteCodeSelection[];
|
|
393
423
|
/**
|
|
394
424
|
* Callback(s) that can translate detected site text into a coded body site.
|
|
395
425
|
* Return a promise when using asynchronous terminology services.
|