@peopl-health/nexus 5.10.0-dev.1037 → 5.10.0-dev.1040
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.
|
@@ -10,6 +10,7 @@ const CACHE_TTL = 5 * 60 * 1000;
|
|
|
10
10
|
const CACHE_KEY = 'subAgentsConfig';
|
|
11
11
|
const EXTRACTOR_DEFAULTS = { presetId: '' };
|
|
12
12
|
const COMPOSER_DEFAULTS = { presetId: '' };
|
|
13
|
+
const PATTERN_CONSULT_DEFAULTS = { presetId: '' };
|
|
13
14
|
|
|
14
15
|
const cache = new MapCache({ maxSize: 1, ttl: CACHE_TTL });
|
|
15
16
|
|
|
@@ -29,7 +30,11 @@ async function load() {
|
|
|
29
30
|
if (byKey.COMPOSER && !isPlainObject(parsedComposer)) throw new Error('subAgents config COMPOSER is not a JSON object');
|
|
30
31
|
const composer = { ...COMPOSER_DEFAULTS, ...(parsedComposer || {}) };
|
|
31
32
|
|
|
32
|
-
const
|
|
33
|
+
const parsedPatternConsult = safeParse(byKey.PATTERN_CONSULT, null);
|
|
34
|
+
if (byKey.PATTERN_CONSULT && !isPlainObject(parsedPatternConsult)) throw new Error('subAgents config PATTERN_CONSULT is not a JSON object');
|
|
35
|
+
const patternConsult = { ...PATTERN_CONSULT_DEFAULTS, ...(parsedPatternConsult || {}) };
|
|
36
|
+
|
|
37
|
+
const config = { EXTRACTOR: extractor, COMPOSER: composer, PATTERN_CONSULT: patternConsult };
|
|
33
38
|
cache.set(CACHE_KEY, config);
|
|
34
39
|
return config;
|
|
35
40
|
}
|
|
@@ -41,4 +46,5 @@ async function get(key) {
|
|
|
41
46
|
module.exports = {
|
|
42
47
|
getExtractor: () => get('EXTRACTOR'),
|
|
43
48
|
getComposer: () => get('COMPOSER'),
|
|
49
|
+
getPatternConsult: () => get('PATTERN_CONSULT'),
|
|
44
50
|
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const mongoose = require('mongoose');
|
|
2
|
+
|
|
3
|
+
const { clinicalConnection } = require('../config/connection');
|
|
4
|
+
|
|
5
|
+
const patternConsultSchema = new mongoose.Schema({
|
|
6
|
+
consultationId: { type: String, required: true },
|
|
7
|
+
patientCode: { type: String, required: true },
|
|
8
|
+
turnId: { type: String, default: null },
|
|
9
|
+
concern: { type: String, default: null },
|
|
10
|
+
impressions: { type: Array, default: [] },
|
|
11
|
+
recommendedProbes: { type: [String], default: [] },
|
|
12
|
+
watchFor: { type: [String], default: [] },
|
|
13
|
+
escalationAdvice: { type: Object, default: null },
|
|
14
|
+
degraded: { type: Boolean, default: false },
|
|
15
|
+
degradedReasons: { type: [String], default: [] },
|
|
16
|
+
}, { timestamps: true });
|
|
17
|
+
|
|
18
|
+
patternConsultSchema.index({ consultationId: 1 }, { unique: true });
|
|
19
|
+
patternConsultSchema.index({ patientCode: 1, createdAt: -1 });
|
|
20
|
+
|
|
21
|
+
function patternConsultStore() {
|
|
22
|
+
const conn = clinicalConnection();
|
|
23
|
+
const model = conn.models.PatternConsult || conn.model('PatternConsult', patternConsultSchema, 'patternConsult');
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
get(consultationId) {
|
|
27
|
+
return model.findOne({ consultationId }).lean();
|
|
28
|
+
},
|
|
29
|
+
put(doc) {
|
|
30
|
+
return model.create(doc);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
module.exports = {
|
|
36
|
+
patternConsultStore,
|
|
37
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
const crypto = require('node:crypto');
|
|
2
|
+
|
|
3
|
+
const { getPatternConsult } = require('../config/subAgentsConfig');
|
|
4
|
+
const { patternConsultStore } = require('../models/patternConsultModel');
|
|
5
|
+
const { readOutputText } = require('../helpers/clinicalMentionHelper');
|
|
6
|
+
const { assembleLandscape } = require('../tools/getActiveSymptomLandscapeTool');
|
|
7
|
+
const { safeParse } = require('../../utils/jsonUtils');
|
|
8
|
+
const { logger } = require('../../utils/logger');
|
|
9
|
+
|
|
10
|
+
const PLAUSIBILITY = new Set(['low', 'moderate', 'high']);
|
|
11
|
+
|
|
12
|
+
function asStringArray(value) {
|
|
13
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function shapeImpressions(raw) {
|
|
17
|
+
if (!Array.isArray(raw)) return [];
|
|
18
|
+
return raw
|
|
19
|
+
.filter((item) => item && typeof item === 'object')
|
|
20
|
+
.map((item) => ({
|
|
21
|
+
label: typeof item.label === 'string' ? item.label : '',
|
|
22
|
+
plausibility: PLAUSIBILITY.has(item.plausibility) ? item.plausibility : 'low',
|
|
23
|
+
supporting: asStringArray(item.supporting),
|
|
24
|
+
against: asStringArray(item.against),
|
|
25
|
+
missing_discriminators: asStringArray(item.missing_discriminators),
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function shapeEscalation(raw) {
|
|
30
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
31
|
+
return {
|
|
32
|
+
indicated_now: raw.indicated_now === true,
|
|
33
|
+
if_then: typeof raw.if_then === 'string' ? raw.if_then : '',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function degradedEnvelope(consultationId, degradedReasons) {
|
|
38
|
+
return { consultationId, impressions: [], recommendedProbes: [], watchFor: [], escalationAdvice: null, degradedReasons, degraded: true };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildConsultInput(concern, bundle) {
|
|
42
|
+
return [
|
|
43
|
+
`S/B/A/R concern: ${concern}`,
|
|
44
|
+
`evidence_bundle: ${JSON.stringify(bundle)}`,
|
|
45
|
+
].join('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function persist(store, record) {
|
|
49
|
+
if (!store) return false;
|
|
50
|
+
try {
|
|
51
|
+
await store.put(record);
|
|
52
|
+
return true;
|
|
53
|
+
} catch (err) {
|
|
54
|
+
logger.warn('[patternConsult] persist failed', { turnId: record.turnId, error: err?.message });
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function requestPatternConsult({ concern, patientId, turnId, provider }) {
|
|
60
|
+
const consultationId = `pc_${turnId}_${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}`;
|
|
61
|
+
let store = null;
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
store = patternConsultStore();
|
|
65
|
+
const { presetId } = await getPatternConsult();
|
|
66
|
+
const bundle = await assembleLandscape(patientId);
|
|
67
|
+
|
|
68
|
+
if (!bundle.active_cases?.length) {
|
|
69
|
+
await persist(store, { consultationId, patientCode: patientId, turnId, concern, degraded: true, degradedReasons: ['no_open_cases_to_consult_on'] });
|
|
70
|
+
return degradedEnvelope(consultationId, ['no_open_cases_to_consult_on']);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!presetId || !provider?.runStructured) {
|
|
74
|
+
await persist(store, { consultationId, patientCode: patientId, turnId, concern, degraded: true, degradedReasons: ['consultant_unavailable'] });
|
|
75
|
+
return degradedEnvelope(consultationId, ['consultant_unavailable']);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const result = await provider.runStructured({
|
|
79
|
+
presetId,
|
|
80
|
+
input: [{ role: 'user', content: buildConsultInput(concern, bundle) }],
|
|
81
|
+
metadata: { subAgent: 'pattern-consult', turnId },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
if (result?.status && result.status !== 'completed') throw new Error(`pattern consult incomplete: status=${result.status}`);
|
|
85
|
+
|
|
86
|
+
const outputText = readOutputText(result);
|
|
87
|
+
const parsed = safeParse(outputText, null);
|
|
88
|
+
if (outputText && outputText.trim() && parsed === null) {
|
|
89
|
+
logger.warn('[patternConsult] model output did not parse as JSON; degraded', { turnId });
|
|
90
|
+
await persist(store, { consultationId, patientCode: patientId, turnId, concern, degraded: true, degradedReasons: ['malformed_model_output'] });
|
|
91
|
+
return degradedEnvelope(consultationId, ['malformed_model_output']);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const impressions = shapeImpressions(parsed?.impressions);
|
|
95
|
+
const recommendedProbes = asStringArray(parsed?.recommended_probes);
|
|
96
|
+
const watchFor = asStringArray(parsed?.watch_for);
|
|
97
|
+
const escalationAdvice = shapeEscalation(parsed?.escalation_advice);
|
|
98
|
+
|
|
99
|
+
const persisted = await persist(store, {
|
|
100
|
+
consultationId, patientCode: patientId, turnId, concern,
|
|
101
|
+
impressions, recommendedProbes, watchFor, escalationAdvice,
|
|
102
|
+
});
|
|
103
|
+
if (!persisted) return degradedEnvelope(consultationId, ['persist_failed']);
|
|
104
|
+
|
|
105
|
+
return { consultationId, impressions, recommendedProbes, watchFor, escalationAdvice, degradedReasons: [], degraded: false };
|
|
106
|
+
} catch (err) {
|
|
107
|
+
logger.error('[patternConsult] consult failed; degraded', { turnId, error: err?.message });
|
|
108
|
+
await persist(store, { consultationId, patientCode: patientId, turnId, concern, degraded: true, degradedReasons: ['consultant_unavailable'] });
|
|
109
|
+
return degradedEnvelope(consultationId, ['consultant_unavailable']);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
requestPatternConsult,
|
|
115
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
const { requireGatewayProvider } = require('../config/llmConfig');
|
|
2
|
+
const { requestPatternConsult } = require('../services/patternConsultService');
|
|
3
|
+
|
|
4
|
+
const definition = {
|
|
5
|
+
name: 'requestPatternConsult',
|
|
6
|
+
description: '**Does:** Consults a symptom-pattern specialist sub-agent that reviews the patient\'s active symptom landscape and returns RANKED differential impressions, each with supporting AND against evidence plus the discriminators still missing. Read-only and NON-TERMINAL — it informs your reasoning; it does not resolve the turn.\n\n**Required inputs:** `concern` (SBAR-style, 1-2 sentences framing what to reason about).\n\n**When to call:** when several concurrent symptoms, an ambiguous presentation, or a possible cluster warrant an expert second read before you route or probe.\n\n**Returns:** `data.impressions[]` (each: `label`, `plausibility` low|moderate|high, `supporting[]`, `against[]`, `missing_discriminators[]`), `data.recommended_probes[]`, `data.watch_for[]`, `data.escalation_advice` ({ indicated_now, if_then } or null), `data.degraded` (true when the consult could not run — treat as advisory-absent, not as a clear result) with `data.degraded_reasons[]`.',
|
|
7
|
+
strict: false,
|
|
8
|
+
parameters: {
|
|
9
|
+
type: 'object',
|
|
10
|
+
properties: {
|
|
11
|
+
concern: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
description: 'SBAR-style framing of the clinical question, 1-2 sentences: what the patient presents and what you want the specialist to reason about.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
required: ['concern'],
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
async function handler(args = {}, context = {}) {
|
|
21
|
+
try {
|
|
22
|
+
const runtime = context?.toolRuntimeContext || null;
|
|
23
|
+
const trace = runtime?.trace || null;
|
|
24
|
+
|
|
25
|
+
if (!runtime?.turnId || !runtime?.patientCode) {
|
|
26
|
+
return JSON.stringify({ success: false, error: 'requestPatternConsult requires an active turn context (turnId, patientCode).', data: {} });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const concern = (args?.concern || '').trim();
|
|
30
|
+
if (!concern) {
|
|
31
|
+
return JSON.stringify({ success: false, error: 'requestPatternConsult requires `concern`.', data: {} });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const r = await requestPatternConsult({
|
|
35
|
+
concern,
|
|
36
|
+
patientId: runtime.patientCode,
|
|
37
|
+
turnId: runtime.turnId,
|
|
38
|
+
provider: requireGatewayProvider(),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
trace?.setSignals?.({
|
|
42
|
+
patternConsult: {
|
|
43
|
+
consultationId: r.consultationId,
|
|
44
|
+
impressions: r.impressions.length,
|
|
45
|
+
escalationIndicated: !!r.escalationAdvice?.indicated_now,
|
|
46
|
+
degraded: r.degraded,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return JSON.stringify({
|
|
51
|
+
success: true,
|
|
52
|
+
data: {
|
|
53
|
+
consultation_id: r.consultationId,
|
|
54
|
+
impressions: r.impressions,
|
|
55
|
+
recommended_probes: r.recommendedProbes,
|
|
56
|
+
watch_for: r.watchFor,
|
|
57
|
+
escalation_advice: r.escalationAdvice,
|
|
58
|
+
degraded: r.degraded,
|
|
59
|
+
degraded_reasons: r.degradedReasons,
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
} catch (err) {
|
|
63
|
+
return JSON.stringify({ success: false, error: err?.message || 'requestPatternConsult failed', data: {} });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = {
|
|
68
|
+
definition,
|
|
69
|
+
handler,
|
|
70
|
+
};
|