fhirsmith 0.18.0 → 0.18.2

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 (47) hide show
  1. package/CHANGELOG.md +72 -10
  2. package/ips/ips.js +251 -26
  3. package/ips/jurisdictions.js +65 -0
  4. package/ips/pipeline.js +67 -6
  5. package/ips/profile-editor.js +283 -4
  6. package/ips/readme.md +48 -5
  7. package/ips/render.js +317 -46
  8. package/ips/scripts.js +46 -0
  9. package/ips/store.js +55 -0
  10. package/library/daily-report.js +25 -15
  11. package/package.json +3 -3
  12. package/registry/api.js +179 -1
  13. package/registry/crawler.js +2 -0
  14. package/registry/documentation/api.md +68 -2
  15. package/registry/documentation/internals.md +4 -1
  16. package/registry/documentation/readme.md +3 -2
  17. package/registry/model.js +8 -0
  18. package/registry/openapi.yaml +84 -0
  19. package/registry/registry.js +53 -2
  20. package/translations/Messages.properties +1 -0
  21. package/tx/README.md +4 -0
  22. package/tx/closure/closure-store.js +14 -0
  23. package/tx/cm/cm-package.js +21 -15
  24. package/tx/cs/cs-api.js +3 -1
  25. package/tx/cs/cs-cs.js +59 -12
  26. package/tx/cs/cs-icf.js +47 -34
  27. package/tx/cs/cs-loinc.js +257 -3
  28. package/tx/cs/cs-snomed.js +544 -55
  29. package/tx/importers/csv-records.js +35 -0
  30. package/tx/importers/import-loinc.module.js +177 -41
  31. package/tx/importers/import-sct.module.js +131 -1
  32. package/tx/importers/subset-loinc.module.js +25 -6
  33. package/tx/library/codesystem.js +4 -1
  34. package/tx/library/designations.js +23 -30
  35. package/tx/library/tho-precedence.js +40 -0
  36. package/tx/openapi-operations.js +2 -1
  37. package/tx/provider.js +46 -29
  38. package/tx/sct/documentation.md +45 -21
  39. package/tx/sct/ecl.js +34 -11
  40. package/tx/sct/structures.js +15 -2
  41. package/tx/tx.fhir.org.yml +2 -2
  42. package/tx/vs/vs-package.js +17 -13
  43. package/tx/vs/vs-vsac.js +1 -1
  44. package/tx/workers/closure.js +20 -0
  45. package/tx/workers/expand.js +2 -2
  46. package/tx/workers/validate.js +10 -0
  47. package/utilities/make-sct-loinc-subset.js +250 -0
package/ips/pipeline.js CHANGED
@@ -12,10 +12,11 @@
12
12
  * 1. translate translate CodeableConcepts that fail the terminology check to codes that pass it
13
13
  * 2. narrative generate the narrative with the IPS presenter (presenter.js), into the analysed copy
