ezmedicationinput 0.1.16 → 0.1.18

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 CHANGED
@@ -8,6 +8,7 @@
8
8
  - Emits timing abbreviations (`timing.code`) and repeat structures simultaneously where possible.
9
9
  - Maps meal/time blocks to the correct `Timing.repeat.when` **EventTiming** codes and can auto-expand AC/PC/C into specific meals.
10
10
  - Outputs SNOMED CT route codings (while providing friendly text) and round-trips known SNOMED routes back into the parser.
11
+ - Auto-codes common PRN (as-needed) reasons and additional dosage instructions while keeping the raw text when no coding is available.
11
12
  - Understands ocular and intravitreal shorthand (OD/OS/OU, LE/RE/BE, IVT*, VOD/VOS, etc.) and warns when intravitreal instructions omit an eye side.
12
13
  - Parses fractional/ minute-based intervals (`q0.5h`, `q30 min`, `q1/4hr`) plus dose and timing ranges.
13
14
  - Supports extensible dictionaries for routes, units, frequency shorthands, and event timing tokens.
@@ -50,6 +51,59 @@ Example output:
50
51
  }
51
52
  ```
52
53
 
54
+ ### PRN reasons & additional instructions
55
+
56
+ `parseSig` identifies PRN (as-needed) clauses and trailing instructions, then
57
+ codes them with SNOMED CT whenever possible.
58
+
59
+ ```ts
60
+ const result = parseSig("1 tab po q4h prn headache; do not exceed 6 tabs/day");
61
+
62
+ result.fhir.asNeededFor;
63
+ // → [{
64
+ // text: "headache",
65
+ // coding: [{
66
+ // system: "http://snomed.info/sct",
67
+ // code: "25064002",
68
+ // display: "Headache"
69
+ // }]
70
+ // }]
71
+
72
+ result.fhir.additionalInstruction;
73
+ // → [{ text: "Do not exceed 6 tablets daily" }]
74
+ ```
75
+
76
+ Customize the dictionaries and lookups through `ParseOptions`:
77
+
78
+ ```ts
79
+ parseSig(input, {
80
+ prnReasonMap: {
81
+ migraine: {
82
+ text: "Migraine",
83
+ coding: {
84
+ system: "http://snomed.info/sct",
85
+ code: "37796009",
86
+ display: "Migraine"
87
+ }
88
+ }
89
+ },
90
+ prnReasonResolvers: async (request) => terminologyService.lookup(request),
91
+ prnReasonSuggestionResolvers: async (request) => terminologyService.suggest(request),
92
+ });
93
+ ```
94
+
95
+ Use `{reason}` in the sig string (e.g. `prn {migraine}`) to force a lookup even
96
+ when a direct match exists. Additional instructions are sourced from a built-in
97
+ set of SNOMED CT concepts under *419492006 – Additional dosage instructions* and
98
+ fall back to plain text when no coding is available. Parsed instructions are
99
+ also echoed in `ParseResult.meta.normalized.additionalInstructions` for quick UI
100
+ rendering.
101
+
102
+ When a PRN reason cannot be auto-resolved, any registered suggestion resolvers
103
+ are invoked and their responses are surfaced through
104
+ `ParseResult.meta.prnReasonLookups` so client applications can prompt the user
105
+ to choose a coded concept.
106
+
53
107
  ### Sig (directions) suggestions
54
108
 
55
109
  Use `suggestSig` to drive autocomplete experiences while the clinician is
@@ -126,6 +180,8 @@ result.fhir.site?.coding?.[0];
126
180
 
127
181
  When the parser encounters an unfamiliar site, it leaves the text untouched and records nothing in `meta.siteLookups`. Wrapping the phrase in braces (e.g. `apply to {mole on scalp}`) preserves the same parsing behavior but flags the entry as a **probe** so `meta.siteLookups` always contains the request. This allows UIs to display lookup widgets even before a matching code exists. Braces are optional when the site is already recognized—they simply make the clinician's intent explicit.
128
182
 
183
+ Unknown body sites still populate `Dosage.site.text` and `ParseResult.meta.normalized.site.text`, allowing UIs to echo the verbatim phrase while terminology lookups run asynchronously.
184
+
129
185
  You can extend or replace the built-in codings via `ParseOptions`:
130
186
 
131
187
  ```ts
