@peopl-health/nexus 5.69.0-dev.12295 → 5.69.0-dev.12578
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/config/cohortConfig.js +1 -1
- package/lib/clinical/context/ToolRuntimeContext.js +5 -2
- package/lib/clinical/flags/emergencyConsultFlags.js +16 -0
- package/lib/clinical/flags/flagSnapshot.js +59 -0
- package/lib/clinical/helpers/clinicalWorkflowGuard.js +3 -2
- package/lib/clinical/helpers/skillActivationHelper.js +53 -0
- package/lib/clinical/monitoring/LlmMonitorService.js +12 -4
- package/lib/clinical/monitoring/contingencyMonitorSpec.js +2 -1
- package/lib/clinical/monitoring/emergencyConsultMonitorSpec.js +215 -0
- package/lib/clinical/providers/AnthropicProvider.js +17 -5
- package/lib/clinical/providers/BaseLLMProvider.js +13 -2
- package/lib/clinical/services/clinicalExtractionService.js +9 -1
- package/lib/clinical/services/clinicalPatternFloorService.js +48 -6
- package/lib/clinical/services/emergencyConsultService.js +76 -0
- package/lib/clinical/services/outreachSweepService.js +4 -7
- package/lib/clinical/services/promptComposerService.js +4 -1
- package/lib/clinical/tools/extractClinicalInfoTool.js +7 -2
- package/lib/clinical/tools/reportMedicalEscalationTool.js +1 -0
- package/lib/clinical/tools/skillTool.js +11 -6
- package/lib/clinical/tools/submitRoutingDecisionTool.js +6 -2
- package/lib/core/NexusMessaging.js +7 -3
- package/lib/fhir/projections/clinicalMentionProjection.js +3 -1
- package/lib/fhir/projections/contextFactProjection.js +3 -1
- package/lib/helpers/messageHelper.js +4 -3
- package/lib/models/escalationReviewModel.js +1 -0
- package/lib/services/escalationReviewService.js +35 -8
- package/lib/services/reviewDraftSweepService.js +1 -1
- package/package.json +1 -1
|
@@ -56,6 +56,7 @@ class ToolRuntimeContext {
|
|
|
56
56
|
this.skillActivationIteration = new Map();
|
|
57
57
|
this.currentIteration = null;
|
|
58
58
|
this.toolHistory = [];
|
|
59
|
+
this.toolRejections = [];
|
|
59
60
|
this.safetyArtifact = null;
|
|
60
61
|
this.palliativeAssessment = null;
|
|
61
62
|
this.careplan = null;
|
|
@@ -93,9 +94,11 @@ class ToolRuntimeContext {
|
|
|
93
94
|
this.trace.addStep({ iteration, ...toolData });
|
|
94
95
|
if (toolData.success) this.toolHistory.push(toolData);
|
|
95
96
|
if (!toolData.success || isToolFailure(toolData.tool_output)) {
|
|
97
|
+
const code = refusalCode(toolData.tool_output);
|
|
98
|
+
this.toolRejections.push({ tool: toolData.tool_name, code, iteration: iteration ?? null });
|
|
96
99
|
logger.warn('[toolRuntime] tool call rejected', {
|
|
97
100
|
tool: toolData.tool_name,
|
|
98
|
-
reason:
|
|
101
|
+
reason: code,
|
|
99
102
|
iteration: iteration ?? null,
|
|
100
103
|
turnId: this.turnId || null,
|
|
101
104
|
});
|
|
@@ -108,7 +111,7 @@ class ToolRuntimeContext {
|
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
async store({ outbound = null, completed = false, predictionTimeMs = null, error = null } = {}) {
|
|
111
|
-
this.trace.setSignals({ activatedSkills: [...this.activatedSkills].sort() });
|
|
114
|
+
this.trace.setSignals({ activatedSkills: [...this.activatedSkills].sort(), toolRejections: [...this.toolRejections] });
|
|
112
115
|
this.trace.setOutbound(outbound || this.trace.outbound || null);
|
|
113
116
|
const hasError = error !== null && error !== undefined;
|
|
114
117
|
const isSuperseded = hasError ? false : !this.isLive();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const runtimeConfig = require('../../config/runtimeConfig');
|
|
2
|
+
|
|
3
|
+
const MODES = ['off', 'shadow'];
|
|
4
|
+
const DEFAULT_SAMPLE_RATE = 0.1;
|
|
5
|
+
|
|
6
|
+
function emergencyConsultMode() {
|
|
7
|
+
const value = String(runtimeConfig.get('EMERGENCY_CONSULT_MODE', 'off')).trim().toLowerCase();
|
|
8
|
+
return MODES.includes(value) ? value : 'off';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function emergencyConsultSampleRate() {
|
|
12
|
+
const value = Number(runtimeConfig.get('EMERGENCY_CONSULT_SAMPLE_RATE', DEFAULT_SAMPLE_RATE));
|
|
13
|
+
return Number.isFinite(value) && value >= 0 && value <= 1 ? value : DEFAULT_SAMPLE_RATE;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = { emergencyConsultMode, emergencyConsultSampleRate };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const { isCohortEnabled } = require('../config/cohortConfig');
|
|
2
|
+
const { isBridgeEnabled } = require('./bridgeConfig');
|
|
3
|
+
const { contextReadLive, contextMirrorLive, contextVerifierLive } = require('./clinicalContextFlags');
|
|
4
|
+
const { clinicalPatternFloorLive } = require('./clinicalPatternFloorFlags');
|
|
5
|
+
const { isComposerEnabled, isAgentSelfRenderEnabled } = require('./composerFlags');
|
|
6
|
+
const { isContingencyCancelOnReplyLive, isArmOnOpenCeilingLive, isContingencyRuleBlockLive } = require('./contingencyFlags');
|
|
7
|
+
const { escalationAssessmentLive, escalationUpdateModeLive } = require('./escalationFlags');
|
|
8
|
+
const { inboundRecoveryLive, coldInboundMode } = require('./inboundRecoveryFlags');
|
|
9
|
+
const { isLlmAuditEnabled } = require('./llmAuditConfig');
|
|
10
|
+
const { isLlmMonitorEnabled } = require('./llmMonitorConfig');
|
|
11
|
+
const { patternConsultLive } = require('./patternConsultFlags');
|
|
12
|
+
const { recommendationRetryLive } = require('./recommendationRecoveryFlags');
|
|
13
|
+
const { isShadowEnabled } = require('./shadowConfig');
|
|
14
|
+
const { supportLightFromChatLive } = require('./supportLightFlags');
|
|
15
|
+
const { triageGateFloorLive } = require('./triageGateFloorFlags');
|
|
16
|
+
const { triageRecommendationLive, rankComparatorLive } = require('./triageRecommendationFlags');
|
|
17
|
+
const { isConversationalDeclarationLive, isConversationalDraftReuseLive, isDeclarationModelCheckLive } = require('./turnEfficiencyFlags');
|
|
18
|
+
|
|
19
|
+
const FLAG_READERS = {
|
|
20
|
+
cohort: isCohortEnabled,
|
|
21
|
+
bridge: isBridgeEnabled,
|
|
22
|
+
contextRead: contextReadLive,
|
|
23
|
+
contextMirror: contextMirrorLive,
|
|
24
|
+
contextVerifier: contextVerifierLive,
|
|
25
|
+
clinicalPatternFloor: clinicalPatternFloorLive,
|
|
26
|
+
composer: isComposerEnabled,
|
|
27
|
+
agentSelfRender: isAgentSelfRenderEnabled,
|
|
28
|
+
contingencyCancelOnReply: isContingencyCancelOnReplyLive,
|
|
29
|
+
armOnOpenCeiling: isArmOnOpenCeilingLive,
|
|
30
|
+
contingencyRuleBlock: isContingencyRuleBlockLive,
|
|
31
|
+
escalationAssessment: escalationAssessmentLive,
|
|
32
|
+
escalationUpdateMode: escalationUpdateModeLive,
|
|
33
|
+
inboundRecovery: inboundRecoveryLive,
|
|
34
|
+
coldInboundMode,
|
|
35
|
+
llmAudit: isLlmAuditEnabled,
|
|
36
|
+
llmMonitor: isLlmMonitorEnabled,
|
|
37
|
+
patternConsult: patternConsultLive,
|
|
38
|
+
recommendationRetry: recommendationRetryLive,
|
|
39
|
+
shadow: isShadowEnabled,
|
|
40
|
+
supportLightFromChat: supportLightFromChatLive,
|
|
41
|
+
triageGateFloor: triageGateFloorLive,
|
|
42
|
+
triageRecommendation: triageRecommendationLive,
|
|
43
|
+
rankComparator: rankComparatorLive,
|
|
44
|
+
conversationalDeclaration: isConversationalDeclarationLive,
|
|
45
|
+
conversationalDraftReuse: isConversationalDraftReuseLive,
|
|
46
|
+
declarationModelCheck: isDeclarationModelCheckLive,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function snapshotClinicalFlags() {
|
|
50
|
+
return Object.fromEntries(Object.entries(FLAG_READERS).map(([name, read]) => {
|
|
51
|
+
try {
|
|
52
|
+
return [name, read()];
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return [name, null];
|
|
55
|
+
}
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = { snapshotClinicalFlags };
|
|
@@ -60,7 +60,7 @@ function routingSkillStep(trace, { enabledSkills = [], skillActivationIteration
|
|
|
60
60
|
|
|
61
61
|
return {
|
|
62
62
|
instruction: activatedAt != null
|
|
63
|
-
? `read the ${ROUTING_SKILL} body returned to you (it was
|
|
63
|
+
? `read the ${ROUTING_SKILL} body returned to you (it was returned in this same response, so its content did not reach you yet)`
|
|
64
64
|
: `call Skill with skill="${ROUTING_SKILL}" and read what it returns`,
|
|
65
65
|
toolIds: [SKILL_TOOL],
|
|
66
66
|
};
|
|
@@ -154,7 +154,7 @@ function validateClinicalDelivery(trace, { enabledSkills = [], skillActivationIt
|
|
|
154
154
|
return {
|
|
155
155
|
error: REFUSAL.skillBeforeDelivery,
|
|
156
156
|
recovery: activatedAt != null
|
|
157
|
-
? `The ${downstream} body was
|
|
157
|
+
? `The ${downstream} body was returned in this same response, so its content did not reach you yet. Read it, then compose the patient message from the rules it defines.`
|
|
158
158
|
: `The plan you committed routes to ${downstream}. Call Skill with skill="${downstream}", read the body it returns, then compose the patient message from the rules it defines.`,
|
|
159
159
|
requiredToolIds: [SKILL_TOOL],
|
|
160
160
|
};
|
|
@@ -166,6 +166,7 @@ module.exports = {
|
|
|
166
166
|
ROUTING_SKILL,
|
|
167
167
|
SKILL_TOOL,
|
|
168
168
|
clinicalIntakeRequiresRisk,
|
|
169
|
+
hasAlternateTerminalCommit,
|
|
169
170
|
hasUsableRiskContext,
|
|
170
171
|
isPurelyConversational,
|
|
171
172
|
validateClinicalDelivery,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
const { loadSkillBody } = require('../services/skillService');
|
|
2
|
+
const { logger } = require('../../utils/logger');
|
|
3
|
+
|
|
4
|
+
const { SKILL_TOOL } = require('./clinicalWorkflowGuard');
|
|
5
|
+
|
|
6
|
+
function markSkillActivated(runtime, name) {
|
|
7
|
+
runtime?.activatedSkills?.add(name);
|
|
8
|
+
if (runtime?.skillActivationIteration && !runtime.skillActivationIteration.has(name)) {
|
|
9
|
+
runtime.skillActivationIteration.set(name, runtime.currentIteration ?? null);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// Hands the model a skill the workflow guard is about to require, inside the result of the tool that made it
|
|
14
|
+
// required, so the turn does not spend an iteration being refused and then asking for it. The guard stays the net.
|
|
15
|
+
async function attachRequiredSkill(runtime, name) {
|
|
16
|
+
if (!runtime || !name) return null;
|
|
17
|
+
if (!(runtime.enabledSkills || []).includes(name)) return null;
|
|
18
|
+
if (!(runtime.trace?.signals?.offeredToolIds || []).includes(SKILL_TOOL)) return null;
|
|
19
|
+
if (runtime.activatedSkills?.has(name)) return null;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const { skill } = await loadSkillBody(name);
|
|
23
|
+
if (!skill) return null;
|
|
24
|
+
markSkillActivated(runtime, skill.name);
|
|
25
|
+
runtime.trace?.setSignals?.({ attachedSkills: [...(runtime.trace.signals?.attachedSkills || []), skill.name] });
|
|
26
|
+
return { name: skill.name, version: skill.version, body: skill.body };
|
|
27
|
+
} catch (error) {
|
|
28
|
+
logger.warn('[skillActivation] required skill could not be attached; the guard will ask for it', {
|
|
29
|
+
skill: name, turnId: runtime.turnId || null, error: error?.message,
|
|
30
|
+
});
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// A skill body governs only the turn it was handed over in. Outside that turn (history replay, monitor evidence)
|
|
36
|
+
// the attachment is dropped, as for a Skill call, whose output is never replayed.
|
|
37
|
+
function withoutAttachedSkillBodies(output) {
|
|
38
|
+
let parsed = output;
|
|
39
|
+
if (typeof output === 'string') {
|
|
40
|
+
try { parsed = JSON.parse(output); } catch (error) { return output; }
|
|
41
|
+
}
|
|
42
|
+
const skills = parsed?.data?.activated_skills;
|
|
43
|
+
if (!Array.isArray(skills)) return output;
|
|
44
|
+
const { activated_skills: _attached, ...data } = parsed.data;
|
|
45
|
+
const stripped = { ...parsed, data };
|
|
46
|
+
return typeof output === 'string' ? JSON.stringify(stripped) : stripped;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = {
|
|
50
|
+
attachRequiredSkill,
|
|
51
|
+
markSkillActivated,
|
|
52
|
+
withoutAttachedSkillBodies,
|
|
53
|
+
};
|
|
@@ -230,7 +230,7 @@ class LlmMonitorService {
|
|
|
230
230
|
if (!evaluation) return Evaluations.findOne(key);
|
|
231
231
|
|
|
232
232
|
try {
|
|
233
|
-
const evidence = spec.buildEvidence(trace);
|
|
233
|
+
const evidence = await spec.buildEvidence(trace);
|
|
234
234
|
let judge = null;
|
|
235
235
|
let result = null;
|
|
236
236
|
let judgePrompt = null;
|
|
@@ -248,12 +248,19 @@ class LlmMonitorService {
|
|
|
248
248
|
text: { format: { type: 'json_schema', name: `${spec.id}_verdict`, schema: spec.schema, strict: true } },
|
|
249
249
|
metadata: { source: 'llm_monitor', turn_id: trace.turnId, monitor_id: spec.id, monitor_version: String(spec.version) },
|
|
250
250
|
});
|
|
251
|
-
|
|
252
|
-
spec.
|
|
251
|
+
// A refusal repeats on retry, so a spec that can record it does not pay for it again.
|
|
252
|
+
const isFinalRefusal = result?.status === 'refused' && spec.refusalIsFinal === true;
|
|
253
|
+
if (result?.status && result.status !== 'completed' && !isFinalRefusal) {
|
|
254
|
+
throw new Error(`monitor judge did not complete: ${result.stop_reason || result.status}`);
|
|
255
|
+
}
|
|
256
|
+
if (!isFinalRefusal) {
|
|
257
|
+
judge = parseJudge(result);
|
|
258
|
+
spec.validateJudge?.(judge);
|
|
259
|
+
}
|
|
253
260
|
}
|
|
254
261
|
let findings = spec.interpret({ trace, evidence, judge });
|
|
255
262
|
const evalKey = `${spec.id}:${spec.version}:${trace.turnId}`;
|
|
256
|
-
const passSampled = findings.length === 0 && stableFraction(evalKey) < this.passSampleRate;
|
|
263
|
+
const passSampled = findings.length === 0 && judge !== null && stableFraction(evalKey) < this.passSampleRate;
|
|
257
264
|
if (passSampled) findings = [passFinding(judge)];
|
|
258
265
|
findings = findings.map((finding, index) => ({
|
|
259
266
|
...finding,
|
|
@@ -552,6 +559,7 @@ class LlmMonitorService {
|
|
|
552
559
|
|
|
553
560
|
module.exports = {
|
|
554
561
|
LlmMonitorService,
|
|
562
|
+
stableFraction,
|
|
555
563
|
EVALUATE_QUEUE,
|
|
556
564
|
RECONCILE_QUEUE,
|
|
557
565
|
_test: { stableFraction, findingId, needsSync, syncBacklogFilter, RECONCILE_BATCH_SIZE, MAX_EVALUATION_ATTEMPTS },
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { patientMessageFromTrace } = require('../helpers/evidenceAnchorHelper');
|
|
2
2
|
const { parseGradeEstimate, projectGradeEstimate } = require('../helpers/gradeEstimateHelper');
|
|
3
|
+
const { withoutAttachedSkillBodies } = require('../helpers/skillActivationHelper');
|
|
3
4
|
const { redactDirectIdentifiers } = require('../../utils/sanitizerUtils');
|
|
4
5
|
const { logger } = require('../../utils/logger');
|
|
5
6
|
|
|
@@ -256,7 +257,7 @@ function compactStep(step) {
|
|
|
256
257
|
return {
|
|
257
258
|
tool_name: step.tool_name,
|
|
258
259
|
arguments: minimized(step.tool_arguments || null),
|
|
259
|
-
output: minimized(step.tool_output || null),
|
|
260
|
+
output: minimized(withoutAttachedSkillBodies(step.tool_output) || null),
|
|
260
261
|
success: step.success !== false,
|
|
261
262
|
iteration: step.iteration ?? null,
|
|
262
263
|
};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
const { patientMessageFromTrace } = require('../helpers/evidenceAnchorHelper');
|
|
2
|
+
const { buildConsultContext } = require('../services/emergencyConsultService');
|
|
3
|
+
const { emergencyConsultSampleRate } = require('../flags/emergencyConsultFlags');
|
|
4
|
+
const { URGENCY_RANK } = require('../../models/escalationReviewModel');
|
|
5
|
+
const { isPlainObject } = require('../../utils/jsonUtils');
|
|
6
|
+
|
|
7
|
+
const { stableFraction } = require('./LlmMonitorService');
|
|
8
|
+
|
|
9
|
+
const MONITOR_ID = 'emergency_consult';
|
|
10
|
+
const MONITOR_VERSION = '1';
|
|
11
|
+
const PRESET_ID = 'sa_emergency_consult';
|
|
12
|
+
const LEVELS = ['stat', 'asap', 'urgent', 'routine'];
|
|
13
|
+
const IMMEDIATE_LEVELS = ['stat', 'asap'];
|
|
14
|
+
const DISPOSITIONS = ['escalate_now', 'ask_first', 'no_escalation'];
|
|
15
|
+
const STALE_AFTER_MS = 15 * 60 * 1000;
|
|
16
|
+
const UNREADABLE_PATIENT = [400, 404];
|
|
17
|
+
|
|
18
|
+
const strings = { type: 'array', items: { type: 'string' } };
|
|
19
|
+
const nullableLevel = { anyOf: [{ type: 'string', enum: LEVELS }, { type: 'null' }] };
|
|
20
|
+
|
|
21
|
+
const CONSULT_SCHEMA = {
|
|
22
|
+
type: 'object',
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
required: ['disposition', 'urgency_level', 'confidence', 'clinical_reasoning', 'serious_differential', 'red_flags_present',
|
|
25
|
+
'red_flags_ruled_out', 'missing_information', 'level_if_unanswered', 'context_gaps', 'recommended_action', 'what_to_tell_patient'],
|
|
26
|
+
properties: {
|
|
27
|
+
disposition: { type: 'string', enum: DISPOSITIONS },
|
|
28
|
+
urgency_level: nullableLevel,
|
|
29
|
+
confidence: { type: 'string', enum: ['low', 'moderate', 'high'] },
|
|
30
|
+
clinical_reasoning: { type: 'string' },
|
|
31
|
+
serious_differential: {
|
|
32
|
+
type: 'array',
|
|
33
|
+
items: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
required: ['condition', 'status', 'features'],
|
|
37
|
+
properties: {
|
|
38
|
+
condition: { type: 'string' },
|
|
39
|
+
status: { type: 'string', enum: ['supported', 'not_excluded', 'unlikely'] },
|
|
40
|
+
features: strings,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
red_flags_present: strings,
|
|
45
|
+
red_flags_ruled_out: strings,
|
|
46
|
+
missing_information: {
|
|
47
|
+
type: 'array',
|
|
48
|
+
items: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
additionalProperties: false,
|
|
51
|
+
required: ['question_for_patient', 'why', 'level_if_positive'],
|
|
52
|
+
properties: {
|
|
53
|
+
question_for_patient: { type: 'string' },
|
|
54
|
+
why: { type: 'string' },
|
|
55
|
+
level_if_positive: { type: 'string', enum: LEVELS },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
level_if_unanswered: nullableLevel,
|
|
60
|
+
context_gaps: strings,
|
|
61
|
+
recommended_action: {
|
|
62
|
+
type: 'object',
|
|
63
|
+
additionalProperties: false,
|
|
64
|
+
required: ['now', 'notify'],
|
|
65
|
+
properties: {
|
|
66
|
+
now: { type: 'string' },
|
|
67
|
+
notify: { type: 'string', enum: ['none', 'team_review', 'treating_team_today', 'oncologist_now', 'emergency_services'] },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
what_to_tell_patient: { type: 'string' },
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const asArray = (value) => (Array.isArray(value) ? value : []);
|
|
75
|
+
|
|
76
|
+
function triggers(trace) {
|
|
77
|
+
const signals = trace?.signals || {};
|
|
78
|
+
const found = [];
|
|
79
|
+
if (signals.medicalEscalation) found.push('medical_escalation');
|
|
80
|
+
if (asArray(signals.safetyFlags).length) found.push('safety_flags');
|
|
81
|
+
if (asArray(signals.activatedSkills).includes('emergency')) found.push('emergency_skill');
|
|
82
|
+
if (!found.length && asArray(signals.clinicalMentions).length
|
|
83
|
+
&& stableFraction(`${MONITOR_ID}:${trace.turnId}`) < emergencyConsultSampleRate()) found.push('mention_sample');
|
|
84
|
+
return found;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isEligible(trace) {
|
|
88
|
+
return trace?.status === 'completed'
|
|
89
|
+
&& trace?.shadow !== true
|
|
90
|
+
&& triggers(trace).length > 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function agentDecision(trace) {
|
|
94
|
+
const escalation = trace.signals?.medicalEscalation;
|
|
95
|
+
if (!escalation) return { escalated: false, level: null, requestedLevel: null, patternFloor: null };
|
|
96
|
+
return {
|
|
97
|
+
escalated: true,
|
|
98
|
+
level: escalation.urgencyLevel || null,
|
|
99
|
+
requestedLevel: escalation.requestedUrgencyLevel || null,
|
|
100
|
+
patternFloor: escalation.clinicalPatternFloor || null,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const mentionFacts = (trace) => asArray(trace.signals?.clinicalMentions)
|
|
105
|
+
.map(({ fact }) => {
|
|
106
|
+
if (!isPlainObject(fact)) return null;
|
|
107
|
+
const { patientId: _patientId, mentionId: _mentionId, ...rest } = fact;
|
|
108
|
+
return rest;
|
|
109
|
+
})
|
|
110
|
+
.filter(Boolean);
|
|
111
|
+
|
|
112
|
+
async function buildEvidence(trace, now = Date.now()) {
|
|
113
|
+
// The trace is saved once the turn ends, so the turn started predictionTimeMs before createdAt.
|
|
114
|
+
const turnStart = new Date(trace.createdAt).getTime() - (trace.timings?.predictionTimeMs || 0);
|
|
115
|
+
const base = { triggers: triggers(trace), agent: agentDecision(trace), lag_ms: now - turnStart };
|
|
116
|
+
if (base.lag_ms > STALE_AFTER_MS) return { ...base, extraction_succeeded: false, degraded: 'stale' };
|
|
117
|
+
try {
|
|
118
|
+
const { input, degradedSections, hasCoreContext } = await buildConsultContext({
|
|
119
|
+
code: trace.patientCode,
|
|
120
|
+
turnStart,
|
|
121
|
+
patientMessage: patientMessageFromTrace(trace),
|
|
122
|
+
mentions: mentionFacts(trace),
|
|
123
|
+
});
|
|
124
|
+
if (!hasCoreContext) return { ...base, extraction_succeeded: false, degraded: 'core_context_missing', degraded_sections: degradedSections };
|
|
125
|
+
return { ...base, extraction_succeeded: true, input, degraded_sections: degradedSections };
|
|
126
|
+
} catch (error) {
|
|
127
|
+
if (!UNREADABLE_PATIENT.includes(error?.statusCode)) throw error;
|
|
128
|
+
return { ...base, extraction_succeeded: false, degraded: 'patient_unreadable', error: error.message };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function buildJudgeInput(evidence) {
|
|
133
|
+
return [{ role: 'user', content: evidence.input }];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function validateJudge(judge) {
|
|
137
|
+
if (!isPlainObject(judge) || !DISPOSITIONS.includes(judge.disposition)) {
|
|
138
|
+
throw new Error('emergency consult returned no valid disposition');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function invariantViolations(judge) {
|
|
143
|
+
const violations = [];
|
|
144
|
+
const askFirst = judge.disposition === 'ask_first';
|
|
145
|
+
if (judge.disposition === 'escalate_now' && !judge.urgency_level) violations.push('escalate_now_without_level');
|
|
146
|
+
if (askFirst && !asArray(judge.missing_information).length) violations.push('ask_first_without_questions');
|
|
147
|
+
if (askFirst && !judge.level_if_unanswered) violations.push('ask_first_without_level_if_unanswered');
|
|
148
|
+
if (askFirst && [judge.urgency_level, judge.level_if_unanswered].some((level) => IMMEDIATE_LEVELS.includes(level))) {
|
|
149
|
+
violations.push('ask_first_on_immediate_level');
|
|
150
|
+
}
|
|
151
|
+
if (judge.disposition !== 'escalate_now' && asArray(judge.red_flags_present).length) violations.push('red_flags_without_escalation');
|
|
152
|
+
return violations;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const rank = (level) => URGENCY_RANK[level] || 0;
|
|
156
|
+
|
|
157
|
+
function consultFinding(fields) {
|
|
158
|
+
return {
|
|
159
|
+
type: 'emergency_consult',
|
|
160
|
+
severity: 'normal',
|
|
161
|
+
confidence: 'high',
|
|
162
|
+
opsReviewRequired: false,
|
|
163
|
+
targetId: null,
|
|
164
|
+
observedCallIndex: null,
|
|
165
|
+
...fields,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function interpret({ evidence, judge }) {
|
|
170
|
+
if (!evidence.extraction_succeeded || !judge) {
|
|
171
|
+
return [consultFinding({
|
|
172
|
+
expectedAction: null,
|
|
173
|
+
observedAction: evidence.agent?.level || null,
|
|
174
|
+
criteria: { degraded: evidence.extraction_succeeded ? 'judge_refused' : evidence.degraded },
|
|
175
|
+
summary: 'El especialista no evaluó el turno.',
|
|
176
|
+
evidence: evidence.error || 'Sin detalle.',
|
|
177
|
+
})];
|
|
178
|
+
}
|
|
179
|
+
const specialistLevel = judge.disposition === 'ask_first' ? judge.level_if_unanswered : judge.urgency_level;
|
|
180
|
+
const agentLevel = evidence.agent?.level || null;
|
|
181
|
+
return [consultFinding({
|
|
182
|
+
expectedAction: specialistLevel || null,
|
|
183
|
+
observedAction: agentLevel,
|
|
184
|
+
criteria: {
|
|
185
|
+
disposition: judge.disposition,
|
|
186
|
+
agent_below_specialist: rank(agentLevel) < rank(specialistLevel),
|
|
187
|
+
invariant_violations: invariantViolations(judge),
|
|
188
|
+
},
|
|
189
|
+
summary: `Especialista: ${judge.disposition} ${specialistLevel || 'sin nivel'}; agente: ${agentLevel || 'sin escalamiento'}.`,
|
|
190
|
+
evidence: judge.clinical_reasoning || 'Sin razonamiento.',
|
|
191
|
+
})];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const emergencyConsultMonitorSpec = {
|
|
195
|
+
id: MONITOR_ID,
|
|
196
|
+
version: MONITOR_VERSION,
|
|
197
|
+
presetId: PRESET_ID,
|
|
198
|
+
schema: CONSULT_SCHEMA,
|
|
199
|
+
refusalIsFinal: true,
|
|
200
|
+
reconcileQuery: {
|
|
201
|
+
$or: [
|
|
202
|
+
{ 'signals.medicalEscalation': { $exists: true } },
|
|
203
|
+
{ 'signals.safetyFlags.0': { $exists: true } },
|
|
204
|
+
{ 'signals.activatedSkills': 'emergency' },
|
|
205
|
+
{ 'signals.clinicalMentions.0': { $exists: true } },
|
|
206
|
+
],
|
|
207
|
+
},
|
|
208
|
+
isEligible,
|
|
209
|
+
buildEvidence,
|
|
210
|
+
buildJudgeInput,
|
|
211
|
+
validateJudge,
|
|
212
|
+
interpret,
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
module.exports = { emergencyConsultMonitorSpec };
|
|
@@ -240,7 +240,7 @@ class AnthropicProvider extends BaseLLMProvider {
|
|
|
240
240
|
if (lastUserTurn?.role === 'user') {
|
|
241
241
|
deliveryNudges++;
|
|
242
242
|
previousRoundTools = [];
|
|
243
|
-
trace?.setSignals?.({ deliveryNudges });
|
|
243
|
+
trace?.setSignals?.({ deliveryNudges, nudgeKind: canDecide ? 'outreach' : 'delivery' });
|
|
244
244
|
logger.warn('[AnthropicProvider] Turn will not close — nudging', { tool: canDecide ? OUTREACH_DECISION_TOOL : 'DeliverPatientMessage', deliveryNudges, hasEnded, isBudgetSpent });
|
|
245
245
|
const turnContent = Array.isArray(lastUserTurn.content)
|
|
246
246
|
? lastUserTurn.content
|
|
@@ -348,15 +348,27 @@ class AnthropicProvider extends BaseLLMProvider {
|
|
|
348
348
|
};
|
|
349
349
|
if (system) apiConfig.system = system;
|
|
350
350
|
|
|
351
|
+
// Always-thinking models (Opus/Sonnet 5.5) reject a forced tool_choice; presets opt into output_config.format instead.
|
|
352
|
+
const nativeFormat = modelConfig.structured_mode === 'output_format';
|
|
353
|
+
if (nativeFormat) {
|
|
354
|
+
const effort = modelConfig.reasoning?.effort;
|
|
355
|
+
if (effort) apiConfig.output_config = { effort: effort === 'minimal' ? 'low' : effort };
|
|
356
|
+
if (modelConfig.thinking) apiConfig.thinking = modelConfig.thinking;
|
|
357
|
+
}
|
|
358
|
+
|
|
351
359
|
const spec = text || modelConfig.text;
|
|
352
360
|
let tool = null;
|
|
353
361
|
if (spec) {
|
|
354
362
|
const format = spec.format ? spec.format : spec;
|
|
355
363
|
if (!format.schema) throw new Error('runStructured: format spec has no schema');
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
364
|
+
if (nativeFormat) {
|
|
365
|
+
apiConfig.output_config = { ...apiConfig.output_config, format: { type: 'json_schema', schema: format.schema } };
|
|
366
|
+
} else {
|
|
367
|
+
tool = { name: format.name || 'structuredOutput', input_schema: format.schema };
|
|
368
|
+
if (format.strict !== undefined) tool.strict = format.strict;
|
|
369
|
+
apiConfig.tools = [tool];
|
|
370
|
+
apiConfig.tool_choice = { type: 'tool', name: tool.name };
|
|
371
|
+
}
|
|
360
372
|
}
|
|
361
373
|
|
|
362
374
|
const { result: { result }, durationMs } = await withTiming(() =>
|
|
@@ -10,6 +10,7 @@ const { resolveSkillCatalog } = require('../services/skillService');
|
|
|
10
10
|
const { RISK_HEADLINE_MARKER } = require('../services/riskHeadlineService');
|
|
11
11
|
const { resolveClinicalContext } = require('../services/clinicalContextReadService');
|
|
12
12
|
const { isContingencyRuleBlockLive } = require('../flags/contingencyFlags');
|
|
13
|
+
const { snapshotClinicalFlags } = require('../flags/flagSnapshot');
|
|
13
14
|
const { TURN_KIND } = require('../context/turnContext');
|
|
14
15
|
const { extractPatientMessage } = require('../../helpers/patientMessageHelper');
|
|
15
16
|
const runtimeConfig = require('../../config/runtimeConfig');
|
|
@@ -102,7 +103,7 @@ class BaseLLMProvider {
|
|
|
102
103
|
presetId: resolvedPresetId, presetVersion: resolvedPresetVersion, modelConfig,
|
|
103
104
|
} = await composePrompt({ presetId, presetVersion, promptId: assistantId, variables: promptVariables });
|
|
104
105
|
|
|
105
|
-
const { toolIds, filtered, descriptions: toolDescriptions } = await resolveTools({
|
|
106
|
+
const { toolIds, filtered, descriptions: toolDescriptions, unregisteredToolIds = [] } = await resolveTools({
|
|
106
107
|
promptId: resolvedPromptId || assistantId, assistant, presetToolIds,
|
|
107
108
|
});
|
|
108
109
|
const requestedAdditionalToolIds = Array.isArray(prePromptResult?.additionalToolIds)
|
|
@@ -200,7 +201,12 @@ class BaseLLMProvider {
|
|
|
200
201
|
}
|
|
201
202
|
|
|
202
203
|
trace?.setPrompt({ resolvedPresetId, resolvedPresetVersion });
|
|
203
|
-
trace?.setSignals?.({
|
|
204
|
+
trace?.setSignals?.({
|
|
205
|
+
offeredToolIds: effectiveToolIds,
|
|
206
|
+
enabledSkills,
|
|
207
|
+
flags: snapshotClinicalFlags(),
|
|
208
|
+
...(unregisteredToolIds.length > 0 && { unregisteredToolIds }),
|
|
209
|
+
});
|
|
204
210
|
|
|
205
211
|
const { response, toolsExecuted, retries, usage } = await this._invokeModel({
|
|
206
212
|
instructions: instructions || devContent || '',
|
|
@@ -619,6 +625,11 @@ class BaseLLMProvider {
|
|
|
619
625
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
620
626
|
try {
|
|
621
627
|
logger.info('[runConversation] Attempt', { attempt, maxRetries, threadId, assistantId });
|
|
628
|
+
// A retry starts from the original input, so a skill body handed over in an earlier attempt is not in context.
|
|
629
|
+
if (attempt > 1) {
|
|
630
|
+
config?.assistant?.toolRuntimeContext?.activatedSkills?.clear();
|
|
631
|
+
config?.assistant?.toolRuntimeContext?.skillActivationIteration?.clear();
|
|
632
|
+
}
|
|
622
633
|
|
|
623
634
|
const result = await this._executeConversation(config);
|
|
624
635
|
// DeliverPatientMessage is the curated patient reply; prefer it over any model narration in the final response.
|
|
@@ -19,6 +19,7 @@ const { logger } = require('../../utils/logger');
|
|
|
19
19
|
const CONFIG_TABLE = 'registry';
|
|
20
20
|
const CACHE_TTL = 5 * 60 * 1000;
|
|
21
21
|
const CONFIG_CACHE_KEY = 'clinicalExtractorConfig';
|
|
22
|
+
const ENDING_STATUSES = new Set(['paused', 'completed']);
|
|
22
23
|
|
|
23
24
|
const extractorConfigCache = new MapCache({ maxSize: 1, ttl: CACHE_TTL });
|
|
24
25
|
|
|
@@ -37,6 +38,12 @@ function namesNothingSpecific(canonicalTerm, vagueWords) {
|
|
|
37
38
|
return tokens.length > 0 && tokens.every((token) => vagueWords.has(token));
|
|
38
39
|
}
|
|
39
40
|
|
|
41
|
+
function endsNamedLine(raw, fact) {
|
|
42
|
+
return fact.category === 'treatment'
|
|
43
|
+
&& Boolean(raw.treatment_line)
|
|
44
|
+
&& ENDING_STATUSES.has(fact.observedFields?.status);
|
|
45
|
+
}
|
|
46
|
+
|
|
40
47
|
async function loadPatientContext(code) {
|
|
41
48
|
try {
|
|
42
49
|
const { clinicalContext, contextAsk } = await getClinicalContext(code);
|
|
@@ -115,7 +122,8 @@ async function extractClinicalMentions({ text, turnId, code, provider, detectedT
|
|
|
115
122
|
unanchored += 1;
|
|
116
123
|
return;
|
|
117
124
|
}
|
|
118
|
-
if (!mention.provenance.needsConfirmation &&
|
|
125
|
+
if (!mention.provenance.needsConfirmation && !endsNamedLine(raw, mention.fact)
|
|
126
|
+
&& namesNothingSpecific(mention.fact.canonicalTerm, vagueWords)) {
|
|
119
127
|
forcedPending += 1;
|
|
120
128
|
mention = toClinicalMention(
|
|
121
129
|
{ ...raw, needs_confirmation: true, missing: raw.missing?.length ? raw.missing : ['name'] },
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
|
|
1
3
|
const { getEscalationPatternFloor } = require('../config/subAgentsConfig');
|
|
2
4
|
const { requireGatewayProvider } = require('../config/llmConfig');
|
|
3
5
|
const { readClinicalProfile } = require('../helpers/clinicalProfileHelper');
|
|
@@ -9,13 +11,17 @@ const { isPlainObject, safeParse } = require('../../utils/jsonUtils');
|
|
|
9
11
|
const { logger } = require('../../utils/logger');
|
|
10
12
|
const { redactDirectIdentifiers } = require('../../utils/sanitizerUtils');
|
|
11
13
|
const { withTimeout } = require('../../utils/timeoutUtils');
|
|
14
|
+
const { composePrompt } = require('./promptComposerService');
|
|
12
15
|
|
|
13
16
|
const FLOOR_LEVELS = ['asap', 'stat'];
|
|
14
17
|
const PATTERN_NAME = /^[a-z0-9_]{1,64}$/;
|
|
15
18
|
const UNLABELLED_PATTERN = 'sin_etiqueta';
|
|
16
19
|
const MAX_TEXT_LENGTH = 500;
|
|
20
|
+
const MAX_LEVEL_LENGTH = 32;
|
|
21
|
+
const MAX_PATTERN_LENGTH = 64;
|
|
22
|
+
const PROVENANCE_GRACE_MS = 500;
|
|
17
23
|
|
|
18
|
-
const degraded = (reason) => ({ floor: null, degraded: reason });
|
|
24
|
+
const degraded = (reason, check = null) => ({ floor: null, degraded: reason, check });
|
|
19
25
|
|
|
20
26
|
function normalizePattern(raw) {
|
|
21
27
|
const name = typeof raw === 'string' ? raw.trim().toLowerCase() : '';
|
|
@@ -24,6 +30,32 @@ function normalizePattern(raw) {
|
|
|
24
30
|
|
|
25
31
|
const clippedText = (value) => cleanText(value)?.slice(0, MAX_TEXT_LENGTH) || null;
|
|
26
32
|
|
|
33
|
+
// Which prompt judged this escalation. Prompt text is edited in Airtable without bumping the preset
|
|
34
|
+
// version, so the version alone cannot tell two prompts apart; the hash of the composed prompt can.
|
|
35
|
+
async function promptProvenance(presetId, turnId) {
|
|
36
|
+
try {
|
|
37
|
+
// Composed alongside runStructured's own compose, with the same inputs and caches: it names the prompt
|
|
38
|
+
// the model ran with, though an Airtable edit landing between the two reads could make them differ.
|
|
39
|
+
const composed = await composePrompt({ presetId });
|
|
40
|
+
// A missing preset composes to an empty prompt; a hash of nothing would read as real provenance.
|
|
41
|
+
const promptHash = composed.resolvedPrompt
|
|
42
|
+
? crypto.createHash('sha256').update(`${composed.resolvedPrompt}\n${JSON.stringify(composed.modelConfig || null)}`).digest('hex').slice(0, 12)
|
|
43
|
+
: null;
|
|
44
|
+
return { presetId, presetVersion: composed.presetVersion || null, promptHash };
|
|
45
|
+
} catch (error) {
|
|
46
|
+
logger.warn('[clinicalPatternFloor] The prompt provenance could not be read', { turnId, error: error.message });
|
|
47
|
+
return { presetId, presetVersion: null, promptHash: null };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// What the model answered, before validation, so a rejected or empty answer can be audited too.
|
|
52
|
+
const rawOutput = (parsed) => ({
|
|
53
|
+
minLevel: typeof parsed.min_level === 'string' ? parsed.min_level.slice(0, MAX_LEVEL_LENGTH) : null,
|
|
54
|
+
pattern: typeof parsed.pattern === 'string' ? parsed.pattern.slice(0, MAX_PATTERN_LENGTH) : null,
|
|
55
|
+
evidence: clippedText(parsed.evidence),
|
|
56
|
+
reason: clippedText(parsed.reason),
|
|
57
|
+
});
|
|
58
|
+
|
|
27
59
|
function buildPatternInput({ profile, details, justification, today }) {
|
|
28
60
|
const indicators = Array.isArray(details.indicators) ? details.indicators.filter((item) => typeof item === 'string' && item.trim()) : [];
|
|
29
61
|
const exposure = buildExposureBlock(profile);
|
|
@@ -55,32 +87,42 @@ async function evaluatePatternFloor({ code, turnId, details, justification, prov
|
|
|
55
87
|
logger.warn('[clinicalPatternFloor] The clinical profile is missing or unreadable; the check is skipped', { turnId, profile: reason });
|
|
56
88
|
return degraded(reason);
|
|
57
89
|
}
|
|
90
|
+
// Read alongside the model call so the audit never spends the floor's deadline.
|
|
91
|
+
const pendingProvenance = promptProvenance(presetId, turnId);
|
|
58
92
|
const result = await (provider || requireGatewayProvider()).runStructured({
|
|
59
93
|
presetId,
|
|
60
94
|
input: [{ role: 'user', content: buildPatternInput({ profile, details, justification, today: now.toISOString().slice(0, 10) }) }],
|
|
61
95
|
metadata: { subAgent: 'escalation-pattern-floor', turnId },
|
|
62
96
|
});
|
|
97
|
+
// Once the model has answered, provenance gets a short grace and is otherwise dropped: a slow
|
|
98
|
+
// Airtable read must never turn an answer the floor already has into a timeout.
|
|
99
|
+
const provenance = await withTimeout(pendingProvenance, PROVENANCE_GRACE_MS, 'pattern floor provenance').catch((error) => {
|
|
100
|
+
logger.warn('[clinicalPatternFloor] The prompt provenance was not ready; the answer is kept without it', { turnId, error: error.message });
|
|
101
|
+
return { presetId, presetVersion: null, promptHash: null };
|
|
102
|
+
});
|
|
63
103
|
if (result?.status && result.status !== 'completed') {
|
|
64
104
|
logger.warn('[clinicalPatternFloor] The pattern check did not complete', { turnId, status: result.status });
|
|
65
|
-
return degraded('incomplete');
|
|
105
|
+
return degraded('incomplete', { ...provenance, output: null });
|
|
66
106
|
}
|
|
67
107
|
const parsed = safeParse(readStructuredText(result), null);
|
|
68
108
|
if (!isPlainObject(parsed)) {
|
|
69
109
|
logger.warn('[clinicalPatternFloor] The pattern check did not return a JSON object', { turnId });
|
|
70
|
-
return degraded('malformed_output');
|
|
110
|
+
return degraded('malformed_output', { ...provenance, output: null });
|
|
71
111
|
}
|
|
112
|
+
const check = { ...provenance, output: rawOutput(parsed) };
|
|
72
113
|
if (parsed.min_level == null) {
|
|
73
|
-
if (parsed.pattern == null) return { floor: null, degraded: null };
|
|
114
|
+
if (parsed.pattern == null) return { floor: null, degraded: null, check };
|
|
74
115
|
logger.warn('[clinicalPatternFloor] The pattern check named a pattern without a level', { turnId, pattern: parsed.pattern });
|
|
75
|
-
return degraded('missing_level');
|
|
116
|
+
return degraded('missing_level', check);
|
|
76
117
|
}
|
|
77
118
|
if (!FLOOR_LEVELS.includes(parsed.min_level)) {
|
|
78
119
|
logger.warn('[clinicalPatternFloor] The pattern check returned an unsupported level', { turnId, minLevel: parsed.min_level, pattern: parsed.pattern });
|
|
79
|
-
return degraded('unsupported_level');
|
|
120
|
+
return degraded('unsupported_level', check);
|
|
80
121
|
}
|
|
81
122
|
return {
|
|
82
123
|
floor: { level: parsed.min_level, pattern: normalizePattern(parsed.pattern), evidence: clippedText(parsed.evidence), reason: clippedText(parsed.reason) },
|
|
83
124
|
degraded: null,
|
|
125
|
+
check,
|
|
84
126
|
};
|
|
85
127
|
}
|
|
86
128
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const { readClinicalProfile } = require('../helpers/clinicalProfileHelper');
|
|
2
|
+
const { buildExposureBlock } = require('../helpers/triageCaseBaseHelper');
|
|
3
|
+
const { readDispatchedEscalations } = require('../../fhir');
|
|
4
|
+
const { getEscalationContext } = require('../../services/escalationContextService');
|
|
5
|
+
const { redactDirectIdentifiers } = require('../../utils/sanitizerUtils');
|
|
6
|
+
const { assembleRiskProfile } = require('./riskAssessmentService');
|
|
7
|
+
const { escalationLookbackSince } = require('./turnRiskBriefService');
|
|
8
|
+
|
|
9
|
+
const CONTEXT_SECTIONS = ['agentContext', 'conversation', 'history'];
|
|
10
|
+
const MAX_INPUT_CHARS = 120000;
|
|
11
|
+
|
|
12
|
+
const optional = (promise) => promise.catch(() => undefined);
|
|
13
|
+
|
|
14
|
+
// The conversation ends with this turn's own reply; the consult must not read the decision it is judging.
|
|
15
|
+
function conversationBeforeReply(conversation) {
|
|
16
|
+
const messages = Array.isArray(conversation) ? [...conversation] : [];
|
|
17
|
+
while (messages.length && messages[messages.length - 1].role === 'assistant') messages.pop();
|
|
18
|
+
return messages;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function clinicalProfile(profile) {
|
|
22
|
+
if (!profile) return undefined;
|
|
23
|
+
const { edad = null, sexo = null } = profile.demografia || {};
|
|
24
|
+
return { demografia: { edad, sexo }, datos_clinicos: profile.datos_clinicos ?? null };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const section = (title, value) => [`## ${title}`, value === undefined ? 'no disponible' : JSON.stringify(value), ''];
|
|
28
|
+
|
|
29
|
+
function fitConversation(head, conversation) {
|
|
30
|
+
const messages = [...conversation];
|
|
31
|
+
let text = section('Conversación', messages).join('\n');
|
|
32
|
+
while (messages.length && head.length + text.length > MAX_INPUT_CHARS) {
|
|
33
|
+
messages.shift();
|
|
34
|
+
text = section('Conversación', messages).join('\n');
|
|
35
|
+
}
|
|
36
|
+
return { text, truncated: messages.length < conversation.length };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function buildConsultContext({ code, turnStart, patientMessage = null, mentions = [] }) {
|
|
40
|
+
const asOf = new Date(turnStart);
|
|
41
|
+
const [context, profile, risk, escalations] = await Promise.all([
|
|
42
|
+
getEscalationContext({ code, sections: CONTEXT_SECTIONS }),
|
|
43
|
+
readClinicalProfile(code),
|
|
44
|
+
optional(assembleRiskProfile(code)),
|
|
45
|
+
optional(readDispatchedEscalations({ patientId: code, since: escalationLookbackSince(asOf.getTime()) })),
|
|
46
|
+
]);
|
|
47
|
+
const exposure = buildExposureBlock(profile);
|
|
48
|
+
const degradedSections = [...context.degradedSections];
|
|
49
|
+
if (!profile) degradedSections.push('clinicalProfile');
|
|
50
|
+
if (risk === undefined) degradedSections.push('risk');
|
|
51
|
+
if (escalations === undefined) degradedSections.push('escalations');
|
|
52
|
+
|
|
53
|
+
const head = [
|
|
54
|
+
`Fecha de hoy: ${asOf.toISOString().slice(0, 10)}`,
|
|
55
|
+
'',
|
|
56
|
+
'## Mensaje del paciente en este turno',
|
|
57
|
+
patientMessage || 'no capturado',
|
|
58
|
+
'',
|
|
59
|
+
...section('Menciones clínicas extraídas en este turno', mentions),
|
|
60
|
+
...section('Contexto clínico que ve la enfermera', context.agentContext ?? undefined),
|
|
61
|
+
'## Exposición antineoplásica y medicación documentadas',
|
|
62
|
+
exposure || 'no documentada',
|
|
63
|
+
'',
|
|
64
|
+
...section('Perfil clínico', clinicalProfile(profile)),
|
|
65
|
+
...section('Perfil de riesgo', risk?.profile ?? undefined),
|
|
66
|
+
...section('Escalamientos de los últimos 14 días', escalations?.filter((event) => Date.parse(event.escalatedAt) < asOf.getTime())),
|
|
67
|
+
...section('Historia clínica reciente', context.history ?? undefined),
|
|
68
|
+
].join('\n');
|
|
69
|
+
const conversation = fitConversation(head, conversationBeforeReply(context.conversation));
|
|
70
|
+
if (conversation.truncated) degradedSections.push('conversation_truncated');
|
|
71
|
+
const input = `${head}\n${conversation.text}`;
|
|
72
|
+
|
|
73
|
+
return { input: redactDirectIdentifiers(input), degradedSections, hasCoreContext: Boolean(context.agentContext || profile) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { buildConsultContext };
|
|
@@ -62,7 +62,6 @@ const CONTACT_SENT = {
|
|
|
62
62
|
const RECENT_OUTBOUND_HOURS = 24;
|
|
63
63
|
const COLLIDING_REMINDER_MINUTES = 24 * 60;
|
|
64
64
|
const INACTIVITY_DAYS = 7;
|
|
65
|
-
const MAX_SILENCE_DAYS = 30;
|
|
66
65
|
const TRIAGE_PROTECTION_START_MINUTE = (7 * 60) + 30;
|
|
67
66
|
const TRIAGE_PROTECTION_END_MINUTE = 11 * 60;
|
|
68
67
|
|
|
@@ -270,13 +269,11 @@ async function rejectionReason(candidate, {
|
|
|
270
269
|
if (!await mondayTriageStillPending(candidate)) return 'triage_no_longer_pending';
|
|
271
270
|
} else if (cadence === 'daily') {
|
|
272
271
|
const inactiveSince = moment.tz(now, DEFAULT_TIMEZONE).subtract(INACTIVITY_DAYS, 'days').toDate();
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
{
|
|
276
|
-
{ select: { createdAt: 1 }, sort: { createdAt: -1 }, limit: 1 },
|
|
272
|
+
const recentInbound = await getMessages(
|
|
273
|
+
{ numero: candidate.code, from_me: false, createdAt: { $gte: inactiveSince } },
|
|
274
|
+
{ sort: { createdAt: -1 }, limit: 1 },
|
|
277
275
|
);
|
|
278
|
-
if (
|
|
279
|
-
if (new Date(lastInbound.createdAt) >= inactiveSince) return 'patient_active_within_inactivity_window';
|
|
276
|
+
if (recentInbound?.length) return 'patient_active_within_inactivity_window';
|
|
280
277
|
|
|
281
278
|
const lastTriageAt = candidate?.brief?.lastTriageAt;
|
|
282
279
|
if (lastTriageAt && moment.tz(lastTriageAt, DEFAULT_TIMEZONE).isSameOrAfter(inactiveSince)) {
|
|
@@ -191,6 +191,7 @@ async function fetchToolsByRecordIds(recordIds) {
|
|
|
191
191
|
return tools;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
+
const BLANK_TOOL_ID = '(blank tool_id)';
|
|
194
195
|
const warnedUnlinked = new Set();
|
|
195
196
|
|
|
196
197
|
function warnUnlinkedRegisteredTools(linkedToolIds, promptId) {
|
|
@@ -228,6 +229,7 @@ async function resolveTools({ promptId, assistant, presetToolIds = null, status
|
|
|
228
229
|
const activeTools = mappedTools.filter(t => !t.status || t.status === 'prod' || (!isProd && t.status === 'dev'));
|
|
229
230
|
|
|
230
231
|
const validToolIds = [];
|
|
232
|
+
const unregisteredToolIds = [];
|
|
231
233
|
const descriptions = {};
|
|
232
234
|
|
|
233
235
|
for (const tool of activeTools) {
|
|
@@ -237,6 +239,7 @@ async function resolveTools({ promptId, assistant, presetToolIds = null, status
|
|
|
237
239
|
// An Airtable column retyped away from long text would otherwise reach the model as [object Object].
|
|
238
240
|
if (typeof tool.description === 'string' && tool.description) descriptions[id] = tool.description;
|
|
239
241
|
} else {
|
|
242
|
+
unregisteredToolIds.push(id || BLANK_TOOL_ID);
|
|
240
243
|
logger.error('[promptComposer] Tool mapped in Airtable but not registered in code', { toolId: id, promptId });
|
|
241
244
|
}
|
|
242
245
|
}
|
|
@@ -252,7 +255,7 @@ async function resolveTools({ promptId, assistant, presetToolIds = null, status
|
|
|
252
255
|
|
|
253
256
|
logger.debug('[promptComposer] Tools resolved', { promptId, mapped: activeTools.length, valid: validToolIds.length, filtered: true });
|
|
254
257
|
|
|
255
|
-
return { toolIds: validToolIds, filtered: true, descriptions };
|
|
258
|
+
return { toolIds: validToolIds, filtered: true, descriptions, unregisteredToolIds };
|
|
256
259
|
}
|
|
257
260
|
|
|
258
261
|
function clearCache() {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
const { requireGatewayProvider } = require('../config/llmConfig');
|
|
2
2
|
const { getSafetyFlagPatterns } = require('../config/divergenceConfig');
|
|
3
|
-
const { isPurelyConversational } = require('../helpers/clinicalWorkflowGuard');
|
|
3
|
+
const { ROUTING_SKILL, isPurelyConversational } = require('../helpers/clinicalWorkflowGuard');
|
|
4
4
|
const { detectSafetyFlags } = require('../helpers/safetyFlagsHelper');
|
|
5
5
|
const { projectClinicalFacts, renderClinicalIntakeSummary } = require('../helpers/clinicalFactsHelper');
|
|
6
|
+
const { attachRequiredSkill } = require('../helpers/skillActivationHelper');
|
|
6
7
|
const { extractClinicalMentions, getConversationalTokens } = require('../services/clinicalExtractionService');
|
|
7
8
|
const { recordClinicalIntake } = require('../services/clinicalAirtableService');
|
|
8
9
|
const { getCtcaeCatalog } = require('../stores/CtcaeCatalog');
|
|
@@ -40,7 +41,7 @@ const MAX_CONTEXT_TURNS = 6;
|
|
|
40
41
|
|
|
41
42
|
const definition = {
|
|
42
43
|
name: 'extractClinicalInfo',
|
|
43
|
-
description: '**Does:** Extracts ALL structured clinical facts from the current patient message — symptoms, labs, medications, diagnoses, appointments, treatments — and returns them as a discriminated-union `clinical_facts[]` list keyed by `kind`. Call ONCE per turn with ALL detected tags in a single call — one call covers every category.\n\n**Required inputs:** `raw_message` (the patient\'s current turn text, VERBATIM — the full contents of `<patient_message>`, every line of it when the turn carries more than one), `detected_tags` (list, at least one of: `symptom`, `indication`, `treatment`, `adherence`, `medication`, `appointment`, `emotion`, `lab_value`, `diagnosis`, `allergy`, `comorbidity`, `performance_status`; empty only for a purely conversational message or when declaring `non_clinical_request`), `intake_source` (`self` | `caregiver` | `team_relay`).\n\n**When to call:** at the start of any turn where the patient message mentions, explicitly or implicitly, any of the 12 supported categories. The result drives every downstream router-side read (landscape / history / risk / patterns).\n\n**When NOT to call:**\n- When the patient SHARED raw numeric results / lab values / files — use `reportResultsReceived` instead (it both logs the share AND emits FHIR Observations directly).\n- More than once per turn — a single call covers every category.\n\n**Returns:** `data.clinical_facts[]` — a discriminated-union list where each record has `kind` (observation | medication | condition | procedure | appointment | allergy) plus common fields `{id, code, effective, notes}` (null/default fields are OMITTED — e.g. `interpretation` appears only when non-default) and kind-specific fields:\n- `kind=observation`: `code_ctcae`, `value`, `grade_scale`, `episode_id`, `trend`. Read `grade_scale` to anchor the grade assigned in `recordClinicalImpression`. It is present whenever the term has a curated CTCAE ladder, and ABSENT when it does not — an absent `grade_scale` means there is no rubric for this term, not that one was withheld. An absent `grade_scale` means the term has no curated rubric, not that one was withheld — so there is nothing to grade against from memory. Call `lookupCtcaeEntry` when you suspect the term itself is wrong; otherwise say where the grade came from in `grade_estimate.source`.\n- `kind=medication`: `adherence_status` (`started` | `ongoing` | `non_adherent` | …).\n- `kind=condition`: `clinical_status` (`active` | `resolved` | …) — patient self-report of an existing diagnosis (distinct from `openCondition` which commits a new agent-tracked case).\n- `kind=procedure`: patient-reported procedure done elsewhere (distinct from `recordIntervention` which is agent-authored); `venue`/`outcome` may be null in the current extraction phase.\n- `kind=appointment`: `scheduled_for`, `purpose` (may be null in the current extraction phase).\nThe system writes the matching FHIR resource (Observation / MedicationStatement / Condition / Procedure / Appointment) + `Provenance` per fact on the audit lane; `allergy`, `comorbidity`, and `performance_status` are captured in a dedicated aggregate, not as a per-fact resource.\n\n**When the result asks you to confirm:** `data.pending_confirmation[]` means the patient reported a REAL clinical event but named it only generically («me suspendieron el tratamiento»), so it was deliberately NOT recorded. Each entry carries `{term, category, missing, quote}`; the list holds EVERY fact withheld this turn, but ask about the FIRST one only. Ask the patient for exactly what its `missing` names, in ONE short natural question, and never state or imply that anything was recorded. When they answer, call `extractClinicalInfo` again on their reply and it will be recorded then. `data.context_gaps[]` — only ever sent when there is nothing to confirm — names ONE stored variable that is stale or incomplete; raise it only if the conversation allows it naturally. Both lists are empty whenever the turn carries a safety flag: never ask housekeeping questions during a safety event.\n\n**Side effects:** System emits a FHIR resource + `Provenance` per fact on the audit lane, except `allergy`/`comorbidity`/`performance_status` (aggregated). Facts listed in `pending_confirmation` are NOT
|
|
44
|
+
description: '**Does:** Extracts ALL structured clinical facts from the current patient message — symptoms, labs, medications, diagnoses, appointments, treatments — and returns them as a discriminated-union `clinical_facts[]` list keyed by `kind`. Call ONCE per turn with ALL detected tags in a single call — one call covers every category.\n\n**Required inputs:** `raw_message` (the patient\'s current turn text, VERBATIM — the full contents of `<patient_message>`, every line of it when the turn carries more than one), `detected_tags` (list, at least one of: `symptom`, `indication`, `treatment`, `adherence`, `medication`, `appointment`, `emotion`, `lab_value`, `diagnosis`, `allergy`, `comorbidity`, `performance_status`; empty only for a purely conversational message or when declaring `non_clinical_request`), `intake_source` (`self` | `caregiver` | `team_relay`).\n\n**When to call:** at the start of any turn where the patient message mentions, explicitly or implicitly, any of the 12 supported categories. The result drives every downstream router-side read (landscape / history / risk / patterns).\n\n**When NOT to call:**\n- When the patient SHARED raw numeric results / lab values / files — use `reportResultsReceived` instead (it both logs the share AND emits FHIR Observations directly).\n- More than once per turn — a single call covers every category.\n\n**Returns:** `data.clinical_facts[]` — a discriminated-union list where each record has `kind` (observation | medication | condition | procedure | appointment | allergy) plus common fields `{id, code, effective, notes}` (null/default fields are OMITTED — e.g. `interpretation` appears only when non-default) and kind-specific fields:\n- `kind=observation`: `code_ctcae`, `value`, `grade_scale`, `episode_id`, `trend`. Read `grade_scale` to anchor the grade assigned in `recordClinicalImpression`. It is present whenever the term has a curated CTCAE ladder, and ABSENT when it does not — an absent `grade_scale` means there is no rubric for this term, not that one was withheld. An absent `grade_scale` means the term has no curated rubric, not that one was withheld — so there is nothing to grade against from memory. Call `lookupCtcaeEntry` when you suspect the term itself is wrong; otherwise say where the grade came from in `grade_estimate.source`.\n- `kind=medication`: `adherence_status` (`started` | `ongoing` | `non_adherent` | …).\n- `kind=condition`: `clinical_status` (`active` | `resolved` | …) — patient self-report of an existing diagnosis (distinct from `openCondition` which commits a new agent-tracked case).\n- `kind=procedure`: patient-reported procedure done elsewhere (distinct from `recordIntervention` which is agent-authored); `venue`/`outcome` may be null in the current extraction phase.\n- `kind=appointment`: `scheduled_for`, `purpose` (may be null in the current extraction phase).\nThe system writes the matching FHIR resource (Observation / MedicationStatement / Condition / Procedure / Appointment) + `Provenance` per fact on the audit lane; `allergy`, `comorbidity`, and `performance_status` are captured in a dedicated aggregate, not as a per-fact resource.\n\n`data.activated_skills[]` — present when clinical facts were extracted and `clinical_routing` is enabled but not yet loaded this turn: its `body` is the routing protocol. Read it before `submitRoutingDecision`; no `Skill` call needed.\n\n**When the result asks you to confirm:** `data.pending_confirmation[]` means the patient reported a REAL clinical event but named it only generically («me suspendieron el tratamiento»), so it was deliberately NOT recorded. Each entry carries `{term, category, missing, quote}`; the list holds EVERY fact withheld this turn, but ask about the FIRST one only. Ask the patient for exactly what its `missing` names, in ONE short natural question, and never state or imply that anything was recorded. When they answer, call `extractClinicalInfo` again on their reply and it will be recorded then. `data.context_gaps[]` — only ever sent when there is nothing to confirm — names ONE stored variable that is stale or incomplete; raise it only if the conversation allows it naturally. Both lists are empty whenever the turn carries a safety flag: never ask housekeeping questions during a safety event.\n\n**Side effects:** System emits a FHIR resource + `Provenance` per fact on the audit lane, except `allergy`/`comorbidity`/`performance_status` (aggregated). Facts listed in `pending_confirmation` are NOT recorded as facts — they never reach the clinical intake or the patient\'s chart; FHIR keeps them only as unconfirmed drafts until the patient confirms them.',
|
|
44
45
|
strict: true,
|
|
45
46
|
parameters: {
|
|
46
47
|
type: 'object',
|
|
@@ -265,6 +266,10 @@ async function handler(args, context = {}) {
|
|
|
265
266
|
const gradeScales = {};
|
|
266
267
|
for (const term of terms) gradeScales[term] = catalog.getGradeScale(term);
|
|
267
268
|
const envelope = projectClinicalFacts({ clinicalMentions, safetyFlags, gradeScales, askAgenda });
|
|
269
|
+
const routingSkill = clinicalMentions.length || pendingMentions.length
|
|
270
|
+
? await attachRequiredSkill(runtime, ROUTING_SKILL)
|
|
271
|
+
: null;
|
|
272
|
+
if (routingSkill) envelope.data.activated_skills = [routingSkill];
|
|
268
273
|
return JSON.stringify(envelope);
|
|
269
274
|
}
|
|
270
275
|
|
|
@@ -349,6 +349,7 @@ async function handler(args, context = {}) {
|
|
|
349
349
|
...(patternFloor ? { clinicalPatternFloor: { ...patternFloor, raised: raisedByPattern } } : {}),
|
|
350
350
|
...(gateCheck?.degraded ? { triageGateFloorDegraded: gateCheck.degraded } : {}),
|
|
351
351
|
...(patternCheck?.degraded ? { clinicalPatternFloorDegraded: patternCheck.degraded } : {}),
|
|
352
|
+
...(patternCheck?.check ? { clinicalPatternFloorCheck: patternCheck.check } : {}),
|
|
352
353
|
},
|
|
353
354
|
});
|
|
354
355
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
const { MEDICATION_NAMING_RULE } = require('../helpers/medicationNamingRule');
|
|
2
|
+
const { markSkillActivated } = require('../helpers/skillActivationHelper');
|
|
2
3
|
const { loadSkillBody } = require('../services/skillService');
|
|
3
4
|
|
|
4
5
|
const definition = {
|
|
5
6
|
name: 'Skill',
|
|
6
|
-
description: '**Does:** Activates an enabled skill by name and returns its `SKILL.md` body inline; the body then governs the rest of the turn.\n\n**Required inputs:** `skill` (enabled skill name, no leading slash, e.g. `emergency`), `args` (context string the skill body can interpret — pass `null` when there is no additional context).\n\n**When to call:** the turn matches a protocol the operator has enabled for this run (e.g. an emergency/red-flag turn) and you need its full body to proceed correctly.\n\n**When NOT to call:** the skill is not enabled for this run; a plain reply covers the turn without a protocol.\n\n**Returns:** `activated_skills[]` — `{name, version, body}`; read `body` and follow it verbatim. A skill not enabled for this run returns the enabled skill names, and an unknown name returns the available skill names, in the error payload for self-correction. Read-only.',
|
|
7
|
+
description: '**Does:** Activates an enabled skill by name and returns its `SKILL.md` body inline; the body then governs the rest of the turn.\n\n**Required inputs:** `skill` (enabled skill name, no leading slash, e.g. `emergency`), `args` (context string the skill body can interpret — pass `null` when there is no additional context).\n\n**When to call:** the turn matches a protocol the operator has enabled for this run (e.g. an emergency/red-flag turn) and you need its full body to proceed correctly.\n\n**When NOT to call:** the skill is not enabled for this run; a plain reply covers the turn without a protocol.\n\n**Returns:** `activated_skills[]` — `{name, version, body}`; read `body` and follow it verbatim. A skill already returned this turn (by `Skill` or inside another tool\'s `activated_skills`) returns `already_active` instead of the body again. A skill not enabled for this run returns the enabled skill names, and an unknown name returns the available skill names, in the error payload for self-correction. Read-only.',
|
|
7
8
|
strict: true,
|
|
8
9
|
replayScope: 'turn',
|
|
9
10
|
parameters: {
|
|
@@ -46,6 +47,14 @@ async function handler(args, context) {
|
|
|
46
47
|
});
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
const runtime = context?.toolRuntimeContext;
|
|
51
|
+
if (runtime?.activatedSkills?.has(name)) {
|
|
52
|
+
return JSON.stringify({
|
|
53
|
+
success: true,
|
|
54
|
+
data: { already_active: name, note: `The ${name} body was already returned to you in this turn; follow it from there.` },
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
49
58
|
const { skill, availableSkills } = await loadSkillBody(name);
|
|
50
59
|
if (!skill) {
|
|
51
60
|
return JSON.stringify({
|
|
@@ -58,11 +67,7 @@ async function handler(args, context) {
|
|
|
58
67
|
const fragment = { name: skill.name, version: skill.version, body: skill.body };
|
|
59
68
|
if (args?.args) fragment.args_received = args.args;
|
|
60
69
|
|
|
61
|
-
|
|
62
|
-
runtime?.activatedSkills?.add(skill.name);
|
|
63
|
-
if (runtime?.skillActivationIteration && !runtime.skillActivationIteration.has(skill.name)) {
|
|
64
|
-
runtime.skillActivationIteration.set(skill.name, runtime.currentIteration ?? null);
|
|
65
|
-
}
|
|
70
|
+
markSkillActivated(runtime, skill.name);
|
|
66
71
|
|
|
67
72
|
return JSON.stringify({ success: true, data: { activated_skills: [fragment] } });
|
|
68
73
|
} catch (err) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
const crypto = require('node:crypto');
|
|
2
2
|
|
|
3
|
-
const { validateRoutingPrerequisites } = require('../helpers/clinicalWorkflowGuard');
|
|
3
|
+
const { clinicalIntakeRequiresRisk, hasAlternateTerminalCommit, validateRoutingPrerequisites } = require('../helpers/clinicalWorkflowGuard');
|
|
4
4
|
const { MEDICATION_NAMING_RULE } = require('../helpers/medicationNamingRule');
|
|
5
|
+
const { attachRequiredSkill } = require('../helpers/skillActivationHelper');
|
|
5
6
|
const { storeRoutingDecision, readRoutingDecisions, readSymptomCases } = require('../../fhir');
|
|
6
7
|
const { RoutingDecision } = require('../../shared/dtos/RoutingDecision');
|
|
7
8
|
const { foldAccents } = require('../../utils/formatUtils');
|
|
@@ -92,7 +93,7 @@ const _CASE_IMPLYING_ACTIONS = new Set([
|
|
|
92
93
|
|
|
93
94
|
const definition = {
|
|
94
95
|
name: 'submitRoutingDecision',
|
|
95
|
-
description: '**Does:** The terminal routing checkpoint — call ONCE per turn to commit the turn plan. Decomposes `trace` (the per-mention evaluation record) and `turn_plan` (the primary action + optional secondary/deferred) into a persisted RoutingDecision (assessment + disposition + provenance), sets the turn\'s outbound signals, and
|
|
96
|
+
description: '**Does:** The terminal routing checkpoint — call ONCE per turn to commit the turn plan. Decomposes `trace` (the per-mention evaluation record) and `turn_plan` (the primary action + optional secondary/deferred) into a persisted RoutingDecision (assessment + disposition + provenance), sets the turn\'s outbound signals, and returns the body of the skill that governs the reply.\n\n**Required inputs:** `trace.mentions_evaluated[]` — one entry per clinical mention: `clinical_reasoning`, `decision.action`, `case_id_opened_or_updated` — REQUIRED whenever `decision.action` acts on a symptom (`emergency_escalation`, `team_escalation`, `symptom_recommendation`, `symptom_probe`, `acknowledge_improvement_on_case`, `follow_up_on_open_case`, `revisit_neglected_case`, `educate_about_symptom`, `weekly_triage_recommendations`, `generate_recommendations`), null only for a genuinely caseless mention (a medication, an appointment, a mood), plus `safety_flag_disposition` (string or object) when `safety_flags_received` is non-empty. `turn_plan.primary` — exactly one `{action, reason, case_id?}`; `turn_plan.secondary[]` — up to 2 `{action, compose_as}`; `turn_plan.deferred[]` — optional `{item?, defer_reason}`.\n\n**Routing-skill prerequisite:** when `clinical_routing` is among your enabled skills and `Skill` is offered to you this turn, this checkpoint fails until that body has been read in an EARLIER iteration. Presets without either proceed straight to routing. `extractClinicalInfo` returns that body in its `activated_skills` when it extracts clinical mentions: read it there and commit here in a LATER response, not in the same one as the intake. If no body was returned, call `Skill` with `skill=clinical_routing` first.\n\n**Risk prerequisite:** after a clinical intake with extracted mentions, this checkpoint fails until the turn trace records a successful, processed risk read with status `final`, `no_signal`, or `not_generated`. Normally call `getRouterContextBundle` first and use its `risk.turn_focus`; if `sections_returned` lacks `risk` or reports `_diagnostics.risk_error`, call `getPatientRiskAssessment`, then retry this checkpoint.\n\n**Enum coercion:** primary/secondary `action` and secondary `compose_as` are folded (case/accent/separator) and synonym-mapped to canonical values; each repair is disclosed via `auto_repairs`. A genuinely unknown action is rejected.\n\n**Returns:** `decision_id`, `downstream_skill_to_invoke` (emergency | accompaniment | symptom_management | patient_education | null, derived from `primary.action`), `activated_skills` (that skill\'s body, when it is enabled and not yet loaded this turn — read it and compose the reply from it; no `Skill` call needed), and `auto_repairs` (only when non-empty). Fails hard with `risk_assessment_required_before_routing` when clinical intake ran without a processed risk disposition, with `decision_already_committed_for_turn` if called twice in one turn, when a symptom-acting mention carries no `case_id_opened_or_updated`, and when a named `case_id` does not exist for this patient. A clinical `DeliverPatientMessage` remains blocked until this commit succeeds.\n\n**Side effects:** persists the RoutingDecision; sets `branch_taken=ai_agent` on the turn trace.',
|
|
96
97
|
strict: false,
|
|
97
98
|
parameters: {
|
|
98
99
|
type: 'object',
|
|
@@ -431,6 +432,9 @@ async function handler(args = {}, context = {}) {
|
|
|
431
432
|
const downstream = derivedDownstreamSkill(plan.primaryAction);
|
|
432
433
|
runtime.trace.setSignals({ downstreamSkill: downstream || null });
|
|
433
434
|
const data = { decision_id: decision.decisionId, downstream_skill_to_invoke: downstream };
|
|
435
|
+
const deliveryNeedsSkill = clinicalIntakeRequiresRisk(runtime.trace) && !hasAlternateTerminalCommit(runtime.trace);
|
|
436
|
+
const downstreamSkill = deliveryNeedsSkill ? await attachRequiredSkill(runtime, downstream) : null;
|
|
437
|
+
if (downstreamSkill) data.activated_skills = [downstreamSkill];
|
|
434
438
|
if (repairs.length) data.auto_repairs = repairs;
|
|
435
439
|
return JSON.stringify({ success: true, data });
|
|
436
440
|
} catch (err) {
|
|
@@ -77,7 +77,11 @@ const unbatchedRoutes = {
|
|
|
77
77
|
function loadDefaultLlmMonitor() {
|
|
78
78
|
const { LlmMonitorService } = require('../clinical/monitoring/LlmMonitorService');
|
|
79
79
|
const { contingencyMonitorSpec } = require('../clinical/monitoring/contingencyMonitorSpec');
|
|
80
|
-
|
|
80
|
+
const { emergencyConsultMonitorSpec } = require('../clinical/monitoring/emergencyConsultMonitorSpec');
|
|
81
|
+
const { emergencyConsultMode } = require('../clinical/flags/emergencyConsultFlags');
|
|
82
|
+
const specs = [contingencyMonitorSpec];
|
|
83
|
+
if (emergencyConsultMode() !== 'off') specs.push(emergencyConsultMonitorSpec);
|
|
84
|
+
return { LlmMonitorService, specs };
|
|
81
85
|
}
|
|
82
86
|
|
|
83
87
|
function normalizeLlmMonitorConfig(value) {
|
|
@@ -88,8 +92,8 @@ function normalizeLlmMonitorConfig(value) {
|
|
|
88
92
|
function createDefaultLlmMonitor(options, loader = loadDefaultLlmMonitor) {
|
|
89
93
|
if (!options.enabled) return null;
|
|
90
94
|
try {
|
|
91
|
-
const { LlmMonitorService,
|
|
92
|
-
return new LlmMonitorService({ ...options, specs
|
|
95
|
+
const { LlmMonitorService, specs } = loader();
|
|
96
|
+
return new LlmMonitorService({ ...options, specs });
|
|
93
97
|
} catch (error) {
|
|
94
98
|
logger.warn('[llmMonitor] construction failed; patient messaging remains available', {
|
|
95
99
|
error: error?.message || String(error),
|
|
@@ -5,6 +5,7 @@ const { reference, patientReference, codeableConcept, extension, toInstant } = r
|
|
|
5
5
|
const { baseResource, problemListCategory, observationCategory } = require('../helpers/resourceHelper');
|
|
6
6
|
const { MENTION_SLUG } = require('../constants/projectionSlugs');
|
|
7
7
|
const { MENTION_EXT } = require('../constants/extensionSlugs');
|
|
8
|
+
const { PROCEDURE_STATUS, statusFrom } = require('./contextFactProjection');
|
|
8
9
|
|
|
9
10
|
const ACTIVITY_TEXT = 'clinical-mention-extraction';
|
|
10
11
|
const OBSERVED_FIELD_SYSTEM_KEY = 'observedField';
|
|
@@ -137,7 +138,8 @@ function adherenceFor(assertion) {
|
|
|
137
138
|
|
|
138
139
|
function procedureStatus(fact, provenance) {
|
|
139
140
|
if (fact.assertion.negated) return 'not-done';
|
|
140
|
-
|
|
141
|
+
if (provenance.needsConfirmation) return 'unknown';
|
|
142
|
+
return statusFrom(PROCEDURE_STATUS, fact.observedFields?.status, { absent: 'completed', unrecognised: 'completed' });
|
|
141
143
|
}
|
|
142
144
|
|
|
143
145
|
function procedureResource({ fact, provenance }) {
|
|
@@ -33,7 +33,7 @@ const PROCEDURE_STATUS = {
|
|
|
33
33
|
ended: 'completed', realizada: 'completed', realizado: 'completed',
|
|
34
34
|
stopped: 'stopped', suspendido: 'stopped', suspendida: 'stopped',
|
|
35
35
|
suspended: 'stopped', discontinued: 'stopped', not_active: 'stopped',
|
|
36
|
-
paused: 'on-hold',
|
|
36
|
+
paused: 'on-hold', started: 'in-progress',
|
|
37
37
|
finalizado: 'completed', end: 'completed', past: 'completed', previous: 'completed',
|
|
38
38
|
historical: 'completed', historico: 'completed',
|
|
39
39
|
cancelled: 'not-done', cancelado: 'not-done', cancelada: 'not-done',
|
|
@@ -363,6 +363,8 @@ function fromFhir(resources) {
|
|
|
363
363
|
|
|
364
364
|
module.exports = {
|
|
365
365
|
CONTEXT_RESOURCE_TYPES,
|
|
366
|
+
PROCEDURE_STATUS,
|
|
367
|
+
statusFrom,
|
|
366
368
|
toFhir,
|
|
367
369
|
fromFhir,
|
|
368
370
|
conceptKeyOf,
|
|
@@ -3,6 +3,8 @@ const moment = require('moment-timezone');
|
|
|
3
3
|
const { logger } = require('../utils/logger');
|
|
4
4
|
const { isPlainObject } = require('../utils/jsonUtils');
|
|
5
5
|
|
|
6
|
+
const { withoutAttachedSkillBodies } = require('../clinical/helpers/skillActivationHelper');
|
|
7
|
+
|
|
6
8
|
function formatMessage(reply) {
|
|
7
9
|
try {
|
|
8
10
|
if (!reply.createdAt) return [];
|
|
@@ -62,9 +64,8 @@ function getMessageTools(reply, { skipTools, replayArgs } = {}) {
|
|
|
62
64
|
const argsStr = JSON.stringify(args);
|
|
63
65
|
let outputStr;
|
|
64
66
|
if (t.tool_output != null && !keepArgs) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
: JSON.stringify(t.tool_output);
|
|
67
|
+
const output = withoutAttachedSkillBodies(t.tool_output);
|
|
68
|
+
outputStr = typeof output === 'string' ? output : JSON.stringify(output);
|
|
68
69
|
} else {
|
|
69
70
|
outputStr = JSON.stringify({ success: t.success !== false });
|
|
70
71
|
}
|
|
@@ -98,6 +98,7 @@ const draftSchema = new mongoose.Schema({
|
|
|
98
98
|
missingCharacterization: { type: [String], default: undefined },
|
|
99
99
|
suggestCall: { type: suggestCallSchema, default: undefined },
|
|
100
100
|
sectionsUnavailable: { type: [String], default: undefined },
|
|
101
|
+
snapshotAt: { type: Date, default: undefined },
|
|
101
102
|
}, { timestamps: { createdAt: true, updatedAt: false } });
|
|
102
103
|
|
|
103
104
|
const doctorHandoffSchema = new mongoose.Schema({
|
|
@@ -44,7 +44,7 @@ const AUDIT_SOURCE = 'escalation_review';
|
|
|
44
44
|
const RECENT_REVIEW_LIMIT = 20;
|
|
45
45
|
const TIMELINE_LIMIT = 500;
|
|
46
46
|
const MAX_REVIEW_UPDATES = 50;
|
|
47
|
-
const DRAFT_DEBOUNCE_MS =
|
|
47
|
+
const DRAFT_DEBOUNCE_MS = 20 * 1000;
|
|
48
48
|
const DRAFT_OPERATOR = 'bot:escalationDraft';
|
|
49
49
|
const DIRECT_CHAT_ATTEMPTS = 3;
|
|
50
50
|
const RECHECK_DELAY_MS = 60 * 1000;
|
|
@@ -507,6 +507,25 @@ async function recordReviewUpdate({ code, update, now = new Date(), settleInBack
|
|
|
507
507
|
}
|
|
508
508
|
}
|
|
509
509
|
|
|
510
|
+
const STALE_INTERRUPTION_NOTES = 'Cerrada automáticamente al llegar una alarma nueva; nadie verificó si la operación interrumpida se completó';
|
|
511
|
+
|
|
512
|
+
async function closeStaleInterruption(EscalationReview, patientCode, now) {
|
|
513
|
+
try {
|
|
514
|
+
const stale = await EscalationReview.findOne({
|
|
515
|
+
patientCode,
|
|
516
|
+
closedAt: null,
|
|
517
|
+
status: { $in: INTERRUPTED_STATUSES },
|
|
518
|
+
updatedAt: { $lte: new Date(now.getTime() - INTERRUPTION_GRACE_MS) },
|
|
519
|
+
}, { updatedAt: 1 }).lean();
|
|
520
|
+
if (!stale) return;
|
|
521
|
+
await closeEscalationReview({
|
|
522
|
+
id: String(stale._id), closedBy: BOT_OPERATOR, interruptionNotes: STALE_INTERRUPTION_NOTES, expectedUpdatedAt: stale.updatedAt,
|
|
523
|
+
});
|
|
524
|
+
} catch (error) {
|
|
525
|
+
logger[error?.statusCode === 409 ? 'warn' : 'error']('[EscalationReview] The interrupted review was not closed before the new alarm', { error: redactedError(error) });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
510
529
|
async function recordEscalationAlarm({ code, alarm, now = new Date() } = {}) {
|
|
511
530
|
try {
|
|
512
531
|
const patientCode = patientCodeOf(code);
|
|
@@ -518,6 +537,7 @@ async function recordEscalationAlarm({ code, alarm, now = new Date() } = {}) {
|
|
|
518
537
|
.lean();
|
|
519
538
|
|
|
520
539
|
await ensureActiveReviewIndex(EscalationReview);
|
|
540
|
+
await closeStaleInterruption(EscalationReview, patientCode, now);
|
|
521
541
|
const joined = await join();
|
|
522
542
|
if (joined) {
|
|
523
543
|
await scheduleDoctorAlert(joined, entry, now);
|
|
@@ -1078,6 +1098,7 @@ async function settleFollowUp(snapshot, { background = true } = {}) {
|
|
|
1078
1098
|
}
|
|
1079
1099
|
|
|
1080
1100
|
async function generateBotDraft({ id, lease = null } = {}) {
|
|
1101
|
+
const startedAt = new Date();
|
|
1081
1102
|
const review = await loadReview(reviewIdOf(id));
|
|
1082
1103
|
if (review.closedAt || !DOCUMENTING_STATUSES.includes(review.status)) return { review: null, retry: false };
|
|
1083
1104
|
const latest = (review.drafts ?? []).at(-1);
|
|
@@ -1107,6 +1128,7 @@ async function generateBotDraft({ id, lease = null } = {}) {
|
|
|
1107
1128
|
}
|
|
1108
1129
|
|
|
1109
1130
|
const entry = {
|
|
1131
|
+
snapshotAt: startedAt,
|
|
1110
1132
|
doctorText: withIdentity({ identity, review, doctorText: draft.doctorText }),
|
|
1111
1133
|
patientMessage: draft.patientMessage,
|
|
1112
1134
|
writtenBy: DRAFT_OPERATOR,
|
|
@@ -1117,13 +1139,16 @@ async function generateBotDraft({ id, lease = null } = {}) {
|
|
|
1117
1139
|
...(draft.suggestCall && { suggestCall: draft.suggestCall }),
|
|
1118
1140
|
...(draft.sectionsUnavailable?.length && { sectionsUnavailable: draft.sectionsUnavailable }),
|
|
1119
1141
|
};
|
|
1120
|
-
const
|
|
1121
|
-
|
|
1122
|
-
{
|
|
1142
|
+
const EscalationReview = getEscalationReview();
|
|
1143
|
+
const saved = await EscalationReview.findOneAndUpdate(
|
|
1144
|
+
{ _id: review._id, status: { $in: DOCUMENTING_STATUSES }, draftDueAt: { $lte: lease }, drafts: { $size: (review.drafts ?? []).length } },
|
|
1145
|
+
{ $push: { drafts: entry }, $set: { status: 'draft_ready', draftFailedAt: null }, $unset: { identityBlock: '' } },
|
|
1123
1146
|
{ new: true },
|
|
1124
1147
|
).lean();
|
|
1125
|
-
if (saved)
|
|
1126
|
-
|
|
1148
|
+
if (!saved) return { review: null, retry: false };
|
|
1149
|
+
const released = await EscalationReview.updateOne({ _id: review._id, draftDueAt: lease }, { $set: { draftDueAt: null } }, BOOKKEEPING);
|
|
1150
|
+
await audit(saved, DRAFT_OPERATOR, null, { action: 'draft_saved', draftNumber: saved.drafts.length });
|
|
1151
|
+
return { review: released.matchedCount ? { ...saved, draftDueAt: null } : saved, retry: false };
|
|
1127
1152
|
}
|
|
1128
1153
|
|
|
1129
1154
|
async function noteFactsContext(review) {
|
|
@@ -1341,7 +1366,7 @@ async function handOffToDoctor({ id, mode, physicianEmail = null, sentBy, reques
|
|
|
1341
1366
|
if (!approver) throw httpError(403, 'The physician is not an escalation review approver');
|
|
1342
1367
|
}
|
|
1343
1368
|
|
|
1344
|
-
const draftedAt = draft
|
|
1369
|
+
const draftedAt = draftTimeOf(draft) ? new Date(draftTimeOf(draft)).getTime() : null;
|
|
1345
1370
|
if (draftedAt && (review.alarms ?? []).some((alarm) => new Date(alarm.raisedAt).getTime() > draftedAt)) {
|
|
1346
1371
|
throw httpError(409, 'A new alarm arrived after this draft was written; review it and save a new draft before sending it to the doctor');
|
|
1347
1372
|
}
|
|
@@ -1437,7 +1462,7 @@ async function recordDoctorDecision({
|
|
|
1437
1462
|
// Truthiness, not != null: an empty textarea is a decline with nothing to say.
|
|
1438
1463
|
if (decision === 'needs_intervention' && finalText) approvedText = requiredText(finalText, 'finalText');
|
|
1439
1464
|
|
|
1440
|
-
if (APPROVALS.includes(decision) && arrivedAfter(review, draft
|
|
1465
|
+
if (APPROVALS.includes(decision) && arrivedAfter(review, draftTimeOf(draft))) {
|
|
1441
1466
|
if (review.status === 'awaiting_doctor') {
|
|
1442
1467
|
const redraft = draft.writtenBy === DRAFT_OPERATOR ? { draftDueAt: new Date(), draftAttempts: 0 } : {};
|
|
1443
1468
|
const returned = await updateReview(reviewId, ['awaiting_doctor'], { $set: { status: 'documenting', ...redraft } }, {
|
|
@@ -1552,6 +1577,8 @@ const latestUpdateGuard = (review) => {
|
|
|
1552
1577
|
return { updates: { $size: updates.length }, [`updates.${updates.length - 1}.turnId`]: updates.at(-1).turnId };
|
|
1553
1578
|
};
|
|
1554
1579
|
|
|
1580
|
+
const draftTimeOf = (draft) => draft.snapshotAt ?? draft.createdAt;
|
|
1581
|
+
|
|
1555
1582
|
const arrivedAfter = (review, since) => Boolean(since) && [
|
|
1556
1583
|
...(review.alarms ?? []).map((alarm) => alarm.raisedAt),
|
|
1557
1584
|
...(review.updates ?? []).map((update) => update.at),
|
|
@@ -6,7 +6,7 @@ const { getEscalationReview } = require('../models/escalationReviewModel');
|
|
|
6
6
|
const { BOOKKEEPING, DOCUMENTING_STATUSES, generateBotDraft } = require('./escalationReviewService');
|
|
7
7
|
|
|
8
8
|
const SWEEP_LIMIT = 1;
|
|
9
|
-
const DEFAULT_SWEEP_INTERVAL_MS =
|
|
9
|
+
const DEFAULT_SWEEP_INTERVAL_MS = 10 * 1000;
|
|
10
10
|
const RETRY_DELAY_MS = 5 * 60 * 1000;
|
|
11
11
|
const MAX_DRAFT_ATTEMPTS = 3;
|
|
12
12
|
const LEASE_MS = 10 * 60 * 1000;
|