14
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)
15
+ * 4. national validate it against each national profile (the national IGs' profiles on Bundle)
16
+ * 5. terminology check each CodeableConcept against the value sets for international exchange
17
+ * 6. piqi measure the quality of the document with PIQI (not yet)
18
+ * 7. profiles check the document against each clinical profile (profiles.js)
19
+ * 8. ai assess the document for consistency and its terminology with an AI service (ai-client.js)
19
20
  *
20
21
  * Each step is { name, run(row, project, module) -> result }. A step keeps what it finds in
21
22
  * one of two places: as a result stored against the document (store.saveResult - the
@@ -122,6 +123,66 @@ const validation = {
122
123
  }
123
124
  };
124
125
 
126
+ /**
127
+ * National profiles: the document is validated against each national profile - the
128
+ * national IG's package is loaded into the validator first (once), then the document is
129
+ * validated against the IG's profile on Bundle, as the validation step validates it. For
130
+ * each profile, the result keeps the OperationOutcome and whether the document passed: no
131
+ * errors (or fatal issues). A profile whose package can't be loaded, or that the validator
132
+ * can't validate against, is kept with the reason (pass is then null), and doesn't stop the
133
+ * pipeline.
134
+ */
135
+ const national = {
136
+ name: 'national',
137
+ async run(row, project, module) {
138
+ const list = module.store.nationalProfiles();
139
+ const started = Date.now();
140
+ if (list.length === 0) {
141
+ module.store.saveResult(row.id, 'national', { ms: 0, profiles: [] });
142
+ return 'national profiles: none';
143
+ }
144
+ const v = module.validator;
145
+ if (!v) {
146
+ throw new Error('The validator is not configured (modules.ips.validator)');
147
+ }
148
+ if (!v.isRunning()) {
149
+ throw new Error('The validator is not running');
150
+ }
151
+ const cfg = module.config.validator || {};
152
+ const checkIpsCodes = cfg.checkIpsCodes !== false;
153
+ const doc = JSON.stringify(row.analysed || row.bundle);
154
+ const profiles = [];
155
+ for (const np of list) {
156
+ const entry = { id: np.id, jurisdiction: np.jurisdiction, packageId: np.packageId, version: np.version, url: np.url };
157
+ const t0 = Date.now();
158
+ try {
159
+ await module.loadNationalPackage(np.packageId, np.version);
160
+ const outcome = await v.validate(doc, { profiles: [np.url], checkIpsCodes, timeout: VALIDATE_TIMEOUT_MS });
161
+ if (!outcome || outcome.resourceType !== 'OperationOutcome') {
162
+ throw new Error('The validator did not return an OperationOutcome');
163
+ }
164
+ entry.counts = issueCounts(outcome);
165
+ entry.pass = !entry.counts.error && !entry.counts.fatal;
166
+ entry.outcome = outcome;
167
+ } catch (e) {
168
+ entry.pass = null;
169
+ entry.error = e.message;
170
+ }
171
+ entry.ms = Date.now() - t0;
172
+ profiles.push(entry);
173
+ }
174
+ module.store.saveResult(row.id, 'national', {
175
+ ms: Date.now() - started,
176
+ validatorVersion: typeof v.jarVersion === 'function' ? v.jarVersion() : null,
177
+ profiles
178
+ });
179
+ const passed = profiles.filter(p => p.pass === true).length;
180
+ const failed = profiles.filter(p => p.pass === false).length;
181
+ const unchecked = profiles.length - passed - failed;
182
+ return `national profiles: ${passed} passed, ${failed} failed` + (unchecked ? `, ${unchecked} not checked` : '');
183
+ }
184
+ };
185
+
125
186
  // ---- the terminology check ------------------------------------------------------------
126
187
 
127
188
  /**
@@ -1003,10 +1064,10 @@ const aiStep = {
1003
1064
  };
1004
1065
 
1005
1066
  /** The steps, in order. Only the ones that exist yet. */
1006
- const STEPS = [translate, narrative, validation, terminology, profilesStep, aiStep];
1067
+ const STEPS = [translate, narrative, validation, national, terminology, profilesStep, aiStep];
1007
1068
 
1008
1069
  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,
1070
+ STEPS, translate, narrative, validation, national, terminology, profiles: profilesStep, ai: aiStep, checkConstraint, valueSetsIn, lookupDisplay, checkConcept, documentForAi, assessmentOf, aiSystemPrompt, AI_SYSTEM_PROMPT, checkOf, addCheck, stripChecks, translateConcept, translationOf,
1010
1071
  validateCodeInValueSet, validateCodeInSystem, issueCounts, issueSummary,
1011
1072
  IPS_BUNDLE_PROFILE, CHECK_EXTENSION, TRANSLATION_EXTENSION, MAX_CHECKS_PER_DOCUMENT, MAX_CACHED_ANSWERS
1012
1073
  };
@@ -14,6 +14,8 @@
14
14
  * the rest: a row of the second - type, system, code or value set, the rest), and
15
15
  * other rules (FHIRPath). A row's expression is made from it. Saving posts the title and the
16
16
  * constraints (key, description, expression); the server checks them, and keeps the keys.
17
+ * With cfg.mode 'rows' it is the profile's own page instead: the rows are the page's tables,
18
+ * and one at a time is added or edited, in a dialog, and saved by itself.
17
19
  */