package/dist/format.js CHANGED
@@ -69,6 +69,11 @@ const ROUTE_GRAMMAR = {
69
69
  routePhrase: ({ hasSite }) => (hasSite ? undefined : "into the eye"),
70
70
  sitePreposition: "into"
71
71
  },
72
+ [types_1.RouteCode["Per rectum"]]: {
73
+ verb: "Use",
74
+ routePhrase: ({ hasSite }) => (hasSite ? undefined : "rectally"),
75
+ sitePreposition: "into"
76
+ },
72
77
  [types_1.RouteCode["Topical route"]]: {
73
78
  verb: "Apply",
74
79
  routePhrase: ({ hasSite }) => (hasSite ? undefined : "topically"),
@@ -137,6 +142,9 @@ function grammarFromRouteText(text) {
137
142
  if (normalized.includes("intravenous") || normalized === "iv") {
138
143
  return ROUTE_GRAMMAR[types_1.RouteCode["Intravenous route"]];
139
144
  }
145
+ if (normalized.includes("rectal") || normalized.includes("rectum")) {
146
+ return ROUTE_GRAMMAR[types_1.RouteCode["Per rectum"]];
147
+ }
140
148
  if (normalized.includes("nasal")) {
141
149
  return ROUTE_GRAMMAR[types_1.RouteCode["Nasal route"]];
142
150
  }
@@ -412,6 +420,11 @@ function formatSite(internal, grammar) {
412
420
  return undefined;
413
421
  }
414
422
  const lower = text.toLowerCase();
423
+ if (internal.routeCode === types_1.RouteCode["Per rectum"]) {
424
+ if (lower === "rectum" || lower === "rectal") {
425
+ return undefined;
426
+ }
427
+ }
415
428
  let preposition = grammar.sitePreposition;
416
429
  if (!preposition) {
417
430
  if (lower.includes("eye")) {
package/dist/maps.js CHANGED
@@ -1254,6 +1254,7 @@ exports.DEFAULT_UNIT_BY_ROUTE = (() => {
1254
1254
  ensure(types_1.RouteCode["Otic route"], "drop");
1255
1255
  ensure(types_1.RouteCode["Respiratory tract route (qualifier value)"], "puff");
1256
1256
  ensure(types_1.RouteCode["Transdermal route"], "patch");
1257
+ ensure(types_1.RouteCode["Per rectum"], "suppository");
1257
1258
  return resolved;
1258
1259
  })();
1259
1260
  function normalizePrnReasonKey(value) {
@@ -1281,7 +1282,7 @@ const DEFAULT_PRN_REASON_SOURCE = [
1281
1282
  }
1282
1283
  },
1283
1284
  {
1284
- names: ["nausea", "queasiness"],
1285
+ names: ["nausea", "queasiness", "vomiting", "n/v", "nausea and vomiting"],
1285
1286
  definition: {
1286
1287
  coding: { system: SNOMED_SYSTEM, code: "422587007", display: "Nausea" },
1287
1288
  text: "Nausea"
package/dist/parser.js CHANGED
@@ -1897,43 +1897,105 @@ function parseInternal(input, options) {
1897
1897
  if (internal.asNeeded && prnReasonStart !== undefined) {
1898
1898
  const reasonTokens = [];
1899
1899
  const reasonIndices = [];
1900
+ const reasonObjects = [];
1900
1901
  for (let i = prnReasonStart; i < tokens.length; i++) {
1901
1902
  const token = tokens[i];
1902
1903
  if (internal.consumed.has(token.index)) {
1903
- continue;
1904
+ internal.consumed.delete(token.index);
1904
1905
  }
1905
1906
  reasonTokens.push(token.original);
1906
1907
  reasonIndices.push(token.index);
1908
+ reasonObjects.push(token);
1907
1909
  mark(internal.consumed, token);
1908
1910
  }
1909
1911
  if (reasonTokens.length > 0) {
1910
- const joined = reasonTokens.join(" ").trim();
1911
- if (joined) {
1912
- const sortedIndices = reasonIndices.sort((a, b) => a - b);
1913
- const range = computeTokenRange(internal.input, tokens, sortedIndices);
1914
- const sourceText = range ? internal.input.slice(range.start, range.end) : undefined;
1915
- let sanitized = joined.replace(/\s+/g, " ").trim();
1916
- let isProbe = false;
1917
- const probeMatch = sanitized.match(/^\{(.+)}$/);
1918
- if (probeMatch) {
1919
- isProbe = true;
1920
- sanitized = probeMatch[1];
1912
+ let sortedIndices = reasonIndices.slice().sort((a, b) => a - b);
1913
+ let range = computeTokenRange(internal.input, tokens, sortedIndices);
1914
+ let sourceText = range ? internal.input.slice(range.start, range.end) : undefined;
1915
+ if (sourceText) {
1916
+ const cutoff = determinePrnReasonCutoff(reasonObjects, sourceText);
1917
+ if (cutoff !== undefined) {
1918
+ for (let i = cutoff; i < reasonObjects.length; i++) {
1919
+ internal.consumed.delete(reasonObjects[i].index);
1920
+ }
1921
+ reasonObjects.splice(cutoff);
1922
+ reasonTokens.splice(cutoff);
1923
+ reasonIndices.splice(cutoff);
1924
+ while (reasonTokens.length > 0) {
1925
+ const lastToken = reasonTokens[reasonTokens.length - 1];
1926
+ if (!lastToken || /^[;:.,-]+$/.test(lastToken.trim())) {
1927
+ const removedObject = reasonObjects.pop();
1928
+ if (removedObject) {
1929
+ internal.consumed.delete(removedObject.index);
1930
+ }
1931
+ reasonTokens.pop();
1932
+ const removedIndex = reasonIndices.pop();
1933
+ if (removedIndex !== undefined) {
1934
+ internal.consumed.delete(removedIndex);
1935
+ }
1936
+ continue;
1937
+ }
1938
+ break;
1939
+ }
1940
+ if (reasonTokens.length > 0) {
1941
+ sortedIndices = reasonIndices.slice().sort((a, b) => a - b);
1942
+ range = computeTokenRange(internal.input, tokens, sortedIndices);
1943
+ sourceText = range ? internal.input.slice(range.start, range.end) : undefined;
1944
+ }
1945
+ else {
1946
+ range = undefined;
1947
+ sourceText = undefined;
1948
+ }
1949
+ }
1950
+ }
1951
+ if (reasonTokens.length > 0) {
1952
+ const siteStart = findTrailingPrnSiteSuffix(reasonObjects, internal, options);
1953
+ if (siteStart !== undefined) {
1954
+ for (let i = siteStart; i < reasonObjects.length; i++) {
1955
+ internal.consumed.delete(reasonObjects[i].index);
1956
+ }
1957
+ reasonObjects.splice(siteStart);
1958
+ reasonTokens.splice(siteStart);
1959
+ reasonIndices.splice(siteStart);
1960
+ if (reasonTokens.length > 0) {
1961
+ sortedIndices = reasonIndices.slice().sort((a, b) => a - b);
1962
+ range = computeTokenRange(internal.input, tokens, sortedIndices);
1963
+ sourceText = range ? internal.input.slice(range.start, range.end) : undefined;
1964
+ }
1965
+ else {
1966
+ range = undefined;
1967
+ sourceText = undefined;
1968
+ }
1969
+ }
1970
+ }
1971
+ if (reasonTokens.length > 0) {
1972
+ const joined = reasonTokens.join(" ").trim();
1973
+ if (joined) {
1974
+ let sanitized = joined.replace(/\s+/g, " ").trim();
1975
+ let isProbe = false;
1976
+ const probeMatch = sanitized.match(/^\{(.+)}$/);
1977
+ if (probeMatch) {
1978
+ isProbe = true;
1979
+ sanitized = probeMatch[1];
1980
+ }
1981
+ sanitized = sanitized.replace(/[{}]/g, " ").replace(/\s+/g, " ").trim();
1982
+ const text = sanitized || joined;
1983
+ internal.asNeededReason = text;
1984
+ const normalized = text.toLowerCase();
1985
+ const canonical = sanitized
1986
+ ? (0, maps_1.normalizePrnReasonKey)(sanitized)
1987
+ : (0, maps_1.normalizePrnReasonKey)(text);
1988
+ internal.prnReasonLookupRequest = {
1989
+ originalText: joined,
1990
+ text,
1991
+ normalized,
1992
+ canonical: canonical !== null && canonical !== void 0 ? canonical : "",
1993
+ isProbe,
1994
+ inputText: internal.input,
1995
+ sourceText,
1996
+ range
1997
+ };
1921
1998
  }
1922
- sanitized = sanitized.replace(/[{}]/g, " ").replace(/\s+/g, " ").trim();
1923
- const text = sanitized || joined;
1924
- internal.asNeededReason = text;
1925
- const normalized = text.toLowerCase();
1926
- const canonical = sanitized ? (0, maps_1.normalizePrnReasonKey)(sanitized) : (0, maps_1.normalizePrnReasonKey)(text);
1927
- internal.prnReasonLookupRequest = {
1928
- originalText: joined,
1929
- text,
1930
- normalized,
1931
- canonical: canonical !== null && canonical !== void 0 ? canonical : "",
1932
- isProbe,
1933
- inputText: internal.input,
1934
- sourceText,
1935
- range
1936
- };
1937
1999
  }
1938
2000
  }
1939
2001
  }
@@ -2028,27 +2090,30 @@ function parseInternal(input, options) {
2028
2090
  sanitized = sanitized.replace(/[{}]/g, " ").replace(/\s+/g, " ").trim();
2029
2091
  const range = refineSiteRange(internal.input, sanitized, tokenRange);
2030
2092
  const sourceText = range ? internal.input.slice(range.start, range.end) : undefined;
2093
+ const displayText = normalizeSiteDisplayText(sanitized, options === null || options === void 0 ? void 0 : options.siteCodeMap);
2094
+ const displayLower = displayText.toLowerCase();
2095
+ const canonical = displayText ? (0, maps_1.normalizeBodySiteKey)(displayText) : "";
2031
2096
  internal.siteLookupRequest = {
2032
2097
  originalText: normalizedSite,
2033
- text: sanitized,
2034
- normalized: sanitized.toLowerCase(),
2035
- canonical: sanitized ? (0, maps_1.normalizeBodySiteKey)(sanitized) : "",
2098
+ text: displayText,
2099
+ normalized: displayLower,
2100
+ canonical,
2036
2101
  isProbe,
2037
2102
  inputText: internal.input,
2038
2103
  sourceText,
2039
2104
  range
2040
2105
  };
2041
- if (sanitized) {
2106
+ if (displayText) {
2042
2107
  const normalizedLower = sanitized.toLowerCase();
2043
2108
  const strippedDescriptor = normalizeRouteDescriptorPhrase(normalizedLower);
2044
- const siteWords = normalizedLower.split(/\s+/).filter((word) => word.length > 0);
2109
+ const siteWords = displayLower.split(/\s+/).filter((word) => word.length > 0);
2045
2110
  const hasNonSiteWords = siteWords.some((word) => !isBodySiteHint(word, internal.customSiteHints));
2046
2111
  const shouldAttemptRouteDescriptor = strippedDescriptor !== normalizedLower || hasNonSiteWords || strippedDescriptor === "mouth";
2047
2112
  const appliedRouteDescriptor = shouldAttemptRouteDescriptor && maybeApplyRouteDescriptor(sanitized);
2048
2113
  if (!appliedRouteDescriptor) {
2049
2114
  // Preserve the clean site text for FHIR output and resolver context
2050
2115
  // whenever we keep the original phrase.
2051
- internal.siteText = sanitized;
2116
+ internal.siteText = displayText;
2052
2117
  if (!internal.siteSource) {
2053
2118
  internal.siteSource = "text";
2054
2119
  }
@@ -2399,29 +2464,228 @@ function findAdditionalInstructionDefinition(text, canonical) {
2399
2464
  }
2400
2465
  return undefined;
2401
2466
  }
2467
+ const BODY_SITE_ADJECTIVE_SUFFIXES = [
2468
+ "al",
2469
+ "ial",
2470
+ "ual",
2471
+ "ic",
2472
+ "ous",
2473
+ "ive",
2474
+ "ary",
2475
+ "ory",
2476
+ "atic",
2477
+ "etic",
2478
+ "ular",
2479
+ "otic",
2480
+ "ile",
2481
+ "eal",
2482
+ "inal",
2483
+ "aneal",
2484
+ "enal"
2485
+ ];
2486
+ const DEFAULT_SITE_SYNONYM_KEYS = (() => {
2487
+ const map = new Map();
2488
+ for (const [key, definition] of (0, object_1.objectEntries)(maps_1.DEFAULT_BODY_SITE_SNOMED)) {
2489
+ if (!definition) {
2490
+ continue;
2491
+ }
2492
+ const normalized = key.trim();
2493
+ if (!normalized) {
2494
+ continue;
2495
+ }
2496
+ const existing = map.get(definition);
2497
+ if (existing) {
2498
+ if (existing.indexOf(normalized) === -1) {
2499
+ existing.push(normalized);
2500
+ }
2501
+ }
2502
+ else {
2503
+ map.set(definition, [normalized]);
2504
+ }
2505
+ }
2506
+ return map;
2507
+ })();
2508
+ function normalizeSiteDisplayText(text, customSiteMap) {
2509
+ var _a;
2510
+ const trimmed = text.trim();
2511
+ if (!trimmed) {
2512
+ return trimmed;
2513
+ }
2514
+ const canonicalInput = (0, maps_1.normalizeBodySiteKey)(trimmed);
2515
+ if (!canonicalInput) {
2516
+ return trimmed;
2517
+ }
2518
+ const resolvePreferred = (canonical) => {
2519
+ var _a;
2520
+ const definition = (_a = lookupBodySiteDefinition(customSiteMap, canonical)) !== null && _a !== void 0 ? _a : maps_1.DEFAULT_BODY_SITE_SNOMED[canonical];
2521
+ if (!definition) {
2522
+ return undefined;
2523
+ }
2524
+ const preferred = pickPreferredBodySitePhrase(canonical, definition, customSiteMap);
2525
+ const textValue = preferred !== null && preferred !== void 0 ? preferred : canonical;
2526
+ const normalized = (0, maps_1.normalizeBodySiteKey)(textValue);
2527
+ if (!normalized) {
2528
+ return undefined;
2529
+ }
2530
+ return { text: textValue, canonical: normalized };
2531
+ };
2532
+ if (isAdjectivalSitePhrase(canonicalInput)) {
2533
+ const direct = resolvePreferred(canonicalInput);
2534
+ return (_a = direct === null || direct === void 0 ? void 0 : direct.text) !== null && _a !== void 0 ? _a : trimmed;
2535
+ }
2536
+ const words = canonicalInput.split(/\s+/).filter((word) => word.length > 0);
2537
+ for (let i = 1; i < words.length; i++) {
2538
+ const prefix = words.slice(0, i);
2539
+ if (!prefix.every((word) => isAdjectivalSitePhrase(word))) {
2540
+ continue;
2541
+ }
2542
+ const candidateCanonical = words.slice(i).join(" ");
2543
+ if (!candidateCanonical) {
2544
+ continue;
2545
+ }
2546
+ const candidatePreferred = resolvePreferred(candidateCanonical);
2547
+ if (!candidatePreferred) {
2548
+ continue;
2549
+ }
2550
+ const prefixMatches = prefix.every((word) => {
2551
+ const normalizedPrefix = resolvePreferred(word);
2552
+ return (normalizedPrefix !== undefined &&
2553
+ normalizedPrefix.canonical === candidatePreferred.canonical);
2554
+ });
2555
+ if (!prefixMatches) {
2556
+ continue;
2557
+ }
2558
+ return candidatePreferred.text;
2559
+ }
2560
+ return trimmed;
2561
+ }
2562
+ function pickPreferredBodySitePhrase(canonical, definition, customSiteMap) {
2563
+ const synonyms = new Set();
2564
+ synonyms.add(canonical);
2565
+ if (definition.aliases) {
2566
+ for (const alias of definition.aliases) {
2567
+ const normalizedAlias = (0, maps_1.normalizeBodySiteKey)(alias);
2568
+ if (normalizedAlias) {
2569
+ synonyms.add(normalizedAlias);
2570
+ }
2571
+ }
2572
+ }
2573
+ const defaultSynonyms = DEFAULT_SITE_SYNONYM_KEYS.get(definition);
2574
+ if (defaultSynonyms) {
2575
+ for (const synonym of defaultSynonyms) {
2576
+ synonyms.add(synonym);
2577
+ }
2578
+ }
2579
+ if (customSiteMap) {
2580
+ for (const [key, candidate] of (0, object_1.objectEntries)(customSiteMap)) {
2581
+ if (!candidate) {
2582
+ continue;
2583
+ }
2584
+ if (candidate === definition) {
2585
+ const normalizedKey = (0, maps_1.normalizeBodySiteKey)(key);
2586
+ if (normalizedKey) {
2587
+ synonyms.add(normalizedKey);
2588
+ }
2589
+ if (candidate.aliases) {
2590
+ for (const alias of candidate.aliases) {
2591
+ const normalizedAlias = (0, maps_1.normalizeBodySiteKey)(alias);
2592
+ if (normalizedAlias) {
2593
+ synonyms.add(normalizedAlias);
2594
+ }
2595
+ }
2596
+ }
2597
+ }
2598
+ }
2599
+ }
2600
+ const candidates = Array.from(synonyms).filter((phrase) => phrase && !isAdjectivalSitePhrase(phrase));
2601
+ if (!candidates.length) {
2602
+ return undefined;
2603
+ }
2604
+ candidates.sort((a, b) => scoreBodySitePhrase(b) - scoreBodySitePhrase(a));
2605
+ const best = candidates[0];
2606
+ if (!best) {
2607
+ return undefined;
2608
+ }
2609
+ if ((0, maps_1.normalizeBodySiteKey)(best) === canonical) {
2610
+ return undefined;
2611
+ }
2612
+ return best;
2613
+ }
2614
+ function scoreBodySitePhrase(phrase) {
2615
+ const lower = phrase.toLowerCase();
2616
+ const words = lower.split(/\s+/).filter((part) => part.length > 0);
2617
+ let score = 0;
2618
+ if (!/(structure|region|entire|proper|body)/.test(lower)) {
2619
+ score += 3;
2620
+ }
2621
+ if (!lower.includes(" of ")) {
2622
+ score += 1;
2623
+ }
2624
+ if (words.length <= 2) {
2625
+ score += 1;
2626
+ }
2627
+ if (words.length === 1) {
2628
+ score += 0.5;
2629
+ }
2630
+ score -= words.length * 0.2;
2631
+ score -= lower.length * 0.01;
2632
+ return score;
2633
+ }
2634
+ function isAdjectivalSitePhrase(phrase) {
2635
+ const normalized = phrase.trim().toLowerCase();
2636
+ if (!normalized) {
2637
+ return false;
2638
+ }
2639
+ const words = normalized.split(/\s+/).filter((word) => word.length > 0);
2640
+ if (words.length !== 1) {
2641
+ return false;
2642
+ }
2643
+ const last = words[words.length - 1];
2644
+ if (last.length <= 3) {
2645
+ return false;
2646
+ }
2647
+ return BODY_SITE_ADJECTIVE_SUFFIXES.some((suffix) => last.endsWith(suffix));
2648
+ }
2402
2649
  function collectAdditionalInstructions(internal, tokens) {
2403
2650
  var _a, _b, _c, _d, _e, _f;
2404
2651
  if (internal.additionalInstructions.length) {
2405
2652
  return;
2406
2653
  }
2407
- const leftover = tokens.filter((token) => !internal.consumed.has(token.index));
2408
- if (!leftover.length) {
2409
- return;
2410
- }
2411
2654
  const punctuationOnly = /^[;:.,-]+$/;
2412
- const contentTokens = leftover.filter((token) => !punctuationOnly.test(token.original));
2413
- if (!contentTokens.length) {
2655
+ const trailing = [];
2656
+ let expectedIndex;
2657
+ for (let cursor = tokens.length - 1; cursor >= 0; cursor--) {
2658
+ const token = tokens[cursor];
2659
+ if (!token) {
2660
+ continue;
2661
+ }
2662
+ if (internal.consumed.has(token.index)) {
2663
+ if (trailing.length > 0) {
2664
+ break;
2665
+ }
2666
+ continue;
2667
+ }
2668
+ if (expectedIndex !== undefined && token.index !== expectedIndex - 1) {
2669
+ break;
2670
+ }
2671
+ trailing.unshift(token);
2672
+ expectedIndex = token.index;
2673
+ }
2674
+ if (!trailing.length) {
2414
2675
  return;
2415
2676
  }
2416
- const leftoverIndices = leftover.map((token) => token.index).sort((a, b) => a - b);
2417
- const contiguous = leftoverIndices.every((index, i) => i === 0 || index === leftoverIndices[i - 1] + 1);
2418
- if (!contiguous) {
2677
+ const contentTokens = trailing.filter((token) => !punctuationOnly.test(token.original));
2678
+ if (!contentTokens.length) {
2419
2679
  return;
2420
2680
  }
2421
- const lastIndex = leftoverIndices[leftoverIndices.length - 1];
2681
+ const trailingIndices = trailing.map((token) => token.index).sort((a, b) => a - b);
2682
+ const lastIndex = trailingIndices[trailingIndices.length - 1];
2422
2683
  for (let i = lastIndex + 1; i < tokens.length; i++) {
2423
- const trailingToken = tokens[i];
2424
- if (!internal.consumed.has(trailingToken.index)) {
2684
+ const nextToken = tokens[i];
2685
+ if (!nextToken) {
2686
+ continue;
2687
+ }
2688
+ if (!internal.consumed.has(nextToken.index)) {
2425
2689
  return;
2426
2690
  }
2427
2691
  }
@@ -2434,7 +2698,33 @@ function collectAdditionalInstructions(internal, tokens) {
2434
2698
  return;
2435
2699
  }
2436
2700
  const contentIndices = contentTokens.map((token) => token.index).sort((a, b) => a - b);
2437
- const range = computeTokenRange(internal.input, tokens, contentIndices);
2701
+ const lowerInput = internal.input.toLowerCase();
2702
+ let trailingRange;
2703
+ let searchEnd = lowerInput.length;
2704
+ let rangeStart;
2705
+ let rangeEnd;
2706
+ for (let i = contentTokens.length - 1; i >= 0; i--) {
2707
+ const fragment = contentTokens[i].original.trim();
2708
+ if (!fragment) {
2709
+ continue;
2710
+ }
2711
+ const lowerFragment = fragment.toLowerCase();
2712
+ const foundIndex = lowerInput.lastIndexOf(lowerFragment, searchEnd - 1);
2713
+ if (foundIndex === -1) {
2714
+ rangeStart = undefined;
2715
+ rangeEnd = undefined;
2716
+ break;
2717
+ }
2718
+ rangeStart = foundIndex;
2719
+ if (rangeEnd === undefined) {
2720
+ rangeEnd = foundIndex + lowerFragment.length;
2721
+ }
2722
+ searchEnd = foundIndex;
2723
+ }
2724
+ if (rangeStart !== undefined && rangeEnd !== undefined) {
2725
+ trailingRange = { start: rangeStart, end: rangeEnd };
2726
+ }
2727
+ const range = trailingRange !== null && trailingRange !== void 0 ? trailingRange : computeTokenRange(internal.input, tokens, contentIndices);
2438
2728
  let separatorDetected = false;
2439
2729
  if (range) {
2440
2730
  for (let cursor = range.start - 1; cursor >= 0; cursor--) {
@@ -2499,11 +2789,147 @@ function collectAdditionalInstructions(internal, tokens) {
2499
2789
  }
2500
2790
  if (instructions.length) {
2501
2791
  internal.additionalInstructions = instructions;
2502
- for (const token of leftover) {
2792
+ for (const token of trailing) {
2503
2793
  mark(internal.consumed, token);
2504
2794
  }
2505
2795
  }
2506
2796
  }
2797
+ function determinePrnReasonCutoff(tokens, sourceText) {
2798
+ const separatorIndex = findPrnReasonSeparator(sourceText);
2799
+ if (separatorIndex === undefined) {
2800
+ return undefined;
2801
+ }
2802
+ const lowerSource = sourceText.toLowerCase();
2803
+ let searchOffset = 0;
2804
+ for (let i = 0; i < tokens.length; i++) {
2805
+ const token = tokens[i];
2806
+ const fragment = token.original.trim();
2807
+ if (!fragment) {
2808
+ continue;
2809
+ }
2810
+ const lowerFragment = fragment.toLowerCase();
2811
+ const position = lowerSource.indexOf(lowerFragment, searchOffset);
2812
+ if (position === -1) {
2813
+ continue;
2814
+ }
2815
+ const end = position + lowerFragment.length;
2816
+ searchOffset = end;
2817
+ if (position >= separatorIndex) {
2818
+ return i;
2819
+ }
2820
+ }
2821
+ return undefined;
2822
+ }
2823
+ function findPrnReasonSeparator(sourceText) {
2824
+ var _a;
2825
+ for (let i = 0; i < sourceText.length; i++) {
2826
+ const ch = sourceText[i];
2827
+ if (ch === "\n" || ch === "\r") {
2828
+ if (sourceText.slice(i + 1).trim().length > 0) {
2829
+ return i;
2830
+ }
2831
+ continue;
2832
+ }
2833
+ if (ch === ";") {
2834
+ if (sourceText.slice(i + 1).trim().length > 0) {
2835
+ return i;
2836
+ }
2837
+ continue;
2838
+ }
2839
+ if (ch === "-") {
2840
+ const prev = sourceText[i - 1];
2841
+ const next = sourceText[i + 1];
2842
+ const hasWhitespaceAround = (!prev || /\s/.test(prev)) && (!next || /\s/.test(next));
2843
+ if (hasWhitespaceAround && sourceText.slice(i + 1).trim().length > 0) {
2844
+ return i;
2845
+ }
2846
+ continue;
2847
+ }
2848
+ if (ch === ":" || ch === ".") {
2849
+ const rest = sourceText.slice(i + 1);
2850
+ if (!rest.trim().length) {
2851
+ continue;
2852
+ }
2853
+ const nextChar = rest.replace(/^\s+/, "")[0];
2854
+ if (!nextChar) {
2855
+ continue;
2856
+ }
2857
+ if (ch === "." &&
2858
+ /[0-9]/.test((_a = sourceText[i - 1]) !== null && _a !== void 0 ? _a : "") &&
2859
+ /[0-9]/.test(nextChar)) {
2860
+ continue;
2861
+ }
2862
+ return i;
2863
+ }
2864
+ }
2865
+ return undefined;
2866
+ }
2867
+ function findTrailingPrnSiteSuffix(tokens, internal, options) {
2868
+ var _a;
2869
+ let suffixStart;
2870
+ let hasSiteHint = false;
2871
+ let hasConnector = false;
2872
+ for (let i = tokens.length - 1; i >= 0; i--) {
2873
+ const token = tokens[i];
2874
+ const lower = normalizeTokenLower(token);
2875
+ if (!lower) {
2876
+ if (suffixStart !== undefined && token.original.trim()) {
2877
+ break;
2878
+ }
2879
+ continue;
2880
+ }
2881
+ if (isBodySiteHint(lower, internal.customSiteHints)) {
2882
+ hasSiteHint = true;
2883
+ suffixStart = i;
2884
+ continue;
2885
+ }
2886
+ if (suffixStart !== undefined) {
2887
+ if (SITE_CONNECTORS.has(lower)) {
2888
+ hasConnector = true;
2889
+ suffixStart = i;
2890
+ continue;
2891
+ }
2892
+ if (SITE_FILLER_WORDS.has(lower) || ROUTE_DESCRIPTOR_FILLER_WORDS.has(lower)) {
2893
+ suffixStart = i;
2894
+ continue;
2895
+ }
2896
+ }
2897
+ if (suffixStart !== undefined) {
2898
+ break;
2899
+ }
2900
+ }
2901
+ if (!hasSiteHint || !hasConnector || suffixStart === undefined || suffixStart === 0) {
2902
+ return undefined;
2903
+ }
2904
+ const suffixTokens = tokens.slice(suffixStart);
2905
+ const siteWords = [];
2906
+ for (const token of suffixTokens) {
2907
+ const trimmed = token.original.trim();
2908
+ if (!trimmed) {
2909
+ continue;
2910
+ }
2911
+ const lower = normalizeTokenLower(token);
2912
+ if (SITE_CONNECTORS.has(lower) ||
2913
+ SITE_FILLER_WORDS.has(lower) ||
2914
+ ROUTE_DESCRIPTOR_FILLER_WORDS.has(lower)) {
2915
+ continue;
2916
+ }
2917
+ siteWords.push(trimmed);
2918
+ }
2919
+ if (!siteWords.length) {
2920
+ return undefined;
2921
+ }
2922
+ const sitePhrase = siteWords.join(" ");
2923
+ const canonical = (0, maps_1.normalizeBodySiteKey)(sitePhrase);
2924
+ if (!canonical) {
2925
+ return undefined;
2926
+ }
2927
+ const definition = (_a = lookupBodySiteDefinition(options === null || options === void 0 ? void 0 : options.siteCodeMap, canonical)) !== null && _a !== void 0 ? _a : maps_1.DEFAULT_BODY_SITE_SNOMED[canonical];
2928
+ if (!definition) {
2929
+ return undefined;
2930
+ }
2931
+ return suffixStart;
2932
+ }
2507
2933
  function lookupPrnReasonDefinition(map, canonical) {
2508
2934
  if (!map) {
2509
2935
  return undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ezmedicationinput",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Parse concise medication sigs into FHIR R5 Dosage JSON",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",