fhirsmith 0.17.0 → 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.
- package/CHANGELOG.md +83 -0
- package/README.md +80 -13
- package/config-template.json +106 -1
- package/ips/ai-client.js +263 -0
- package/ips/composer.js +805 -0
- package/ips/editor-schema.js +289 -0
- package/ips/editor.js +290 -0
- package/ips/fhirpath-ips.js +397 -0
- package/ips/ips-codeable-concepts.md +1742 -0
- package/ips/ips-template.html +125 -0
- package/ips/ips-valuesets.js +159 -0
- package/ips/ips.js +1736 -0
- package/ips/membership.js +179 -0
- package/ips/pipeline.js +1012 -0
- package/ips/presenter.js +974 -0
- package/ips/profile-editor.js +578 -0
- package/ips/profiles.js +506 -0
- package/ips/readme.md +460 -0
- package/ips/render.js +1482 -0
- package/ips/search-params.js +83 -0
- package/ips/search.js +667 -0
- package/ips/share.js +178 -0
- package/ips/store.js +650 -0
- package/ips/terminology.js +395 -0
- package/ips/tx-client.js +202 -0
- package/ips/valuesets/CodeSystem-absent-unknown-uv-ips.json +206 -0
- package/ips/valuesets/ValueSet-absent-or-unknown-immunizations-uv-ips.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-code.json +59 -0
- package/ips/valuesets/ValueSet-ips-allergy-manifestation.json +356 -0
- package/ips/valuesets/ValueSet-ips-allergy-substance.json +37 -0
- package/ips/valuesets/ValueSet-ips-body-site.json +26 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding-or-none.json +35 -0
- package/ips/valuesets/ValueSet-ips-clinical-finding.json +26 -0
- package/ips/valuesets/ValueSet-ips-condition-code.json +72 -0
- package/ips/valuesets/ValueSet-ips-condition-severity.json +30 -0
- package/ips/valuesets/ValueSet-ips-condition-stage.json +26 -0
- package/ips/valuesets/ValueSet-ips-exposure-route.json +26 -0
- package/ips/valuesets/ValueSet-ips-imaging-procedure.json +25 -0
- package/ips/valuesets/ValueSet-ips-immunization-status-reason.json +43 -0
- package/ips/valuesets/ValueSet-ips-international-codes.json +25 -0
- package/ips/valuesets/ValueSet-ips-medication-ingredient.json +26 -0
- package/ips/valuesets/ValueSet-ips-medication-status-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-medication.json +50 -0
- package/ips/valuesets/ValueSet-ips-observation-code.json +18 -0
- package/ips/valuesets/ValueSet-ips-observation-interpretation.json +18 -0
- package/ips/valuesets/ValueSet-ips-procedure-followup.json +51 -0
- package/ips/valuesets/ValueSet-ips-procedure-outcome.json +30 -0
- package/ips/valuesets/ValueSet-ips-procedure-reason.json +37 -0
- package/ips/valuesets/ValueSet-ips-procedure.json +116 -0
- package/ips/valuesets/ValueSet-ips-route.json +46 -0
- package/ips/valuesets/ValueSet-ips-target-disease.json +171 -0
- package/ips/valuesets/ValueSet-ips-vaccine.json +31 -0
- package/ips/valuesets/elements.json +44 -0
- package/library/daily-report.js +422 -0
- package/library/fhir-search-values.js +138 -0
- package/library/html-server.js +32 -23
- package/library/mailer.js +77 -0
- package/library/narrative-check.js +21 -2
- package/library/npm-audit.js +25 -0
- package/library/package-manager.js +4 -2
- package/library/sqlite-session-store.js +29 -1
- package/package.json +7 -2
- package/packages/packages.js +34 -3
- package/publisher/publisher.js +29 -0
- package/registry/auth-store.js +366 -0
- package/registry/auth.js +872 -0
- package/registry/crawler.js +41 -1
- package/registry/documentation/api.md +162 -0
- package/registry/documentation/configuration.md +93 -0
- package/registry/documentation/documents.md +74 -0
- package/registry/documentation/internals.md +151 -0
- package/registry/documentation/readme.md +100 -0
- package/registry/documentation/security.md +199 -0
- package/registry/documents.js +309 -0
- package/registry/openapi.yaml +30 -0
- package/registry/registry-template.html +1 -1
- package/registry/registry.js +230 -44
- package/registry/uptime.js +154 -0
- package/server.js +105 -44
- package/shl/shl.js +64 -9
- package/stats-db.js +93 -0
- package/stats.js +158 -1
- package/testing/search.js +2 -108
- package/testing/store.js +5 -0
- package/testing/testing.js +20 -0
- package/token/token-template.html +5 -4
- package/token/token.js +17 -10
- package/translations/Messages.properties +9 -0
- package/tx/README.md +52 -0
- package/tx/cs/cs-api.js +90 -14
- package/tx/cs/cs-cs.js +56 -1
- package/tx/cs/cs-icd11.js +366 -112
- package/tx/cs/cs-icf.js +418 -0
- package/tx/cs/cs-loinc.js +47 -25
- package/tx/cs/cs-omop.js +10 -3
- package/tx/cs/cs-snomed.js +918 -55
- package/tx/cs/cs-ucum.js +6 -0
- package/tx/cs/icd11-expressions.js +191 -78
- package/tx/importers/export-icd11-codesystem.js +58 -12
- package/tx/importers/icd11-schema.md +9 -5
- package/tx/library.js +98 -4
- package/tx/openapi-operations.js +4 -0
- package/tx/params.js +8 -1
- package/tx/provider.js +16 -4
- package/tx/sct/documentation.md +251 -0
- package/tx/sct/ecl.js +758 -46
- package/tx/sct/expressions.js +113 -52
- package/tx/syndication/syndication-feed.js +218 -0
- package/tx/syndication/syndication-store.js +415 -0
- package/tx/tx-html.js +15 -1
- package/tx/tx.fhir.org.yml +2 -3
- package/tx/tx.js +144 -2
- package/tx/vs/vs-vsac.js +25 -6
- package/tx/workers/expand.js +33 -6
- package/tx/workers/lookup.js +61 -6
- package/tx/workers/subsumes.js +1 -1
- package/tx/workers/translate.js +166 -15
- package/tx/workers/validate.js +128 -6
- package/utilities/make-sct-test-extension.js +264 -0
- package/xig/xig.js +32 -0
- package/registry/readme.md +0 -124
package/ips/readme.md
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
# IPS module
|
|
2
|
+
|
|
3
|
+
Collections of International Patient Summary documents, called projects, each with its own
|
|
4
|
+
FHIR endpoint and web pages. It runs at `/ips` when `modules.ips.enabled` is true.
|
|
5
|
+
|
|
6
|
+
## Configuration
|
|
7
|
+
|
|
8
|
+
```json
|
|
9
|
+
"ips": {
|
|
10
|
+
"enabled": true,
|
|
11
|
+
"database": "ips/ips-content.db",
|
|
12
|
+
"adminUsername": "admin",
|
|
13
|
+
"adminPassword": "<secret>",
|
|
14
|
+
"sessionSecret": "<random string>",
|
|
15
|
+
"cookieSecure": true,
|
|
16
|
+
"loginAttemptsPerWindow": 20,
|
|
17
|
+
"maxSize": 10485760,
|
|
18
|
+
"rateLimit": { "windowMinutes": 1, "max": 60 },
|
|
19
|
+
"processing": { "enabled": true, "intervalSeconds": 5 },
|
|
20
|
+
"txServer": "/tx/r4"
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Everything is in one SQLite database; a relative path resolves under the data folder's
|
|
25
|
+
`databases` directory. The administrator's login is `adminUsername` (default `admin`) with
|
|
26
|
+
`adminPassword` - without a password there is no administrator, so nothing can be created.
|
|
27
|
+
`sessionSecret` signs the login cookie; `cookieSecure: false` is for local testing over
|
|
28
|
+
plain http (see the publisher's notes on nginx and `X-Forwarded-Proto`). `maxSize` is the
|
|
29
|
+
biggest document accepted, in bytes; `rateLimit` is submissions per IP address (`max: 0`
|
|
30
|
+
turns it off). `txServer` is the terminology server the module uses - for links from the
|
|
31
|
+
terminology view to code systems, and for the analysis of documents: either an http(s) URL
|
|
32
|
+
of an external server, or a path on this server (`/tx/r4`) when the tx module is enabled,
|
|
33
|
+
in which case requests are dispatched to it in-process (`ips/tx-client.js`), with no
|
|
34
|
+
network. Without it there are no links and no analysis.
|
|
35
|
+
|
|
36
|
+
## Projects, the administrator, and managers
|
|
37
|
+
|
|
38
|
+
`/ips` lists the projects. The administrator logs in at `/ips/login` and, at
|
|
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,
|
|
41
|
+
digits and hyphens, fixed once it exists - and lives at `/ips/{code}`. Deleting a project
|
|
42
|
+
deletes its documents.
|
|
43
|
+
|
|
44
|
+
A project's managers (and the administrator) can:
|
|
45
|
+
|
|
46
|
+
- set the project's submission token at `/ips/{code}/settings`. When one is set, a document
|
|
47
|
+
can only be submitted with it: in the `token` field of the submission form, or as
|
|
48
|
+
`Authorization: Bearer <token>` on a `POST`. Managers can read the token back, so it is
|
|
49
|
+
stored as it is, not hashed
|
|
50
|
+
- delete documents, and put a document back in the processing queue
|
|
51
|
+
|
|
52
|
+
Logins work as the publisher's and the testing module's do: a session cookie
|
|
53
|
+
(`ips.sid`, SameSite=Lax, HTTPS-only unless `cookieSecure` is false), bcrypt password
|
|
54
|
+
hashes, a rate limited login, an Origin check and a CSRF token on every form post - except
|
|
55
|
+
the submission form, which anyone may post (with the project's token, if it has one), so it
|
|
56
|
+
carries no CSRF token and starts no session; the Origin check still applies.
|
|
57
|
+
|
|
58
|
+
## Limits
|
|
59
|
+
|
|
60
|
+
A document is at most `maxSize` bytes. The module reads request bodies itself: the server's body parsers leave `/ips` alone, the submission token is checked before a document is read, and a document is read no bigger than `maxSize`. When it is indexed, at most 200 values are kept per
|
|
61
|
+
search parameter; a search takes at most 5 sort keys, and a page of the FHIR search carries at
|
|
62
|
+
most 32MB of documents (the `next` link follows on from where the page stopped). The
|
|
63
|
+
terminology check asks the terminology server at most 2000 questions per document (the rest
|
|
64
|
+
are left unknown) and remembers 20000 answers. Narratives are shown as parsed, never as the
|
|
65
|
+
string submitted. Error responses carry no internal detail; the reason a document's
|
|
66
|
+
processing failed is shown only to the project's managers.
|
|
67
|
+
|
|
68
|
+
## Documents
|
|
69
|
+
|
|
70
|
+
A document is an R4 `Bundle` of type `document` whose first entry is a `Composition` whose
|
|
71
|
+
subject is a `Patient` in the bundle. Nothing else about its content is checked (that is for
|
|
72
|
+
processing, later), except that no narrative in it - any resource's `text.div`, or a
|
|
73
|
+
section's - may have active content (script, event handlers, `javascript:` URLs, frames,
|
|
74
|
+
forms and the like; see `library/narrative-check.js`), because the web site shows the
|
|
75
|
+
narratives as they are.
|
|
76
|
+
|
|
77
|
+
POST /ips/{code}/Bundle
|
|
78
|
+
Content-Type: application/fhir+json
|
|
79
|
+
Authorization: Bearer <token> (only if the project has one)
|
|
80
|
+
|
|
81
|
+
or paste the JSON (or pick a file) at `/ips/{code}/submit`. The server gives the document its
|
|
82
|
+
id - a serial number, one series across all projects - sets `meta.lastUpdated` to the time it
|
|
83
|
+
was received, and drops `meta.versionId`: documents can't be changed once received.
|
|
84
|
+
Responses follow the spec: `201 Created` with a `Location` header, and the stored document,
|
|
85
|
+
or nothing (`Prefer: return=minimal`), or an OperationOutcome (`Prefer: return=OperationOutcome`).
|
|
86
|
+
Errors are OperationOutcomes: 400, 401 (token), 413 (too big), 415 (not JSON), 429 (rate
|
|
87
|
+
limit). The submitter's IP address is recorded, for tracing abuse, and never returned.
|
|
88
|
+
|
|
89
|
+
GET /ips/{code}/Bundle/{id} the document (JSON), or a view of it for a browser
|
|
90
|
+
GET /ips/{code}/Bundle?{params} search
|
|
91
|
+
GET /ips/{code}/metadata CapabilityStatement
|
|
92
|
+
|
|
93
|
+
A browser (or `_format=html`) gets pages: `/ips/{code}` is the project's page - its name
|
|
94
|
+
and description in a banner, "Submit a document" (and, for managers, "Settings") buttons,
|
|
95
|
+
the filterable list (patient name, gender, document date, document id or patient identifier,
|
|
96
|
+
processing status; the FHIR parameters below work there too), and at the foot a
|
|
97
|
+
documentation section with the endpoint's URL, the CapabilityStatement link and a table of
|
|
98
|
+
the requests it answers - and `/ips/{code}/Bundle/{id}` shows the document.
|
|
99
|
+
The document page's header names the patient, the document's date, when and how it was
|
|
100
|
+
received and its size, with links to the FHIR JSON and to download it (`?download=1`, sent
|
|
101
|
+
as an attachment named `ips-{code}-{id}.json`). Below it, each processing step is shown
|
|
102
|
+
with where it is (tick: done, cross: failed, gear: running, moon: waiting - the step's note,
|
|
103
|
+
or for managers the reason it failed, on hover), and managers get "Process again" and
|
|
104
|
+
"Delete" buttons. The views of a document are tabs, chosen with `?view=`: `narrative` (the
|
|
105
|
+
default - the Composition's narrative followed by each section's, with the section titles),
|
|
106
|
+
`translation` (what the translate step did with each concept, below), `generated` (the
|
|
107
|
+
narrative this server generates, below), `validation` (below), `terminology`, `ai` (the AI assessment, below) and `json`
|
|
108
|
+
(pretty-printed, monospaced).
|
|
109
|
+
`terminology` lists every CodeableConcept in the document (`ips/terminology.js`): the
|
|
110
|
+
Composition's own, then each section's entries in the IPS section order (Patient Story,
|
|
111
|
+
Problems, Allergies, Medications, ... Results; sections not in that list after, in document
|
|
112
|
+
order), found by walking each resource against the R4 type model. Section codes, every
|
|
113
|
+
`category`, `Composition.type`, `Composition.event.code`, and the clinical and verification
|
|
114
|
+
status of Conditions and AllergyIntolerances are left out (`IGNORED` in terminology.js). Codings are shown as the Java
|
|
115
|
+
DataRenderer shows them: the code system by name with the URL on hover, the version
|
|
116
|
+
described after it in brackets - for SNOMED CT the edition's short code and release
|
|
117
|
+
("US rel. Mar 2024", from `tx/sct/editions.js`, with the edition's full name on hover) - then
|
|
118
|
+
the code and display. The external terminologies (SNOMED CT, LOINC, RxNorm, NCI Thesaurus,
|
|
119
|
+
ISO 3166) link name and code to their own browsers (SNOMED CT to the edition's URI,
|
|
120
|
+
`http://snomed.info/sct/{edition}/id/{code}`, when the version names a known edition); any
|
|
121
|
+
other code system links to its page on the terminology server. More
|
|
122
|
+
views will come.
|
|
123
|
+
|
|
124
|
+
### Searching
|
|
125
|
+
|
|
126
|
+
A search is over Bundle; the Composition's and Patient's parameters are reached by chaining
|
|
127
|
+
through `composition` and then `patient` (or `subject`). The standard R4 parameters are
|
|
128
|
+
indexed (`ips/search-params.js` lists them, with their FHIRPath expressions):
|
|
129
|
+
|
|
130
|
+
_id, _lastUpdated the server's id, and when it received the document
|
|
131
|
+
processing-status submitted | processing | processed | error (this module's own)
|
|
132
|
+
identifier, type, timestamp, composition Bundle
|
|
133
|
+
composition.date, composition.type, composition.title, composition.status, composition.author, ...
|
|
134
|
+
composition.patient.name, composition.patient.birthdate, composition.patient.gender,
|
|
135
|
+
composition.patient.identifier, composition.patient.address-city, ...
|
|
136
|
+
|
|
137
|
+
Strings match from the start, case-insensitively, with `:exact` and `:contains`; tokens are
|
|
138
|
+
`code` or `system|code` (`|code` for no system, `system|` for any code in the system), with
|
|
139
|
+
`:not`; dates take the usual prefixes (`ge2026`, `lt2026-03-01`) and compare as ranges;
|
|
140
|
+
references match as written, or by `Type/id` or `id` at the end of what was written;
|
|
141
|
+
`:missing` works everywhere. Comma separated values are ORed, repeated parameters ANDed.
|
|
142
|
+
`_sort` takes any of these (`-` for descending), `_count`/`_offset` page, `_summary=count`
|
|
143
|
+
gives the total alone. Unknown parameters are ignored, with a warning OperationOutcome in the
|
|
144
|
+
result - unless `Prefer: handling=strict`, when they are a 400.
|
|
145
|
+
|
|
146
|
+
Each document is indexed once, when it arrives: every parameter's expression is evaluated
|
|
147
|
+
with fhirpath.js against the Bundle, the Composition and the Patient, and the values go into
|
|
148
|
+
one index table (`ips_index`). Adding a parameter to `search-params.js` indexes new documents
|
|
149
|
+
only; the index for existing ones would need rebuilding.
|
|
150
|
+
|
|
151
|
+
## Processing
|
|
152
|
+
|
|
153
|
+
A received document's status is `submitted`. A background loop (every `intervalSeconds`)
|
|
154
|
+
takes submitted documents in turn, marks each `processing`, runs the pipeline's steps over
|
|
155
|
+
it (`ips/pipeline.js`), and marks it `processed` with the steps' notes, or `error` with the
|
|
156
|
+
message of the step that failed. Each step's own state (`waiting`, `running`, `done` or
|
|
157
|
+
`failed`, with its note or error) is kept in its result row as it goes, which is what the
|
|
158
|
+
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
|
|
162
|
+
document in `document_results` (one row per step - the validation outcome), or in the
|
|
163
|
+
**analysed copy** - a second copy of the document (`documents.analysed_json`) that the steps
|
|
164
|
+
adorn with extensions, so that the master document is never changed. The analysed copy is
|
|
165
|
+
rebuilt from the master on every run, so a document can always be processed again.
|
|
166
|
+
Anything left `processing` when the server stopped is queued again at startup. Managers can
|
|
167
|
+
queue a document again from its page, though not while it is being processed.
|
|
168
|
+
|
|
169
|
+
### Translation
|
|
170
|
+
|
|
171
|
+
The first step. Every CodeableConcept the terminology view lists is checked as the
|
|
172
|
+
terminology check (below) checks it; one that fails - no coding in the element's value set,
|
|
173
|
+
or, for an element with no value set of its own, no international coding - is sent to the
|
|
174
|
+
terminology server to translate: `ConceptMap/$translate` with `sourceCodeableConcept` (the
|
|
175
|
+
concept as submitted) and `targetScope` (the element's value set, or the fallback), the
|
|
176
|
+
value set and what it draws on as `tx-resource`. The server chooses the concept maps. Every
|
|
177
|
+
match that comes back (whatever its relationship) is added to the concept in the analysed
|
|
178
|
+
copy as a Coding, unless the concept already has that system and code, with an extension
|
|
179
|
+
saying where it came from:
|
|
180
|
+
`http://healthintersections.com.au/fhirsmith/StructureDefinition/ips-translation`, with
|
|
181
|
+
`server` (the terminology server, as configured), `conceptMap` (the match's `originMap`, when
|
|
182
|
+
the server names one) and `relationship` (the match's, R4's `equivalence` accepted too). The
|
|
183
|
+
terminology view marks such a coding "translated", with the detail on hover. A concept the
|
|
184
|
+
server could not say anything about (unknown) is not translated; a translation the server
|
|
185
|
+
fails on is logged and the step goes on. Translations are cached as the check's answers are,
|
|
186
|
+
and counted against the same per-document limit. The step's note says how many concepts
|
|
187
|
+
were tried and translated, and its result (`document_results`, step `translate`) records
|
|
188
|
+
what happened to every concept - not needed (already in its value set), not checked (the
|
|
189
|
+
server could not say), translated (the codings that came back, with their concept maps and
|
|
190
|
+
relationships, and which were already present), no translation, or failed - which the
|
|
191
|
+
document's `translation` view shows as a table.
|
|
192
|
+
|
|
193
|
+
### Narrative
|
|
194
|
+
|
|
195
|
+
The second step: the IPS presenter (`ips/presenter.js`) generates the document's narrative
|
|
196
|
+
from the analysed copy (so the translations are in it), as xhtml. It follows the FHIR rules
|
|
197
|
+
for presenting a document - the Composition's title, its subject, and its facts (date,
|
|
198
|
+
status, type, authors, attesters, custodian, encounter, event, confidentiality), then the
|
|
199
|
+
sections - with the patient summarised the way the Java PatientRenderer does (name, gender,
|
|
200
|
+
date of birth and identifiers on one line, then other names, contact details, address,
|
|
201
|
+
marital status, language, managing organization, general practitioner and contacts) and the
|
|
202
|
+
other people and organizations named by their names. The sections come in the IPS order
|
|
203
|
+
(`SECTION_ORDER` in terminology.js), sections the IPS doesn't know after them in document
|
|
204
|
+
order, nested sections after their parent with a smaller heading. Each section is titled
|
|
205
|
+
with its own title, else the IPS's default for its code. A section with entries is a table
|
|
206
|
+
of its resources, one row each (a resource referenced twice appears once), with the columns
|
|
207
|
+
that suit the resource type (`COLUMNS` in presenter.js: roughly the IPS profile's
|
|
208
|
+
must-support elements - AllergyIntolerance, Condition, MedicationStatement, MedicationRequest,
|
|
209
|
+
Medication, Immunization, Procedure, Observation, DiagnosticReport, DeviceUseStatement,
|
|
210
|
+
Device, Flag, Consent, CarePlan and ClinicalImpression have their own; anything else, or a
|
|
211
|
+
section of mixed types, gets the generic ones: resource, category, name, type, code, value,
|
|
212
|
+
status); a column no resource in the section has a value for is left out, and a resource's
|
|
213
|
+
nested parts (reactions, body sites, dosages, components, reference ranges, notes...) are
|
|
214
|
+
bullet points in a follow-up row beneath it. A section with no entries shows its own
|
|
215
|
+
narrative (parsed, not copied, and headed "Narrative as submitted"), else its emptyReason,
|
|
216
|
+
else "Nothing found to render".
|
|
217
|
+
References to a Medication show the medication's code; a reason or evidence that is a
|
|
218
|
+
reference is followed, an Observation shown as "code: value"; medication[x] and the other
|
|
219
|
+
choice elements are rendered by their type. A condition's or allergy's clinical and
|
|
220
|
+
verification statuses share one Status column ("Active / Confirmed"). An Observation's
|
|
221
|
+
single reference range is a column (several go beneath), and a blood pressure with systolic
|
|
222
|
+
and diastolic components is shown as one value (120/80 mmHg). Dates are dd-mmm yy, date
|
|
223
|
+
times dd-mmm yy hh:nn (tz), partial dates as far as they go. A coded value shows its text, else the first coding's
|
|
224
|
+
display, else any coding's display, else what the terminology server's `CodeSystem/$lookup`
|
|
225
|
+
says for a coding (cached, and at most 500 questions per document), else the code, else
|
|
226
|
+
`--`. The output is plain xhtml (tables with class names, no styles or scripts), every
|
|
227
|
+
value escaped.
|
|
228
|
+
|
|
229
|
+
The narrative is kept as the step's result (the whole document, and each section) and
|
|
230
|
+
written into the analysed copy as the Composition's `text` and each section's `text`
|
|
231
|
+
(status `generated`), which is what the validator then sees. The document's `generated`
|
|
232
|
+
view shows it.
|
|
233
|
+
|
|
234
|
+
### Validation
|
|
235
|
+
|
|
236
|
+
`modules.ips.validator` configures a FHIR validator service, exactly as the SHL module's
|
|
237
|
+
does (`fhir-validator-wrapper`: the validator CLI jar in the data folder's `bin` directory,
|
|
238
|
+
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
|
|
240
|
+
it. Each document - its analysed copy, so the translations are seen (the validator will
|
|
241
|
+
remark on the translation extension until it is given its definition) - is validated against the IPS Bundle profile
|
|
242
|
+
(`http://hl7.org/fhir/uv/ips/StructureDefinition/Bundle-uv-ips`; set `profiles` to validate
|
|
243
|
+
against something else) with the validator's IPS code checks on (`checkIpsCodes`), and the
|
|
244
|
+
OperationOutcome is stored with the document and shown on
|
|
245
|
+
its `validation` view: the counts, each issue's severity, code, location and message, and
|
|
246
|
+
the raw OperationOutcome. Without a configured, running validator the step fails, and the
|
|
247
|
+
document's status is `error`.
|
|
248
|
+
|
|
249
|
+
### Terminology check
|
|
250
|
+
|
|
251
|
+
After validation, every CodeableConcept the terminology view lists is checked for its
|
|
252
|
+
suitability for international exchange, against this module's own value sets - not the IPS
|
|
253
|
+
profiles' bindings. The value sets are FHIR ValueSets in `ips/valuesets/`
|
|
254
|
+
(`ValueSet-ips-*.json`, canonical `http://healthintersections.com.au/fhirsmith/ValueSet/ips-*`),
|
|
255
|
+
and `elements.json` there says which one each element is held to, by path
|
|
256
|
+
(`Condition.code` → `ips-condition-code`, `MedicationStatement.medicationCodeableConcept`,
|
|
257
|
+
`MedicationRequest.medicationCodeableConcept` and `Medication.code` → `ips-medication`,
|
|
258
|
+
`Observation.code` and `DiagnosticReport.code` → `ips-observation-code` (any LOINC code),
|
|
259
|
+
...). They are built on SNOMED CT International (every SNOMED CT include is pinned to the
|
|
260
|
+
International edition, `http://snomed.info/sct/900000000000207008`), LOINC, EDQM routes and
|
|
261
|
+
a few HL7 codes; `ips-codeable-concepts.md` lists every CodeableConcept element the IPS
|
|
262
|
+
profiles have, with their bindings, as the review the value sets came from. The folder also
|
|
263
|
+
carries the IPS's absent/unknown ValueSet and CodeSystem, which the vaccine and fallback
|
|
264
|
+
value sets draw on; they go with each question as `tx-resource`, so the server need not
|
|
265
|
+
know the IPS. The value sets are read at startup (`ips/ips-valuesets.js`); a folder that
|
|
266
|
+
doesn't hold together (an element mapped to a value set that isn't there, an include of
|
|
267
|
+
one that isn't) is logged and the step fails.
|
|
268
|
+
|
|
269
|
+
Elements not in `elements.json` have no value set of their own. For every element there is
|
|
270
|
+
a fallback: a coding is international anyway if it is in `ips-international-codes` (any
|
|
271
|
+
SNOMED CT International or LOINC code, or an IPS absent/unknown code), or if its system is
|
|
272
|
+
an HL7 terminology (`http://terminology.hl7.org/...` or `http://hl7.org/fhir/...`, but not
|
|
273
|
+
the `http://hl7.org/fhir/sid/` URLs that name other people's terminologies) and the code
|
|
274
|
+
exists in it (`CodeSystem/$validate-code`). The fallback is coded logic, not a value set,
|
|
275
|
+
for the HL7 case.
|
|
276
|
+
|
|
277
|
+
For each concept the terminology server is asked, with `ValueSet/$validate-code`, whether
|
|
278
|
+
any of its codings is in the element's value set (the value set itself and what it draws on
|
|
279
|
+
go with the request); failing that, whether any coding passes the fallback. The verdict is
|
|
280
|
+
written onto the concept in the analysed copy as an extension
|
|
281
|
+
(`http://healthintersections.com.au/fhirsmith/StructureDefinition/ips-valueset-check`:
|
|
282
|
+
`result` in | international | not-in | unknown, the `valueSet` it is held to or that
|
|
283
|
+
accepted it, the `system` for an HL7 code, the matching `coding`, a `message`), and the
|
|
284
|
+
terminology view's Analysis column shows it: a green tick when a coding is in the element's
|
|
285
|
+
value set (or, for an element with no value set of its own, when a coding is international),
|
|
286
|
+
an orange warning sign when no coding is in the element's value set but one is international,
|
|
287
|
+
a red cross when no coding is anywhere (a concept with no codings is a cross too), and a grey
|
|
288
|
+
question mark when the server could not say true or false. Answers are cached across
|
|
289
|
+
documents. A resubmitted analysed copy has its old verdicts stripped before it is checked
|
|
290
|
+
again.
|
|
291
|
+
|
|
292
|
+
### Clinical profiles
|
|
293
|
+
|
|
294
|
+
The profiles step runs after the terminology check and before the AI assessment. It checks the document against every [clinical profile](#clinical-profiles). Each constraint is evaluated on the analysed copy of the document, so translated codings count. A constraint is met when its expression evaluates to `true`, and only `true`. For each profile, the step records one of three outcomes, along with the descriptions of the constraints the document doesn't meet:
|
|
295
|
+
|
|
296
|
+
- **conforms:** every constraint is met.
|
|
297
|
+
- **does not conform:** at least one constraint plainly fails.
|
|
298
|
+
- **could not be fully checked:** the only failures are constraints that couldn't be decided. Either the expression couldn't be evaluated, or it uses a value set the terminology server couldn't answer for.
|
|
299
|
+
|
|
300
|
+
Value set membership (`entryIn()`, `resourcesIn()`) is asked of the terminology server through one membership session for the whole document, so a question is asked once however many profiles use it. Definite answers are kept across documents. A constraint is given 60 seconds.
|
|
301
|
+
|
|
302
|
+
The **Clinical Profiles** view lists each profile, linked to it, with its outcome and the constraints the document doesn't meet. A constraint that couldn't be decided is marked as such, and its reason is shown to the project's managers. The document is checked against the profiles as they were when it was processed; reprocess it to check against them as they are now.
|
|
303
|
+
|
|
304
|
+
### AI assessment
|
|
305
|
+
|
|
306
|
+
The last step, and off unless a project's managers turn it on (Settings → AI assessment),
|
|
307
|
+
because it sends the document to a third party and costs money: the analysed copy of the
|
|
308
|
+
document, as JSON, goes to an AI service with a prompt
|
|
309
|
+
(`AI_SYSTEM_PROMPT` in pipeline.js) asking for two assessments - the document as a whole,
|
|
310
|
+
for consistency and integrity (do its details agree with each other and the overall
|
|
311
|
+
picture; is anything missing) and its terminology (are the codes consistent with each other
|
|
312
|
+
and their text; are any wrong, contradictory or misplaced) - answered as JSON: a grade A-F,
|
|
313
|
+
a summary, consistency findings (severity, finding, where), what is missing, and terminology
|
|
314
|
+
findings (severity, finding, the codes). Whether the narrative goes is the project's choice
|
|
315
|
+
(its setting, else the server's `sendNarrative`, else not): without it the document is
|
|
316
|
+
smaller and cheaper; with it, the narratives are the ones the sender wrote (from the master -
|
|
317
|
+
the analysed copy's were generated by the narrative step), and the prompt asks for a third
|
|
318
|
+
assessment, what the narrative says that the structured data does not back up, and the
|
|
319
|
+
reverse. The submission page says so when it is on.
|
|
320
|
+
|
|
321
|
+
The service is an account - provider, base URL, model, API key - of one of two shapes
|
|
322
|
+
(`ips/ai-client.js`): `openai`, the chat-completions API that OpenAI, OpenRouter, LiteLLM,
|
|
323
|
+
Groq, Ollama, Azure and others speak (so a routing service is just a base URL), or
|
|
324
|
+
`anthropic`, the Messages API. A project may keep its own account (its managers enter it on
|
|
325
|
+
the settings page; the key is encrypted at rest with AES-256-GCM under `modules.ips.aiKeySecret`,
|
|
326
|
+
else the `sessionSecret`, so a copy of the database is not a copy of the account); a
|
|
327
|
+
project with none uses the server's default, `modules.ips.ai`; with neither, the step is
|
|
328
|
+
skipped. Each project may make `dailyCap` assessments in a rolling day (its own setting,
|
|
329
|
+
else the server's, else 50; the calls are counted in `ai_calls`), and a document bigger
|
|
330
|
+
than `maxBytes` (300000) is not sent; both are skips with a note, not failures. A service
|
|
331
|
+
that fails is a failure (the document's status is `error`), and still counts against the
|
|
332
|
+
cap. The result (model, token counts, cost, bytes sent, the tidied assessment and the
|
|
333
|
+
model's raw answer) is the step's result; the `ai` view shows the grade, the summary, the
|
|
334
|
+
findings as tables, and the raw answer behind a button. What each call used - model, tokens
|
|
335
|
+
in and out, cost - is kept in `ai_calls`, and the settings page totals it for the project
|
|
336
|
+
(today, the last 30 days, all time). The cost is what the service reports (OpenRouter's
|
|
337
|
+
`usage.cost`), else worked out from a price list - USD per million tokens by model name or
|
|
338
|
+
a prefix of it: Anthropic's current models are built in (`DEFAULT_PRICES` in ai-client.js,
|
|
339
|
+
and they will go stale), and `modules.ips.ai.prices` (`"gpt-4o": { "input": 2.5, "output": 10 }`)
|
|
340
|
+
adds to or overrides them - else not known, and the totals say how many calls had no known
|
|
341
|
+
cost. The answer is the model's opinion, and the page
|
|
342
|
+
says so.
|
|
343
|
+
|
|
344
|
+
## The editor
|
|
345
|
+
|
|
346
|
+
`/ips/{code}/compose` is an editor for composing a document in the browser ("Compose a
|
|
347
|
+
document" on the project's page; "Edit" on a document's page opens the editor with what
|
|
348
|
+
the form can hold of that document, and submitting makes a new document). The page is the
|
|
349
|
+
patient and the document's header (title, author - a practitioner or an organization -
|
|
350
|
+
custodian, date), then a panel per IPS section in the IPS order; the three the IPS requires
|
|
351
|
+
(problems, allergies, medications) are open, the rest collapsed. Each section is filled one
|
|
352
|
+
of three ways: entries - rows of the fields the presenter renders for that resource type,
|
|
353
|
+
coded fields looking codes up as you type; a narrative written by hand (paragraphs at
|
|
354
|
+
blank lines); or a reason it is empty (the `list-empty-reason` codes). Optional sections
|
|
355
|
+
can also be left out. What the form holds is declared in `ips/editor-schema.js` (the
|
|
356
|
+
fields, their types, the value set each coded field is bound to, where each lands in the
|
|
357
|
+
resource) and drives the form (`ips/editor.js`, served at `/ips/editor.js`), the building
|
|
358
|
+
of the document (`ips/composer.js`: the Bundle, the Composition with its sections, the
|
|
359
|
+
Patient, the author, each row's resource - a blood pressure as components, a device as a
|
|
360
|
+
Device and a DeviceUseStatement - and the narrative, generated by the presenter for the
|
|
361
|
+
data sections) and the reading of a document back into the form.
|
|
362
|
+
|
|
363
|
+
Lookups go to `/ips/{code}/lookup?vs={value set id}&q={text}`, which runs
|
|
364
|
+
`ValueSet/$expand` with a filter on the terminology server against one of the module's
|
|
365
|
+
own value sets (`ips/valuesets/`), returning up to 20 codes (rate-limited on their own:
|
|
366
|
+
`rateLimit.lookupMax`, 600 a minute). Anything can also be entered as text. The browser
|
|
367
|
+
keeps the model in localStorage until it is submitted. Submitting (`POST /ips/{code}/compose`,
|
|
368
|
+
JSON `{ action: 'submit' | 'download', model, token }`) builds the document, checks it as
|
|
369
|
+
a submission is checked (the project's token applies), and stores it like any other
|
|
370
|
+
(source `editor`), or hands it back as a file without storing it; the problems with a model
|
|
371
|
+
come back as a list for the page to show.
|
|
372
|
+
|
|
373
|
+
## Clinical profiles
|
|
374
|
+
|
|
375
|
+
A clinical profile says what a kind of clinical document is expected to contain. It is a StructureDefinition: a profile on Bundle (the document) with one element, the root, whose constraints are the expectations. Each constraint has a description and a FHIRPath expression on the document. Profiles are shared across all projects (`/ips/StructureDefinition`). Everyone can see them; only the administrator creates, edits, uploads and deletes them.
|
|
376
|
+
|
|
377
|
+
The administrator edits only the title and the constraints. The server sets everything else:
|
|
378
|
+
|
|
379
|
+
- the id: `profile-` plus a random private id;
|
|
380
|
+
- the url: `[base]/ips/StructureDefinition/profile-{id}`, fixed when the profile is made;
|
|
381
|
+
- the name, derived from the title (`Cardiology referral` → `CardiologyReferral`);
|
|
382
|
+
- each constraint's key (`cp-1`, `cp-2`...). A key is kept for as long as its constraint is, and never reused;
|
|
383
|
+
- severity `error`, status `active` and the date.
|
|
384
|
+
|
|
385
|
+
Most expectations take one of two shapes, using the module's own [FHIRPath functions](#fhirpath-functions):
|
|
386
|
+
|
|
387
|
+
```
|
|
388
|
+
Bundle.section(system, code [, min, max]).exists()
|
|
389
|
+
Bundle.section(system, code [, min, max]).entry(system, code [, min, max]).exists()
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
Rules about the resources in the document, whatever section they are in, take a third shape:
|
|
393
|
+
|
|
394
|
+
```
|
|
395
|
+
Bundle.resources(system, code [, type]).{the rest of the rule}
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
Entries and resources can be matched by value set instead of by one code: `Bundle.section(...).entryIn(valueSet [, min, max]).exists()`, and `Bundle.resourcesIn(valueSet [, type])...`. Sections are always matched by code. An example rule: `Bundle.resources('http://snomed.info/sct', '38341003', 'Condition').all(clinicalStatus.coding.code = 'active')`.
|
|
399
|
+
|
|
400
|
+
The editor (`/ips/StructureDefinition/new`, or Edit on a profile) is in three parts, and so is the page that shows a profile:
|
|
401
|
+
|
|
402
|
+
- **Required sections and entries:** the first two shapes, each a row of a table with four columns (the section, its cardinality, the entry code and its cardinality).
|
|
403
|
+
- **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
|
+
- **Other rules:** any other FHIRPath.
|
|
405
|
+
|
|
406
|
+
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
|
+
|
|
408
|
+
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
|
+
|
|
410
|
+
As FHIR: `GET /ips/StructureDefinition` is a searchset of them all, and `GET /ips/StructureDefinition/{id}` is one of them.
|
|
411
|
+
|
|
412
|
+
## FHIRPath functions
|
|
413
|
+
|
|
414
|
+
`ips/fhirpath-ips.js` adds two functions to fhirpath.js, for expressions on IPS documents:
|
|
415
|
+
|
|
416
|
+
- `section(system, code [, min, max])`: the input is documents (Bundles) or Compositions. On a document it moves into the document's Composition by itself, which is why a clinical profile's rules, evaluated on the document, can say `Bundle.section(...)`. For each input, it returns the Composition's sections, nested ones included, whose `code` has a coding with that system and code.
|
|
417
|
+
- `resources(system, code [, type])`: the input is documents, or anything in one (a Composition, say). It returns every resource in the document whose `code`, `medicationCodeableConcept` or `vaccineCode` has a coding with that system and code, and that is of the given type when a type is given.
|
|
418
|
+
- `entryIn(valueSet [, min, max])` and `resourcesIn(valueSet [, type])`: as `entry()` and `resources()`, but they match a code in a value set (a canonical, with or without `|version`) rather than one given code. A resource is in when any coding of its code is. These two ask a terminology server, so the evaluation must be asynchronous (`evaluateAsync`), with a `membership` function in the options. A `MembershipSession` (`ips/membership.js`, made by the module's `membershipSession()`) provides one: it asks `ValueSet/$validate-code` with the CodeableConcept, only for what a rule actually reaches. Within a session, each question is asked once, and there's a budget of 500 per document. Definite answers are also kept in the module's cache, so the next document doesn't ask again. When the server can't answer (it doesn't know the value set, say), the answer is "not a member" for that run, and the session's `unresolved()` lists the value set and the server's message, so a rule that failed for that reason can be told apart from one that plainly failed. The module's own value sets (`ips/valuesets/`) are sent with the question, so any server can answer for them; any other value set must be one the server knows.
|
|
419
|
+
- `entry(system, code [, min, max])`: the input is sections, usually from `section()`. For each one, it returns its resolved entries whose `code`, `medicationCodeableConcept` or `vaccineCode` has a coding with that system and code. `medicationReference` is not followed.
|
|
420
|
+
|
|
421
|
+
`min` and `max` are optional (both or neither), and `max` may be `'*'`. The bounds apply to each input item: when the number found is outside them, that item adds nothing. That isn't an error. References are resolved in the Bundle the section is in, or failing that the Bundle the expression was evaluated on:
|
|
422
|
+
|
|
423
|
+
- absolute `fullUrl`s (`urn:uuid:`, `http…`) match as they are;
|
|
424
|
+
- a relative `Type/id` is resolved against the base of the Composition's `fullUrl`, or matched by type and id when there's no usable base;
|
|
425
|
+
- `#id` is a resource contained in the Composition.
|
|
426
|
+
|
|
427
|
+
Anything still unresolved goes to the caller's `resolve(reference, composition)`, if there is one.
|
|
428
|
+
|
|
429
|
+
```js
|
|
430
|
+
const { evaluate, ipsFunctions } = require('./fhirpath-ips');
|
|
431
|
+
evaluate(bundle, "section('http://loinc.org', '11450-4', 1, 1).entry('http://snomed.info/sct', '38341003', 1, '*').exists()");
|
|
432
|
+
// or, with fhirpath.js directly:
|
|
433
|
+
fhirpath.evaluate(bundle, expr, null, r4Model, { userInvocationTable: ipsFunctions({ resolve }) });
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
## Sharing as SHL / VHL
|
|
437
|
+
|
|
438
|
+
With `modules.ips.shl` configured, a document's page has "Share as SHL" and "Share as VHL"
|
|
439
|
+
buttons (for anyone who can see the document). Each makes a QR code in a panel over the
|
|
440
|
+
page, to scan or copy, with what the code carries in a box beside a Copy button, and the
|
|
441
|
+
link to the manifest; without scripts, the same lands on its own page. The steps are the
|
|
442
|
+
ones the healthintersections.com.au passport page takes, here on the server (`ips/share.js`):
|
|
443
|
+
an entry is created on the SHL server (`POST {shl}/create`, with the server's password and
|
|
444
|
+
the number of days the copy lives), the document (the master, as submitted) is encrypted
|
|
445
|
+
as a JWE (`dir` / `A256GCM`, a random key) and uploaded (`POST {shl}/upload`), and the
|
|
446
|
+
shlink payload - the manifest link, the key, flag `U`, a label naming the patient - is
|
|
447
|
+
base64url-encoded. An SHL's code carries `{viewer}#shlink:/{payload}`; a VHL's carries
|
|
448
|
+
`HC1:` and the SHL server's signature of `shlink://{payload}` (`POST {shl}/sign`: a
|
|
449
|
+
COSE_Sign1 of the GDHCN payload, deflated and base45). The SHL server is this server's own
|
|
450
|
+
shl module (`"server": "/shl"`, dispatched in-process, the password taken from
|
|
451
|
+
`modules.shl.password` unless given) or another FHIRsmith's (an https URL and its password).
|
|
452
|
+
Shares are rate-limited as submissions are, and a document whose JWE is over 2MB is not
|
|
453
|
+
shared this way. The QR codes are made with `qrcode-generator`, error correction L.
|
|
454
|
+
|
|
455
|
+
### Validation view
|
|
456
|
+
|
|
457
|
+
The view shows everything by default; its "Show" selector (`?level=warning` or
|
|
458
|
+
`?level=error`) hides the lesser issues - the summary line still counts them all.
|
|
459
|
+
`fhir-validator-wrapper` 1.5.0 or later is needed: earlier versions sent the profiles in a
|
|
460
|
+
form the validator service refuses.
|