fhirsmith 0.16.1 → 0.18.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.
Files changed (142) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +105 -30
  3. package/config-template.json +130 -4
  4. package/extension-tracker/extension-tracker.js +111 -3
  5. package/extension-tracker/readme.md +3 -0
  6. package/ips/ai-client.js +263 -0
  7. package/ips/composer.js +805 -0
  8. package/ips/editor-schema.js +289 -0
  9. package/ips/editor.js +290 -0
  10. package/ips/fhirpath-ips.js +397 -0
  11. package/ips/ips-codeable-concepts.md +1742 -0
  12. package/ips/ips-template.html +125 -0
  13. package/ips/ips-valuesets.js +159 -0
  14. package/ips/ips.js +1736 -0
  15. package/ips/membership.js +179 -0
  16. package/ips/pipeline.js +1012 -0
  17. package/ips/presenter.js +974 -0
  18. package/ips/profile-editor.js +578 -0
  19. package/ips/profiles.js +506 -0
  20. package/ips/readme.md +460 -0
  21. package/ips/render.js +1482 -0
  22. package/ips/search-params.js +83 -0
  23. package/ips/search.js +667 -0
  24. package/ips/share.js +178 -0
  25. package/ips/store.js +650 -0
  26. package/ips/terminology.js +395 -0
  27. package/ips/tx-client.js +202 -0
  28. package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
  29. package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
  30. package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
  31. package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
  32. package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
  33. package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
  34. package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
  35. package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
  36. package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
  37. package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
  38. package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
  39. package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
  40. package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
  41. package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
  42. package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
  43. package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
  44. package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
  45. package/ips/valuesets/ValueSet-ips-medication.json +50 -0
  46. package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
  47. package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
  48. package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
  49. package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
  50. package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
  51. package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
  52. package/ips/valuesets/ValueSet-ips-route.json +46 -0
  53. package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
  54. package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
  55. package/ips/valuesets/elements.json +44 -0
  56. package/library/daily-report.js +422 -0
  57. package/library/fhir-search-values.js +138 -0
  58. package/library/html-server.js +32 -23
  59. package/library/html.js +9 -0
  60. package/library/mailer.js +77 -0
  61. package/library/narrative-check.js +222 -0
  62. package/library/npm-audit.js +25 -0
  63. package/library/package-manager.js +4 -2
  64. package/library/sqlite-session-store.js +29 -1
  65. package/package.json +8 -2
  66. package/packages/package-crawler.js +189 -82
  67. package/packages/packages.js +36 -63
  68. package/packages/utilities/dedupe-versions.js +22 -8
  69. package/publisher/publisher.js +29 -0
  70. package/registry/auth-store.js +366 -0
  71. package/registry/auth.js +872 -0
  72. package/registry/crawler.js +48 -1
  73. package/registry/documentation/api.md +162 -0
  74. package/registry/documentation/configuration.md +93 -0
  75. package/registry/documentation/documents.md +74 -0
  76. package/registry/documentation/internals.md +151 -0
  77. package/registry/documentation/readme.md +100 -0
  78. package/registry/documentation/security.md +199 -0
  79. package/registry/documents.js +309 -0
  80. package/registry/openapi.yaml +30 -0
  81. package/registry/registry-template.html +1 -1
  82. package/registry/registry.js +247 -54
  83. package/registry/uptime.js +154 -0
  84. package/release.sh +80 -0
  85. package/server.js +105 -44
  86. package/shl/shl.js +64 -9
  87. package/stats-db.js +93 -0
  88. package/stats.js +158 -1
  89. package/testing/readme.md +1 -1
  90. package/testing/search.js +2 -108
  91. package/testing/store.js +5 -0
  92. package/testing/testing.js +20 -0
  93. package/token/token-template.html +5 -4
  94. package/token/token.js +17 -10
  95. package/translations/Messages.properties +9 -0
  96. package/tx/README.md +52 -0
  97. package/tx/cm/cm-database.js +62 -0
  98. package/tx/cm/cm-package.js +20 -3
  99. package/tx/cs/cs-api.js +90 -14
  100. package/tx/cs/cs-cs.js +142 -19
  101. package/tx/cs/cs-icd11.js +366 -112
  102. package/tx/cs/cs-icf.js +418 -0
  103. package/tx/cs/cs-loinc.js +79 -39
  104. package/tx/cs/cs-omop.js +10 -3
  105. package/tx/cs/cs-snomed.js +918 -55
  106. package/tx/cs/cs-ucum.js +6 -0
  107. package/tx/cs/icd11-expressions.js +191 -78
  108. package/tx/importers/export-icd11-codesystem.js +58 -12
  109. package/tx/importers/icd11-schema.md +9 -5
  110. package/tx/library.js +107 -6
  111. package/tx/ocl/README.md +62 -6
  112. package/tx/ocl/cm-ocl.cjs +166 -44
  113. package/tx/ocl/cs-ocl.cjs +163 -13
  114. package/tx/ocl/resolve/public-access.js +143 -0
  115. package/tx/ocl/resolve/reference-resolver.js +458 -0
  116. package/tx/ocl/vs-ocl.cjs +167 -4
  117. package/tx/openapi-operations.js +4 -0
  118. package/tx/params.js +8 -1
  119. package/tx/problems.js +7 -5
  120. package/tx/provider.js +21 -5
  121. package/tx/sct/documentation.md +251 -0
  122. package/tx/sct/ecl.js +758 -46
  123. package/tx/sct/expressions.js +113 -52
  124. package/tx/syndication/syndication-feed.js +218 -0
  125. package/tx/syndication/syndication-store.js +415 -0
  126. package/tx/tx-html.js +37 -8
  127. package/tx/tx.fhir.org.yml +2 -3
  128. package/tx/tx.js +148 -5
  129. package/tx/vs/vs-database.js +26 -0
  130. package/tx/vs/vs-package.js +42 -10
  131. package/tx/vs/vs-vsac.js +25 -6
  132. package/tx/workers/expand.js +34 -7
  133. package/tx/workers/lookup.js +61 -6
  134. package/tx/workers/metadata.js +6 -1
  135. package/tx/workers/subsumes.js +1 -1
  136. package/tx/workers/translate.js +166 -15
  137. package/tx/workers/validate.js +128 -6
  138. package/utilities/make-sct-test-extension.js +264 -0
  139. package/utilities/submit-test-report.js +1 -1
  140. package/xig/xig.js +120 -12
  141. package/gr.sh +0 -3
  142. package/registry/readme.md +0 -124