18
20
  (function () {
19
21
  'use strict';
@@ -22,6 +24,8 @@
22
24
  var LOINC = cfg.sectionSystem;
23
25
  var dirty = false;
24
26
  var saving = false;
27
+ // what to draw again when a row's fields change shape: the whole editor, or the row being edited
28
+ var redraw = function () { draw(); };
25
29
 
26
30
  function el(tag, attrs, children) {
27
31
  var e = document.createElement(tag);
@@ -359,11 +363,13 @@
359
363
  url.hidden = select.value !== '*';
360
364
  url.addEventListener('input', function () { r.entSystem = url.value.trim(); changed(); onChange(); });
361
365
  select.addEventListener('change', function () {
362
- var wasVs = r.entSystem === VS;
366
+ var was = r.entSystem;
367
+ var wasVs = was === VS;
363
368
  r.entSystem = select.value === '*' ? url.value.trim() : select.value;
364
369
  if (!select.value) { r.entCode = ''; r.entCard = ''; r.entVs = ''; }
365
370
  changed();
366
- if (wasVs !== (r.entSystem === VS) || !select.value || select.value === '*') { draw(); } else { onChange(); }
371
+ // the code (or value set) input comes and goes with the choice
372
+ if (!was || wasVs !== (r.entSystem === VS) || !select.value || select.value === '*') { redraw(); } else { onChange(); }
367
373
  });
368
374
  cell.appendChild(el('div', { class: 'ips-pf-pair' }, [select, url]));
369
375
  if (r.entSystem === VS) {
@@ -414,7 +420,7 @@
414
420
  r.resSystem = select.value === '*' ? url.value.trim() : select.value;
415
421
  changed();
416
422
  // the code column becomes a value set's, or a code's
417
- if (wasVs !== (r.resSystem === VS)) { draw(); } else { onChange(); }
423
+ if (wasVs !== (r.resSystem === VS)) { redraw(); } else { onChange(); }
418
424
  });
419
425
  cell.appendChild(select);
420
426
  cell.appendChild(url);
@@ -573,6 +579,279 @@
573
579
  .catch(function (e) { saving = false; show(['The profile was not saved: ' + e.message]); });
574
580
  }
575
581
 
582
+ // ---- the profile's page: a rule at a time, added or edited in a dialog ----
583
+
584
+ // what must be true of the resources a rule is about: the rest of the rule, by kind
585
+ var NAME_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)?$/;
586
+ var RULE_KINDS = [
587
+ ['present', 'Must be present somewhere'],
588
+ ['subject', 'Patient must match the Document'],
589
+ ['element', 'Must have an element'],
590
+ ['conforms', 'Conform to a profile'],
591
+ ['category', 'Has a category'],
592
+ ['other', 'Something else (FHIRPath)']
593
+ ];
594
+ function allOf(body) { return new RegExp('^\\.\\s*(?:' + body + '|all\\s*\\(\\s*' + body + '\\s*\\))$'); }
595
+ var CONFORMS_RE = allOf('conformsTo\\s*\\(\\s*' + STR + '\\s*\\)');
596
+ var CATEGORY_RE = allOf('category\\s*\\.\\s*hasCoding\\s*\\(\\s*' + STR + '\\s*,\\s*' + STR + '\\s*(?:,\\s*' + STR + '\\s*)?\\)');
597
+
598
+ /** the rule's kind, and its fields, from the rest of a rule about resources */
599
+ function ruleFields(r) {
600
+ var rest = (r.resRest || '').trim();
601
+ var m;
602
+ r.ruleKind = 'other';
603
+ r.ruleOther = rest;
604
+ r.ruleUrl = ''; r.ruleSystem = cfg.categorySystem || ''; r.ruleCode = ''; r.ruleVersion = ''; r.rulePath = ''; r.ruleDisplay = '';
605
+ r.ruleAll = true;
606
+ if (/^\.\s*exists\s*\(\s*\)$/.test(rest)) {
607
+ r.ruleKind = 'present';
608
+ } else if (/^\.\s*all\s*\(\s*sameSubject\s*\(\s*\)\s*\)$/.test(rest)) {
609
+ r.ruleKind = 'subject';
610
+ } else if ((m = CONFORMS_RE.exec(rest))) {
611
+ r.ruleKind = 'conforms';
612
+ r.ruleAll = m[1] === undefined;
613
+ r.ruleUrl = unquote(m[1] !== undefined ? m[1] : m[2]);
614
+ } else if ((m = CATEGORY_RE.exec(rest))) {
615
+ r.ruleKind = 'category';
616
+ r.ruleAll = m[1] === undefined;
617
+ var g = m[1] !== undefined ? [m[1], m[2], m[3]] : [m[4], m[5], m[6]];
618
+ r.ruleSystem = unquote(g[0]); r.ruleCode = unquote(g[1]); r.ruleVersion = g[2] !== undefined ? unquote(g[2]) : '';
619
+ } else if ((m = /^\.\s*([A-Za-z][A-Za-z0-9_]*(?:\s*\.\s*[A-Za-z][A-Za-z0-9_]*)?)\s*\.\s*exists\s*\(\s*\)$/.exec(rest))) {
620
+ r.ruleKind = 'element';
621
+ r.rulePath = m[1].replace(/\s+/g, '');
622
+ }
623
+ }
624
+ /** the rest of a rule about resources, from its kind and fields, or null with what is wrong */
625
+ function ruleRest(r) {
626
+ var each = function (body) { return r.ruleAll ? '.all(' + body + ')' : '.' + body; };
627
+ switch (r.ruleKind) {
628
+ case 'present': return { rest: '.exists()', problems: [] };
629
+ case 'subject': return { rest: '.all(sameSubject())', problems: [] };
630
+ case 'element': return NAME_RE.test(r.rulePath.trim()) ? { rest: '.' + r.rulePath.trim() + '.exists()', problems: [] } : { rest: null, problems: ['rulePath'] };
631
+ case 'conforms': return CANONICAL_RE.test(r.ruleUrl.trim()) ? { rest: each('conformsTo(' + quote(r.ruleUrl.trim()) + ')'), problems: [] } : { rest: null, problems: ['ruleUrl'] };
632
+ case 'category': {
633
+ var problems = [];
634
+ if (!r.ruleSystem.trim()) { problems.push('ruleSystem'); }
635
+ if (!r.ruleCode.trim()) { problems.push('ruleCode'); }
636
+ if (problems.length) { return { rest: null, problems: problems }; }
637
+ return { rest: each('category.hasCoding(' + quote(r.ruleSystem.trim()) + ', ' + quote(r.ruleCode.trim()) + (r.ruleVersion.trim() ? ', ' + quote(r.ruleVersion.trim()) : '') + ')'), problems: [] };
638
+ }
639
+ default: {
640
+ var rest = (r.ruleOther || '').trim();
641
+ return rest && rest !== '.' ? { rest: rest.charAt(0) === '.' ? rest : '.' + rest, problems: [] } : { rest: null, problems: ['ruleOther'] };
642
+ }
643
+ }
644
+ }
645
+ /** the expression of a row in the dialog, or null with what is wrong */
646
+ function dialogExpression(r) {
647
+ if (r.mode !== 'resource') { return rowExpression(r); }
648
+ var t = ruleRest(r);
649
+ r.resRest = t.rest || '';
650
+ var e = resourceExpression(r);
651
+ return { expression: t.rest ? e.expression : null, problems: t.problems.concat(e.problems.filter(function (p) { return p !== 'resRest'; })) };
652
+ }
653
+
654
+ function rowsMode() {
655
+ var MODE = { section: 'section', resource: 'resource', other: 'text' };
656
+ var TITLES = { section: 'a required section or entry', resource: 'a rule about resources', text: 'a rule' };
657
+ var byKey = {};
658
+ (cfg.profile.constraints || []).forEach(function (c) { byKey[c.key] = c; });
659
+ var dialog = el('dialog', { class: 'ips-pf-dialog', 'aria-labelledby': 'ips-pf-dialog-title' });
660
+ document.body.appendChild(dialog);
661
+ var editing = null;
662
+
663
+ function showIn(box, problems, kind) {
664
+ box.innerHTML = '';
665
+ if (!problems.length) { return; }
666
+ var alert = el('div', { class: 'alert alert-' + (kind || 'danger'), style: 'margin: 0' });
667
+ if (problems.length === 1) { alert.textContent = problems[0]; } else {
668
+ alert.appendChild(el('ul', { style: 'margin: 0' }, problems.map(function (p) { return el('li', { text: p }); })));
669
+ }
670
+ box.appendChild(alert);
671
+ }
672
+ // a labelled part of the dialog: a label, what goes in it, and a hint
673
+ function field(label, controls, hint) {
674
+ return el('div', { class: 'ips-pf-field' }, [el('label', { class: 'ips-pf-label', text: label })].concat(controls).concat(hint ? [el('div', { class: 'ips-pf-hint', text: hint })] : []));
675
+ }
676
+ function howMany(r, fieldName, update, fields) {
677
+ var input = cardInput(r, fieldName, update);
678
+ fields[fieldName] = input;
679
+ return el('div', { class: 'ips-pf-pair ips-pf-how' }, [el('span', { text: 'How many' }), input,
680
+ el('span', { class: 'ips-pf-hint', text: 'blank: at least one; or min..max - 1..1, 0..1, 1..*' })]);
681
+ }
682
+ // a code system for the category: one of the named ones, or another by URL
683
+ function categorySystem(r, update) {
684
+ var cell = el('div', { class: 'ips-pf-pair' });
685
+ var select = el('select', { class: 'form-control ips-ed-input' });
686
+ cfg.systems.forEach(function (s) { select.appendChild(el('option', { value: s.url, text: s.name })); });
687
+ (cfg.categorySystems || []).forEach(function (s) { select.appendChild(el('option', { value: s.url, text: s.name })); });
688
+ select.appendChild(el('option', { value: '*', text: 'Another system...' }));
689
+ var known = Array.prototype.some.call(select.options, function (o) { return o.value === r.ruleSystem; });
690
+ select.value = known ? r.ruleSystem : '*';
691
+ var url = el('input', { type: 'text', class: 'form-control ips-ed-input', placeholder: 'code system URL' });
692
+ url.value = known ? '' : r.ruleSystem;
693
+ url.hidden = known;
694
+ url.addEventListener('input', function () { r.ruleSystem = url.value.trim(); changed(); update(); });
695
+ select.addEventListener('change', function () {
696
+ url.hidden = select.value !== '*';
697
+ r.ruleSystem = select.value === '*' ? url.value.trim() : select.value;
698
+ changed();
699
+ update();
700
+ });
701
+ cell.appendChild(select);
702
+ cell.appendChild(url);
703
+ cell.url = url;
704
+ return cell;
705
+ }
706
+
707
+ /** the dialog's contents, (re)drawn for the row being edited */
708
+ function drawDialog() {
709
+ var e = editing;
710
+ var r = e.r;
711
+ dialog.innerHTML = '';
712
+ var fields = {};
713
+ var exprLine = el('div', { class: 'ips-pf-expr' });
714
+ var update = function () {
715
+ var t = dialogExpression(r);
716
+ exprLine.textContent = r.mode === 'text' ? '' : (t.expression || '(incomplete - the fields in red)');
717
+ Object.keys(fields).forEach(function (k) { fields[k].classList.toggle('ips-pf-bad', t.problems.indexOf(k) >= 0); });
718
+ };
719
+ var body = el('div', { class: 'ips-pf-dialog-body' });
720
+ var human = el('textarea', { class: 'form-control', rows: 2, placeholder: 'e.g. A problem list, with the patient\'s hypertension' });
721
+ human.value = r.human;
722
+ human.addEventListener('input', function () { r.human = human.value; changed(); });
723
+ body.appendChild(field('Expectation', [human], 'What is expected, as people will read it.'));
724
+
725
+ if (r.mode === 'section') {
726
+ body.appendChild(field('Section', [sectionCell(r, update), howMany(r, 'secCard', update, fields)]));
727
+ var ent = entryCell(r, update);
728
+ if (ent.code) { fields.entCode = ent.code.input; }
729
+ if (ent.vs) { fields.entVs = ent.vs.input; }
730
+ body.appendChild(field('Entries', [ent].concat(r.entSystem ? [howMany(r, 'entCard', update, fields)] : []),
731
+ 'Optionally, entries the section must have: with a code in a code system, or a code in a value set.'));
732
+ } else if (r.mode === 'resource') {
733
+ var type = typeCell(r, update);
734
+ var system = systemCell(r, update);
735
+ var byVs = r.resSystem === VS;
736
+ var code = byVs ? vsInput(r, 'resVs', 'resVsTitle', update) : codeInput(r, 'resCode', 'resDisplay', function () { return r.resSystem; }, update);
737
+ fields.resType = type.other;
738
+ fields.resSystem = system.url;
739
+ fields[byVs ? 'resVs' : 'resCode'] = code.input;
740
+ body.appendChild(field('Resources', [type], 'The type of resource, or any.'));
741
+ body.appendChild(field('With a code', [system, code], 'Anywhere in the document, whatever section they are in.'));
742
+ var kind = el('select', { class: 'form-control ips-ed-input' });
743
+ RULE_KINDS.forEach(function (k) { kind.appendChild(el('option', { value: k[0], text: k[1] })); });
744
+ kind.value = r.ruleKind;
745
+ kind.addEventListener('change', function () { r.ruleKind = kind.value; changed(); drawDialog(); });
746
+ var more = [];
747
+ if (r.ruleKind === 'element') {
748
+ var path = el('input', { type: 'text', class: 'form-control ips-ed-input', placeholder: 'e.g. onset, or code.text' });
749
+ path.value = r.rulePath;
750
+ path.addEventListener('input', function () { r.rulePath = path.value; changed(); update(); });
751
+ fields.rulePath = path;
752
+ more.push(field('Element', [path], 'An element of the resources, or an element of one of their elements (one dot, no deeper).'));
753
+ } else if (r.ruleKind === 'conforms') {
754
+ var url = el('input', { type: 'text', class: 'form-control ips-ed-input', placeholder: 'the profile\'s canonical URL' });
755
+ url.value = r.ruleUrl;
756
+ url.addEventListener('input', function () { r.ruleUrl = url.value; changed(); update(); });
757
+ fields.ruleUrl = url;
758
+ more.push(field('Profile', [url], 'Each of the resources must conform to it. It can be one of the clinical profiles, or a profile the validator has loaded.'));
759
+ } else if (r.ruleKind === 'category') {
760
+ var csys = categorySystem(r, update);
761
+ var ccode = codeInput(r, 'ruleCode', 'ruleDisplay', function () { return r.ruleSystem; }, update);
762
+ var version = el('input', { type: 'text', class: 'form-control ips-ed-input', placeholder: 'version (optional)' });
763
+ version.value = r.ruleVersion;
764
+ version.addEventListener('input', function () { r.ruleVersion = version.value; changed(); update(); });
765
+ fields.ruleSystem = csys.url;
766
+ fields.ruleCode = ccode.input;
767
+ more.push(field('Category', [csys, ccode, version], 'Each of the resources must have a category with this code.'));
768
+ } else if (r.ruleKind === 'other') {
769
+ var other = el('textarea', { class: 'form-control ips-ed-input', rows: 3, placeholder: '.count() = 1', style: 'font-family: monospace' });
770
+ other.value = r.ruleOther;
771
+ other.addEventListener('input', function () { r.ruleOther = other.value; changed(); update(); });
772
+ fields.ruleOther = other;
773
+ more.push(field('FHIRPath', [other], 'FHIRPath on the resources found, starting with a dot.'));
774
+ }
775
+ body.appendChild(field('What must be true', [kind].concat(more)));
776
+ } else {
777
+ var text = el('textarea', { class: 'form-control ips-ed-input', rows: 5, placeholder: 'A FHIRPath expression on the document (the Bundle), true when it meets the expectation', style: 'font-family: monospace' });
778
+ text.value = r.expression;
779
+ text.addEventListener('input', function () { r.expression = text.value; changed(); });
780
+ body.appendChild(field('FHIRPath', [text], 'Evaluated on the document (the Bundle): the rule is met when it is true.'));
781
+ }
782
+ if (r.mode !== 'text') { body.appendChild(field('As FHIRPath', [exprLine])); }
783
+ body.appendChild(e.msg);
784
+
785
+ var save = el('button', { type: 'button', class: 'btn btn-primary', text: 'Save', onclick: function () { saveRow(); } });
786
+ var cancel = el('button', { type: 'button', class: 'btn btn-default', text: 'Cancel', onclick: function () { stop(false); } });
787
+ e.save = save;
788
+ dialog.appendChild(el('div', { class: 'ips-pf-dialog-head' }, [
789
+ el('h4', { id: 'ips-pf-dialog-title', text: (r.key ? 'Edit ' : 'Add ') + TITLES[r.mode] }),
790
+ el('button', { type: 'button', class: 'ips-pf-dialog-x', 'aria-label': 'Close', text: '×', onclick: function () { stop(false); } })
791
+ ]));
792
+ dialog.appendChild(body);
793
+ dialog.appendChild(el('div', { class: 'ips-pf-dialog-foot' }, [cancel, ' ', save]));
794
+ update();
795
+ if (!e.focused) { e.focused = true; human.focus(); }
796
+ }
797
+
798
+ function start(r) {
799
+ if (editing) { return; }
800
+ if (r.mode === 'resource') { ruleFields(r); }
801
+ editing = { r: r, msg: el('div', { class: 'ips-pf-messages' }) };
802
+ dirty = false;
803
+ drawDialog();
804
+ dialog.showModal();
805
+ }
806
+ // close the dialog: when there are changes, only if the user says so (unless saved)
807
+ function stop(saved) {
808
+ if (!editing) { return; }
809
+ if (!saved && dirty && !window.confirm('Discard your changes to this rule?')) { return; }
810
+ editing = null;
811
+ dirty = false;
812
+ dialog.close();
813
+ }
814
+ dialog.addEventListener('cancel', function (ev) { ev.preventDefault(); stop(false); });
815
+
816
+ function saveRow() {
817
+ var e = editing;
818
+ if (!e || saving) { return; }
819
+ var t = dialogExpression(e.r);
820
+ if (!t.expression && e.r.mode !== 'text') { showIn(e.msg, ['The rule is incomplete (the fields in red)']); return; }
821
+ saving = true;
822
+ e.save.disabled = true;
823
+ showIn(e.msg, ['Saving...'], 'info');
824
+ var url = cfg.urls.constraint + (e.r.key ? '/' + encodeURIComponent(e.r.key) : '');
825
+ fetch(url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
826
+ body: JSON.stringify({ human: e.r.human, expression: t.expression || '', _csrf: cfg.csrf }) })
827
+ .then(function (res) {
828
+ return res.json().then(function (j) { return { ok: res.ok, j: j }; }, function () { return { ok: false, j: { problems: ['The server answered ' + res.status + (res.status === 403 ? ' (has your session expired? reload the page)' : '')] } }; });
829
+ })
830
+ .then(function (a) {
831
+ saving = false;
832
+ if (a.ok) { stop(true); location.reload(); return; }
833
+ e.save.disabled = false;
834
+ showIn(e.msg, a.j.problems || ['The rule was not saved']);
835
+ })
836
+ .catch(function (x) { saving = false; e.save.disabled = false; showIn(e.msg, ['The rule was not saved: ' + x.message]); });
837
+ }
838
+
839
+ redraw = drawDialog;
840
+ Array.prototype.forEach.call(document.querySelectorAll('.ips-pf-edit'), function (b) {
841
+ b.addEventListener('click', function () {
842
+ var c = byKey[b.closest('tr').getAttribute('data-key')];
843
+ if (c) { start(rowFrom(c)); }
844
+ });
845
+ });
846
+ Array.prototype.forEach.call(document.querySelectorAll('.ips-pf-add'), function (b) {
847
+ b.addEventListener('click', function () {
848
+ var r = rowFrom({});
849
+ r.mode = MODE[b.getAttribute('data-part')];
850
+ start(r);
851
+ });
852
+ });
853
+ }
854
+
576
855
  window.addEventListener('beforeunload', function (e) { if (dirty) { e.preventDefault(); e.returnValue = ''; } });
