ezmedicationinput 0.1.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.
- package/README.md +153 -0
- package/dist/context.d.ts +3 -0
- package/dist/context.js +29 -0
- package/dist/fhir.d.ts +4 -0
- package/dist/fhir.js +158 -0
- package/dist/format.d.ts +2 -0
- package/dist/format.js +322 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +57 -0
- package/dist/maps.d.ts +51 -0
- package/dist/maps.js +729 -0
- package/dist/parser.d.ts +33 -0
- package/dist/parser.js +870 -0
- package/dist/safety.d.ts +8 -0
- package/dist/safety.js +12 -0
- package/dist/schedule.d.ts +6 -0
- package/dist/schedule.js +653 -0
- package/dist/types.d.ts +363 -0
- package/dist/types.js +224 -0
- package/package.json +32 -0
package/dist/parser.js
ADDED
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
import { DAY_OF_WEEK_TOKENS, DEFAULT_ROUTE_SYNONYMS, DEFAULT_UNIT_SYNONYMS, EVENT_TIMING_TOKENS, MEAL_KEYWORDS, ROUTE_TEXT, TIMING_ABBREVIATIONS, WORD_FREQUENCIES } from "./maps";
|
|
2
|
+
import { inferUnitFromContext } from "./context";
|
|
3
|
+
import { checkDiscouraged } from "./safety";
|
|
4
|
+
import { EventTiming, FhirPeriodUnit, RouteCode } from "./types";
|
|
5
|
+
const BODY_SITE_HINTS = new Set([
|
|
6
|
+
"left",
|
|
7
|
+
"right",
|
|
8
|
+
"bilateral",
|
|
9
|
+
"arm",
|
|
10
|
+
"arms",
|
|
11
|
+
"leg",
|
|
12
|
+
"legs",
|
|
13
|
+
"thigh",
|
|
14
|
+
"thighs",
|
|
15
|
+
"shoulder",
|
|
16
|
+
"shoulders",
|
|
17
|
+
"hand",
|
|
18
|
+
"hands",
|
|
19
|
+
"foot",
|
|
20
|
+
"feet",
|
|
21
|
+
"eye",
|
|
22
|
+
"eyes",
|
|
23
|
+
"ear",
|
|
24
|
+
"ears",
|
|
25
|
+
"nostril",
|
|
26
|
+
"nostrils",
|
|
27
|
+
"abdomen",
|
|
28
|
+
"belly",
|
|
29
|
+
"cheek",
|
|
30
|
+
"cheeks",
|
|
31
|
+
"upper",
|
|
32
|
+
"lower",
|
|
33
|
+
"forearm",
|
|
34
|
+
"back"
|
|
35
|
+
]);
|
|
36
|
+
const COMBO_EVENT_TIMINGS = {
|
|
37
|
+
"early morning": EventTiming["Early Morning"],
|
|
38
|
+
"late morning": EventTiming["Late Morning"],
|
|
39
|
+
"early afternoon": EventTiming["Early Afternoon"],
|
|
40
|
+
"late afternoon": EventTiming["Late Afternoon"],
|
|
41
|
+
"early evening": EventTiming["Early Evening"],
|
|
42
|
+
"late evening": EventTiming["Late Evening"],
|
|
43
|
+
"after sleep": EventTiming["After Sleep"],
|
|
44
|
+
"upon waking": EventTiming.Wake
|
|
45
|
+
};
|
|
46
|
+
// Tracking explicit breakfast/lunch/dinner markers lets the meal-expansion
|
|
47
|
+
// logic bail early when the clinician already specified precise events.
|
|
48
|
+
const SPECIFIC_MEAL_TIMINGS = new Set([
|
|
49
|
+
EventTiming["Before Breakfast"],
|
|
50
|
+
EventTiming["Before Lunch"],
|
|
51
|
+
EventTiming["Before Dinner"],
|
|
52
|
+
EventTiming["After Breakfast"],
|
|
53
|
+
EventTiming["After Lunch"],
|
|
54
|
+
EventTiming["After Dinner"],
|
|
55
|
+
EventTiming.Breakfast,
|
|
56
|
+
EventTiming.Lunch,
|
|
57
|
+
EventTiming.Dinner
|
|
58
|
+
]);
|
|
59
|
+
// Ocular shorthand tokens commonly used in ophthalmic sigs.
|
|
60
|
+
const EYE_SITE_TOKENS = {
|
|
61
|
+
od: { site: "right eye", route: RouteCode["Ophthalmic route"] },
|
|
62
|
+
re: { site: "right eye", route: RouteCode["Ophthalmic route"] },
|
|
63
|
+
os: { site: "left eye", route: RouteCode["Ophthalmic route"] },
|
|
64
|
+
le: { site: "left eye", route: RouteCode["Ophthalmic route"] },
|
|
65
|
+
ou: { site: "both eyes", route: RouteCode["Ophthalmic route"] },
|
|
66
|
+
be: { site: "both eyes", route: RouteCode["Ophthalmic route"] },
|
|
67
|
+
vod: {
|
|
68
|
+
site: "right eye",
|
|
69
|
+
route: RouteCode["Intravitreal route (qualifier value)"]
|
|
70
|
+
},
|
|
71
|
+
vos: {
|
|
72
|
+
site: "left eye",
|
|
73
|
+
route: RouteCode["Intravitreal route (qualifier value)"]
|
|
74
|
+
},
|
|
75
|
+
ivtod: {
|
|
76
|
+
site: "right eye",
|
|
77
|
+
route: RouteCode["Intravitreal route (qualifier value)"]
|
|
78
|
+
},
|
|
79
|
+
ivtre: {
|
|
80
|
+
site: "right eye",
|
|
81
|
+
route: RouteCode["Intravitreal route (qualifier value)"]
|
|
82
|
+
},
|
|
83
|
+
ivtos: {
|
|
84
|
+
site: "left eye",
|
|
85
|
+
route: RouteCode["Intravitreal route (qualifier value)"]
|
|
86
|
+
},
|
|
87
|
+
ivtle: {
|
|
88
|
+
site: "left eye",
|
|
89
|
+
route: RouteCode["Intravitreal route (qualifier value)"]
|
|
90
|
+
},
|
|
91
|
+
ivtou: {
|
|
92
|
+
site: "both eyes",
|
|
93
|
+
route: RouteCode["Intravitreal route (qualifier value)"]
|
|
94
|
+
},
|
|
95
|
+
ivtbe: {
|
|
96
|
+
site: "both eyes",
|
|
97
|
+
route: RouteCode["Intravitreal route (qualifier value)"]
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
export function tokenize(input) {
|
|
101
|
+
const separators = /[(),]/g;
|
|
102
|
+
let normalized = input.trim().replace(separators, " ");
|
|
103
|
+
normalized = normalized.replace(/(\d+)\s*\/\s*(\d+)/g, (match, num, den) => {
|
|
104
|
+
const numerator = parseFloat(num);
|
|
105
|
+
const denominator = parseFloat(den);
|
|
106
|
+
if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator === 0) {
|
|
107
|
+
return match;
|
|
108
|
+
}
|
|
109
|
+
const value = numerator / denominator;
|
|
110
|
+
return value.toString();
|
|
111
|
+
});
|
|
112
|
+
normalized = normalized.replace(/(\d+(?:\.\d+)?[x*])([A-Za-z]+)/g, "$1 $2");
|
|
113
|
+
normalized = normalized.replace(/(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)/g, "$1-$2");
|
|
114
|
+
normalized = normalized.replace(/(\d+(?:\.\d+)?)(tab|tabs|tablet|tablets|cap|caps|capsule|capsules|mg|mcg|ml|g|drops|drop|puff|puffs|spray|sprays|patch|patches)/gi, "$1 $2");
|
|
115
|
+
normalized = normalized.replace(/[\\/]/g, " ");
|
|
116
|
+
const rawTokens = normalized
|
|
117
|
+
.split(/\s+/)
|
|
118
|
+
.map((t) => t.trim())
|
|
119
|
+
.filter((t) => t.length > 0 && t !== "." && t !== "-");
|
|
120
|
+
const tokens = [];
|
|
121
|
+
for (let i = 0; i < rawTokens.length; i++) {
|
|
122
|
+
const raw = rawTokens[i];
|
|
123
|
+
const parts = splitToken(raw);
|
|
124
|
+
for (const part of parts) {
|
|
125
|
+
if (!part)
|
|
126
|
+
continue;
|
|
127
|
+
tokens.push({ original: part, lower: part.toLowerCase(), index: tokens.length });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return tokens;
|
|
131
|
+
}
|
|
132
|
+
function splitToken(token) {
|
|
133
|
+
if (/^[0-9]+(?:\.[0-9]+)?$/.test(token)) {
|
|
134
|
+
return [token];
|
|
135
|
+
}
|
|
136
|
+
if (/^[A-Za-z]+$/.test(token)) {
|
|
137
|
+
return [token];
|
|
138
|
+
}
|
|
139
|
+
const qRange = token.match(/^q([0-9]+(?:\.[0-9]+)?)-([0-9]+(?:\.[0-9]+)?)([A-Za-z]+)$/i);
|
|
140
|
+
if (qRange) {
|
|
141
|
+
const [, low, high, unit] = qRange;
|
|
142
|
+
return [token.charAt(0), `${low}-${high}`, unit];
|
|
143
|
+
}
|
|
144
|
+
const match = token.match(/^([0-9]+(?:\.[0-9]+)?)([A-Za-z]+)$/);
|
|
145
|
+
if (match) {
|
|
146
|
+
const [, num, unit] = match;
|
|
147
|
+
if (!/^x\d+/i.test(unit) && !/^q\d+/i.test(unit)) {
|
|
148
|
+
return [num, unit];
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return [token];
|
|
152
|
+
}
|
|
153
|
+
function mark(consumed, token) {
|
|
154
|
+
consumed.add(token.index);
|
|
155
|
+
}
|
|
156
|
+
function addWhen(target, code) {
|
|
157
|
+
if (!target.includes(code)) {
|
|
158
|
+
target.push(code);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Removing is slightly more work than adding because a clinician might repeat
|
|
162
|
+
// the same token; trimming them all keeps downstream assertions tidy.
|
|
163
|
+
function removeWhen(target, code) {
|
|
164
|
+
let index = target.indexOf(code);
|
|
165
|
+
while (index !== -1) {
|
|
166
|
+
target.splice(index, 1);
|
|
167
|
+
index = target.indexOf(code);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Translate the requested expansion context into the appropriate sequence of
|
|
171
|
+
// EventTiming values (e.g., AC -> ACM/ACD/ACV) for the detected frequency.
|
|
172
|
+
function computeMealExpansions(base, frequency, pairPreference) {
|
|
173
|
+
if (frequency < 1 || frequency > 4) {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
const bedtime = EventTiming["Before Sleep"];
|
|
177
|
+
const beforePair = pairPreference === "breakfast+lunch"
|
|
178
|
+
? [EventTiming["Before Breakfast"], EventTiming["Before Lunch"]]
|
|
179
|
+
: [EventTiming["Before Breakfast"], EventTiming["Before Dinner"]];
|
|
180
|
+
const afterPair = pairPreference === "breakfast+lunch"
|
|
181
|
+
? [EventTiming["After Breakfast"], EventTiming["After Lunch"]]
|
|
182
|
+
: [EventTiming["After Breakfast"], EventTiming["After Dinner"]];
|
|
183
|
+
const withPair = pairPreference === "breakfast+lunch"
|
|
184
|
+
? [EventTiming.Breakfast, EventTiming.Lunch]
|
|
185
|
+
: [EventTiming.Breakfast, EventTiming.Dinner];
|
|
186
|
+
if (base === "before") {
|
|
187
|
+
if (frequency === 1)
|
|
188
|
+
return [EventTiming["Before Breakfast"]];
|
|
189
|
+
if (frequency === 2)
|
|
190
|
+
return beforePair;
|
|
191
|
+
if (frequency === 3) {
|
|
192
|
+
return [
|
|
193
|
+
EventTiming["Before Breakfast"],
|
|
194
|
+
EventTiming["Before Lunch"],
|
|
195
|
+
EventTiming["Before Dinner"]
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
return [
|
|
199
|
+
EventTiming["Before Breakfast"],
|
|
200
|
+
EventTiming["Before Lunch"],
|
|
201
|
+
EventTiming["Before Dinner"],
|
|
202
|
+
bedtime
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
if (base === "after") {
|
|
206
|
+
if (frequency === 1)
|
|
207
|
+
return [EventTiming["After Breakfast"]];
|
|
208
|
+
if (frequency === 2)
|
|
209
|
+
return afterPair;
|
|
210
|
+
if (frequency === 3) {
|
|
211
|
+
return [
|
|
212
|
+
EventTiming["After Breakfast"],
|
|
213
|
+
EventTiming["After Lunch"],
|
|
214
|
+
EventTiming["After Dinner"]
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
return [
|
|
218
|
+
EventTiming["After Breakfast"],
|
|
219
|
+
EventTiming["After Lunch"],
|
|
220
|
+
EventTiming["After Dinner"],
|
|
221
|
+
bedtime
|
|
222
|
+
];
|
|
223
|
+
}
|
|
224
|
+
// base === "with"
|
|
225
|
+
if (frequency === 1)
|
|
226
|
+
return [EventTiming.Breakfast];
|
|
227
|
+
if (frequency === 2)
|
|
228
|
+
return withPair;
|
|
229
|
+
if (frequency === 3) {
|
|
230
|
+
return [EventTiming.Breakfast, EventTiming.Lunch, EventTiming.Dinner];
|
|
231
|
+
}
|
|
232
|
+
return [EventTiming.Breakfast, EventTiming.Lunch, EventTiming.Dinner, bedtime];
|
|
233
|
+
}
|
|
234
|
+
// Optionally replace generic meal tokens with concrete breakfast/lunch/dinner
|
|
235
|
+
// EventTiming codes when the cadence makes the intent obvious.
|
|
236
|
+
function expandMealTimings(internal, options) {
|
|
237
|
+
if (!options?.smartMealExpansion) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (!internal.when.length) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (internal.when.some((code) => SPECIFIC_MEAL_TIMINGS.has(code))) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const frequency = internal.frequency;
|
|
247
|
+
if (!frequency || frequency < 1 || frequency > 4) {
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (internal.period !== undefined &&
|
|
251
|
+
internal.periodUnit !== undefined &&
|
|
252
|
+
(internal.periodUnit !== FhirPeriodUnit.Day || internal.period !== 1)) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (internal.period !== undefined &&
|
|
256
|
+
internal.periodUnit === undefined &&
|
|
257
|
+
internal.period !== 1) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
if (internal.periodUnit && internal.periodUnit !== FhirPeriodUnit.Day) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (internal.frequencyMax !== undefined || internal.periodMax !== undefined) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const pairPreference = options.twoPerDayPair ?? "breakfast+dinner";
|
|
267
|
+
const replacements = [];
|
|
268
|
+
if (internal.when.includes(EventTiming["Before Meal"])) {
|
|
269
|
+
const specifics = computeMealExpansions("before", frequency, pairPreference);
|
|
270
|
+
if (specifics) {
|
|
271
|
+
replacements.push({ general: EventTiming["Before Meal"], specifics });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (internal.when.includes(EventTiming["After Meal"])) {
|
|
275
|
+
const specifics = computeMealExpansions("after", frequency, pairPreference);
|
|
276
|
+
if (specifics) {
|
|
277
|
+
replacements.push({ general: EventTiming["After Meal"], specifics });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (internal.when.includes(EventTiming.Meal)) {
|
|
281
|
+
const specifics = computeMealExpansions("with", frequency, pairPreference);
|
|
282
|
+
if (specifics) {
|
|
283
|
+
replacements.push({ general: EventTiming.Meal, specifics });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
for (const { general, specifics } of replacements) {
|
|
287
|
+
removeWhen(internal.when, general);
|
|
288
|
+
for (const specific of specifics) {
|
|
289
|
+
addWhen(internal.when, specific);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function setRoute(internal, code, text) {
|
|
294
|
+
internal.routeCode = code;
|
|
295
|
+
internal.routeText = text ?? ROUTE_TEXT[code];
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Convert hour-based values into minutes when fractional quantities appear so
|
|
299
|
+
* the resulting FHIR repeat payloads avoid unwieldy decimals.
|
|
300
|
+
*/
|
|
301
|
+
function normalizePeriodValue(value, unit) {
|
|
302
|
+
if (unit === FhirPeriodUnit.Hour && (!Number.isInteger(value) || value < 1)) {
|
|
303
|
+
return { value: Math.round(value * 60 * 1000) / 1000, unit: FhirPeriodUnit.Minute };
|
|
304
|
+
}
|
|
305
|
+
return { value, unit };
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Ensure ranges expressed in hours remain consistent when fractional values
|
|
309
|
+
* demand conversion into minutes.
|
|
310
|
+
*/
|
|
311
|
+
function normalizePeriodRange(low, high, unit) {
|
|
312
|
+
if (unit === FhirPeriodUnit.Hour && (!Number.isInteger(low) || !Number.isInteger(high) || low < 1 || high < 1)) {
|
|
313
|
+
return {
|
|
314
|
+
low: Math.round(low * 60 * 1000) / 1000,
|
|
315
|
+
high: Math.round(high * 60 * 1000) / 1000,
|
|
316
|
+
unit: FhirPeriodUnit.Minute
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
return { low, high, unit };
|
|
320
|
+
}
|
|
321
|
+
function periodUnitSuffix(unit) {
|
|
322
|
+
switch (unit) {
|
|
323
|
+
case FhirPeriodUnit.Minute:
|
|
324
|
+
return "min";
|
|
325
|
+
case FhirPeriodUnit.Hour:
|
|
326
|
+
return "h";
|
|
327
|
+
case FhirPeriodUnit.Day:
|
|
328
|
+
return "d";
|
|
329
|
+
case FhirPeriodUnit.Week:
|
|
330
|
+
return "wk";
|
|
331
|
+
case FhirPeriodUnit.Month:
|
|
332
|
+
return "mo";
|
|
333
|
+
case FhirPeriodUnit.Year:
|
|
334
|
+
return "a";
|
|
335
|
+
default:
|
|
336
|
+
return undefined;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function maybeAssignTimingCode(internal, value, unit) {
|
|
340
|
+
const suffix = periodUnitSuffix(unit);
|
|
341
|
+
if (!suffix) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const key = `q${value}${suffix}`;
|
|
345
|
+
const descriptor = TIMING_ABBREVIATIONS[key];
|
|
346
|
+
if (descriptor?.code && !internal.timingCode) {
|
|
347
|
+
internal.timingCode = descriptor.code;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Apply the chosen period/unit pair and infer helpful timing codes when the
|
|
352
|
+
* period clearly represents common cadences (daily/weekly/monthly).
|
|
353
|
+
*/
|
|
354
|
+
function applyPeriod(internal, period, unit) {
|
|
355
|
+
const normalized = normalizePeriodValue(period, unit);
|
|
356
|
+
internal.period = normalized.value;
|
|
357
|
+
internal.periodUnit = normalized.unit;
|
|
358
|
+
maybeAssignTimingCode(internal, normalized.value, normalized.unit);
|
|
359
|
+
if (normalized.unit === FhirPeriodUnit.Day && normalized.value === 1) {
|
|
360
|
+
internal.frequency = internal.frequency ?? 1;
|
|
361
|
+
}
|
|
362
|
+
if (normalized.unit === FhirPeriodUnit.Week && normalized.value === 1) {
|
|
363
|
+
internal.timingCode = internal.timingCode ?? "WK";
|
|
364
|
+
}
|
|
365
|
+
if (normalized.unit === FhirPeriodUnit.Month && normalized.value === 1) {
|
|
366
|
+
internal.timingCode = internal.timingCode ?? "MO";
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Parse compact q-interval tokens like q30min, q0.5h, or q1w, optionally using
|
|
371
|
+
* the following token as the unit if the compact token only carries the value.
|
|
372
|
+
*/
|
|
373
|
+
function tryParseCompactQ(internal, tokens, index) {
|
|
374
|
+
const token = tokens[index];
|
|
375
|
+
const lower = token.lower;
|
|
376
|
+
const compact = lower.match(/^q([0-9]+(?:\.[0-9]+)?)([a-z]+)$/);
|
|
377
|
+
if (compact) {
|
|
378
|
+
const value = parseFloat(compact[1]);
|
|
379
|
+
const unitCode = mapIntervalUnit(compact[2]);
|
|
380
|
+
if (Number.isFinite(value) && unitCode) {
|
|
381
|
+
applyPeriod(internal, value, unitCode);
|
|
382
|
+
mark(internal.consumed, token);
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const valueOnly = lower.match(/^q([0-9]+(?:\.[0-9]+)?)$/);
|
|
387
|
+
if (valueOnly) {
|
|
388
|
+
const unitToken = tokens[index + 1];
|
|
389
|
+
if (!unitToken || internal.consumed.has(unitToken.index)) {
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
const unitCode = mapIntervalUnit(unitToken.lower);
|
|
393
|
+
if (!unitCode) {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
const value = parseFloat(valueOnly[1]);
|
|
397
|
+
if (!Number.isFinite(value)) {
|
|
398
|
+
return false;
|
|
399
|
+
}
|
|
400
|
+
applyPeriod(internal, value, unitCode);
|
|
401
|
+
mark(internal.consumed, token);
|
|
402
|
+
mark(internal.consumed, unitToken);
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
return false;
|
|
406
|
+
}
|
|
407
|
+
function applyFrequencyDescriptor(internal, token, descriptor, options) {
|
|
408
|
+
if (descriptor.discouraged) {
|
|
409
|
+
const check = checkDiscouraged(token.original, options);
|
|
410
|
+
if (check.warning) {
|
|
411
|
+
internal.warnings.push(check.warning);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (descriptor.code) {
|
|
415
|
+
internal.timingCode = descriptor.code;
|
|
416
|
+
}
|
|
417
|
+
if (descriptor.frequency !== undefined) {
|
|
418
|
+
internal.frequency = descriptor.frequency;
|
|
419
|
+
}
|
|
420
|
+
if (descriptor.frequencyMax !== undefined) {
|
|
421
|
+
internal.frequencyMax = descriptor.frequencyMax;
|
|
422
|
+
}
|
|
423
|
+
if (descriptor.period !== undefined) {
|
|
424
|
+
internal.period = descriptor.period;
|
|
425
|
+
}
|
|
426
|
+
if (descriptor.periodMax !== undefined) {
|
|
427
|
+
internal.periodMax = descriptor.periodMax;
|
|
428
|
+
}
|
|
429
|
+
if (descriptor.periodUnit) {
|
|
430
|
+
internal.periodUnit = descriptor.periodUnit;
|
|
431
|
+
}
|
|
432
|
+
if (descriptor.when) {
|
|
433
|
+
for (const w of descriptor.when) {
|
|
434
|
+
addWhen(internal.when, w);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
mark(internal.consumed, token);
|
|
438
|
+
}
|
|
439
|
+
function applyWhenToken(internal, token, code) {
|
|
440
|
+
addWhen(internal.when, code);
|
|
441
|
+
mark(internal.consumed, token);
|
|
442
|
+
}
|
|
443
|
+
function parseMealContext(internal, tokens, index, code) {
|
|
444
|
+
const token = tokens[index];
|
|
445
|
+
const next = tokens[index + 1];
|
|
446
|
+
if (!next || internal.consumed.has(next.index)) {
|
|
447
|
+
applyWhenToken(internal, token, code);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
const meal = MEAL_KEYWORDS[next.lower];
|
|
451
|
+
if (meal) {
|
|
452
|
+
const whenCode = code === EventTiming["After Meal"]
|
|
453
|
+
? meal.pc
|
|
454
|
+
: code === EventTiming["Before Meal"]
|
|
455
|
+
? meal.ac
|
|
456
|
+
: code;
|
|
457
|
+
applyWhenToken(internal, token, whenCode);
|
|
458
|
+
mark(internal.consumed, next);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
applyWhenToken(internal, token, code);
|
|
462
|
+
}
|
|
463
|
+
function parseSeparatedQ(internal, tokens, index, options) {
|
|
464
|
+
const token = tokens[index];
|
|
465
|
+
const next = tokens[index + 1];
|
|
466
|
+
if (!next || internal.consumed.has(next.index)) {
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
const after = tokens[index + 2];
|
|
470
|
+
const lowerNext = next.lower;
|
|
471
|
+
const range = parseNumericRange(lowerNext);
|
|
472
|
+
if (range) {
|
|
473
|
+
const unitToken = after;
|
|
474
|
+
if (!unitToken) {
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
const unitCode = mapIntervalUnit(unitToken.lower);
|
|
478
|
+
if (!unitCode) {
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
const normalized = normalizePeriodRange(range.low, range.high, unitCode);
|
|
482
|
+
internal.period = normalized.low;
|
|
483
|
+
internal.periodMax = normalized.high;
|
|
484
|
+
internal.periodUnit = normalized.unit;
|
|
485
|
+
mark(internal.consumed, token);
|
|
486
|
+
mark(internal.consumed, next);
|
|
487
|
+
mark(internal.consumed, unitToken);
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
const isNumber = /^[0-9]+(?:\.[0-9]+)?$/.test(lowerNext);
|
|
491
|
+
if (!isNumber) {
|
|
492
|
+
const unitCode = mapIntervalUnit(lowerNext);
|
|
493
|
+
if (unitCode) {
|
|
494
|
+
mark(internal.consumed, token);
|
|
495
|
+
mark(internal.consumed, next);
|
|
496
|
+
applyPeriod(internal, 1, unitCode);
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
return false;
|
|
500
|
+
}
|
|
501
|
+
const unitToken = after;
|
|
502
|
+
if (!unitToken) {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
const unitCode = mapIntervalUnit(unitToken.lower);
|
|
506
|
+
if (!unitCode) {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
const value = parseFloat(next.original);
|
|
510
|
+
applyPeriod(internal, value, unitCode);
|
|
511
|
+
mark(internal.consumed, token);
|
|
512
|
+
mark(internal.consumed, next);
|
|
513
|
+
mark(internal.consumed, unitToken);
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
function mapIntervalUnit(token) {
|
|
517
|
+
if (token === "min" ||
|
|
518
|
+
token === "mins" ||
|
|
519
|
+
token === "minute" ||
|
|
520
|
+
token === "minutes" ||
|
|
521
|
+
token === "m") {
|
|
522
|
+
return FhirPeriodUnit.Minute;
|
|
523
|
+
}
|
|
524
|
+
if (token === "h" || token === "hr" || token === "hrs" || token === "hour" || token === "hours") {
|
|
525
|
+
return FhirPeriodUnit.Hour;
|
|
526
|
+
}
|
|
527
|
+
if (token === "d" || token === "day" || token === "days") {
|
|
528
|
+
return FhirPeriodUnit.Day;
|
|
529
|
+
}
|
|
530
|
+
if (token === "wk" || token === "w" || token === "week" || token === "weeks") {
|
|
531
|
+
return FhirPeriodUnit.Week;
|
|
532
|
+
}
|
|
533
|
+
if (token === "mo" || token === "month" || token === "months") {
|
|
534
|
+
return FhirPeriodUnit.Month;
|
|
535
|
+
}
|
|
536
|
+
return undefined;
|
|
537
|
+
}
|
|
538
|
+
function parseNumericRange(token) {
|
|
539
|
+
const rangeMatch = token.match(/^([0-9]+(?:\.[0-9]+)?)-([0-9]+(?:\.[0-9]+)?)$/);
|
|
540
|
+
if (!rangeMatch) {
|
|
541
|
+
return undefined;
|
|
542
|
+
}
|
|
543
|
+
const low = parseFloat(rangeMatch[1]);
|
|
544
|
+
const high = parseFloat(rangeMatch[2]);
|
|
545
|
+
if (!Number.isFinite(low) || !Number.isFinite(high)) {
|
|
546
|
+
return undefined;
|
|
547
|
+
}
|
|
548
|
+
return { low, high };
|
|
549
|
+
}
|
|
550
|
+
export function parseInternal(input, options) {
|
|
551
|
+
const tokens = tokenize(input);
|
|
552
|
+
const internal = {
|
|
553
|
+
input,
|
|
554
|
+
tokens,
|
|
555
|
+
consumed: new Set(),
|
|
556
|
+
dayOfWeek: [],
|
|
557
|
+
when: [],
|
|
558
|
+
warnings: []
|
|
559
|
+
};
|
|
560
|
+
const context = options?.context ?? undefined;
|
|
561
|
+
const customRouteMap = options?.routeMap
|
|
562
|
+
? new Map(Object.entries(options.routeMap).map(([key, value]) => [
|
|
563
|
+
key.toLowerCase(),
|
|
564
|
+
value
|
|
565
|
+
]))
|
|
566
|
+
: undefined;
|
|
567
|
+
if (tokens.length === 0) {
|
|
568
|
+
return internal;
|
|
569
|
+
}
|
|
570
|
+
// PRN detection
|
|
571
|
+
let prnReasonStart;
|
|
572
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
573
|
+
const token = tokens[i];
|
|
574
|
+
if (token.lower === "prn") {
|
|
575
|
+
internal.asNeeded = true;
|
|
576
|
+
mark(internal.consumed, token);
|
|
577
|
+
prnReasonStart = i + 1;
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
580
|
+
if (token.lower === "as" && tokens[i + 1]?.lower === "needed") {
|
|
581
|
+
internal.asNeeded = true;
|
|
582
|
+
mark(internal.consumed, token);
|
|
583
|
+
mark(internal.consumed, tokens[i + 1]);
|
|
584
|
+
let reasonIndex = i + 2;
|
|
585
|
+
if (tokens[reasonIndex]?.lower === "for") {
|
|
586
|
+
mark(internal.consumed, tokens[reasonIndex]);
|
|
587
|
+
reasonIndex += 1;
|
|
588
|
+
}
|
|
589
|
+
prnReasonStart = reasonIndex;
|
|
590
|
+
break;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
// Multiplicative tokens like 1x3
|
|
594
|
+
for (const token of tokens) {
|
|
595
|
+
if (internal.consumed.has(token.index))
|
|
596
|
+
continue;
|
|
597
|
+
const match = token.lower.match(/^([0-9]+(?:\.[0-9]+)?)[x*]([0-9]+(?:\.[0-9]+)?)$/);
|
|
598
|
+
if (match) {
|
|
599
|
+
const dose = parseFloat(match[1]);
|
|
600
|
+
const freq = parseFloat(match[2]);
|
|
601
|
+
if (internal.dose === undefined) {
|
|
602
|
+
internal.dose = dose;
|
|
603
|
+
}
|
|
604
|
+
internal.frequency = freq;
|
|
605
|
+
internal.period = 1;
|
|
606
|
+
internal.periodUnit = FhirPeriodUnit.Day;
|
|
607
|
+
mark(internal.consumed, token);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
// Process tokens sequentially
|
|
611
|
+
const tryRouteSynonym = (startIndex) => {
|
|
612
|
+
const maxSpan = Math.min(5, tokens.length - startIndex);
|
|
613
|
+
for (let span = maxSpan; span >= 1; span--) {
|
|
614
|
+
const slice = tokens.slice(startIndex, startIndex + span);
|
|
615
|
+
if (slice.some((part) => internal.consumed.has(part.index))) {
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
const phrase = slice.map((part) => part.lower).join(" ");
|
|
619
|
+
const customCode = customRouteMap?.get(phrase);
|
|
620
|
+
const synonym = customCode
|
|
621
|
+
? { code: customCode, text: ROUTE_TEXT[customCode] }
|
|
622
|
+
: DEFAULT_ROUTE_SYNONYMS[phrase];
|
|
623
|
+
if (synonym) {
|
|
624
|
+
setRoute(internal, synonym.code, synonym.text);
|
|
625
|
+
for (const part of slice) {
|
|
626
|
+
mark(internal.consumed, part);
|
|
627
|
+
}
|
|
628
|
+
return true;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return false;
|
|
632
|
+
};
|
|
633
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
634
|
+
const token = tokens[i];
|
|
635
|
+
if (internal.consumed.has(token.index)) {
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
if (token.lower === "bld" || token.lower === "b-l-d") {
|
|
639
|
+
const check = checkDiscouraged(token.original, options);
|
|
640
|
+
if (check.warning) {
|
|
641
|
+
internal.warnings.push(check.warning);
|
|
642
|
+
}
|
|
643
|
+
applyWhenToken(internal, token, EventTiming.Meal);
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
if (token.lower === "q") {
|
|
647
|
+
if (parseSeparatedQ(internal, tokens, i, options)) {
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
// Frequency abbreviation map
|
|
652
|
+
const freqDescriptor = TIMING_ABBREVIATIONS[token.lower];
|
|
653
|
+
if (freqDescriptor) {
|
|
654
|
+
applyFrequencyDescriptor(internal, token, freqDescriptor, options);
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
if (tryParseCompactQ(internal, tokens, i)) {
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
// Event timing tokens
|
|
661
|
+
if (token.lower === "pc" || token.lower === "ac") {
|
|
662
|
+
parseMealContext(internal, tokens, i, token.lower === "pc"
|
|
663
|
+
? EventTiming["After Meal"]
|
|
664
|
+
: EventTiming["Before Meal"]);
|
|
665
|
+
continue;
|
|
666
|
+
}
|
|
667
|
+
const nextToken = tokens[i + 1];
|
|
668
|
+
if (nextToken && !internal.consumed.has(nextToken.index)) {
|
|
669
|
+
const combo = `${token.lower} ${nextToken.lower}`;
|
|
670
|
+
const comboWhen = COMBO_EVENT_TIMINGS[combo] ?? EVENT_TIMING_TOKENS[combo];
|
|
671
|
+
if (comboWhen) {
|
|
672
|
+
applyWhenToken(internal, token, comboWhen);
|
|
673
|
+
mark(internal.consumed, nextToken);
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const customWhen = options?.whenMap?.[token.lower];
|
|
678
|
+
if (customWhen) {
|
|
679
|
+
applyWhenToken(internal, token, customWhen);
|
|
680
|
+
continue;
|
|
681
|
+
}
|
|
682
|
+
const whenCode = EVENT_TIMING_TOKENS[token.lower];
|
|
683
|
+
if (whenCode) {
|
|
684
|
+
applyWhenToken(internal, token, whenCode);
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
// Day of week
|
|
688
|
+
const day = DAY_OF_WEEK_TOKENS[token.lower];
|
|
689
|
+
if (day) {
|
|
690
|
+
if (!internal.dayOfWeek.includes(day)) {
|
|
691
|
+
internal.dayOfWeek.push(day);
|
|
692
|
+
}
|
|
693
|
+
mark(internal.consumed, token);
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
// Units following numbers handled later
|
|
697
|
+
if (tryRouteSynonym(i)) {
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
const eyeSite = EYE_SITE_TOKENS[token.lower];
|
|
701
|
+
if (eyeSite) {
|
|
702
|
+
internal.siteText = eyeSite.site;
|
|
703
|
+
if (eyeSite.route && !internal.routeCode) {
|
|
704
|
+
setRoute(internal, eyeSite.route);
|
|
705
|
+
}
|
|
706
|
+
mark(internal.consumed, token);
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
// Numeric dose
|
|
710
|
+
const rangeValue = parseNumericRange(token.lower);
|
|
711
|
+
if (rangeValue) {
|
|
712
|
+
if (!internal.doseRange) {
|
|
713
|
+
internal.doseRange = rangeValue;
|
|
714
|
+
}
|
|
715
|
+
mark(internal.consumed, token);
|
|
716
|
+
const unitToken = tokens[i + 1];
|
|
717
|
+
if (unitToken && !internal.consumed.has(unitToken.index)) {
|
|
718
|
+
const unit = normalizeUnit(unitToken.lower, options);
|
|
719
|
+
if (unit) {
|
|
720
|
+
internal.unit = unit;
|
|
721
|
+
mark(internal.consumed, unitToken);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
if (/^[0-9]+(?:\.[0-9]+)?$/.test(token.lower)) {
|
|
727
|
+
const value = parseFloat(token.original);
|
|
728
|
+
if (internal.dose === undefined) {
|
|
729
|
+
internal.dose = value;
|
|
730
|
+
}
|
|
731
|
+
mark(internal.consumed, token);
|
|
732
|
+
const unitToken = tokens[i + 1];
|
|
733
|
+
if (unitToken && !internal.consumed.has(unitToken.index)) {
|
|
734
|
+
const unit = normalizeUnit(unitToken.lower, options);
|
|
735
|
+
if (unit) {
|
|
736
|
+
internal.unit = unit;
|
|
737
|
+
mark(internal.consumed, unitToken);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
// Patterns like 1x or 2x
|
|
743
|
+
const timesMatch = token.lower.match(/^([0-9]+(?:\.[0-9]+)?)[x*]$/);
|
|
744
|
+
if (timesMatch) {
|
|
745
|
+
const val = parseFloat(timesMatch[1]);
|
|
746
|
+
if (internal.dose === undefined) {
|
|
747
|
+
internal.dose = val;
|
|
748
|
+
}
|
|
749
|
+
mark(internal.consumed, token);
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
// Words for frequency
|
|
753
|
+
const wordFreq = WORD_FREQUENCIES[token.lower];
|
|
754
|
+
if (wordFreq) {
|
|
755
|
+
internal.frequency = wordFreq.frequency;
|
|
756
|
+
internal.period = 1;
|
|
757
|
+
internal.periodUnit = wordFreq.periodUnit;
|
|
758
|
+
mark(internal.consumed, token);
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
// Skip generic connectors
|
|
762
|
+
if (token.lower === "per" || token.lower === "a" || token.lower === "every") {
|
|
763
|
+
mark(internal.consumed, token);
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
// Units from trailing tokens if still undefined
|
|
768
|
+
if (internal.unit === undefined) {
|
|
769
|
+
for (const token of tokens) {
|
|
770
|
+
if (internal.consumed.has(token.index))
|
|
771
|
+
continue;
|
|
772
|
+
const unit = normalizeUnit(token.lower, options);
|
|
773
|
+
if (unit) {
|
|
774
|
+
internal.unit = unit;
|
|
775
|
+
mark(internal.consumed, token);
|
|
776
|
+
break;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
if (internal.unit === undefined) {
|
|
781
|
+
internal.unit = inferUnitFromContext(context);
|
|
782
|
+
}
|
|
783
|
+
// Frequency defaults when timing code implies it
|
|
784
|
+
if (internal.frequency === undefined &&
|
|
785
|
+
internal.period === undefined &&
|
|
786
|
+
internal.timingCode) {
|
|
787
|
+
const descriptor = TIMING_ABBREVIATIONS[internal.timingCode.toLowerCase()];
|
|
788
|
+
if (descriptor) {
|
|
789
|
+
if (descriptor.frequency !== undefined) {
|
|
790
|
+
internal.frequency = descriptor.frequency;
|
|
791
|
+
}
|
|
792
|
+
if (descriptor.period !== undefined) {
|
|
793
|
+
internal.period = descriptor.period;
|
|
794
|
+
}
|
|
795
|
+
if (descriptor.periodUnit) {
|
|
796
|
+
internal.periodUnit = descriptor.periodUnit;
|
|
797
|
+
}
|
|
798
|
+
if (descriptor.when) {
|
|
799
|
+
for (const w of descriptor.when) {
|
|
800
|
+
addWhen(internal.when, w);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
if (!internal.timingCode &&
|
|
806
|
+
internal.frequency !== undefined &&
|
|
807
|
+
internal.periodUnit === FhirPeriodUnit.Day &&
|
|
808
|
+
(internal.period === undefined || internal.period === 1)) {
|
|
809
|
+
if (internal.frequency === 2) {
|
|
810
|
+
internal.timingCode = "BID";
|
|
811
|
+
}
|
|
812
|
+
else if (internal.frequency === 3) {
|
|
813
|
+
internal.timingCode = "TID";
|
|
814
|
+
}
|
|
815
|
+
else if (internal.frequency === 4) {
|
|
816
|
+
internal.timingCode = "QID";
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
// Expand generic meal markers into specific EventTiming codes when asked to.
|
|
820
|
+
expandMealTimings(internal, options);
|
|
821
|
+
// Determine site text from leftover tokens (excluding PRN reason tokens)
|
|
822
|
+
const leftoverTokens = tokens.filter((t) => !internal.consumed.has(t.index));
|
|
823
|
+
if (leftoverTokens.length > 0) {
|
|
824
|
+
const siteCandidates = leftoverTokens.filter((t) => BODY_SITE_HINTS.has(t.lower));
|
|
825
|
+
if (siteCandidates.length > 0) {
|
|
826
|
+
const indices = new Set(siteCandidates.map((t) => t.index));
|
|
827
|
+
const words = [];
|
|
828
|
+
for (const token of leftoverTokens) {
|
|
829
|
+
if (indices.has(token.index) || BODY_SITE_HINTS.has(token.lower)) {
|
|
830
|
+
words.push(token.original);
|
|
831
|
+
mark(internal.consumed, token);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
if (words.length > 0) {
|
|
835
|
+
internal.siteText = words.join(" ");
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
// PRN reason text
|
|
840
|
+
if (internal.asNeeded && prnReasonStart !== undefined) {
|
|
841
|
+
const reasonTokens = [];
|
|
842
|
+
for (let i = prnReasonStart; i < tokens.length; i++) {
|
|
843
|
+
const token = tokens[i];
|
|
844
|
+
if (internal.consumed.has(token.index)) {
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
reasonTokens.push(token.original);
|
|
848
|
+
mark(internal.consumed, token);
|
|
849
|
+
}
|
|
850
|
+
if (reasonTokens.length > 0) {
|
|
851
|
+
internal.asNeededReason = reasonTokens.join(" ");
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
if (internal.routeCode === RouteCode["Intravitreal route (qualifier value)"] &&
|
|
855
|
+
(!internal.siteText || !/eye/i.test(internal.siteText))) {
|
|
856
|
+
internal.warnings.push("Intravitreal administrations require an eye site (e.g., OD/OS/OU).");
|
|
857
|
+
}
|
|
858
|
+
return internal;
|
|
859
|
+
}
|
|
860
|
+
function normalizeUnit(token, options) {
|
|
861
|
+
const override = options?.unitMap?.[token];
|
|
862
|
+
if (override) {
|
|
863
|
+
return override;
|
|
864
|
+
}
|
|
865
|
+
const defaultUnit = DEFAULT_UNIT_SYNONYMS[token];
|
|
866
|
+
if (defaultUnit) {
|
|
867
|
+
return defaultUnit;
|
|
868
|
+
}
|
|
869
|
+
return undefined;
|
|
870
|
+
}
|