@@ -0,0 +1,1012 @@
1
+ //
2
+ // Copyright 2026, Health Intersections Pty Ltd (http://www.healthintersections.com.au)
3
+ //
4
+ // Licensed under BSD-3: https://opensource.org/license/bsd-3-clause
5
+ //
6
+
7
+ /**
8
+ * The processing pipeline: what happens to an IPS document once it has been received.
9
+ * One process works through the queue (ips.js: processQueue), and runs these steps over
10
+ * each document, in this order:
11
+ *
12
+ * 1. translate translate CodeableConcepts that fail the terminology check to codes that pass it
13
+ * 2. narrative generate the narrative with the IPS presenter (presenter.js), into the analysed copy
14
+ * 3. validation validate the (translated) document with the FHIR validator (fhir-validator-wrapper)
15
+ * 4. terminology check each CodeableConcept against the value sets for international exchange
16
+ * 5. piqi measure the quality of the document with PIQI (not yet)
17
+ * 6. profiles check the document against each clinical profile (profiles.js)
18
+ * 7. ai assess the document for consistency and its terminology with an AI service (ai-client.js)
19
+ *
20
+ * Each step is { name, run(row, project, module) -> result }. A step keeps what it finds in
21
+ * one of two places: as a result stored against the document (store.saveResult - the
22
+ * validation outcome), or written into row.analysed, a second copy of the document that
23
+ * the steps adorn with extensions (the terminology check); the master document is never
24
+ * changed. A step that throws marks the document 'error' with its message and stops the
25
+ * pipeline; a step that returns normally gives a note for the document's status line.
26
+ * Steps not yet written are simply not in the list.
27
+ *
28
+ * @module ips/pipeline
29
+ */
30
+
31
+ const { terminologyOf } = require('./terminology');
32
+ const { isHl7System } = require('./ips-valuesets');
33
+ const { Presenter } = require('./presenter');
34
+ const ai = require('./ai-client');
35
+ const { evaluateAsync } = require('./fhirpath-ips');
36
+ const { checkNarrative } = require('../library/narrative-check');
37
+
38
+ const IPS_BUNDLE_PROFILE = 'http://hl7.org/fhir/uv/ips/StructureDefinition/Bundle-uv-ips';
39
+ const VALIDATE_TIMEOUT_MS = 120000;
40
+
41
+ // the extension the terminology check leaves on each CodeableConcept in the analysed copy
42
+ const CHECK_EXTENSION = 'http://healthintersections.com.au/fhirsmith/StructureDefinition/ips-valueset-check';
43
+ // its result codes
44
+ const CHECK_RESULTS = ['in', 'international', 'not-in', 'unknown'];
45
+ // the extension the translate step leaves on each Coding it adds: where it came from
46
+ const TRANSLATION_EXTENSION = 'http://healthintersections.com.au/fhirsmith/StructureDefinition/ips-translation';
47
+ // how the step marks a Coding's extension list, so a resubmitted analysed copy can be cleaned
48
+ const OUR_EXTENSIONS = new Set([CHECK_EXTENSION, TRANSLATION_EXTENSION]);
49
+ // the most terminology-server questions asked for one document; beyond it, concepts are
50
+ // left unknown. A document is the submitter's, and each question carries a value set
51
+ const MAX_CHECKS_PER_DOCUMENT = 2000;
52
+ // answers remembered across documents
53
+ const MAX_CACHED_ANSWERS = 20000;
54
+
55
+ /** The counts of an OperationOutcome's issues, by severity. */
56
+ function issueCounts(outcome) {
57
+ const counts = { fatal: 0, error: 0, warning: 0, information: 0 };
58
+ for (const i of Array.isArray(outcome?.issue) ? outcome.issue : []) {
59
+ if (i && typeof i.severity === 'string' && counts[i.severity] !== undefined) {
60
+ counts[i.severity]++;
61
+ }
62
+ }
63
+ return counts;
64
+ }
65
+
66
+ /** "2 errors, 5 warnings" / "no issues" */
67
+ function issueSummary(counts) {
68
+ const parts = [];
69
+ const word = (n, s) => `${n} ${s}${n === 1 ? '' : 's'}`;
70
+ if (counts.fatal) {
71
+ parts.push(word(counts.fatal, 'fatal error'));
72
+ }
73
+ if (counts.error) {
74
+ parts.push(word(counts.error, 'error'));
75
+ }
76
+ if (counts.warning) {
77
+ parts.push(word(counts.warning, 'warning'));
78
+ }
79
+ if (counts.information) {
80
+ parts.push(word(counts.information, 'note'));
81
+ }
82
+ return parts.length ? parts.join(', ') : 'no issues';
83
+ }
84
+
85
+ /**
86
+ * Validation: the document goes to the FHIR validator (started by the module from
87
+ * modules.ips.validator), against the IPS Bundle profile unless the configuration says
88
+ * otherwise, with the validator's IPS code checks on, and the OperationOutcome is kept.
89
+ */
90
+ const validation = {
91
+ name: 'validation',
92
+ async run(row, project, module) {
93
+ const v = module.validator;
94
+ if (!v) {
95
+ throw new Error('The validator is not configured (modules.ips.validator)');
96
+ }
97
+ if (!v.isRunning()) {
98
+ throw new Error('The validator is not running');
99
+ }
100
+ const cfg = module.config.validator || {};
101
+ const profiles = Array.isArray(cfg.profiles) ? cfg.profiles : [IPS_BUNDLE_PROFILE];
102
+ // the validator's own IPS checks (code systems and value sets the IPS expects), unless
103
+ // the configuration turns them off
104
+ const checkIpsCodes = cfg.checkIpsCodes !== false;
105
+ const started = Date.now();
106
+ // fhir-validator-wrapper 1.5.0 or later: earlier versions sent the profiles in a form the
107
+ // validator service refuses
108
+ // the analysed copy: the translations are in it, and the validator should see them
109
+ const outcome = await v.validate(JSON.stringify(row.analysed || row.bundle), { profiles, checkIpsCodes, timeout: VALIDATE_TIMEOUT_MS });
110
+ if (!outcome || outcome.resourceType !== 'OperationOutcome') {
111
+ throw new Error('The validator did not return an OperationOutcome');
112
+ }
113
+ const counts = issueCounts(outcome);
114
+ module.store.saveResult(row.id, 'validation', {
115
+ ms: Date.now() - started,
116
+ profiles,
117
+ validatorVersion: typeof v.jarVersion === 'function' ? v.jarVersion() : null,
118
+ counts,
119
+ outcome
120
+ });
121
+ return `validation: ${issueSummary(counts)}`;
122
+ }
123
+ };
124
+
125
+ // ---- the terminology check ------------------------------------------------------------
126
+
127
+ /**
128
+ * Ask the terminology server whether a coding is in a value set. The value set itself goes
129
+ * with the request (as the operation's valueSet parameter), and so do the value sets it
130
+ * includes and the code systems of our own it draws on (as tx-resource parameters), so the
131
+ * server needn't know them.
132
+ *
133
+ * @returns {Promise<{result: boolean|null, message: string|null}>} null when the server
134
+ * didn't say true or false
135
+ */
136
+ async function validateCodeInValueSet(tx, valueSets, valueSetUrl, coding) {
137
+ const vs = valueSets.valueSet(valueSetUrl);
138
+ const params = { resourceType: 'Parameters', parameter: [] };
139
+ if (vs) {
140
+ params.parameter.push({ name: 'valueSet', resource: vs });
141
+ for (const other of valueSets.referencedResources(valueSetUrl)) {
142
+ params.parameter.push({ name: 'tx-resource', resource: other });
143
+ }
144
+ } else {
145
+ params.parameter.push({ name: 'url', valueUri: valueSetUrl });
146
+ }
147
+ // the code, not its display: the question is whether the code is suitable (the validator
148
+ // checks displays), and the answer is cached by system, version and code alone
149
+ params.parameter.push({ name: 'coding', valueCoding: codeOnly(coding) });
150
+ return answerOf(tx, await ask(tx, '/ValueSet/$validate-code', params));
151
+ }
152
+
153
+ /**
154
+ * Ask the terminology server whether a code is in a code system at all (the fallback for a
155
+ * code in an HL7 terminology, which has no value set here).
156
+ */
157
+ async function validateCodeInSystem(tx, coding) {
158
+ const params = { resourceType: 'Parameters', parameter: [{ name: 'url', valueUri: coding.system }] };
159
+ if (typeof coding.version === 'string') {
160
+ params.parameter.push({ name: 'version', valueString: coding.version });
161
+ }
162
+ params.parameter.push({ name: 'code', valueCode: coding.code });
163
+ // no display, for the same reason as validateCodeInValueSet
164
+ return answerOf(tx, await ask(tx, '/CodeSystem/$validate-code', params));
165
+ }
166
+
167
+ /** A coding's system, version and code - what a membership question is about. */
168
+ function codeOnly(coding) {
169
+ const c = { system: coding.system, code: coding.code };
170
+ if (typeof coding.version === 'string') {
171
+ c.version = coding.version;
172
+ }
173
+ return c;
174
+ }
175
+
176
+ function plainCoding(coding) {
177
+ const c = { system: coding.system, code: coding.code };
178
+ if (typeof coding.version === 'string') {
179
+ c.version = coding.version;
180
+ }
181
+ if (typeof coding.display === 'string') {
182
+ c.display = coding.display;
183
+ }
184
+ return c;
185
+ }
186
+
187
+ async function ask(tx, operation, params) {
188
+ try {
189
+ return await tx.post(operation, params);
190
+ } catch (e) {
191
+ // the detail (host, port, path) is for the log, not the page
192
+ if (tx.log) {
193
+ tx.log.error(`IPS module: the terminology server could not be reached: ${e.message}`);
194
+ }
195
+ return null;
196
+ }
197
+ }
198
+
199
+ /**
200
+ * What a $validate-code response says: true, false, or null with the reason it said neither;
201
+ * and whether the server called the code inactive.
202
+ */
203
+ function answerOf(tx, r) {
204
+ if (!r) {
205
+ return { result: null, message: 'The terminology server could not be reached' };
206
+ }
207
+ const res = r.resource;
208
+ const issueText = (oo) => (Array.isArray(oo?.issue) ? oo.issue : []).map(i => i?.details?.text || i?.diagnostics).filter(Boolean).join('; ');
209
+ if (res?.resourceType === 'Parameters') {
210
+ const result = (res.parameter || []).find(p => p?.name === 'result');
211
+ if (result && typeof result.valueBoolean === 'boolean') {
212
+ const message = (res.parameter || []).find(p => p?.name === 'message');
213
+ const inactive = (res.parameter || []).find(p => p?.name === 'inactive');
214
+ return { result: result.valueBoolean, message: typeof message?.valueString === 'string' ? message.valueString : null, inactive: inactive?.valueBoolean === true };
215
+ }
216
+ const issues = (res.parameter || []).find(p => p?.name === 'issues');
217
+ return { result: null, message: issueText(issues?.resource) || 'The terminology server gave no result' };
218
+ }
219
+ if (res?.resourceType === 'OperationOutcome') {
220
+ return { result: null, message: issueText(res) || `The terminology server answered HTTP ${r.status}` };
221
+ }
222
+ if (r.status === 404 && tx.internal) {
223
+ return { result: null, message: 'The terminology server is not configured correctly (see the server log)' };
224
+ }
225
+ return { result: null, message: `The terminology server answered HTTP ${r.status}` };
226
+ }
227
+
228
+ /**
229
+ * Check a CodeableConcept: is any coding in the element's value set (if it has one)? If
230
+ * not, is any coding international anyway - in the fallback value set (SNOMED CT
231
+ * International, LOINC), or a code in an HL7 terminology? Answers are cached on the module
232
+ * (module.txCache) across documents.
233
+ *
234
+ * @param {Object} concept
235
+ * @param {string|null} valueSetUrl - the element's value set, or null when it has none
236
+ * @returns {Promise<{result: 'in'|'international'|'not-in'|'unknown', valueSet?: string,
237
+ * system?: string, coding?: Object, message: string}>} - result 'in' when a coding is in
238
+ * the element's value set, or when the element has no value set and a coding is
239
+ * international; 'international' when the element has a value set but only the fallback
240
+ * accepts a coding
241
+ */
242
+ async function checkConcept(module, concept, valueSetUrl, budget = { left: Infinity }) {
243
+ const codings = (Array.isArray(concept.coding) ? concept.coding : [])
244
+ .filter(c => c && typeof c === 'object' && typeof c.system === 'string' && typeof c.code === 'string');
245
+ if (codings.length === 0) {
246
+ return { result: 'not-in', message: 'The concept has no codings' };
247
+ }
248
+ const valueSets = module.valueSets;
249
+ const cache = module.txCache || (module.txCache = new Map());
250
+ const remember = (key, answer) => {
251
+ if (cache.size >= MAX_CACHED_ANSWERS) {
252
+ // the oldest goes: a Map iterates in insertion order
253
+ cache.delete(cache.keys().next().value);
254
+ }
255
+ cache.set(key, answer);
256
+ };
257
+ const unknowns = [];
258
+ // one question, through the cache and the budget
259
+ const answered = async (key, what, question) => {
260
+ let answer = cache.get(key);
261
+ if (answer === undefined) {
262
+ if (budget.left <= 0) {
263
+ unknowns.push(`${what}: not checked - the document has more codes than are checked (${MAX_CHECKS_PER_DOCUMENT})`);
264
+ return null;
265
+ }
266
+ budget.left--;
267
+ answer = await question();
268
+ // a server that could not answer is asked again next time; true and false are kept
269
+ if (answer.result !== null) {
270
+ remember(key, answer);
271
+ }
272
+ }
273
+ if (answer.result === null) {
274
+ unknowns.push(`${what}: ${answer.message}`);
275
+ }
276
+ return answer;
277
+ };
278
+ const inactive = (answer) => (answer.inactive ? ' (the code is inactive)' : '');
279
+ const codingKey = (c) => `${c.system}|${c.version || ''}|${c.code}`;
280
+ if (valueSetUrl) {
281
+ for (const coding of codings) {
282
+ const r = await answered(`${valueSetUrl}|${codingKey(coding)}`, `${coding.system}|${coding.code} in ${valueSetUrl}`,
283
+ () => validateCodeInValueSet(module.tx, valueSets, valueSetUrl, coding));
284
+ if (r?.result === true) {
285
+ return { result: 'in', valueSet: valueSetUrl, coding, message: `${coding.system}|${coding.code} is in ${valueSetUrl}${inactive(r)}` };
286
+ }
287
+ }
288
+ }
289
+ // the fallback: international anyway?
290
+ for (const coding of codings) {
291
+ if (isHl7System(coding.system)) {
292
+ const r = await answered(`${coding.system}|${codingKey(coding)}`, `${coding.system}|${coding.code}`,
293
+ () => validateCodeInSystem(module.tx, coding));
294
+ if (r?.result === true) {
295
+ return {
296
+ result: valueSetUrl ? 'international' : 'in', system: coding.system, coding,
297
+ message: `${coding.system}|${coding.code} is a code in an HL7 terminology` + (valueSetUrl ? `, but not in ${valueSetUrl}` : '') + inactive(r)
298
+ };
299
+ }
300
+ } else {
301
+ const r = await answered(`${valueSets.fallback}|${codingKey(coding)}`, `${coding.system}|${coding.code} in ${valueSets.fallback}`,
302
+ () => validateCodeInValueSet(module.tx, valueSets, valueSets.fallback, coding));
303
+ if (r?.result === true) {
304
+ return {
305
+ result: valueSetUrl ? 'international' : 'in', valueSet: valueSets.fallback, coding,
306
+ message: `${coding.system}|${coding.code} is in ${valueSets.fallback}` + (valueSetUrl ? `, but not in ${valueSetUrl}` : '') + inactive(r)
307
+ };
308
+ }
309
+ }
310
+ }
311
+ if (unknowns.length > 0) {
312
+ return { result: 'unknown', message: 'Could not be determined - ' + unknowns.join('; ') };
313
+ }
314
+ return {
315
+ result: 'not-in',
316
+ message: (valueSetUrl ? `No coding is in ${valueSetUrl}, and none` : 'No coding') + ' is in an international terminology (SNOMED CT International, LOINC, or an HL7 terminology)'
317
+ };
318
+ }
319
+
320
+ /** The check's extension on a CodeableConcept in the analysed copy, if there is one. */
321
+ function checkOf(concept) {
322
+ const ext = (Array.isArray(concept?.extension) ? concept.extension : []).find(e => e?.url === CHECK_EXTENSION);
323
+ if (!ext) {
324
+ return null;
325
+ }
326
+ const sub = (name) => (Array.isArray(ext.extension) ? ext.extension : []).filter(e => e?.url === name);
327
+ const result = sub('result')[0]?.valueCode;
328
+ return {
329
+ result: CHECK_RESULTS.includes(result) ? result : 'unknown',
330
+ valueSet: sub('valueSet')[0]?.valueCanonical || null,
331
+ system: sub('system')[0]?.valueUri || null,
332
+ coding: sub('coding')[0]?.valueCoding || null,
333
+ message: sub('message')[0]?.valueString || null
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Remove the check's and the translations' extensions from a document - a resubmitted
339
+ * analysed copy carries the last run's; the analysed copy starts clean. (A translated coding
340
+ * stays: resubmitted, it is the submitter's.)
341
+ */
342
+ function stripChecks(node) {
343
+ if (Array.isArray(node)) {
344
+ node.forEach(stripChecks);
345
+ } else if (node && typeof node === 'object') {
346
+ if (Array.isArray(node.extension)) {
347
+ node.extension = node.extension.filter(e => !OUR_EXTENSIONS.has(e?.url));
348
+ if (node.extension.length === 0) {
349
+ delete node.extension;
350
+ }
351
+ }
352
+ Object.values(node).forEach(stripChecks);
353
+ }
354
+ return node;
355
+ }
356
+
357
+ /**
358
+ * Write the check's extension onto a CodeableConcept (in the analysed copy): the result, the
359
+ * value set the element is held to (or, for a fallback answer, the value set or code system
360
+ * that accepted the coding), the coding that was accepted, and the message.
361
+ */
362
+ function addCheck(concept, outcome, valueSetUrl) {
363
+ const ext = { url: CHECK_EXTENSION, extension: [{ url: 'result', valueCode: outcome.result }] };
364
+ const vs = outcome.result === 'in' || outcome.result === 'international' ? outcome.valueSet : valueSetUrl;
365
+ if (vs) {
366
+ ext.extension.push({ url: 'valueSet', valueCanonical: vs });
367
+ }
368
+ if (outcome.system) {
369
+ ext.extension.push({ url: 'system', valueUri: outcome.system });
370
+ }
371
+ if (outcome.coding) {
372
+ ext.extension.push({ url: 'coding', valueCoding: outcome.coding });
373
+ }
374
+ if (outcome.message) {
375
+ ext.extension.push({ url: 'message', valueString: outcome.message });
376
+ }
377
+ if (!Array.isArray(concept.extension)) {
378
+ concept.extension = [];
379
+ }
380
+ concept.extension.push(ext);
381
+ }
382
+
383
+ /**
384
+ * Terminology: every CodeableConcept the terminology view lists (terminology.js), checked
385
+ * against the value set this module holds its element to for international exchange
386
+ * (ips-valuesets.js), and failing that against the international fallback. The answer goes
387
+ * on the concept in the analysed copy as an extension - in, international (but not in the
388
+ * element's value set), not-in, or unknown when the server could not say.
389
+ */
390
+ const terminology = {
391
+ name: 'terminology',
392
+ async run(row, project, module) {
393
+ if (!module.tx) {
394
+ throw new Error('No terminology server is configured (modules.ips.txServer)');
395
+ }
396
+ const valueSets = module.valueSets;
397
+ if (!valueSets || !valueSets.loaded) {
398
+ throw new Error('The value sets have not been loaded');
399
+ }
400
+ if (!row.analysed) {
401
+ throw new Error('No analysed copy of the document to write to');
402
+ }
403
+ const t = terminologyOf(row.analysed);
404
+ const concepts = [...t.composition];
405
+ for (const s of t.sections) {
406
+ for (const e of s.entries) {
407
+ concepts.push(...e.concepts);
408
+ }
409
+ }
410
+ const counts = { in: 0, international: 0, 'not-in': 0, unknown: 0 };
411
+ const budget = { left: MAX_CHECKS_PER_DOCUMENT };
412
+ // the same concept (same codings, same value set) is answered once per document
413
+ const answered = new Map();
414
+ for (const c of concepts) {
415
+ if (checkOf(c.concept)) {
416
+ continue; // the same resource in two sections
417
+ }
418
+ const valueSetUrl = valueSets.valueSetFor(c.element);
419
+ const key = JSON.stringify([c.concept.coding, valueSetUrl]);
420
+ let outcome = answered.get(key);
421
+ if (!outcome) {
422
+ outcome = await checkConcept(module, c.concept, valueSetUrl, budget);
423
+ answered.set(key, outcome);
424
+ }
425
+ addCheck(c.concept, outcome, valueSetUrl);
426
+ counts[outcome.result]++;
427
+ }
428
+ return `terminology: ${counts.in} in, ${counts.international} international only, ${counts['not-in']} not in, ${counts.unknown} unknown`;
429
+ }
430
+ };
431
+
432
+ // ---- translation ------------------------------------------------------------------------
433
+
434
+ /**
435
+ * Ask the terminology server to translate a CodeableConcept into a value set
436
+ * (ConceptMap/$translate with sourceCodeableConcept and targetScope; the value set and what
437
+ * it draws on go as tx-resource). The server picks the concept maps.
438
+ *
439
+ * @returns {Promise<{matches: Array<{coding: Object, relationship: string|null, conceptMap: string|null}>, message: string|null, failed: boolean}>}
440
+ */
441
+ async function translateConcept(tx, valueSets, concept, valueSetUrl) {
442
+ const params = { resourceType: 'Parameters', parameter: [
443
+ { name: 'sourceCodeableConcept', valueCodeableConcept: concept },
444
+ { name: 'targetScope', valueUri: valueSetUrl }
445
+ ] };
446
+ const vs = valueSets.valueSet(valueSetUrl);
447
+ if (vs) {
448
+ params.parameter.push({ name: 'tx-resource', resource: vs });
449
+ for (const other of valueSets.referencedResources(valueSetUrl)) {
450
+ params.parameter.push({ name: 'tx-resource', resource: other });
451
+ }
452
+ }
453
+ const r = await ask(tx, '/ConceptMap/$translate', params);
454
+ const answer = answerOf(tx, r);
455
+ if (answer.result === null) {
456
+ return { matches: [], message: answer.message, failed: true };
457
+ }
458
+ const matches = [];
459
+ const part = (m, name) => (Array.isArray(m.part) ? m.part : []).find(p => p?.name === name);
460
+ for (const m of (r.resource.parameter || []).filter(p => p?.name === 'match')) {
461
+ const coding = part(m, 'concept')?.valueCoding;
462
+ if (!coding || typeof coding.system !== 'string' || typeof coding.code !== 'string') {
463
+ continue;
464
+ }
465
+ // R5 names, and the R4 ones a server may still use
466
+ const rel = part(m, 'relationship')?.valueCode || part(m, 'equivalence')?.valueCode || null;
467
+ const mapPart = part(m, 'originMap') || part(m, 'source');
468
+ const conceptMap = mapPart?.valueCanonical || mapPart?.valueUri || mapPart?.valueString || null;
469
+ matches.push({ coding: plainCoding(coding), relationship: typeof rel === 'string' ? rel : null, conceptMap: typeof conceptMap === 'string' ? conceptMap : null });
470
+ }
471
+ return { matches, message: answer.message, failed: false };
472
+ }
473
+
474
+ /** The translation's extension on a Coding in the analysed copy, if there is one. */
475
+ function translationOf(coding) {
476
+ const ext = (Array.isArray(coding?.extension) ? coding.extension : []).find(e => e?.url === TRANSLATION_EXTENSION);
477
+ if (!ext) {
478
+ return null;
479
+ }
480
+ const sub = (name) => (Array.isArray(ext.extension) ? ext.extension : []).find(e => e?.url === name);
481
+ return {
482
+ server: sub('server')?.valueUri || null,
483
+ conceptMap: sub('conceptMap')?.valueCanonical || null,
484
+ relationship: sub('relationship')?.valueCode || null
485
+ };
486
+ }
487
+
488
+ /** A CodeableConcept as the server should see it: without our extensions. */
489
+ function withoutOurExtensions(concept) {
490
+ const c = structuredClone(concept);
491
+ stripChecks(c);
492
+ return c;
493
+ }
494
+
495
+ /**
496
+ * Translate: every CodeableConcept that fails the terminology check (no coding in the
497
+ * element's value set - or, for an element with none, in the international fallback) is
498
+ * sent to the terminology server to translate into that value set, and the matches are
499
+ * added to it as codings in the analysed copy, each with an extension saying which server
500
+ * (and concept map, and with what relationship) it came from. The terminology step then
501
+ * checks the document again, translations included. What happened to each concept is kept
502
+ * as the step's result (store.saveResult), for the translation view.
503
+ */
504
+ const translate = {
505
+ name: 'translate',
506
+ async run(row, project, module) {
507
+ if (!module.tx) {
508
+ throw new Error('No terminology server is configured (modules.ips.txServer)');
509
+ }
510
+ const valueSets = module.valueSets;
511
+ if (!valueSets || !valueSets.loaded) {
512
+ throw new Error('The value sets have not been loaded');
513
+ }
514
+ if (!row.analysed) {
515
+ throw new Error('No analysed copy of the document to write to');
516
+ }
517
+ const t = terminologyOf(row.analysed);
518
+ const concepts = [...t.composition];
519
+ for (const s of t.sections) {
520
+ for (const e of s.entries) {
521
+ concepts.push(...e.concepts);
522
+ }
523
+ }
524
+ const counts = { tried: 0, translated: 0, codings: 0, failed: 0 };
525
+ const budget = { left: MAX_CHECKS_PER_DOCUMENT };
526
+ const cache = module.txCache || (module.txCache = new Map());
527
+ const started = Date.now();
528
+ // what happened to each concept, for the translation view
529
+ const entries = [];
530
+ // the same resource in two sections, and the same concept in two places: once
531
+ const seen = new Set();
532
+ const done = new Map();
533
+ for (const c of concepts) {
534
+ if (seen.has(c.concept)) {
535
+ continue;
536
+ }
537
+ seen.add(c.concept);
538
+ const valueSetUrl = valueSets.valueSetFor(c.element);
539
+ const target = valueSetUrl || valueSets.fallback;
540
+ const codings = Array.isArray(c.concept.coding) ? c.concept.coding : [];
541
+ const entry = {
542
+ path: c.path, element: c.element, target, ownValueSet: !!valueSetUrl,
543
+ codings: codings.filter(x => x && typeof x === 'object').map(plainCoding), text: typeof c.concept.text === 'string' ? c.concept.text : null,
544
+ outcome: null, check: null, message: null, added: []
545
+ };
546
+ entries.push(entry);
547
+ if (codings.length === 0) {
548
+ entry.outcome = 'no-codings';
549
+ entry.message = 'The concept has no codings to translate from';
550
+ continue;
551
+ }
552
+ const key = JSON.stringify([target, codings]);
553
+ let translation = done.get(key);
554
+ if (!translation) {
555
+ const check = await checkConcept(module, c.concept, valueSetUrl, budget);
556
+ if (check.result === 'in' || check.result === 'unknown') {
557
+ translation = { matches: [], skipped: true, check };
558
+ } else if (budget.left <= 0) {
559
+ translation = { matches: [], failed: true, check, message: `not translated - the document has more codes than are checked (${MAX_CHECKS_PER_DOCUMENT})` };
560
+ } else {
561
+ const cacheKey = `translate|${key}`;
562
+ translation = cache.get(cacheKey);
563
+ if (!translation) {
564
+ budget.left--;
565
+ translation = await translateConcept(module.tx, valueSets, withoutOurExtensions(c.concept), target);
566
+ if (!translation.failed) {
567
+ if (cache.size >= MAX_CACHED_ANSWERS) {
568
+ cache.delete(cache.keys().next().value);
569
+ }
570
+ cache.set(cacheKey, translation);
571
+ }
572
+ }
573
+ translation = { ...translation, check };
574
+ }
575
+ done.set(key, translation);
576
+ }
577
+ entry.check = { result: translation.check.result, message: translation.check.message };
578
+ if (translation.skipped) {
579
+ entry.outcome = translation.check.result === 'in' ? 'not-needed' : 'not-checked';
580
+ entry.message = translation.check.message;
581
+ continue;
582
+ }
583
+ counts.tried++;
584
+ if (translation.failed) {
585
+ counts.failed++;
586
+ entry.outcome = 'failed';
587
+ entry.message = translation.message;
588
+ if (module.log) {
589
+ module.log.warn(`IPS module: document ${row.id}: ${c.path} could not be translated: ${translation.message}`);
590
+ }
591
+ continue;
592
+ }
593
+ let added = 0;
594
+ for (const m of translation.matches) {
595
+ if (codings.some(x => x && x.system === m.coding.system && x.code === m.coding.code)) {
596
+ entry.added.push({ ...m, present: true });
597
+ continue; // already there
598
+ }
599
+ const ext = { url: TRANSLATION_EXTENSION, extension: [{ url: 'server', valueUri: module.tx.url }] };
600
+ if (m.conceptMap) {
601
+ ext.extension.push({ url: 'conceptMap', valueCanonical: m.conceptMap });
602
+ }
603
+ if (m.relationship) {
604
+ ext.extension.push({ url: 'relationship', valueCode: m.relationship });
605
+ }
606
+ c.concept.coding.push({ ...m.coding, extension: [ext] });
607
+ entry.added.push(m);
608
+ added++;
609
+ }
610
+ if (added > 0) {
611
+ counts.translated++;
612
+ counts.codings += added;
613
+ entry.outcome = 'translated';
614
+ entry.message = `${added} coding${added === 1 ? '' : 's'} added`;
615
+ } else {
616
+ entry.outcome = 'no-translation';
617
+ entry.message = translation.matches.length > 0 ? 'The translation gave only codings the concept already has' : (translation.message || 'The terminology server has no translation');
618
+ }
619
+ }
620
+ module.store.saveResult(row.id, 'translate', { ms: Date.now() - started, server: module.tx.url, counts, entries });
621
+ return `translate: ${counts.translated} of ${counts.tried} concepts translated (${counts.codings} codings added)` +
622
+ (counts.failed ? `, ${counts.failed} not translated (see the log)` : '');
623
+ }
624
+ };
625
+
626
+ // ---- narrative --------------------------------------------------------------------------
627
+
628
+ // the most $lookup questions the narrative asks for one document
629
+ const MAX_LOOKUPS_PER_DOCUMENT = 500;
630
+
631
+ /**
632
+ * Ask the terminology server for a coding's display (CodeSystem/$lookup).
633
+ * @returns {Promise<{display: string|null, definite: boolean}>} definite: the server
634
+ * answered (with a display, or saying it has none) - not an error, a timeout, or no answer
635
+ */
636
+ async function lookupDisplayAnswer(tx, coding) {
637
+ const q = `system=${encodeURIComponent(coding.system)}&code=${encodeURIComponent(coding.code)}` +
638
+ (typeof coding.version === 'string' ? `&version=${encodeURIComponent(coding.version)}` : '');
639
+ let r;
640
+ try {
641
+ r = await tx.get(`/CodeSystem/$lookup?${q}`);
642
+ } catch (e) {
643
+ if (tx.log) {
644
+ tx.log.error(`IPS module: the terminology server could not be reached: ${e.message}`);
645
+ }
646
+ return { display: null, definite: false };
647
+ }
648
+ if (r?.resource?.resourceType !== 'Parameters') {
649
+ // an unknown code or code system is an answer; anything else (5xx, nothing there yet) isn't
650
+ return { display: null, definite: r?.status === 404 || (r?.status >= 400 && r?.status < 500 && r?.resource?.resourceType === 'OperationOutcome') };
651
+ }
652
+ const display = (r.resource.parameter || []).find(p => p?.name === 'display');
653
+ return { display: typeof display?.valueString === 'string' && display.valueString ? display.valueString : null, definite: true };
654
+ }
655
+
656
+ /**
657
+ * Ask the terminology server for a coding's display (CodeSystem/$lookup).
658
+ * @returns {Promise<string|null>} null when it has none, or could not say
659
+ */
660
+ async function lookupDisplay(tx, coding) {
661
+ return (await lookupDisplayAnswer(tx, coding)).display;
662
+ }
663
+
664
+ /**
665
+ * Narrative: generate the document's narrative with the IPS presenter, after translation
666
+ * (so the translated codings are in it) and before validation (so the validator sees it).
667
+ * The narrative is kept as the step's result (the whole document, and each section), and
668
+ * written into the analysed copy as the Composition's and each section's text.
669
+ */
670
+ const narrative = {
671
+ name: 'narrative',
672
+ async run(row, project, module) {
673
+ if (!row.analysed) {
674
+ throw new Error('No analysed copy of the document to write to');
675
+ }
676
+ const started = Date.now();
677
+ const cache = module.txCache || (module.txCache = new Map());
678
+ const budget = { left: MAX_LOOKUPS_PER_DOCUMENT };
679
+ const counts = { lookups: 0 };
680
+ // displays from the terminology server, if there is one: cached, bounded
681
+ const lookup = module.tx ? async (coding) => {
682
+ const key = `lookup|${coding.system}|${coding.version || ''}|${coding.code}`;
683
+ if (cache.has(key)) {
684
+ return cache.get(key);
685
+ }
686
+ if (budget.left <= 0) {
687
+ return null;
688
+ }
689
+ budget.left--;
690
+ counts.lookups++;
691
+ const { display, definite } = await lookupDisplayAnswer(module.tx, coding);
692
+ // a server that was down or slow isn't an answer: asked again next time
693
+ if (definite) {
694
+ if (cache.size >= MAX_CACHED_ANSWERS) {
695
+ cache.delete(cache.keys().next().value);
696
+ }
697
+ cache.set(key, display);
698
+ }
699
+ return display;
700
+ } : null;
701
+ const presenter = new Presenter({ lookup });
702
+ const out = await presenter.render(row.analysed);
703
+ // the presenter escapes what it takes from the document; checked anyway, as every
704
+ // narrative a document brings is, before it is written anywhere or shown
705
+ for (const html of [out.html, out.header, ...out.sections.map(s => s.html)]) {
706
+ const problem = checkNarrative(html);
707
+ if (problem) {
708
+ throw new Error(`the generated narrative failed the active content check (${problem})`);
709
+ }
710
+ }
711
+ const composition = row.analysed.entry?.[0]?.resource;
712
+ if (composition && composition.resourceType === 'Composition') {
713
+ composition.text = { status: 'generated', div: out.header };
714
+ for (const s of out.sections) {
715
+ s.section.text = { status: 'generated', div: s.html };
716
+ }
717
+ }
718
+ module.store.saveResult(row.id, 'narrative', {
719
+ ms: Date.now() - started,
720
+ lookups: counts.lookups,
721
+ html: out.html,
722
+ sections: out.sections.map(s => ({ title: s.title, level: s.level, html: s.html }))
723
+ });
724
+ return `narrative: ${out.sections.length} section${out.sections.length === 1 ? '' : 's'}` + (counts.lookups ? ` (${counts.lookups} display lookup${counts.lookups === 1 ? '' : 's'})` : '');
725
+ }
726
+ };
727
+
728
+ // ---- the AI assessment --------------------------------------------------------------------
729
+
730
+ // what the document can be, as JSON, before it is too big to send
731
+ const DEFAULT_AI_MAX_BYTES = 300000;
732
+ const DEFAULT_AI_DAILY_CAP = 50;
733
+ // what is kept of the model's raw answer
734
+ const MAX_RAW_ANSWER = 64 * 1024;
735
+ const GRADES = ['A', 'B', 'C', 'D', 'F'];
736
+ const SEVERITIES = ['high', 'medium', 'low'];
737
+
738
+ const AI_SYSTEM_PROMPT = `You are a clinical informatics reviewer assessing an International Patient Summary (IPS), a FHIR R4 document Bundle in JSON.
739
+ The document has been processed: some CodeableConcepts carry codings that were added by translation (with an extension whose url ends in /ips-translation), and most carry an extension (url ending in /ips-valueset-check) recording whether a coding is in the value set preferred for international exchange ('in'), only in an international terminology ('international'), in neither ('not-in'), or could not be checked ('unknown').
740
+
741
+ Assess the document in two ways:
742
+ 1. As a whole, for consistency and integrity: do its details agree with each other and with the overall picture of the patient (ages and dates, sexes, problems against medications and procedures, allergies against medications, results against problems, immunizations against age, and so on)? Is anything missing that a summary of this patient would be expected to have, or that one part of the document implies another part should hold?
743
+ 2. Its terminology: are the codes consistent with each other and with the text and displays they sit beside? Are any codes wrong, contradictory, in the wrong element, duplicated with different meanings, or failing the international check in a way that matters clinically? Mention specific codes as system|code.
744
+
745
+ Be concrete and brief. Do not restate the document. Do not invent findings: when the document is consistent, say so.
746
+ `;
747
+
748
+ const AI_NARRATIVE_PROMPT = `The narratives (the text.div of the Composition, its sections and the resources) are as the sender wrote them - they have not been generated from the structured data. A third, important assessment: what does the narrative say that the structured data does not back up, and what does the structured data hold that the narrative leaves out or contradicts? Report these as consistency findings, saying which it is.
749
+ `;
750
+
751
+ const AI_NO_NARRATIVE_PROMPT = `The narratives have been removed from the document to save space.
752
+ `;
753
+
754
+ const AI_ANSWER_PROMPT = `
755
+
756
+ Answer with a single JSON object and nothing else, of this shape:
757
+ {
758
+ "grade": "A" | "B" | "C" | "D" | "F",
759
+ "summary": "two or three sentences on the document as a whole",
760
+ "consistency": [{ "severity": "high" | "medium" | "low", "finding": "...", "where": "the resource or section it concerns" }],
761
+ "missing": ["..."],
762
+ "terminology": [{ "severity": "high" | "medium" | "low", "finding": "...", "codes": ["system|code"] }]
763
+ }
764
+ Grade A: consistent and complete; B: minor issues; C: issues a clinician should notice; D: significant inconsistencies; F: not usable as a summary. Empty arrays are fine.`;
765
+
766
+ /** The whole system prompt, with or without the narrative's part. */
767
+ function aiSystemPrompt(withNarrative) {
768
+ return AI_SYSTEM_PROMPT + (withNarrative ? AI_NARRATIVE_PROMPT : AI_NO_NARRATIVE_PROMPT) + AI_ANSWER_PROMPT;
769
+ }
770
+
771
+ /**
772
+ * The document as the model sees it: the analysed copy, either without any narrative, or
773
+ * with the narratives as the sender wrote them (from the master: the analysed copy's were
774
+ * generated by the narrative step). The two copies have the same entries and sections, in
775
+ * the same order, so the narratives are matched by position.
776
+ */
777
+ function documentForAi(analysed, master = null) {
778
+ const copy = structuredClone(analysed);
779
+ const strip = (node) => {
780
+ if (Array.isArray(node)) {
781
+ node.forEach(strip);
782
+ } else if (node && typeof node === 'object') {
783
+ if (node.text && typeof node.text === 'object' && typeof node.text.div === 'string') {
784
+ delete node.text;
785
+ }
786
+ Object.values(node).forEach(strip);
787
+ }
788
+ };
789
+ strip(copy);
790
+ if (!master) {
791
+ return copy;
792
+ }
793
+ const narrative = (from, to) => {
794
+ if (from && typeof from === 'object' && from.text && typeof from.text === 'object' && typeof from.text.div === 'string' && to && typeof to === 'object') {
795
+ to.text = { status: from.text.status, div: from.text.div };
796
+ }
797
+ };
798
+ const sections = (from, to) => {
799
+ const a = Array.isArray(from?.section) ? from.section : [];
800
+ const b = Array.isArray(to?.section) ? to.section : [];
801
+ for (let i = 0; i < a.length && i < b.length; i++) {
802
+ narrative(a[i], b[i]);
803
+ sections(a[i], b[i]);
804
+ }
805
+ };
806
+ const a = Array.isArray(master.entry) ? master.entry : [];
807
+ const b = Array.isArray(copy.entry) ? copy.entry : [];
808
+ for (let i = 0; i < a.length && i < b.length; i++) {
809
+ narrative(a[i]?.resource, b[i]?.resource);
810
+ if (a[i]?.resource?.resourceType === 'Composition') {
811
+ sections(a[i].resource, b[i].resource);
812
+ }
813
+ }
814
+ return copy;
815
+ }
816
+
817
+ /** The model's answer, tidied into what the view expects (anything odd dropped). */
818
+ function assessmentOf(json) {
819
+ if (!json || typeof json !== 'object') {
820
+ return null;
821
+ }
822
+ const str = (v, max = 2000) => (typeof v === 'string' ? v.substring(0, max) : '');
823
+ const sev = (v) => (SEVERITIES.includes(v) ? v : 'medium');
824
+ const grade = typeof json.grade === 'string' ? json.grade.trim().toUpperCase().charAt(0) : '';
825
+ return {
826
+ grade: GRADES.includes(grade) ? grade : null,
827
+ summary: str(json.summary, 4000),
828
+ consistency: (Array.isArray(json.consistency) ? json.consistency : []).filter(x => x && typeof x === 'object').slice(0, 100)
829
+ .map(x => ({ severity: sev(x.severity), finding: str(x.finding), where: str(x.where, 300) })).filter(x => x.finding),
830
+ missing: (Array.isArray(json.missing) ? json.missing : []).slice(0, 100).map(x => (typeof x === 'string' ? x : (x && typeof x === 'object' ? str(x.finding || x.item) : ''))).filter(Boolean).map(x => x.substring(0, 2000)),
831
+ terminology: (Array.isArray(json.terminology) ? json.terminology : []).filter(x => x && typeof x === 'object').slice(0, 100)
832
+ .map(x => ({ severity: sev(x.severity), finding: str(x.finding), codes: (Array.isArray(x.codes) ? x.codes : []).filter(c => typeof c === 'string').slice(0, 20).map(c => c.substring(0, 300)) })).filter(x => x.finding)
833
+ };
834
+ }
835
+
836
+ /**
837
+ * AI: the analysed document (narratives stripped) goes to the project's AI service - or
838
+ * the server's default one - to be graded for consistency and integrity, and for its
839
+ * terminology. The project must have turned the assessment on; a project with no account
840
+ * of its own uses the server's; a project over its daily cap, or a document too big to
841
+ * send, is skipped with a note (the step does not fail). The assessment, and what the
842
+ * model answered, is the step's result, for the AI view.
843
+ */
844
+ // ---- clinical profiles -----------------------------------------------------------------
845
+
846
+ // a constraint's evaluation that takes longer than this is reported as an error
847
+ const CONSTRAINT_TIMEOUT_MS = 60000;
848
+
849
+ /** The value sets an expression names in entryIn() or resourcesIn(). */
850
+ function valueSetsIn(expression) {
851
+ const out = [];
852
+ const re = /\b(?:entryIn|resourcesIn)\s*\(\s*'((?:[^'\\]|\\.)*)'/g;
853
+ for (let m = re.exec(expression); m; m = re.exec(expression)) {
854
+ out.push(m[1].replace(/\\(.)/g, '$1'));
855
+ }
856
+ return out;
857
+ }
858
+
859
+ /**
860
+ * One constraint on the document: passed, failed, or not checked - an error in the
861
+ * expression, or a value set the terminology server couldn't answer for (a failure for
862
+ * that reason isn't the document's).
863
+ * @returns {Promise<{passed: boolean, reason?: 'failed'|'unresolved'|'error', message?: string}>}
864
+ */
865
+ async function checkConstraint(doc, constraint, session) {
866
+ let result;
867
+ let timer;
868
+ try {
869
+ result = await Promise.race([
870
+ evaluateAsync(doc, constraint.expression, { membership: session.membership }),
871
+ new Promise((_, reject) => {
872
+ timer = setTimeout(() => reject(new Error(`took longer than ${CONSTRAINT_TIMEOUT_MS / 1000} seconds`)), CONSTRAINT_TIMEOUT_MS);
873
+ })
874
+ ]);
875
+ } catch (e) {
876
+ return { passed: false, reason: 'error', message: String(e.message || e).split('\n')[0].substring(0, 300) };
877
+ } finally {
878
+ clearTimeout(timer);
879
+ }
880
+ // a value set the server couldn't answer for makes the result meaningless either way (an
881
+ // unanswered question counts as "not a member", which can make .empty() true)
882
+ const unresolved = valueSetsIn(constraint.expression).filter(url => session.problems.has(url));
883
+ if (unresolved.length) {
884
+ return { passed: false, reason: 'unresolved', message: unresolved.map(url => `${url}: ${session.problems.get(url)}`).join('; ') };
885
+ }
886
+ // true, and only true, passes (as a FHIR invariant must evaluate to true)
887
+ if (Array.isArray(result) && result.length === 1 && result[0] === true) {
888
+ return { passed: true };
889
+ }
890
+ return { passed: false, reason: 'failed' };
891
+ }
892
+
893
+ /**
894
+ * Profiles: the document against each clinical profile. Every constraint is evaluated on
895
+ * the analysed copy (so the translations count); the profile's outcome is 'conforms' when
896
+ * all pass, 'fails' when any plainly fails, and 'unknown' when the only failures are ones
897
+ * that couldn't be checked. Value set membership is asked of the terminology server, once
898
+ * per question for the whole document (one membership session across the profiles). The
899
+ * profiles are as they are when the document is processed: the result keeps their titles
900
+ * and the failed constraints' descriptions.
901
+ */
902
+ const profilesStep = {
903
+ name: 'profiles',
904
+ async run(row, project, module) {
905
+ const started = Date.now();
906
+ const doc = row.analysed || row.bundle;
907
+ const list = module.store.profiles();
908
+ const session = module.membershipSession();
909
+ const results = [];
910
+ for (const p of list) {
911
+ // read now, not with the list: one deleted meanwhile is passed over
912
+ const sd = module.store.profile(p.id);
913
+ if (!sd) {
914
+ continue;
915
+ }
916
+ const constraints = sd.differential?.element?.[0]?.constraint || [];
917
+ const failed = [];
918
+ for (const c of constraints) {
919
+ if (typeof c?.expression !== 'string') {
920
+ continue;
921
+ }
922
+ const r = await checkConstraint(doc, c, session);
923
+ if (!r.passed) {
924
+ failed.push({ key: c.key, human: c.human || c.key, reason: r.reason, ...(r.message ? { message: r.message } : {}) });
925
+ }
926
+ }
927
+ const outcome = failed.length === 0 ? 'conforms' : (failed.some(f => f.reason === 'failed') ? 'fails' : 'unknown');
928
+ results.push({ id: sd.id, url: sd.url, title: sd.title, constraints: constraints.length, outcome, failed });
929
+ }
930
+ module.store.saveResult(row.id, 'profiles', { ms: Date.now() - started, questions: session.questions, profiles: results, unresolved: session.unresolved() });
931
+ if (results.length === 0) {
932
+ return 'profiles: none defined';
933
+ }
934
+ const n = (o) => results.filter(r => r.outcome === o).length;
935
+ return `profiles: ${n('conforms')} conform, ${n('fails')} do not` + (n('unknown') ? `, ${n('unknown')} could not be fully checked` : '');
936
+ }
937
+ };
938
+
939
+ const aiStep = {
940
+ name: 'ai',
941
+ async run(row, project, module) {
942
+ if (!row.analysed) {
943
+ throw new Error('No analysed copy of the document to assess');
944
+ }
945
+ const started = Date.now();
946
+ const settings = module.store.projectAi(project);
947
+ const skip = (reason) => {
948
+ module.store.saveResult(row.id, 'ai', { ms: Date.now() - started, skipped: reason });
949
+ return `ai: ${reason}`;
950
+ };
951
+ if (settings.enabled !== true) {
952
+ return skip('not enabled for this project');
953
+ }
954
+ const resolved = module.aiAccountFor(project);
955
+ if (!resolved) {
956
+ return skip('no AI account: the project has none and the server has no default (modules.ips.ai)');
957
+ }
958
+ const cfg = module.config.ai || {};
959
+ const cap = Number.isInteger(settings.dailyCap) ? settings.dailyCap : (Number.isInteger(cfg.dailyCap) ? cfg.dailyCap : DEFAULT_AI_DAILY_CAP);
960
+ const since = Date.now() - 24 * 60 * 60 * 1000;
961
+ if (cap >= 0 && module.store.aiCallsSince(project.id, since) >= cap) {
962
+ return skip(`the project's daily cap of ${cap} assessments is spent`);
963
+ }
964
+ // the narratives as submitted, when the project (else the server) says so
965
+ const withNarrative = typeof settings.sendNarrative === 'boolean' ? settings.sendNarrative : cfg.sendNarrative === true;
966
+ const payload = JSON.stringify(documentForAi(row.analysed, withNarrative ? row.bundle : null));
967
+ const maxBytes = Number.isInteger(cfg.maxBytes) && cfg.maxBytes > 0 ? cfg.maxBytes : DEFAULT_AI_MAX_BYTES;
968
+ if (Buffer.byteLength(payload, 'utf8') > maxBytes) {
969
+ return skip(`the document is too big to send (${Buffer.byteLength(payload, 'utf8')} bytes; the limit is ${maxBytes})`);
970
+ }
971
+ const callId = module.store.recordAiCall(project.id);
972
+ const answer = await ai.complete(resolved.account, {
973
+ system: aiSystemPrompt(withNarrative),
974
+ user: `Assess this IPS document:\n\n${payload}`,
975
+ maxTokens: Number.isInteger(cfg.maxTokens) ? cfg.maxTokens : undefined,
976
+ timeoutMs: Number.isInteger(cfg.timeout) ? cfg.timeout * 1000 : undefined,
977
+ fetch: module.aiFetch || undefined
978
+ });
979
+ const assessment = assessmentOf(ai.jsonIn(answer.text));
980
+ // the cost: what the service said, else from the price list (the project's, else the server's)
981
+ const cost = ai.costOf(answer.usage, answer.model, { ...ai.DEFAULT_PRICES, ...(settings.prices || cfg.prices || {}) });
982
+ module.store.finishAiCall(callId, { model: answer.model, input: answer.usage.input, output: answer.usage.output, cost });
983
+ module.store.saveResult(row.id, 'ai', {
984
+ ms: Date.now() - started,
985
+ provider: answer.provider,
986
+ model: answer.model,
987
+ account: resolved.source,
988
+ withNarrative,
989
+ usage: answer.usage,
990
+ cost,
991
+ truncated: answer.truncated,
992
+ maxTokens: answer.maxTokens,
993
+ bytesSent: Buffer.byteLength(payload, 'utf8'),
994
+ assessment,
995
+ raw: answer.text.length > MAX_RAW_ANSWER ? answer.text.substring(0, MAX_RAW_ANSWER) + '\n...' : answer.text
996
+ });
997
+ if (!assessment) {
998
+ return `ai: ${answer.model} answered, but not with the expected JSON${answer.truncated ? ` - the answer was cut off at ${answer.maxTokens} tokens (modules.ips.ai.maxTokens)` : ''} (see the AI view)`;
999
+ }
1000
+ const n = assessment.consistency.length + assessment.missing.length + assessment.terminology.length;
1001
+ return `ai: grade ${assessment.grade || '?'}, ${n} finding${n === 1 ? '' : 's'} (${answer.model})`;
1002
+ }
1003
+ };
1004
+
1005
+ /** The steps, in order. Only the ones that exist yet. */
1006
+ const STEPS = [translate, narrative, validation, terminology, profilesStep, aiStep];
1007
+
1008
+ module.exports = {
1009
+ STEPS, translate, narrative, validation, terminology, profiles: profilesStep, ai: aiStep, checkConstraint, valueSetsIn, lookupDisplay, checkConcept, documentForAi, assessmentOf, aiSystemPrompt, AI_SYSTEM_PROMPT, checkOf, addCheck, stripChecks, translateConcept, translationOf,
1010
+ validateCodeInValueSet, validateCodeInSystem, issueCounts, issueSummary,
1011
+ IPS_BUNDLE_PROFILE, CHECK_EXTENSION, TRANSLATION_EXTENSION, MAX_CHECKS_PER_DOCUMENT, MAX_CACHED_ANSWERS
1012
+ };