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/ai-client.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
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
|
+
* A client for the AI services the assessment step can use: two API shapes, which between
|
|
9
|
+
* them cover most of what there is.
|
|
10
|
+
*
|
|
11
|
+
* openai the chat-completions API: OpenAI, OpenRouter, LiteLLM, Groq, Ollama, Azure,
|
|
12
|
+
* Gemini's compatibility endpoint... POST {baseUrl}/chat/completions
|
|
13
|
+
* anthropic the Messages API: POST {baseUrl}/v1/messages
|
|
14
|
+
*
|
|
15
|
+
* An account is { provider, baseUrl, model, apiKey }. The client sends one system prompt
|
|
16
|
+
* and one user message and returns the text that comes back, with the token counts the
|
|
17
|
+
* service reports. Responses are bounded, and nothing about the account is in an error's
|
|
18
|
+
* message beyond the provider and model.
|
|
19
|
+
*
|
|
20
|
+
* Also here: the encryption of API keys at rest (AES-256-GCM, a key derived from a server
|
|
21
|
+
* secret), so a copy of the database is not a copy of the accounts.
|
|
22
|
+
*
|
|
23
|
+
* @module ips/ai-client
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const crypto = require('crypto');
|
|
27
|
+
|
|
28
|
+
const PROVIDERS = {
|
|
29
|
+
openai: { name: 'OpenAI-compatible (chat completions)', defaultBaseUrl: 'https://api.openai.com/v1' },
|
|
30
|
+
anthropic: { name: 'Anthropic (Messages API)', defaultBaseUrl: 'https://api.anthropic.com' }
|
|
31
|
+
};
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 120000;
|
|
33
|
+
// of the answer: a thorough assessment of a big document runs to several thousand tokens
|
|
34
|
+
const DEFAULT_MAX_TOKENS = 16384;
|
|
35
|
+
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
36
|
+
const ANTHROPIC_VERSION = '2023-06-01';
|
|
37
|
+
|
|
38
|
+
function isObject(v) {
|
|
39
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Check an account's shape; throws with a message fit for a settings page. */
|
|
43
|
+
function checkAccount(account) {
|
|
44
|
+
if (!isObject(account)) {
|
|
45
|
+
throw new Error('No AI account');
|
|
46
|
+
}
|
|
47
|
+
if (!PROVIDERS[account.provider]) {
|
|
48
|
+
throw new Error(`Unknown AI provider '${account.provider}' (openai or anthropic)`);
|
|
49
|
+
}
|
|
50
|
+
if (typeof account.model !== 'string' || !account.model.trim()) {
|
|
51
|
+
throw new Error('The AI account has no model');
|
|
52
|
+
}
|
|
53
|
+
if (typeof account.apiKey !== 'string' || !account.apiKey) {
|
|
54
|
+
throw new Error('The AI account has no API key');
|
|
55
|
+
}
|
|
56
|
+
const base = typeof account.baseUrl === 'string' && account.baseUrl.trim() ? account.baseUrl.trim() : PROVIDERS[account.provider].defaultBaseUrl;
|
|
57
|
+
let u;
|
|
58
|
+
try {
|
|
59
|
+
u = new URL(base);
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error('The AI account\'s base URL is not a URL');
|
|
62
|
+
}
|
|
63
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') {
|
|
64
|
+
throw new Error('The AI account\'s base URL must be http(s)');
|
|
65
|
+
}
|
|
66
|
+
return { ...account, baseUrl: base.replace(/\/+$/, '') };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Read a response body, up to a limit. */
|
|
70
|
+
async function readBounded(res, limit) {
|
|
71
|
+
const reader = res.body?.getReader ? res.body.getReader() : null;
|
|
72
|
+
if (!reader) {
|
|
73
|
+
const t = await res.text();
|
|
74
|
+
return t.length > limit ? t.substring(0, limit) : t;
|
|
75
|
+
}
|
|
76
|
+
const chunks = [];
|
|
77
|
+
let size = 0;
|
|
78
|
+
for (;;) {
|
|
79
|
+
const { done, value } = await reader.read();
|
|
80
|
+
if (done) {
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
size += value.length;
|
|
84
|
+
if (size > limit) {
|
|
85
|
+
await reader.cancel();
|
|
86
|
+
throw new Error('The AI service\'s response was too large');
|
|
87
|
+
}
|
|
88
|
+
chunks.push(value);
|
|
89
|
+
}
|
|
90
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Ask the service.
|
|
95
|
+
*
|
|
96
|
+
* @param {{provider: string, baseUrl?: string, model: string, apiKey: string}} account
|
|
97
|
+
* @param {{system: string, user: string, maxTokens?: number, timeoutMs?: number, fetch?: Function}} request
|
|
98
|
+
* @returns {Promise<{text: string, usage: {input: number|null, output: number|null, cost: number|null}, model: string, provider: string, truncated: boolean, maxTokens: number}>}
|
|
99
|
+
* usage.cost: in USD, when the service says (OpenRouter does); truncated: the answer was
|
|
100
|
+
* cut off at maxTokens
|
|
101
|
+
*/
|
|
102
|
+
async function complete(account, request) {
|
|
103
|
+
const a = checkAccount(account);
|
|
104
|
+
const doFetch = request.fetch || fetch;
|
|
105
|
+
const maxTokens = Number.isInteger(request.maxTokens) && request.maxTokens > 0 ? request.maxTokens : DEFAULT_MAX_TOKENS;
|
|
106
|
+
const timeoutMs = Number.isInteger(request.timeoutMs) && request.timeoutMs > 0 ? request.timeoutMs : DEFAULT_TIMEOUT_MS;
|
|
107
|
+
let url;
|
|
108
|
+
let headers;
|
|
109
|
+
let body;
|
|
110
|
+
if (a.provider === 'anthropic') {
|
|
111
|
+
url = `${a.baseUrl}/v1/messages`;
|
|
112
|
+
headers = { 'content-type': 'application/json', 'x-api-key': a.apiKey, 'anthropic-version': ANTHROPIC_VERSION };
|
|
113
|
+
body = { model: a.model, max_tokens: maxTokens, system: request.system, messages: [{ role: 'user', content: request.user }] };
|
|
114
|
+
} else {
|
|
115
|
+
url = `${a.baseUrl}/chat/completions`;
|
|
116
|
+
headers = { 'content-type': 'application/json', authorization: `Bearer ${a.apiKey}` };
|
|
117
|
+
body = { model: a.model, max_tokens: maxTokens, temperature: 0, messages: [{ role: 'system', content: request.system }, { role: 'user', content: request.user }] };
|
|
118
|
+
}
|
|
119
|
+
const controller = new AbortController();
|
|
120
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
121
|
+
let res;
|
|
122
|
+
let text;
|
|
123
|
+
try {
|
|
124
|
+
res = await doFetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: controller.signal, redirect: 'manual' });
|
|
125
|
+
text = await readBounded(res, MAX_RESPONSE_BYTES);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
throw new Error(e.name === 'AbortError' ? `The AI service (${a.provider}, ${a.model}) did not answer within ${timeoutMs} ms` : `The AI service (${a.provider}, ${a.model}) could not be reached: ${e.message}`);
|
|
128
|
+
} finally {
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
}
|
|
131
|
+
let json = null;
|
|
132
|
+
try {
|
|
133
|
+
json = JSON.parse(text);
|
|
134
|
+
} catch {
|
|
135
|
+
// not JSON: reported below
|
|
136
|
+
}
|
|
137
|
+
if (res.status < 200 || res.status >= 300) {
|
|
138
|
+
const detail = json?.error?.message || json?.message || (typeof json?.error === 'string' ? json.error : '') || '';
|
|
139
|
+
throw new Error(`The AI service (${a.provider}, ${a.model}) answered HTTP ${res.status}${detail ? `: ${String(detail).substring(0, 300)}` : ''}`);
|
|
140
|
+
}
|
|
141
|
+
if (!isObject(json)) {
|
|
142
|
+
throw new Error(`The AI service (${a.provider}, ${a.model}) did not answer with JSON`);
|
|
143
|
+
}
|
|
144
|
+
let out;
|
|
145
|
+
let usage;
|
|
146
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
147
|
+
// whether the answer was cut off at the token limit
|
|
148
|
+
let truncated;
|
|
149
|
+
if (a.provider === 'anthropic') {
|
|
150
|
+
out = (Array.isArray(json.content) ? json.content : []).filter(c => c?.type === 'text' && typeof c.text === 'string').map(c => c.text).join('\n');
|
|
151
|
+
usage = { input: num(json.usage?.input_tokens), output: num(json.usage?.output_tokens), cost: null };
|
|
152
|
+
truncated = json.stop_reason === 'max_tokens';
|
|
153
|
+
} else {
|
|
154
|
+
const choice = Array.isArray(json.choices) ? json.choices[0] : null;
|
|
155
|
+
const msg = choice?.message;
|
|
156
|
+
out = typeof msg?.content === 'string' ? msg.content : (Array.isArray(msg?.content) ? msg.content.filter(c => typeof c?.text === 'string').map(c => c.text).join('\n') : '');
|
|
157
|
+
// OpenRouter says what the call cost (in USD) in usage.cost; others don't
|
|
158
|
+
usage = { input: num(json.usage?.prompt_tokens), output: num(json.usage?.completion_tokens), cost: num(json.usage?.cost) };
|
|
159
|
+
truncated = choice?.finish_reason === 'length';
|
|
160
|
+
}
|
|
161
|
+
if (typeof out !== 'string' || !out.trim()) {
|
|
162
|
+
throw new Error(`The AI service (${a.provider}, ${a.model}) answered with no text`);
|
|
163
|
+
}
|
|
164
|
+
return { text: out, usage, model: typeof json.model === 'string' ? json.model : a.model, provider: a.provider, truncated: truncated === true, maxTokens };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The JSON object in a model's answer: the whole answer, or the first {...} in it (models
|
|
169
|
+
* wrap JSON in prose or code fences however they are asked not to), or null.
|
|
170
|
+
*/
|
|
171
|
+
function jsonIn(text) {
|
|
172
|
+
if (typeof text !== 'string') {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const tries = [text.trim()];
|
|
176
|
+
const fence = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
|
|
177
|
+
if (fence) {
|
|
178
|
+
tries.push(fence[1].trim());
|
|
179
|
+
}
|
|
180
|
+
const first = text.indexOf('{');
|
|
181
|
+
const last = text.lastIndexOf('}');
|
|
182
|
+
if (first >= 0 && last > first) {
|
|
183
|
+
tries.push(text.substring(first, last + 1));
|
|
184
|
+
}
|
|
185
|
+
for (const t of tries) {
|
|
186
|
+
try {
|
|
187
|
+
const v = JSON.parse(t);
|
|
188
|
+
if (isObject(v)) {
|
|
189
|
+
return v;
|
|
190
|
+
}
|
|
191
|
+
} catch {
|
|
192
|
+
// the next
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// list prices, USD per million tokens, for the models a server is likely to use without
|
|
199
|
+
// configuring prices (Anthropic's, as published October 2026). They go stale: the
|
|
200
|
+
// configuration's prices (modules.ips.ai.prices) override them, by model name or prefix
|
|
201
|
+
const DEFAULT_PRICES = {
|
|
202
|
+
'claude-fable-5-1': { input: 10, output: 50 },
|
|
203
|
+
'claude-opus-5-5': { input: 4, output: 20 },
|
|
204
|
+
'claude-sonnet-5-5': { input: 2, output: 10 },
|
|
205
|
+
'claude-haiku-5-5': { input: 0.1, output: 0.5 }
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* What a call cost, in USD: what the service said, else from a price list - model name (or
|
|
210
|
+
* a prefix of it, the longest wins) -> { input, output } in USD per million tokens - else null.
|
|
211
|
+
*/
|
|
212
|
+
function costOf(usage, model, prices) {
|
|
213
|
+
if (typeof usage?.cost === 'number') {
|
|
214
|
+
return usage.cost;
|
|
215
|
+
}
|
|
216
|
+
if (!isObject(prices) || typeof model !== 'string' || typeof usage?.input !== 'number' || typeof usage?.output !== 'number') {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
let best = null;
|
|
220
|
+
for (const [name, p] of Object.entries(prices)) {
|
|
221
|
+
if ((model === name || model.startsWith(name)) && isObject(p) && (!best || name.length > best.name.length)) {
|
|
222
|
+
best = { name, p };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (!best || typeof best.p.input !== 'number' || typeof best.p.output !== 'number') {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
return usage.input / 1e6 * best.p.input + usage.output / 1e6 * best.p.output;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// ---- keys at rest ----
|
|
232
|
+
|
|
233
|
+
function keyFrom(secret) {
|
|
234
|
+
return crypto.createHash('sha256').update(`ips-ai-key:${secret}`).digest();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Encrypt an API key for the database: v1:iv:tag:ciphertext, base64. */
|
|
238
|
+
function encryptKey(apiKey, secret) {
|
|
239
|
+
const iv = crypto.randomBytes(12);
|
|
240
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', keyFrom(secret), iv);
|
|
241
|
+
const enc = Buffer.concat([cipher.update(apiKey, 'utf8'), cipher.final()]);
|
|
242
|
+
return `v1:${iv.toString('base64')}:${cipher.getAuthTag().toString('base64')}:${enc.toString('base64')}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** @returns {string|null} the key, or null when it cannot be read (another secret, or damaged) */
|
|
246
|
+
function decryptKey(stored, secret) {
|
|
247
|
+
if (typeof stored !== 'string') {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
const parts = stored.split(':');
|
|
251
|
+
if (parts.length !== 4 || parts[0] !== 'v1') {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
try {
|
|
255
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', keyFrom(secret), Buffer.from(parts[1], 'base64'));
|
|
256
|
+
decipher.setAuthTag(Buffer.from(parts[2], 'base64'));
|
|
257
|
+
return Buffer.concat([decipher.update(Buffer.from(parts[3], 'base64')), decipher.final()]).toString('utf8');
|
|
258
|
+
} catch {
|
|
259
|
+
return null;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
module.exports = { complete, checkAccount, jsonIn, costOf, encryptKey, decryptKey, DEFAULT_PRICES, PROVIDERS, MAX_RESPONSE_BYTES, DEFAULT_TIMEOUT_MS, DEFAULT_MAX_TOKENS };
|