577
- draw();
856
+ if (cfg.mode === 'rows') { rowsMode(); } else { draw(); }
578
857
  })();
package/ips/readme.md CHANGED
@@ -37,7 +37,9 @@ network. Without it there are no links and no analysis.
37
37
 
38
38
  `/ips` lists the projects. The administrator logs in at `/ips/login` and, at
39
39
  `/ips/admin/projects` and `/ips/admin/users`, creates and deletes projects and users, and
40
- says which users manage which projects. A project has a code - 2-32 lower case letters,
40
+ says which users manage which projects. The administrator also looks after the
41
+ [clinical profiles](#clinical-profiles) and the [national profiles](#national-profiles),
42
+ which apply across all projects. A project has a code - 2-32 lower case letters,
41
43
  digits and hyphens, fixed once it exists - and lives at `/ips/{code}`. Deleting a project
42
44
  deletes its documents.
43
45
 
@@ -156,9 +158,9 @@ it (`ips/pipeline.js`), and marks it `processed` with the steps' notes, or `erro
156
158
  message of the step that failed. Each step's own state (`waiting`, `running`, `done` or
157
159
  `failed`, with its note or error) is kept in its result row as it goes, which is what the
158
160
  document page's step line shows; processing again clears every step's result first. The pipeline, in order, will be: **translate** CodeableConcepts
