@peopl-health/nexus 5.11.0-dev.1128 → 5.11.0-dev.1130
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/lib/clinical/AssistantProcessor.js +3 -7
- package/lib/clinical/context/ToolRuntimeContext.js +4 -10
- package/lib/clinical/providers/AnthropicProvider.js +5 -9
- package/lib/clinical/providers/BaseLLMProvider.js +4 -9
- package/lib/clinical/providers/OpenAIResponsesProvider.js +7 -7
- package/lib/clinical/providers/OpenRouterProvider.js +1 -7
- package/lib/clinical/services/bridgeService.js +7 -31
- package/lib/clinical/services/clinicalAirtableService.js +11 -19
- package/lib/clinical/services/shadowService.js +1 -1
- package/lib/clinical/tools/extractClinicalInfoTool.js +0 -1
- package/lib/clinical/tools/schedulePatientReminderTool.js +4 -12
- package/lib/fhir/constants/extensionSlugs.js +0 -15
- package/lib/fhir/helpers/resourceHelper.js +28 -0
- package/lib/fhir/projections/contingencyProjection.js +176 -19
- package/lib/fhir/projections/registerProjectors.js +0 -2
- package/lib/fhir/resources/CarePlan.js +2 -57
- package/lib/fhir/resources/CommunicationRequest.js +0 -44
- package/lib/fhir/resources/Provenance.js +0 -15
- package/lib/fhir/services/contingencyService.js +7 -101
- package/lib/shared/dtos/ContingencySafetyNet.js +0 -1
- package/package.json +1 -1
|
@@ -8,7 +8,7 @@ const { overlayCohortPreset } = require('./helpers/cohortHelper');
|
|
|
8
8
|
const { forkShadowTurn } = require('./services/shadowService');
|
|
9
9
|
const { diverge } = require('./services/divergenceService');
|
|
10
10
|
const { getAssistantById } = require('./services/assistantResolver');
|
|
11
|
-
const { maybeSendBridge
|
|
11
|
+
const { maybeSendBridge } = require('./services/bridgeService');
|
|
12
12
|
const { getBridgePresetId } = require('./flags/bridgeConfig');
|
|
13
13
|
const { ToolRuntimeContext } = require('./context/ToolRuntimeContext');
|
|
14
14
|
|
|
@@ -45,11 +45,7 @@ class AssistantProcessor {
|
|
|
45
45
|
|
|
46
46
|
if (thread.isInCohort && !runOptions.toolRuntimeContext && runOptions.runId && thread.code) {
|
|
47
47
|
try {
|
|
48
|
-
runOptions.toolRuntimeContext = new ToolRuntimeContext({
|
|
49
|
-
patientCode: thread.code,
|
|
50
|
-
turnId: runOptions.runId,
|
|
51
|
-
shouldContinue: runOptions.shouldContinue,
|
|
52
|
-
});
|
|
48
|
+
runOptions.toolRuntimeContext = new ToolRuntimeContext({ patientCode: thread.code, turnId: runOptions.runId });
|
|
53
49
|
} catch (error) {
|
|
54
50
|
logger.warn('[AssistantProcessor] turn will run untraced', { code: thread.code, error: error.message });
|
|
55
51
|
}
|
|
@@ -87,6 +83,7 @@ class AssistantProcessor {
|
|
|
87
83
|
outbound: output,
|
|
88
84
|
completed: runResult?.completed,
|
|
89
85
|
predictionTimeMs,
|
|
86
|
+
shouldContinue: runOptions.shouldContinue,
|
|
90
87
|
});
|
|
91
88
|
} catch (error) {
|
|
92
89
|
logger.warn('[AssistantProcessor] trace not stored', {
|
|
@@ -139,7 +136,6 @@ class AssistantProcessor {
|
|
|
139
136
|
if (!this.sendMessage) throw new Error('sendMessage function not configured');
|
|
140
137
|
if (!result?.output) return null;
|
|
141
138
|
await this.sendMessage({ code, body: result.output, processed: true, origin: 'assistant', tools_executed: result.tools_executed, prompt: result.prompt, preset: result.preset, response_id: result.response_id, turnId: result.turnId, traceId: result.traceId });
|
|
142
|
-
clearBridgeState(code);
|
|
143
139
|
return result.output;
|
|
144
140
|
}
|
|
145
141
|
}
|
|
@@ -8,7 +8,6 @@ class ToolRuntimeContext {
|
|
|
8
8
|
repos = {},
|
|
9
9
|
contextSource = null,
|
|
10
10
|
shadow = false,
|
|
11
|
-
shouldContinue = null,
|
|
12
11
|
} = {}) {
|
|
13
12
|
if (typeof patientCode !== 'string' || !patientCode) {
|
|
14
13
|
throw new Error('ToolRuntimeContext requires a non-empty patientCode');
|
|
@@ -23,7 +22,6 @@ class ToolRuntimeContext {
|
|
|
23
22
|
this.patientCode = patientCode;
|
|
24
23
|
this.turnId = turnId;
|
|
25
24
|
this.sessionId = sessionId;
|
|
26
|
-
this.shouldContinue = shouldContinue;
|
|
27
25
|
this.repos = repos;
|
|
28
26
|
this.contextSource = contextSource;
|
|
29
27
|
this.enabledSkills = [];
|
|
@@ -44,17 +42,13 @@ class ToolRuntimeContext {
|
|
|
44
42
|
return toolData;
|
|
45
43
|
}
|
|
46
44
|
|
|
47
|
-
|
|
48
|
-
return typeof this.shouldContinue !== 'function' || this.shouldContinue();
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
async store({ outbound = null, completed = false, predictionTimeMs = null } = {}) {
|
|
45
|
+
async store({ outbound = null, completed = false, predictionTimeMs = null, shouldContinue = null } = {}) {
|
|
52
46
|
this.trace.setSignals({ activatedSkills: [...this.activatedSkills].sort() });
|
|
53
47
|
this.trace.setOutbound(outbound);
|
|
54
|
-
|
|
55
|
-
this.trace.setStatus(superseded ? 'superseded' : (completed ? 'completed' : 'incomplete'));
|
|
48
|
+
this.trace.setStatus(completed ? 'completed' : 'incomplete');
|
|
56
49
|
this.trace.setTimings({ predictionTimeMs });
|
|
57
|
-
|
|
50
|
+
const superseded = shouldContinue && !shouldContinue();
|
|
51
|
+
if (!superseded) await this.trace.save();
|
|
58
52
|
}
|
|
59
53
|
|
|
60
54
|
setSafetyArtifact(artifact) {
|
|
@@ -11,7 +11,7 @@ const { logger } = require('../../utils/logger');
|
|
|
11
11
|
|
|
12
12
|
const runtimeConfig = require('../../config/runtimeConfig');
|
|
13
13
|
|
|
14
|
-
const { BaseLLMProvider, CONVERSATION_CONTINUATION, DELIVERY_NUDGE, MAX_DELIVERY_NUDGES, TERMINAL_DELIVERY_TOOLS
|
|
14
|
+
const { BaseLLMProvider, CONVERSATION_CONTINUATION, DELIVERY_NUDGE, MAX_DELIVERY_NUDGES, TERMINAL_DELIVERY_TOOLS } = require('./BaseLLMProvider');
|
|
15
15
|
const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
|
|
16
16
|
|
|
17
17
|
const DEFAULT_MAX_FUNCTION_ROUNDS = 12;
|
|
@@ -69,7 +69,7 @@ class AnthropicProvider extends BaseLLMProvider {
|
|
|
69
69
|
async _invokeModel({
|
|
70
70
|
instructions, input, toolSchemas = [], toolChoice = 'auto', followUpToolChoice = 'auto',
|
|
71
71
|
modelConfig, assistant, phiProcessor = null, trace = null, onFirstIteration = null,
|
|
72
|
-
resolvedPromptId = null, resolvedPresetId = null
|
|
72
|
+
resolvedPromptId = null, resolvedPresetId = null
|
|
73
73
|
}, options = {}) {
|
|
74
74
|
const executeTools = options.toolExecutor || handleFunctionCalls;
|
|
75
75
|
let totalRetries = 0;
|
|
@@ -170,7 +170,7 @@ class AnthropicProvider extends BaseLLMProvider {
|
|
|
170
170
|
&& (response.content || []).some(block => block.type === 'tool_use');
|
|
171
171
|
try {
|
|
172
172
|
const decision = onFirstIteration({ narrationText, hasToolCalls });
|
|
173
|
-
if (decision?.dedupNote) bridgeNote = decision.dedupNote;
|
|
173
|
+
if (decision?.sent && decision.dedupNote) bridgeNote = decision.dedupNote;
|
|
174
174
|
} catch (err) {
|
|
175
175
|
logger.warn('[AnthropicProvider] bridge callback failed', { error: err.message });
|
|
176
176
|
}
|
|
@@ -187,10 +187,6 @@ class AnthropicProvider extends BaseLLMProvider {
|
|
|
187
187
|
let deliveryBreak = false;
|
|
188
188
|
const maxIterations = this.maxFunctionRounds + MAX_DELIVERY_NUDGES + LOOP_ITERATION_HEADROOM;
|
|
189
189
|
for (let iteration = 1; iteration <= maxIterations; iteration++) {
|
|
190
|
-
if (isSuperseded(shouldContinue)) {
|
|
191
|
-
logger.info('[AnthropicProvider] turn superseded — aborting tool loop', { iteration });
|
|
192
|
-
break;
|
|
193
|
-
}
|
|
194
190
|
const content = finalResponse.content || [];
|
|
195
191
|
const toolUses = content.filter(block => block.type === 'tool_use');
|
|
196
192
|
const ended = finalResponse.stop_reason !== 'tool_use' || !toolUses.length;
|
|
@@ -255,9 +251,9 @@ class AnthropicProvider extends BaseLLMProvider {
|
|
|
255
251
|
}
|
|
256
252
|
|
|
257
253
|
// Budget exhausted mid-work: the loop stopped while the model still wanted tools, so the reply may be empty. Make it
|
|
258
|
-
// visible — but not on a
|
|
254
|
+
// visible — but not on a delivery break, which also exits with pending calls and is not exhaustion.
|
|
259
255
|
const pendingTools = (finalResponse.content || []).filter(block => block.type === 'tool_use');
|
|
260
|
-
if (finalResponse.stop_reason === 'tool_use' && pendingTools.length && !deliveryBreak
|
|
256
|
+
if (finalResponse.stop_reason === 'tool_use' && pendingTools.length && !deliveryBreak) {
|
|
261
257
|
logger.warn('[AnthropicProvider] Tool-iteration budget exhausted with pending tool calls', { maxFunctionRounds: this.maxFunctionRounds, pending: pendingTools.map(b => b.name) });
|
|
262
258
|
trace?.setSignals?.({ toolBudgetExhausted: true });
|
|
263
259
|
}
|
|
@@ -18,10 +18,6 @@ const TERMINAL_DELIVERY_TOOLS = new Set(['DeliverPatientMessage']);
|
|
|
18
18
|
const DELIVERY_NUDGE = 'sistema: el turno no puede cerrar sin un mensaje para el paciente. Llama ahora a DeliverPatientMessage con message_text en español. No repitas herramientas clínicas ya ejecutadas en este turno.';
|
|
19
19
|
const MAX_DELIVERY_NUDGES = 1;
|
|
20
20
|
|
|
21
|
-
function isSuperseded(shouldContinue) {
|
|
22
|
-
return typeof shouldContinue === 'function' && !shouldContinue();
|
|
23
|
-
}
|
|
24
|
-
|
|
25
21
|
class BaseLLMProvider {
|
|
26
22
|
constructor(options = {}) {
|
|
27
23
|
if (new.target === BaseLLMProvider) {
|
|
@@ -336,8 +332,8 @@ class BaseLLMProvider {
|
|
|
336
332
|
return result;
|
|
337
333
|
}
|
|
338
334
|
|
|
339
|
-
if (
|
|
340
|
-
logger.info('[runConversation]
|
|
335
|
+
if (config.shadow && !config.shouldContinue()) {
|
|
336
|
+
logger.info('[runConversation] Shadow superseded — returning without retry', { attempt });
|
|
341
337
|
return result;
|
|
342
338
|
}
|
|
343
339
|
|
|
@@ -348,8 +344,8 @@ class BaseLLMProvider {
|
|
|
348
344
|
await new Promise(r => setTimeout(r, 500));
|
|
349
345
|
} catch (error) {
|
|
350
346
|
logger.error('[runConversation] Attempt failed', { attempt, error: error.message });
|
|
351
|
-
if (
|
|
352
|
-
logger.info('[runConversation]
|
|
347
|
+
if (config.shadow && !config.shouldContinue()) {
|
|
348
|
+
logger.info('[runConversation] Shadow superseded — abandoning after error', { attempt });
|
|
353
349
|
return { status: 'cancelled', output_text: '', tools_executed: [], retries: 0 };
|
|
354
350
|
}
|
|
355
351
|
if (attempt === maxRetries) throw error;
|
|
@@ -532,5 +528,4 @@ module.exports = {
|
|
|
532
528
|
DELIVERY_NUDGE,
|
|
533
529
|
MAX_DELIVERY_NUDGES,
|
|
534
530
|
TERMINAL_DELIVERY_TOOLS,
|
|
535
|
-
isSuperseded,
|
|
536
531
|
};
|
|
@@ -9,7 +9,7 @@ const { logger } = require('../../utils/logger');
|
|
|
9
9
|
|
|
10
10
|
const runtimeConfig = require('../../config/runtimeConfig');
|
|
11
11
|
|
|
12
|
-
const { BaseLLMProvider
|
|
12
|
+
const { BaseLLMProvider } = require('./BaseLLMProvider');
|
|
13
13
|
const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
|
|
14
14
|
|
|
15
15
|
const DEFAULT_MAX_FUNCTION_ROUNDS = 12;
|
|
@@ -69,7 +69,7 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
|
|
|
69
69
|
shadow = false, shouldContinue = null, onFirstIteration = null
|
|
70
70
|
}, options = {}) {
|
|
71
71
|
const executeTools = options.toolExecutor || handleFunctionCalls;
|
|
72
|
-
const
|
|
72
|
+
const shadowSuperseded = () => shadow && !shouldContinue();
|
|
73
73
|
let totalRetries = 0;
|
|
74
74
|
const allToolsExecuted = [];
|
|
75
75
|
const allToolCalls = [];
|
|
@@ -127,7 +127,7 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
|
|
|
127
127
|
const hasToolCalls = Boolean(assistant) && (response.output || []).some(item => item.type === 'function_call');
|
|
128
128
|
try {
|
|
129
129
|
const decision = onFirstIteration({ narrationText, hasToolCalls });
|
|
130
|
-
if (decision?.dedupNote) bridgeNote = decision.dedupNote;
|
|
130
|
+
if (decision?.sent && decision.dedupNote) bridgeNote = decision.dedupNote;
|
|
131
131
|
} catch (err) {
|
|
132
132
|
logger.warn('[OpenAIResponsesProvider] bridge callback failed', { error: err.message });
|
|
133
133
|
}
|
|
@@ -140,8 +140,8 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
|
|
|
140
140
|
apiCallConfig.tool_choice = followUpToolChoice;
|
|
141
141
|
|
|
142
142
|
for (let round = 1; round <= this.maxFunctionRounds; round++) {
|
|
143
|
-
if (
|
|
144
|
-
logger.info('[OpenAIResponsesProvider]
|
|
143
|
+
if (shadowSuperseded()) {
|
|
144
|
+
logger.info('[OpenAIResponsesProvider] shadow superseded — aborting tool loop', { round });
|
|
145
145
|
break;
|
|
146
146
|
}
|
|
147
147
|
const functionCalls = finalResponse.output.filter(item => item.type === 'function_call');
|
|
@@ -167,9 +167,9 @@ class OpenAIResponsesProvider extends BaseLLMProvider {
|
|
|
167
167
|
}
|
|
168
168
|
|
|
169
169
|
// Budget exhausted mid-work: the loop stopped while the model still wanted tools, so the reply may be empty. Make it
|
|
170
|
-
// visible — but not on a supersede or delivery break, which also exit with pending calls and are not exhaustion.
|
|
170
|
+
// visible — but not on a shadow-supersede or delivery break, which also exit with pending calls and are not exhaustion.
|
|
171
171
|
const pendingCalls = (finalResponse.output || []).filter(item => item.type === 'function_call');
|
|
172
|
-
if (pendingCalls.length && !
|
|
172
|
+
if (pendingCalls.length && !shadowSuperseded() && !deliveryBreak) {
|
|
173
173
|
logger.warn('[OpenAIResponsesProvider] Tool-iteration budget exhausted with pending tool calls', { maxFunctionRounds: this.maxFunctionRounds, pending: pendingCalls.map(c => c.name) });
|
|
174
174
|
trace?.setSignals?.({ toolBudgetExhausted: true });
|
|
175
175
|
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
const { OpenAI } = require('openai');
|
|
2
2
|
|
|
3
|
-
const { logger } = require('../../utils/logger');
|
|
4
3
|
const { retryWithBackoff } = require('../../utils/retryUtils');
|
|
5
4
|
const { withTiming } = require('../../utils/tracingDecorator');
|
|
6
5
|
const { safeParse } = require('../../utils/jsonUtils');
|
|
7
6
|
|
|
8
7
|
const { OpenAIResponsesProvider } = require('./OpenAIResponsesProvider');
|
|
9
|
-
const { isSuperseded } = require('./BaseLLMProvider');
|
|
10
8
|
const { handleFunctionCalls } = require('./OpenAIResponsesProviderTools');
|
|
11
9
|
|
|
12
10
|
const OPENROUTER_BASE_URL = 'https://openrouter.ai/api/v1';
|
|
@@ -39,7 +37,7 @@ class OpenRouterProvider extends OpenAIResponsesProvider {
|
|
|
39
37
|
|
|
40
38
|
async _invokeModel({
|
|
41
39
|
instructions, input, toolSchemas = [], toolChoice = 'auto', followUpToolChoice = 'auto',
|
|
42
|
-
modelConfig, assistant, phiProcessor = null, trace = null
|
|
40
|
+
modelConfig, assistant, phiProcessor = null, trace = null
|
|
43
41
|
}, options = {}) {
|
|
44
42
|
const executeTools = options.toolExecutor || handleFunctionCalls;
|
|
45
43
|
let totalRetries = 0;
|
|
@@ -76,10 +74,6 @@ class OpenRouterProvider extends OpenAIResponsesProvider {
|
|
|
76
74
|
apiCallConfig.tool_choice = followUpToolChoice;
|
|
77
75
|
|
|
78
76
|
for (let round = 1; round <= this.maxFunctionRounds; round++) {
|
|
79
|
-
if (isSuperseded(shouldContinue)) {
|
|
80
|
-
logger.info('[OpenRouterProvider] turn superseded — aborting tool loop', { round });
|
|
81
|
-
break;
|
|
82
|
-
}
|
|
83
77
|
const message = finalResponse.choices?.[0]?.message;
|
|
84
78
|
const toolCalls = message?.tool_calls || [];
|
|
85
79
|
if (!toolCalls.length) break;
|
|
@@ -25,9 +25,9 @@ function buildDedupNote(bridgeText) {
|
|
|
25
25
|
function maybeSendBridge({ narrationText, hasToolCalls, code, turnId = null, traceId = null, shouldContinue, sendMessage }) {
|
|
26
26
|
if (!isBridgeEnabled()) return { sent: false };
|
|
27
27
|
|
|
28
|
-
const skip = (reason
|
|
28
|
+
const skip = (reason) => {
|
|
29
29
|
logger.info('[bridge] skipped', { code, reason });
|
|
30
|
-
return { sent: false
|
|
30
|
+
return { sent: false };
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
if (typeof shouldContinue === 'function' && !shouldContinue()) return skip('superseded');
|
|
@@ -36,44 +36,20 @@ function maybeSendBridge({ narrationText, hasToolCalls, code, turnId = null, tra
|
|
|
36
36
|
const text = sanitizeBridgeText(narrationText);
|
|
37
37
|
if (!text) return skip('no_usable_narration');
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
if (pending !== undefined) {
|
|
41
|
-
return skip('recent_bridge_in_thread', pending.delivered ? { dedupNote: pending.dedupNote } : {});
|
|
42
|
-
}
|
|
39
|
+
if (bridgeTimestamps.get(code) !== undefined) return skip('recent_bridge_in_thread');
|
|
43
40
|
|
|
44
|
-
|
|
45
|
-
const entry = { dedupNote, delivered: false, clearWhenDelivered: false };
|
|
46
|
-
bridgeTimestamps.set(code, entry);
|
|
47
|
-
const failed = (err) => {
|
|
48
|
-
bridgeTimestamps.delete(code);
|
|
49
|
-
logger.warn('[bridge] send failed', { code, error: err.message });
|
|
50
|
-
};
|
|
51
|
-
const delivered = () => {
|
|
52
|
-
entry.delivered = true;
|
|
53
|
-
if (entry.clearWhenDelivered && bridgeTimestamps.get(code) === entry) bridgeTimestamps.delete(code);
|
|
54
|
-
};
|
|
41
|
+
bridgeTimestamps.set(code, true);
|
|
55
42
|
try {
|
|
56
43
|
Promise.resolve(sendMessage({ code, body: text, processed: true, origin: 'assistant', raw: { bridge: true }, turnId, traceId }))
|
|
57
|
-
.
|
|
58
|
-
.catch(failed);
|
|
44
|
+
.catch(err => logger.warn('[bridge] send failed', { code, error: err.message }));
|
|
59
45
|
} catch (err) {
|
|
60
|
-
failed
|
|
46
|
+
logger.warn('[bridge] send failed', { code, error: err.message });
|
|
61
47
|
}
|
|
62
48
|
|
|
63
49
|
logger.info('[bridge] sent', { code });
|
|
64
|
-
return { sent: true, text, dedupNote };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function clearBridgeState(code) {
|
|
68
|
-
const pending = bridgeTimestamps.get(code);
|
|
69
|
-
if (pending && !pending.delivered) {
|
|
70
|
-
pending.clearWhenDelivered = true;
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
bridgeTimestamps.delete(code);
|
|
50
|
+
return { sent: true, text, dedupNote: buildDedupNote(text) };
|
|
74
51
|
}
|
|
75
52
|
|
|
76
53
|
module.exports = {
|
|
77
54
|
maybeSendBridge,
|
|
78
|
-
clearBridgeState,
|
|
79
55
|
};
|
|
@@ -21,21 +21,16 @@ const INTAKE_SOURCE_TO_AIRTABLE = {
|
|
|
21
21
|
team_relay: 'doctor',
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
async function auditedWrite({ trace, code, table, write
|
|
24
|
+
async function auditedWrite({ trace, code, table, write }) {
|
|
25
25
|
const entry = { table, at: new Date().toISOString(), ok: false };
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (record?.id) entry.recordId = record.id;
|
|
35
|
-
} catch (err) {
|
|
36
|
-
entry.error = err?.message || String(err);
|
|
37
|
-
logger.error('[clinicalAirtableService] Airtable write failed', { table, code, error: entry.error });
|
|
38
|
-
}
|
|
26
|
+
try {
|
|
27
|
+
const result = await write();
|
|
28
|
+
entry.ok = true;
|
|
29
|
+
const record = Array.isArray(result) ? result[0] : result;
|
|
30
|
+
if (record?.id) entry.recordId = record.id;
|
|
31
|
+
} catch (err) {
|
|
32
|
+
entry.error = err?.message || String(err);
|
|
33
|
+
logger.error('[clinicalAirtableService] Airtable write failed', { table, code, error: entry.error });
|
|
39
34
|
}
|
|
40
35
|
if (trace?.setSignals) {
|
|
41
36
|
const existing = Array.isArray(trace.signals?.airtableWrites) ? trace.signals.airtableWrites : [];
|
|
@@ -97,11 +92,10 @@ async function recordEmergency({ code, trace, symptom, suspectedCondition = '',
|
|
|
97
92
|
});
|
|
98
93
|
}
|
|
99
94
|
|
|
100
|
-
async function recordClinicalIntake({ code, trace, messageRaw, source = 'self'
|
|
95
|
+
async function recordClinicalIntake({ code, trace, messageRaw, source = 'self' }) {
|
|
101
96
|
return auditedWrite({
|
|
102
97
|
trace,
|
|
103
98
|
code,
|
|
104
|
-
isLive,
|
|
105
99
|
table: 'intake',
|
|
106
100
|
write: async () => {
|
|
107
101
|
const patientId = await findIntakePatientRecordId(code);
|
|
@@ -116,13 +110,12 @@ async function recordClinicalIntake({ code, trace, messageRaw, source = 'self',
|
|
|
116
110
|
});
|
|
117
111
|
}
|
|
118
112
|
|
|
119
|
-
async function recordReminder({ code, trace, actionType, reminderId, reminderDateUtc, reminderType, description
|
|
113
|
+
async function recordReminder({ code, trace, actionType, reminderId, reminderDateUtc, reminderType, description }) {
|
|
120
114
|
const type = REMINDER_TYPE_TO_AIRTABLE[reminderType] || reminderType;
|
|
121
115
|
if (actionType === 'update') {
|
|
122
116
|
return auditedWrite({
|
|
123
117
|
trace,
|
|
124
118
|
code,
|
|
125
|
-
isLive,
|
|
126
119
|
table: 'reminder',
|
|
127
120
|
write: async () => {
|
|
128
121
|
const updated = await airtableService.updateRecordByFilter(
|
|
@@ -140,7 +133,6 @@ async function recordReminder({ code, trace, actionType, reminderId, reminderDat
|
|
|
140
133
|
return auditedWrite({
|
|
141
134
|
trace,
|
|
142
135
|
code,
|
|
143
|
-
isLive,
|
|
144
136
|
table: 'reminder',
|
|
145
137
|
write: () => airtableService.addLinkedRecord(
|
|
146
138
|
Monitoreo_ID,
|
|
@@ -27,7 +27,7 @@ async function runShadowTurn({
|
|
|
27
27
|
const shadowAssistant = resolveAssistant(shadowThread);
|
|
28
28
|
|
|
29
29
|
const context = new ToolRuntimeContext({
|
|
30
|
-
patientCode: code, turnId: runId, sessionId, shadow: true,
|
|
30
|
+
patientCode: code, turnId: runId, sessionId, shadow: true,
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
const runOptions = {
|
|
@@ -107,18 +107,11 @@ async function handler(args = {}, context = {}) {
|
|
|
107
107
|
};
|
|
108
108
|
trace.setSignals({ reminderIntents: [...existing, intent] });
|
|
109
109
|
|
|
110
|
-
const isLive = runtime.isLive?.bind(runtime);
|
|
111
|
-
const superseded = Boolean(isLive) && !isLive();
|
|
112
|
-
|
|
113
110
|
if (runtime.patientCode) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
await dispatchReminder({ intent, patientId: runtime.patientCode });
|
|
119
|
-
} catch (err) {
|
|
120
|
-
logger.warn('[schedulePatientReminder] reminder FHIR projection failed; ack unaffected', { turnId, error: err?.message });
|
|
121
|
-
}
|
|
111
|
+
try {
|
|
112
|
+
await dispatchReminder({ intent, patientId: runtime.patientCode });
|
|
113
|
+
} catch (err) {
|
|
114
|
+
logger.warn('[schedulePatientReminder] reminder FHIR projection failed; ack unaffected', { turnId, error: err?.message });
|
|
122
115
|
}
|
|
123
116
|
}
|
|
124
117
|
|
|
@@ -131,7 +124,6 @@ async function handler(args = {}, context = {}) {
|
|
|
131
124
|
reminderDateUtc: fireMoment.toISOString(),
|
|
132
125
|
reminderType,
|
|
133
126
|
description: storedDescription,
|
|
134
|
-
isLive,
|
|
135
127
|
});
|
|
136
128
|
}
|
|
137
129
|
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
const CONTINGENCY_CATEGORY_PREFIX = 'contingency-';
|
|
2
|
-
|
|
3
1
|
const SYMPTOM_CASE_EXT = {
|
|
4
2
|
STATUS: 'symptom-case-status',
|
|
5
3
|
LEGACY_ABATEMENT: 'legacy-abatement',
|
|
@@ -74,17 +72,6 @@ const PALLIATIVE_EXT = {
|
|
|
74
72
|
FINDING_CLUSTER_NOTE: 'palliative-finding-cluster-note',
|
|
75
73
|
};
|
|
76
74
|
|
|
77
|
-
const CONTINGENCY_EXT = {
|
|
78
|
-
STATUS: 'contingency-status',
|
|
79
|
-
AWAITING_CHARACTERIZATION: 'contingency-awaiting-characterization',
|
|
80
|
-
WHY_THIS_MATTERS: 'contingency-why-this-matters',
|
|
81
|
-
CREATION_REASONING: 'contingency-creation-reasoning',
|
|
82
|
-
RESOLVED_REASON: 'contingency-resolved-reason',
|
|
83
|
-
STEP_PRIORITY: 'contingency-step-priority',
|
|
84
|
-
ACTION_REQUESTED: 'contingency-action-requested',
|
|
85
|
-
FIRES_AFTER_SILENCE_MINUTES: 'fires-after-silence-minutes',
|
|
86
|
-
};
|
|
87
|
-
|
|
88
75
|
const RISK_EXT = {
|
|
89
76
|
PREDICTION_KIND: 'risk-prediction-kind',
|
|
90
77
|
MINIMUM_SYMPTOM_SET: 'risk-minimum-symptom-set',
|
|
@@ -105,7 +92,5 @@ module.exports = {
|
|
|
105
92
|
ROUTING_EXT,
|
|
106
93
|
CLUSTER_EXT,
|
|
107
94
|
PALLIATIVE_EXT,
|
|
108
|
-
CONTINGENCY_EXT,
|
|
109
|
-
CONTINGENCY_CATEGORY_PREFIX,
|
|
110
95
|
RISK_EXT,
|
|
111
96
|
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const { getDeviceId, getOrganizationId } = require('../config/fhirConfig');
|
|
2
|
+
const { fhirId } = require('./fhirHelper');
|
|
3
|
+
const { identifier, patientReference, reference, codeableConcept, toIso } = require('./elementHelper');
|
|
4
|
+
|
|
5
|
+
function baseResource(resourceType, id, slug, patientId) {
|
|
6
|
+
return {
|
|
7
|
+
resourceType,
|
|
8
|
+
id: fhirId(id),
|
|
9
|
+
identifier: [identifier(slug, id)],
|
|
10
|
+
subject: patientReference(patientId),
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function provenance({ provenanceId, targetRefs, recordedAt, activityText }) {
|
|
15
|
+
return {
|
|
16
|
+
resourceType: 'Provenance',
|
|
17
|
+
id: fhirId(provenanceId),
|
|
18
|
+
target: targetRefs,
|
|
19
|
+
recorded: toIso(recordedAt),
|
|
20
|
+
agent: [{ who: reference('Device', getDeviceId()), onBehalfOf: reference('Organization', getOrganizationId()) }],
|
|
21
|
+
activity: codeableConcept(activityText),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
module.exports = {
|
|
26
|
+
baseResource,
|
|
27
|
+
provenance,
|
|
28
|
+
};
|
|
@@ -1,36 +1,193 @@
|
|
|
1
|
-
const {
|
|
2
|
-
|
|
3
|
-
const {
|
|
1
|
+
const { ZodError } = require('zod');
|
|
2
|
+
|
|
3
|
+
const { getOrganizationId } = require('../config/fhirConfig');
|
|
4
|
+
const { fhirId } = require('../helpers/fhirHelper');
|
|
5
|
+
const { patientReference, reference, codeableConcept, extension, toIso } = require('../helpers/elementHelper');
|
|
6
|
+
const { baseResource, provenance } = require('../helpers/resourceHelper');
|
|
7
|
+
const { extMap, identifierValue, refToId } = require('../helpers/fhirReadHelper');
|
|
8
|
+
const { CONTINGENCY_PLAN_SLUG, CONTINGENCY_STEP_SLUG } = require('../constants/projectionSlugs');
|
|
9
|
+
const { ContingencySafetyNet } = require('../../shared/dtos/ContingencySafetyNet');
|
|
10
|
+
const { logger } = require('../../utils/logger');
|
|
4
11
|
|
|
5
|
-
const PROJECTOR_NAME = 'contingency';
|
|
6
12
|
const ACTIVITY_TEXT = 'contingency-safety-net';
|
|
13
|
+
const CATEGORY_PREFIX = 'contingency-';
|
|
7
14
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
const EXT = {
|
|
16
|
+
STATUS: 'contingency-status',
|
|
17
|
+
AWAITING_CHARACTERIZATION: 'contingency-awaiting-characterization',
|
|
18
|
+
WHY_THIS_MATTERS: 'contingency-why-this-matters',
|
|
19
|
+
CREATION_REASONING: 'contingency-creation-reasoning',
|
|
20
|
+
RESOLVED_REASON: 'contingency-resolved-reason',
|
|
21
|
+
STEP_PRIORITY: 'contingency-step-priority',
|
|
22
|
+
ACTION_REQUESTED: 'contingency-action-requested',
|
|
23
|
+
FIRES_AFTER_SILENCE_MINUTES: 'fires-after-silence-minutes',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const STATUS_TO_FHIR = {
|
|
27
|
+
armed: 'active',
|
|
28
|
+
fired_reminder: 'active',
|
|
29
|
+
fired_team: 'active',
|
|
30
|
+
auto_resolved: 'completed',
|
|
31
|
+
cancelled: 'revoked',
|
|
32
|
+
};
|
|
16
33
|
|
|
34
|
+
const PRIORITY_CROSSWALK = {
|
|
35
|
+
critical: 'stat',
|
|
36
|
+
high: 'urgent',
|
|
37
|
+
medium: 'routine',
|
|
38
|
+
low: 'routine',
|
|
39
|
+
routine: 'routine',
|
|
40
|
+
urgent: 'urgent',
|
|
41
|
+
asap: 'asap',
|
|
42
|
+
stat: 'stat',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const RESOURCE_TYPES = ['CarePlan', 'CommunicationRequest'];
|
|
46
|
+
|
|
47
|
+
function toFhir(contingency) {
|
|
48
|
+
const { carePlan, teamEscalation, patientReminder } = contingency;
|
|
17
49
|
const steps = [teamEscalation];
|
|
18
50
|
if (patientReminder) steps.push(patientReminder);
|
|
19
51
|
|
|
20
|
-
const
|
|
52
|
+
const resources = [carePlanResource(carePlan)];
|
|
53
|
+
for (const step of steps) resources.push(stepResource(step, carePlan));
|
|
54
|
+
|
|
55
|
+
const targetRefs = [{ reference: `CarePlan/${fhirId(carePlan.planId)}` }];
|
|
21
56
|
for (const step of steps) {
|
|
22
|
-
|
|
57
|
+
targetRefs.push({ reference: `CommunicationRequest/${fhirId(`${carePlan.planId}-${step.recipient}`)}` });
|
|
23
58
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
59
|
+
resources.push(provenance({
|
|
60
|
+
provenanceId: `${carePlan.planId}-prov`,
|
|
61
|
+
targetRefs,
|
|
27
62
|
recordedAt: carePlan.createdAt,
|
|
28
63
|
activityText: ACTIVITY_TEXT,
|
|
29
64
|
}));
|
|
65
|
+
return resources;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function carePlanResource(carePlan) {
|
|
69
|
+
const payload = {
|
|
70
|
+
...baseResource('CarePlan', carePlan.planId, CONTINGENCY_PLAN_SLUG, carePlan.patientId),
|
|
71
|
+
status: STATUS_TO_FHIR[carePlan.status],
|
|
72
|
+
intent: 'plan',
|
|
73
|
+
created: toIso(carePlan.createdAt),
|
|
74
|
+
period: carePlanPeriod(carePlan),
|
|
75
|
+
};
|
|
76
|
+
if (carePlan.concern) payload.description = carePlan.concern;
|
|
77
|
+
if (carePlan.relatedCaseId) payload.addresses = [{ reference: reference('Condition', carePlan.relatedCaseId) }];
|
|
78
|
+
if (carePlan.relatedClusterId) payload.supportingInfo = [reference('ClinicalImpression', carePlan.relatedClusterId)];
|
|
79
|
+
const ext = carePlanExtensions(carePlan);
|
|
80
|
+
if (ext.length) payload.extension = ext;
|
|
81
|
+
return payload;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function carePlanPeriod(carePlan) {
|
|
85
|
+
const period = { start: toIso(carePlan.createdAt) };
|
|
86
|
+
if (carePlan.resolvedAt) period.end = toIso(carePlan.resolvedAt);
|
|
87
|
+
return period;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function carePlanExtensions(carePlan) {
|
|
91
|
+
const out = [extension(EXT.STATUS, { valueString: carePlan.status })];
|
|
92
|
+
if (carePlan.awaitingCharacterization) out.push(extension(EXT.AWAITING_CHARACTERIZATION, { valueString: carePlan.awaitingCharacterization }));
|
|
93
|
+
if (carePlan.whyThisMatters) out.push(extension(EXT.WHY_THIS_MATTERS, { valueString: carePlan.whyThisMatters }));
|
|
94
|
+
if (carePlan.creationReasoning) out.push(extension(EXT.CREATION_REASONING, { valueString: carePlan.creationReasoning }));
|
|
95
|
+
if (carePlan.resolvedReason) out.push(extension(EXT.RESOLVED_REASON, { valueString: carePlan.resolvedReason }));
|
|
30
96
|
return out;
|
|
31
97
|
}
|
|
32
98
|
|
|
99
|
+
function stepResource(step, carePlan) {
|
|
100
|
+
const stepId = `${carePlan.planId}-${step.recipient}`;
|
|
101
|
+
const payload = {
|
|
102
|
+
...baseResource('CommunicationRequest', stepId, CONTINGENCY_STEP_SLUG, carePlan.patientId),
|
|
103
|
+
status: STATUS_TO_FHIR[carePlan.status],
|
|
104
|
+
intent: 'plan',
|
|
105
|
+
category: [codeableConcept(`${CATEGORY_PREFIX}${step.recipient}`)],
|
|
106
|
+
recipient: [step.recipient === 'patient'
|
|
107
|
+
? patientReference(carePlan.patientId)
|
|
108
|
+
: { reference: `Organization/${fhirId(getOrganizationId())}` }],
|
|
109
|
+
payload: [{ contentCodeableConcept: codeableConcept(step.message) }],
|
|
110
|
+
authoredOn: toIso(carePlan.createdAt),
|
|
111
|
+
basedOn: [{ reference: `CarePlan/${fhirId(carePlan.planId)}` }],
|
|
112
|
+
};
|
|
113
|
+
const priority = step.priority ? PRIORITY_CROSSWALK[step.priority] || 'routine' : null;
|
|
114
|
+
if (priority) payload.priority = priority;
|
|
115
|
+
const ext = stepExtensions(step);
|
|
116
|
+
if (ext.length) payload.extension = ext;
|
|
117
|
+
return payload;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function stepExtensions(step) {
|
|
121
|
+
const out = [];
|
|
122
|
+
if (step.priority) out.push(extension(EXT.STEP_PRIORITY, { valueString: step.priority }));
|
|
123
|
+
if (step.actionRequested) out.push(extension(EXT.ACTION_REQUESTED, { valueString: step.actionRequested }));
|
|
124
|
+
if (step.firesAfterSilenceMinutes !== null && step.firesAfterSilenceMinutes !== undefined) {
|
|
125
|
+
out.push(extension(EXT.FIRES_AFTER_SILENCE_MINUTES, { valueInteger: step.firesAfterSilenceMinutes }));
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function fromFhir(resources, { patientId }) {
|
|
131
|
+
const requests = resources.filter((r) => r.resourceType === 'CommunicationRequest' && identifierValue(r, CONTINGENCY_STEP_SLUG));
|
|
132
|
+
return resources
|
|
133
|
+
.filter((r) => r.resourceType === 'CarePlan' && identifierValue(r, CONTINGENCY_PLAN_SLUG))
|
|
134
|
+
.map((plan) => reconstruct(plan, requests, patientId))
|
|
135
|
+
.filter(Boolean);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function reconstruct(plan, requests, patientId) {
|
|
139
|
+
const planId = identifierValue(plan, CONTINGENCY_PLAN_SLUG);
|
|
140
|
+
const ext = extMap(plan);
|
|
141
|
+
const stepFor = (recipient) => {
|
|
142
|
+
const resource = requests.find((r) => identifierValue(r, CONTINGENCY_STEP_SLUG) === `${planId}-${recipient}`);
|
|
143
|
+
return resource ? reconstructStep(resource, recipient) : null;
|
|
144
|
+
};
|
|
145
|
+
const address = plan.addresses && plan.addresses[0] && plan.addresses[0].reference;
|
|
146
|
+
const reconstructed = {
|
|
147
|
+
schemaVersion: '1',
|
|
148
|
+
carePlan: {
|
|
149
|
+
planId,
|
|
150
|
+
patientId,
|
|
151
|
+
relatedCaseId: refToId(address && address.reference),
|
|
152
|
+
relatedClusterId: refToId(plan.supportingInfo && plan.supportingInfo[0] && plan.supportingInfo[0].reference),
|
|
153
|
+
concern: plan.description || '',
|
|
154
|
+
awaitingCharacterization: ext[EXT.AWAITING_CHARACTERIZATION] || '',
|
|
155
|
+
whyThisMatters: ext[EXT.WHY_THIS_MATTERS] || '',
|
|
156
|
+
creationReasoning: ext[EXT.CREATION_REASONING] || '',
|
|
157
|
+
status: ext[EXT.STATUS] || 'armed',
|
|
158
|
+
createdAt: plan.created,
|
|
159
|
+
resolvedAt: (plan.period && plan.period.end) || null,
|
|
160
|
+
resolvedReason: ext[EXT.RESOLVED_REASON] || null,
|
|
161
|
+
},
|
|
162
|
+
teamEscalation: stepFor('care_team'),
|
|
163
|
+
patientReminder: stepFor('patient'),
|
|
164
|
+
};
|
|
165
|
+
try {
|
|
166
|
+
return new ContingencySafetyNet(reconstructed);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (error instanceof ZodError) {
|
|
169
|
+
logger.warn({ planId }, 'skipping malformed contingency CarePlan');
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function reconstructStep(resource, recipient) {
|
|
177
|
+
const ext = extMap(resource);
|
|
178
|
+
const silence = ext[EXT.FIRES_AFTER_SILENCE_MINUTES];
|
|
179
|
+
const firstPayload = resource.payload && resource.payload[0];
|
|
180
|
+
return {
|
|
181
|
+
recipient,
|
|
182
|
+
message: (firstPayload && firstPayload.contentCodeableConcept && firstPayload.contentCodeableConcept.text) || null,
|
|
183
|
+
priority: ext[EXT.STEP_PRIORITY] || resource.priority || null,
|
|
184
|
+
actionRequested: ext[EXT.ACTION_REQUESTED] || null,
|
|
185
|
+
firesAfterSilenceMinutes: typeof silence === 'number' ? silence : null,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
33
189
|
module.exports = {
|
|
34
|
-
|
|
35
|
-
|
|
190
|
+
toFhir,
|
|
191
|
+
fromFhir,
|
|
192
|
+
RESOURCE_TYPES,
|
|
36
193
|
};
|
|
@@ -6,7 +6,6 @@ const { projectSymptomCase, PROJECTOR_NAME: SYMPTOM_CASE_PROJECTOR_NAME } = requ
|
|
|
6
6
|
const { projectPatientRisk, PROJECTOR_NAME: RISK_PROJECTOR_NAME } = require('./riskProjection');
|
|
7
7
|
const { projectSafetyGate, PROJECTOR_NAME: SAFETY_GATE_PROJECTOR_NAME } = require('./safetyGateProjection');
|
|
8
8
|
const { projectRecommendationPlan, PROJECTOR_NAME: RECOMMENDATION_PLAN_PROJECTOR_NAME } = require('./recommendationPlanProjection');
|
|
9
|
-
const { projectContingency, PROJECTOR_NAME: CONTINGENCY_PROJECTOR_NAME } = require('./contingencyProjection');
|
|
10
9
|
const { projectIntervention, PROJECTOR_NAME: INTERVENTION_PROJECTOR_NAME } = require('./interventionProjection');
|
|
11
10
|
const { projectCluster, PROJECTOR_NAME: CLUSTER_PROJECTOR_NAME } = require('./clusterProjection');
|
|
12
11
|
const { projectRoutingDecision, PROJECTOR_NAME: ROUTING_PROJECTOR_NAME } = require('./routingProjection');
|
|
@@ -21,7 +20,6 @@ const PROJECTORS = [
|
|
|
21
20
|
{ name: RISK_PROJECTOR_NAME, project: projectPatientRisk },
|
|
22
21
|
{ name: SAFETY_GATE_PROJECTOR_NAME, project: projectSafetyGate },
|
|
23
22
|
{ name: RECOMMENDATION_PLAN_PROJECTOR_NAME, project: projectRecommendationPlan },
|
|
24
|
-
{ name: CONTINGENCY_PROJECTOR_NAME, project: projectContingency },
|
|
25
23
|
{ name: INTERVENTION_PROJECTOR_NAME, project: projectIntervention },
|
|
26
24
|
{ name: CLUSTER_PROJECTOR_NAME, project: projectCluster },
|
|
27
25
|
{ name: ROUTING_PROJECTOR_NAME, project: projectRoutingDecision },
|
|
@@ -7,45 +7,14 @@ const {
|
|
|
7
7
|
extension,
|
|
8
8
|
toIso,
|
|
9
9
|
} = require('../helpers/elementHelper');
|
|
10
|
-
const {
|
|
11
|
-
const {
|
|
12
|
-
|
|
13
|
-
const CONTINGENCY_STATUS_TO_FHIR = {
|
|
14
|
-
armed: 'active',
|
|
15
|
-
fired_reminder: 'active',
|
|
16
|
-
fired_team: 'active',
|
|
17
|
-
auto_resolved: 'completed',
|
|
18
|
-
cancelled: 'revoked',
|
|
19
|
-
};
|
|
10
|
+
const { ROUTING_DISPOSITION_SLUG, RECOMMENDATION_PLAN_SLUG, PALLIATIVE_CAREPLAN_SLUG } = require('../constants/projectionSlugs');
|
|
11
|
+
const { PALLIATIVE_EXT } = require('../constants/extensionSlugs');
|
|
20
12
|
|
|
21
13
|
class CarePlan {
|
|
22
14
|
constructor(payload) {
|
|
23
15
|
Object.assign(this, payload);
|
|
24
16
|
}
|
|
25
17
|
|
|
26
|
-
static fromContingency({ carePlan }) {
|
|
27
|
-
const payload = {
|
|
28
|
-
resourceType: 'CarePlan',
|
|
29
|
-
id: fhirId(carePlan.planId),
|
|
30
|
-
identifier: [identifier(CONTINGENCY_PLAN_SLUG, carePlan.planId)],
|
|
31
|
-
status: CONTINGENCY_STATUS_TO_FHIR[carePlan.status],
|
|
32
|
-
intent: 'plan',
|
|
33
|
-
subject: patientReference(carePlan.patientId),
|
|
34
|
-
created: toIso(carePlan.createdAt),
|
|
35
|
-
period: contingencyPeriod(carePlan),
|
|
36
|
-
};
|
|
37
|
-
if (carePlan.concern) payload.description = carePlan.concern;
|
|
38
|
-
if (carePlan.relatedCaseId) {
|
|
39
|
-
payload.addresses = [{ reference: reference('Condition', carePlan.relatedCaseId) }];
|
|
40
|
-
}
|
|
41
|
-
if (carePlan.relatedClusterId) {
|
|
42
|
-
payload.supportingInfo = [reference('ClinicalImpression', carePlan.relatedClusterId)];
|
|
43
|
-
}
|
|
44
|
-
const ext = contingencyExtensions(carePlan);
|
|
45
|
-
if (ext.length) payload.extension = ext;
|
|
46
|
-
return new CarePlan(payload);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
18
|
static fromRecommendationPlan({ plan, taskIds, hasSafetyGate }) {
|
|
50
19
|
const payload = {
|
|
51
20
|
resourceType: 'CarePlan',
|
|
@@ -110,30 +79,6 @@ class CarePlan {
|
|
|
110
79
|
}
|
|
111
80
|
}
|
|
112
81
|
|
|
113
|
-
function contingencyPeriod(carePlan) {
|
|
114
|
-
const period = { start: toIso(carePlan.createdAt) };
|
|
115
|
-
if (carePlan.resolvedAt) period.end = toIso(carePlan.resolvedAt);
|
|
116
|
-
return period;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function contingencyExtensions(carePlan) {
|
|
120
|
-
const out = [extension(CONTINGENCY_EXT.STATUS, { valueString: carePlan.status })];
|
|
121
|
-
if (carePlan.awaitingCharacterization) {
|
|
122
|
-
out.push(extension(CONTINGENCY_EXT.AWAITING_CHARACTERIZATION, { valueString: carePlan.awaitingCharacterization }));
|
|
123
|
-
}
|
|
124
|
-
if (carePlan.whyThisMatters) {
|
|
125
|
-
out.push(extension(CONTINGENCY_EXT.WHY_THIS_MATTERS, { valueString: carePlan.whyThisMatters }));
|
|
126
|
-
}
|
|
127
|
-
if (carePlan.creationReasoning) {
|
|
128
|
-
out.push(extension(CONTINGENCY_EXT.CREATION_REASONING, { valueString: carePlan.creationReasoning }));
|
|
129
|
-
}
|
|
130
|
-
if (carePlan.resolvedReason) {
|
|
131
|
-
out.push(extension(CONTINGENCY_EXT.RESOLVED_REASON, { valueString: carePlan.resolvedReason }));
|
|
132
|
-
}
|
|
133
|
-
return out;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
82
|
module.exports = {
|
|
137
83
|
CarePlan,
|
|
138
|
-
CONTINGENCY_STATUS_TO_FHIR,
|
|
139
84
|
};
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
const { getOrganizationId } = require('../config/fhirConfig');
|
|
2
1
|
const { fhirId, codeSystemUrl } = require('../helpers/fhirHelper');
|
|
3
2
|
const {
|
|
4
3
|
identifier,
|
|
@@ -16,25 +15,11 @@ const {
|
|
|
16
15
|
REMINDER_SLUG,
|
|
17
16
|
REMINDER_KIND_SLUG,
|
|
18
17
|
COMMUNICATION_CHANNEL_SLUG,
|
|
19
|
-
CONTINGENCY_STEP_SLUG,
|
|
20
18
|
INTERVENTION_SLUG,
|
|
21
19
|
INTERVENTION_KIND_SLUG,
|
|
22
20
|
ROUTING_DISPOSITION_SLUG,
|
|
23
21
|
} = require('../constants/projectionSlugs');
|
|
24
|
-
const { CONTINGENCY_EXT, CONTINGENCY_CATEGORY_PREFIX } = require('../constants/extensionSlugs');
|
|
25
22
|
const { SEVERITY_TO_PRIORITY } = require('../constants/severityPriority');
|
|
26
|
-
const { CONTINGENCY_STATUS_TO_FHIR } = require('./CarePlan');
|
|
27
|
-
|
|
28
|
-
const CONTINGENCY_PRIORITY_CROSSWALK = {
|
|
29
|
-
critical: 'stat',
|
|
30
|
-
high: 'urgent',
|
|
31
|
-
medium: 'routine',
|
|
32
|
-
low: 'routine',
|
|
33
|
-
routine: 'routine',
|
|
34
|
-
urgent: 'urgent',
|
|
35
|
-
asap: 'asap',
|
|
36
|
-
stat: 'stat',
|
|
37
|
-
};
|
|
38
23
|
const { interventionExtensions } = require('./interventionExtensions');
|
|
39
24
|
|
|
40
25
|
const NOTIFY_STATUS_TO_CR_STATUS = {
|
|
@@ -106,35 +91,6 @@ class CommunicationRequest {
|
|
|
106
91
|
return new CommunicationRequest(payload);
|
|
107
92
|
}
|
|
108
93
|
|
|
109
|
-
static fromContingencyStep({ step, planId, patientId, createdAt, parentStatus }) {
|
|
110
|
-
const stepId = `${planId}-${step.recipient}`;
|
|
111
|
-
const payload = {
|
|
112
|
-
resourceType: 'CommunicationRequest',
|
|
113
|
-
id: fhirId(stepId),
|
|
114
|
-
identifier: [identifier(CONTINGENCY_STEP_SLUG, stepId)],
|
|
115
|
-
status: CONTINGENCY_STATUS_TO_FHIR[parentStatus],
|
|
116
|
-
intent: 'plan',
|
|
117
|
-
category: [codeableConcept(`${CONTINGENCY_CATEGORY_PREFIX}${step.recipient}`)],
|
|
118
|
-
subject: patientReference(patientId),
|
|
119
|
-
recipient: [step.recipient === 'patient'
|
|
120
|
-
? patientReference(patientId)
|
|
121
|
-
: { reference: `Organization/${fhirId(getOrganizationId())}` }],
|
|
122
|
-
payload: [{ contentCodeableConcept: codeableConcept(step.message) }],
|
|
123
|
-
authoredOn: toIso(createdAt),
|
|
124
|
-
basedOn: [{ reference: `CarePlan/${fhirId(planId)}` }],
|
|
125
|
-
};
|
|
126
|
-
const priority = step.priority ? CONTINGENCY_PRIORITY_CROSSWALK[step.priority] || 'routine' : null;
|
|
127
|
-
if (priority) payload.priority = priority;
|
|
128
|
-
const ext = [];
|
|
129
|
-
if (step.priority) ext.push(extension(CONTINGENCY_EXT.STEP_PRIORITY, { valueString: step.priority }));
|
|
130
|
-
if (step.actionRequested) ext.push(extension(CONTINGENCY_EXT.ACTION_REQUESTED, { valueString: step.actionRequested }));
|
|
131
|
-
if (step.firesAfterSilenceMinutes !== null && step.firesAfterSilenceMinutes !== undefined) {
|
|
132
|
-
ext.push(extension(CONTINGENCY_EXT.FIRES_AFTER_SILENCE_MINUTES, { valueInteger: step.firesAfterSilenceMinutes }));
|
|
133
|
-
}
|
|
134
|
-
if (ext.length) payload.extension = ext;
|
|
135
|
-
return new CommunicationRequest(payload);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
94
|
static fromIntervention({ intervention }) {
|
|
139
95
|
const payload = {
|
|
140
96
|
resourceType: 'CommunicationRequest',
|
|
@@ -109,21 +109,6 @@ class Provenance {
|
|
|
109
109
|
});
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
static fromContingency({ planId, recipients, recordedAt, activityText }) {
|
|
113
|
-
const targets = [{ reference: `CarePlan/${fhirId(planId)}` }];
|
|
114
|
-
for (const recipient of recipients) {
|
|
115
|
-
targets.push({ reference: `CommunicationRequest/${fhirId(`${planId}-${recipient}`)}` });
|
|
116
|
-
}
|
|
117
|
-
return new Provenance({
|
|
118
|
-
resourceType: 'Provenance',
|
|
119
|
-
id: fhirId(`${planId}-prov`),
|
|
120
|
-
target: targets,
|
|
121
|
-
recorded: toIso(recordedAt),
|
|
122
|
-
agent: [{ who: reference('Device', getDeviceId()), onBehalfOf: reference('Organization', getOrganizationId()) }],
|
|
123
|
-
activity: codeableConcept(activityText),
|
|
124
|
-
});
|
|
125
|
-
}
|
|
126
|
-
|
|
127
112
|
static fromResultObservations({ targetIds, provenanceId, activityText, now }) {
|
|
128
113
|
return new Provenance({
|
|
129
114
|
resourceType: 'Provenance',
|
|
@@ -1,117 +1,23 @@
|
|
|
1
|
-
const { ZodError } = require('zod');
|
|
2
|
-
|
|
3
1
|
const { fhirId } = require('../helpers/fhirHelper');
|
|
4
|
-
const { extMap, identifierValue, refToId } = require('../helpers/fhirReadHelper');
|
|
5
|
-
const {
|
|
6
|
-
CONTINGENCY_PLAN_SLUG,
|
|
7
|
-
CONTINGENCY_STEP_SLUG,
|
|
8
|
-
} = require('../constants/projectionSlugs');
|
|
9
|
-
const { CONTINGENCY_EXT, CONTINGENCY_CATEGORY_PREFIX } = require('../constants/extensionSlugs');
|
|
10
|
-
const { CONTINGENCY_STATUS_TO_FHIR } = require('../resources/CarePlan');
|
|
11
|
-
const { PROJECTOR_NAME } = require('../projections/contingencyProjection');
|
|
12
2
|
const { getFhirStore } = require('../stores/fhirStore');
|
|
13
|
-
const {
|
|
14
|
-
const {
|
|
15
|
-
const { project, storeResources } = require('./fhirService');
|
|
16
|
-
|
|
17
|
-
const FHIR_TO_CONTINGENCY_STATUS = invert(CONTINGENCY_STATUS_TO_FHIR);
|
|
3
|
+
const { toFhir, fromFhir, RESOURCE_TYPES } = require('../projections/contingencyProjection');
|
|
4
|
+
const { storeResources } = require('./fhirService');
|
|
18
5
|
|
|
19
6
|
async function storeContingency({ patientId, contingency }) {
|
|
20
7
|
if (contingency.carePlan.patientId !== patientId) {
|
|
21
8
|
throw new Error(`storeContingency patientId mismatch: expected ${patientId}`);
|
|
22
9
|
}
|
|
23
|
-
const
|
|
24
|
-
const resources = bundle.entry.map((entry) => entry.resource);
|
|
25
|
-
const { stored } = await storeResources(resources);
|
|
10
|
+
const { stored } = await storeResources(toFhir(contingency));
|
|
26
11
|
return { count: stored.length, ids: stored };
|
|
27
12
|
}
|
|
28
13
|
|
|
29
14
|
async function readContingencies({ patientId, caseId = null, clusterId = null }) {
|
|
30
15
|
const store = getFhirStore();
|
|
31
16
|
const patient = `Patient/${fhirId(patientId)}`;
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
.filter((
|
|
36
|
-
|
|
37
|
-
const nets = [];
|
|
38
|
-
for (const plan of plans) {
|
|
39
|
-
const net = reconstructContingency(plan, requests, patientId);
|
|
40
|
-
if (caseId && net.carePlan.relatedCaseId !== caseId) continue;
|
|
41
|
-
if (clusterId && net.carePlan.relatedClusterId !== clusterId) continue;
|
|
42
|
-
try {
|
|
43
|
-
nets.push(new ContingencySafetyNet(net));
|
|
44
|
-
} catch (error) {
|
|
45
|
-
if (error instanceof ZodError) {
|
|
46
|
-
logger.warn({ planId: net.carePlan.planId }, 'skipping malformed contingency CarePlan');
|
|
47
|
-
continue;
|
|
48
|
-
}
|
|
49
|
-
throw error;
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
return nets;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function reconstructContingency(plan, requests, patientId) {
|
|
56
|
-
const planId = identifierValue(plan, CONTINGENCY_PLAN_SLUG);
|
|
57
|
-
const ext = extMap(plan);
|
|
58
|
-
const stepIds = new Set([`${planId}-care_team`, `${planId}-patient`]);
|
|
59
|
-
const planSteps = requests
|
|
60
|
-
.filter((resource) => stepIds.has(identifierValue(resource, CONTINGENCY_STEP_SLUG)))
|
|
61
|
-
.map(reconstructStep);
|
|
62
|
-
const teamEscalation = planSteps.find((step) => step.recipient === 'care_team') || null;
|
|
63
|
-
const patientReminder = planSteps.find((step) => step.recipient === 'patient') || null;
|
|
64
|
-
const address = plan.addresses && plan.addresses[0] && plan.addresses[0].reference;
|
|
65
|
-
return {
|
|
66
|
-
schemaVersion: '1',
|
|
67
|
-
carePlan: {
|
|
68
|
-
planId,
|
|
69
|
-
patientId,
|
|
70
|
-
relatedCaseId: refToId(address && address.reference),
|
|
71
|
-
relatedClusterId: refToId(plan.supportingInfo && plan.supportingInfo[0] && plan.supportingInfo[0].reference),
|
|
72
|
-
concern: plan.description || '',
|
|
73
|
-
awaitingCharacterization: ext[CONTINGENCY_EXT.AWAITING_CHARACTERIZATION] || '',
|
|
74
|
-
whyThisMatters: ext[CONTINGENCY_EXT.WHY_THIS_MATTERS] || '',
|
|
75
|
-
creationReasoning: ext[CONTINGENCY_EXT.CREATION_REASONING] || '',
|
|
76
|
-
status: ext[CONTINGENCY_EXT.STATUS] || FHIR_TO_CONTINGENCY_STATUS[plan.status] || 'armed',
|
|
77
|
-
createdAt: plan.created,
|
|
78
|
-
resolvedAt: (plan.period && plan.period.end) || null,
|
|
79
|
-
resolvedReason: ext[CONTINGENCY_EXT.RESOLVED_REASON] || null,
|
|
80
|
-
},
|
|
81
|
-
teamEscalation,
|
|
82
|
-
patientReminder,
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function reconstructStep(resource) {
|
|
87
|
-
const ext = extMap(resource);
|
|
88
|
-
const silence = ext[CONTINGENCY_EXT.FIRES_AFTER_SILENCE_MINUTES];
|
|
89
|
-
const firstPayload = resource.payload && resource.payload[0];
|
|
90
|
-
return {
|
|
91
|
-
recipient: recipientOf(resource),
|
|
92
|
-
message: (firstPayload && firstPayload.contentCodeableConcept && firstPayload.contentCodeableConcept.text) || null,
|
|
93
|
-
priority: ext[CONTINGENCY_EXT.STEP_PRIORITY] || resource.priority || null,
|
|
94
|
-
actionRequested: ext[CONTINGENCY_EXT.ACTION_REQUESTED] || null,
|
|
95
|
-
firesAfterSilenceMinutes: typeof silence === 'number' ? silence : null,
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function recipientOf(resource) {
|
|
100
|
-
const category = resource.category && resource.category[0] && resource.category[0].text;
|
|
101
|
-
if (category && category.startsWith(CONTINGENCY_CATEGORY_PREFIX)) {
|
|
102
|
-
const recipient = category.slice(CONTINGENCY_CATEGORY_PREFIX.length);
|
|
103
|
-
if (RECIPIENTS.includes(recipient)) return recipient;
|
|
104
|
-
}
|
|
105
|
-
const ref = resource.recipient && resource.recipient[0] && resource.recipient[0].reference;
|
|
106
|
-
return ref && ref.startsWith('Organization/') ? 'care_team' : 'patient';
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function invert(map) {
|
|
110
|
-
const out = {};
|
|
111
|
-
for (const [key, value] of Object.entries(map)) {
|
|
112
|
-
if (!(value in out)) out[value] = key;
|
|
113
|
-
}
|
|
114
|
-
return out;
|
|
17
|
+
const found = await Promise.all(RESOURCE_TYPES.map((resourceType) => store.find({ patient, resourceType })));
|
|
18
|
+
return fromFhir(found.flat(), { patientId })
|
|
19
|
+
.filter((contingency) => !caseId || contingency.carePlan.relatedCaseId === caseId)
|
|
20
|
+
.filter((contingency) => !clusterId || contingency.carePlan.relatedClusterId === clusterId);
|
|
115
21
|
}
|
|
116
22
|
|
|
117
23
|
module.exports = {
|