159
- to the international terminologies; generate the **narrative**; **validate** the document;
160
- measure its quality with PIQI; check it against the **clinical profiles**; **assess** it with AI. Translation, the narrative,
161
- validation, the **terminology check**, the clinical profiles check and the AI assessment exist so far (PIQI does not). A step keeps what it finds in one of two places: as a result stored against the
161
+ to the international terminologies; generate the **narrative**; **validate** the document; validate it against the
162
+ **national profiles**; measure its quality with PIQI; check it against the **clinical profiles**; **assess** it with AI. Translation, the narrative,
163
+ validation, the national profiles, the **terminology check**, the clinical profiles check and the AI assessment exist so far (PIQI does not). A step keeps what it finds in one of two places: as a result stored against the
162
164
  document in `document_results` (one row per step - the validation outcome), or in the
163
165
  **analysed copy** - a second copy of the document (`documents.analysed_json`) that the steps
164
166
  adorn with extensions, so that the master document is never changed. The analysed copy is
@@ -236,7 +238,12 @@ view shows it.
236
238
  `modules.ips.validator` configures a FHIR validator service, exactly as the SHL module's
237
239
  does (`fhir-validator-wrapper`: the validator CLI jar in the data folder's `bin` directory,
238
240
  run as a service on its own `port`, with `txServer` - tx.fhir.org, by default - and the IPS
239
- package loaded). It starts in the background when the module does, and the queue waits for
241
+ package loaded). `jar` names a validator jar of its own instead - a validator built locally from
242
+ org.hl7.fhir.core, say: absolute, or relative to the data folder (`bin/local/validator_cli.jar`) - which
243
+ is used as it is, never downloaded or updated. Keep such a jar out of `bin/validator_cli.jar`: the
244
+ wrapper takes that jar's version from the `.version` file it wrote when it downloaded the release
245
+ there, so a different jar in its place is started as if it were that release (and is replaced by
246
+ the next one). It starts in the background when the module does, and the queue waits for
240
247
  it. Each document - its analysed copy, so the translations are seen (the validator will
241
248
  remark on the translation extension until it is given its definition) - is validated against the IPS Bundle profile
242
249
  (`http://hl7.org/fhir/uv/ips/StructureDefinition/Bundle-uv-ips`; set `profiles` to validate
@@ -246,6 +253,23 @@ its `validation` view: the counts, each issue's severity, code, location and mes
246
253
  the raw OperationOutcome. Without a configured, running validator the step fails, and the
247
254
  document's status is `error`.
248
255
 
256
+ ### National profile validation
257
+
258
+ The national step runs after validation. It validates the document (the analysed copy, as validation does) against each
259
+ [national profile](#national-profiles) in turn: the national IG's package is loaded into the validator (once - a package
260
+ that has loaded, or has failed to, is remembered until the national profiles are changed), then the document is
261
+ validated against the IG's profile on Bundle, with the same `checkIpsCodes` setting. For each profile the result keeps
262
+ the OperationOutcome, its counts, and whether the document passed - it passes when the validator reports no errors (or
263
+ fatal issues). A profile whose package can't be loaded, or that the validator can't validate against, is kept with the
264
+ reason and counts as not checked; it doesn't stop the pipeline. With no national profiles there is nothing to do, and no
265
+ validator is needed; with some, the step fails, as validation does, without a configured, running validator.
266
+
267
+ The document's **National Profiles** view lists the profiles as they were when the document was processed, with the
268
+ outcome (passed, failed, or not checked) and the issue counts for each; a profile's page shows its validation as the
269
+ validation view shows the base validation (`?view=national&np={n}`). The packages loaded for national profiles stay
270
+ loaded in the validator alongside its own (`packages`), so two national IGs that define the same things differently can
271
+ affect each other.
272
+
249
273
  ### Terminology check
250
274
 
251
275
  After validation, every CodeableConcept the terminology view lists is checked for its
@@ -403,12 +427,31 @@ The editor (`/ips/StructureDefinition/new`, or Edit on a profile) is in three pa
403
427
  - **Rules about resources:** the third shape, each a row of a table with four columns (the type, which is optional, the system, the code, and the rest of the rule as FHIRPath starting with a dot).
404
428
  - **Other rules:** any other FHIRPath.
405
429
 
430
+ The page that shows a profile is the same for everyone. Its first part is a table of three columns: the **Expectation** (the description), the **Section** (an IPS section by its title, or `system|code`) and the **Entry** (`Code is system|code`, or `Code is in` a value set, by its title, or its URL when the title isn't known). A code system is shown by name when it is a known one. A cardinality, `[m..n]`, follows a section or an entry unless it is `1..1` or not given (none means at least one). The second part is also a table of three columns: the **Expectation**, the **Resource Set** (the type, as `MedicationAdministrations where` or `Any resource where`, then the code as in the Entry column) and the **Rule**, which is the rest of the expression in words where it is one of these: `.exists()` is "Must be present somewhere"; `.all(sameSubject())` is "Patient must match the Document"; `.x.exists()` and `.x.y.exists()` are "Must have an x" and "Must have an x.y" (no deeper); `.conformsTo(url)` (or `.all(conformsTo(url))`) is "Conform to" the profile, named by its title when it is one of the clinical profiles or by its jurisdiction and package when it is a national profile; and `.category.hasCoding(system, code [, version])` (or inside `.all()`) is "Has category system|code". Any other rest is shown as FHIRPath, and so are the other rules. On the same page the administrator edits the rules one at a time: each row has Edit and Delete, and each part has an Add button. Add and Edit open a dialog for the one rule - the expectation, then the parts of the rule, each with what it means (for a rule about resources, what must be true of them is a choice: present somewhere, the patient matches the document's, an element, conformance to a profile, a category, or other FHIRPath), and the FHIRPath it makes - and Save stores that one rule by itself (`POST /ips/StructureDefinition/{id}/constraint` to add, `.../constraint/{key}` to change, `.../constraint/{key}/delete` to delete). An edited rule keeps its key. The full editor is still there (Edit all), for the title, or for many changes at once.
431
+
406
432
  A rule moves between the parts when it is switched between table and FHIRPath, and it lands in the part whose shape it fits. A section is one of the IPS sections, or any code in any system. An entry or resource code is a code in a named code system (SNOMED CT, LOINC, ICD-10...) or in any other given by URL, and it is looked up on the terminology server (`GET /ips/lookup?system=&q=`, for the administrator only). Choosing "In a value set..." as the system matches entries or resources by value set instead. The code box becomes a value set search (`GET /ips/lookup-vs?q=`, for the administrator only): this module's own value sets that match the title come first, then the terminology server's (a `ValueSet?title=` search). A suggestion shows its title, with its URL underneath. A canonical can also be typed or pasted in, with `|version` to pin a version. When the editor opens, it looks up the title of each value set by URL (`?url=`). If the terminology server doesn't know a value set, it says so as a warning, not an error: the value set has to be known when documents are checked, not when the profile is saved. A cardinality is blank (no bounds) or `min..max`, where max may be `*`. Before a profile is saved, every expression is parsed and run once against an empty document. That catches unknown functions and wrong numbers of arguments as well as syntax errors.
407
433
 
408
434
  A profile can be downloaded as JSON (`?download=1`), and a downloaded profile can be uploaded again as a new profile, with a new id, url and keys. An upload must be no more than a profile like this: a profile on Bundle with no snapshot, and one differential element (the root) carrying constraints that have only a key, an `error` severity, a description, an expression and a source. Anything more is rejected, with the reasons. A profile downloaded when clinical profiles were on Composition is accepted and moved onto Bundle, as profiles already stored are when the server starts: `Composition.section(...)` becomes `Bundle.section(...)`, and any other `Composition.x` becomes `Bundle.entry.resource.ofType(Composition).x`.
409
435
 
410
436
  As FHIR: `GET /ips/StructureDefinition` is a searchset of them all, and `GET /ips/StructureDefinition/{id}` is one of them.
411
437
 
438
+ ## National profiles
439
+
440
+ A national profile is the profile a national implementation guide defines for an IPS document. Each has four parts:
441
+ the jurisdiction it is for, the IG's package id (e.g. `hl7.fhir.au.ips`), the package version, and the canonical URL
442
+ of the IG's profile on Bundle. National profiles are shared across all projects (`/ips/national-profiles`). Everyone can see them; only the
443
+ administrator adds, edits and deletes them. There is at most one for a package id and version. Every document is
444
+ validated against each of them by the [national profiles step](#national-profile-validation).
445
+
446
+ The jurisdiction is a code from `http://hl7.org/fhir/ValueSet/jurisdiction`: a country, by its ISO 3166 two-letter
447
+ code (`AU`; lower case is accepted, and stored in upper case), or a region, by its UN M49 three-digit code (`150` for
448
+ Europe, `053` for Australia and New Zealand). The pages show the country's or region's name with it, from the code
449
+ lists the terminology module holds (`tx/cs/cs-country.js`, `tx/cs/cs-areacode.js`); ISO's user-assigned and 'unknown' codes
450
+ (`XK`, `XX`, `ZZ`...) and countries' M49 numeric codes aren't accepted. National profiles made before the jurisdiction was asked
451
+ for show it as not given, until they are saved with one. The package id must be a FHIR package id (lower case letters, digits, `-` and `_`, in two or more parts separated by
452
+ dots), the version a package version (`1.0.0`, `1.1.0-ballot`), and the profile an http or https URL. `GET
453
+ /ips/national-profiles` with `Accept: application/json` returns them as `[{jurisdiction, packageId, version, url}]`.
454
+
412
455
  ## FHIRPath functions
413
456
 
414
457
  `ips/fhirpath-ips.js` adds two functions to fhirpath.js, for expressions on IPS documents: