@peopl-health/nexus 6.0.0-dev.466 → 6.0.0-dev.611

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.
Files changed (67) hide show
  1. package/lib/adapters/messageAdapters/TwilioMessageAdapter.js +17 -3
  2. package/lib/clinical/AssistantProcessor.js +3 -0
  3. package/lib/clinical/config/divergenceConfig.js +63 -0
  4. package/lib/clinical/helpers/divergenceHelper.js +100 -0
  5. package/lib/clinical/helpers/seedHelper.js +62 -0
  6. package/lib/clinical/index.js +7 -0
  7. package/lib/clinical/memory/DefaultMemoryManager.js +13 -1
  8. package/lib/clinical/models/divergenceModel.js +41 -0
  9. package/lib/clinical/models/turnTraceModel.js +11 -0
  10. package/lib/clinical/providers/BaseLLMProvider.js +16 -0
  11. package/lib/clinical/services/clinicalAgentService.js +18 -0
  12. package/lib/clinical/services/divergenceService.js +74 -0
  13. package/lib/clinical/services/promptComposerService.js +2 -1
  14. package/lib/clinical/services/seedService.js +21 -0
  15. package/lib/clinical/services/shadowMessageService.js +58 -0
  16. package/lib/clinical/services/shadowService.js +11 -2
  17. package/lib/clinical/stores/CtcaeCatalog.js +67 -0
  18. package/lib/controllers/conversationController.js +3 -1
  19. package/lib/controllers/templateController.js +27 -12
  20. package/lib/core/BatchingManager.js +2 -2
  21. package/lib/eval/EvalProvider.js +4 -3
  22. package/lib/fhir/config/catalogConfig.js +46 -0
  23. package/lib/fhir/config/connection.js +11 -0
  24. package/lib/fhir/config/fhirConfig.js +41 -0
  25. package/lib/fhir/constants/projectionSlugs.js +3 -0
  26. package/lib/fhir/helpers/elementHelper.js +33 -0
  27. package/lib/fhir/index.js +21 -0
  28. package/lib/fhir/models/envelopeModel.js +129 -0
  29. package/lib/fhir/models/historyModel.js +19 -0
  30. package/lib/fhir/projections/clinicalMentionProjection.js +65 -0
  31. package/lib/fhir/projections/registerProjectors.js +14 -0
  32. package/lib/fhir/resources/Appointment.js +28 -0
  33. package/lib/fhir/resources/Condition.js +34 -0
  34. package/lib/fhir/resources/MedicationStatement.js +42 -0
  35. package/lib/fhir/resources/Observation.js +79 -0
  36. package/lib/fhir/resources/Procedure.js +30 -0
  37. package/lib/fhir/resources/Provenance.js +28 -0
  38. package/lib/fhir/services/clinicalMentionService.js +18 -0
  39. package/lib/fhir/services/fhirService.js +31 -0
  40. package/lib/fhir/stores/fhirStore.js +187 -0
  41. package/lib/helpers/deliveryAttemptHelper.js +5 -13
  42. package/lib/helpers/projectorHelper.js +18 -0
  43. package/lib/helpers/twilioMediaHelper.js +25 -1
  44. package/lib/index.js +14 -0
  45. package/lib/services/airtableService.js +40 -14
  46. package/lib/shared/dtos/BaseDto.js +13 -0
  47. package/lib/shared/dtos/ClinicalFact.js +85 -0
  48. package/lib/shared/dtos/ClinicalMention.js +53 -0
  49. package/lib/shared/dtos/ClusterHistoryRecord.js +21 -0
  50. package/lib/shared/dtos/ClusterImpression.js +40 -0
  51. package/lib/shared/dtos/ContingencySafetyNet.js +47 -0
  52. package/lib/shared/dtos/DispatchedEscalation.js +44 -0
  53. package/lib/shared/dtos/Intervention.js +29 -0
  54. package/lib/shared/dtos/ManagedSymptom.js +75 -0
  55. package/lib/shared/dtos/MonitoringEpisode.js +25 -0
  56. package/lib/shared/dtos/PatientContext.js +22 -0
  57. package/lib/shared/dtos/PatientRiskAssessment.js +46 -0
  58. package/lib/shared/dtos/Reminder.js +32 -0
  59. package/lib/shared/dtos/RoutingDecision.js +68 -0
  60. package/lib/shared/dtos/SymptomEpisode.js +41 -0
  61. package/lib/shared/dtos/TreatmentEpisode.js +31 -0
  62. package/lib/utils/jsonUtils.js +34 -1
  63. package/lib/utils/vcardParser.js +41 -0
  64. package/package.json +7 -2
  65. package/lib/clinical/config/fhirConfig.js +0 -49
  66. package/lib/clinical/services/fhirService.js +0 -24
  67. /package/lib/{clinical → fhir}/helpers/fhirHelper.js +0 -0
@@ -1,6 +1,6 @@
1
1
  const { logger } = require('../../utils/logger');
2
2
 
3
- const { enrichMessageWithTwilioMedia } = require('../../helpers/twilioMediaHelper');
3
+ const { enrichMessageWithTwilioMedia, resolveContactFromTwilioMedia } = require('../../helpers/twilioMediaHelper');
4
4
 
5
5
  const { MessageAdapter } = require('../MessageAdapter');
6
6
 
@@ -8,9 +8,14 @@ class TwilioMessageAdapter extends MessageAdapter {
8
8
  async normalize(rawMessage) {
9
9
  const from = rawMessage.From || '';
10
10
  const interactive = this._extractInteractive(rawMessage);
11
- const media = await this._resolveMedia(rawMessage);
11
+ const contactBody = await this._resolveContact(rawMessage);
12
+ const media = contactBody ? null : await this._resolveMedia(rawMessage);
12
13
  const hasMedia = parseInt(rawMessage.NumMedia || '0', 10) > 0;
13
- const body = rawMessage.Body || this._extractInteractionText(interactive) || (hasMedia ? `[Media:${media?.mediaType || 'attachment'}]` : '');
14
+ const body = contactBody
15
+ ? [contactBody, rawMessage.Body].filter(Boolean).join('\n')
16
+ : (rawMessage.Body
17
+ || this._extractInteractionText(interactive)
18
+ || (hasMedia ? `[Media:${media?.mediaType || 'attachment'}]` : ''));
14
19
 
15
20
  return {
16
21
  messageId: rawMessage.MessageSid || null,
@@ -28,6 +33,15 @@ class TwilioMessageAdapter extends MessageAdapter {
28
33
  };
29
34
  }
30
35
 
36
+ async _resolveContact(rawMessage) {
37
+ try {
38
+ return await resolveContactFromTwilioMedia(rawMessage);
39
+ } catch (error) {
40
+ logger.error('[TwilioMessageAdapter] Error resolving Twilio contact message', error);
41
+ return null;
42
+ }
43
+ }
44
+
31
45
  async _resolveMedia(rawMessage) {
32
46
  const numMedia = parseInt(rawMessage.NumMedia || '0', 10);
33
47
  if (numMedia <= 0) return null;
@@ -4,6 +4,7 @@ const { getThread } = require('../helpers/threadHelper');
4
4
  const { runAssistantWithRetries } = require('./helpers/assistantHelper');
5
5
 
6
6
  const { forkShadowTurn } = require('./services/shadowService');
7
+ const { diverge } = require('./services/divergenceService');
7
8
  const { getAssistantById } = require('./services/assistantResolver');
8
9
 
9
10
  class AssistantProcessor {
@@ -30,7 +31,9 @@ class AssistantProcessor {
30
31
  message,
31
32
  shouldContinue: runOptions.shouldContinue,
32
33
  runId: runOptions.runId,
34
+ additionalInstructions: runOptions.additionalInstructions,
33
35
  executeLLM: this.executeLLM.bind(this),
36
+ onComplete: (shadow) => diverge({ shadow, runId: runOptions.runId }),
34
37
  });
35
38
  }
36
39
 
@@ -0,0 +1,63 @@
1
+ const { Config_ID } = require('../../config/airtableConfig');
2
+
3
+ const { getRecordByFilter } = require('../../services/airtableService');
4
+
5
+ const MapCache = require('../../utils/MapCache');
6
+ const { safeParse, safeParseArray, isPlainObject } = require('../../utils/jsonUtils');
7
+
8
+ const DIVERGENCE_CONFIG_TABLE = 'divergence';
9
+ const CACHE_TTL = 5 * 60 * 1000;
10
+ const CACHE_KEY = 'divergenceConfig';
11
+ const COMPARATOR_TYPES = ['scalar', 'set'];
12
+ const ORACLE_CONNECTION_DEFAULTS = { enabled: false, baseUrl: '', timeoutMs: null };
13
+ const STOCHASTIC_DEFAULTS = { enabled: false, presetId: '' };
14
+
15
+ const cache = new MapCache({ maxSize: 1, ttl: CACHE_TTL });
16
+
17
+ async function load() {
18
+ const cached = cache.get(CACHE_KEY);
19
+ if (cached) return cached;
20
+
21
+ const records = await getRecordByFilter(Config_ID, DIVERGENCE_CONFIG_TABLE, 'TRUE()');
22
+ if (!records) throw new Error('divergence config could not be read from the Config base');
23
+ const byKey = Object.fromEntries(records.map((r) => [r.config, r.value]));
24
+
25
+ if (!byKey.FIELDS) throw new Error('divergence config missing from Config base: FIELDS');
26
+ const fields = safeParseArray(byKey.FIELDS);
27
+ if (!fields) throw new Error('divergence config FIELDS is not a JSON array');
28
+ if (!fields.length) throw new Error('divergence config FIELDS is empty; nothing would be compared');
29
+
30
+ const unknown = fields.filter(({ type }) => !COMPARATOR_TYPES.includes(type));
31
+ if (unknown.length) throw new Error(`divergence config FIELDS has unknown types: ${unknown.map(({ field, type }) => `${field}:${type}`).join(', ')}`);
32
+
33
+ const misplaced = fields.filter(({ type, emergencyKinds }) => emergencyKinds && type !== 'scalar');
34
+ if (misplaced.length) throw new Error(`divergence config emergencyKinds only applies to scalar fields: ${misplaced.map(({ field }) => field).join(', ')}`);
35
+
36
+ const parsedOracle = safeParse(byKey.ORACLE_CONNECTION, null);
37
+ if (byKey.ORACLE_CONNECTION && !isPlainObject(parsedOracle)) throw new Error('divergence config ORACLE_CONNECTION is not a JSON object');
38
+ const oracleConnection = { ...ORACLE_CONNECTION_DEFAULTS, ...(parsedOracle || {}) };
39
+ if (oracleConnection.enabled) {
40
+ oracleConnection.timeoutMs = Number(oracleConnection.timeoutMs);
41
+ if (!Number.isFinite(oracleConnection.timeoutMs) || oracleConnection.timeoutMs <= 0) throw new Error('divergence config ORACLE_CONNECTION.timeoutMs is not a positive number');
42
+ }
43
+
44
+ const parsedStochastic = safeParse(byKey.STOCHASTIC, null);
45
+ if (byKey.STOCHASTIC && !isPlainObject(parsedStochastic)) throw new Error('divergence config STOCHASTIC is not a JSON object');
46
+ const stochastic = { ...STOCHASTIC_DEFAULTS, ...(parsedStochastic || {}) };
47
+ if (stochastic.enabled && !stochastic.presetId) throw new Error('divergence config STOCHASTIC.presetId is required when enabled');
48
+
49
+ const config = { FIELDS: fields, ORACLE_CONNECTION: oracleConnection, STOCHASTIC: stochastic };
50
+ cache.set(CACHE_KEY, config);
51
+ return config;
52
+ }
53
+
54
+ async function get(key) {
55
+ const config = await load();
56
+ return config[key];
57
+ }
58
+
59
+ module.exports = {
60
+ getFields: () => get('FIELDS'),
61
+ getOracleConnection: () => get('ORACLE_CONNECTION'),
62
+ getStochastic: () => get('STOCHASTIC'),
63
+ };
@@ -0,0 +1,100 @@
1
+ const { isDeepStrictEqual } = require('node:util');
2
+
3
+ const divergenceConfig = require('../config/divergenceConfig');
4
+ const { requireOpenAIProvider } = require('../config/llmConfig');
5
+
6
+ const VERDICT_SCHEMA = {
7
+ type: 'object',
8
+ properties: {
9
+ verdict: { type: 'string', enum: ['pass', 'review', 'fail'] },
10
+ rationale: { type: 'string' },
11
+ },
12
+ required: ['verdict', 'rationale'],
13
+ additionalProperties: false,
14
+ };
15
+
16
+ const ungraded = (rationale) => ({ verdict: 'review', rationale, graded: false });
17
+
18
+ function readVerdictText(result) {
19
+ if (result?.output_text) return result.output_text;
20
+ const output = Array.isArray(result?.output) ? result.output : [];
21
+ const message = output.find((item) => item.type === 'message');
22
+ const content = Array.isArray(message?.content) ? message.content : [];
23
+ const part = content.find((c) => typeof c?.text === 'string');
24
+ return part?.text || '';
25
+ }
26
+
27
+ const COMPARATORS = {
28
+ scalar: (expected, actual) => expected === actual,
29
+ set: (expected, actual) => isDeepStrictEqual(new Set(expected || []), new Set(actual || [])),
30
+ };
31
+
32
+ const OUTCOME_RULES = [
33
+ [({ deterministic }) => deterministic.divergences.some((d) => d.isEmergencyFlip), 'review'],
34
+ [({ stochastic }) => stochastic.verdict === 'fail', 'fail'],
35
+ [({ deterministic }) => !deterministic.hasPassed, 'diverged'],
36
+ [({ stochastic }) => stochastic.verdict === 'review', 'review'],
37
+ ];
38
+
39
+ function deterministicDivergence(expected = {}, actual = {}, fields) {
40
+ const divergences = fields
41
+ .filter(({ field, type }) => {
42
+ const compare = COMPARATORS[type];
43
+ if (!compare) throw new Error(`unknown divergence field type '${type}'`);
44
+ return !compare(expected?.[field], actual?.[field]);
45
+ })
46
+ .map(({ field, emergencyKinds = [] }) => ({
47
+ field,
48
+ expected: expected?.[field],
49
+ actual: actual?.[field],
50
+ isEmergencyFlip: emergencyKinds.includes(expected?.[field]) || emergencyKinds.includes(actual?.[field]),
51
+ }));
52
+ return { divergences, hasPassed: divergences.length === 0 };
53
+ }
54
+
55
+ async function stochasticDivergence(expectedMessage, actualMessage) {
56
+ const { enabled, presetId } = await divergenceConfig.getStochastic();
57
+ if (!enabled) return ungraded('stochastic message grading not enabled');
58
+
59
+ const expected = (expectedMessage || '').trim();
60
+ const actual = (actualMessage || '').trim();
61
+ if (!expected || !actual) return ungraded('message text missing on one side');
62
+
63
+ let provider;
64
+ try {
65
+ provider = requireOpenAIProvider();
66
+ } catch (error) {
67
+ return ungraded(`provider unavailable: ${error.message}`);
68
+ }
69
+
70
+ try {
71
+ const result = await provider.runStructured({
72
+ presetId,
73
+ input: [{ role: 'user', content: `ORIGINAL:\n${expected}\n\nREWRITTEN:\n${actual}` }],
74
+ text: { format: { type: 'json_schema', name: 'stochastic_verdict', schema: VERDICT_SCHEMA, strict: true } },
75
+ });
76
+ const parsed = JSON.parse(readVerdictText(result));
77
+ const verdict = ['pass', 'review', 'fail'].includes(parsed.verdict) ? parsed.verdict : 'review';
78
+ return { verdict, rationale: parsed.rationale || '', graded: true };
79
+ } catch (error) {
80
+ return ungraded(`grading error: ${error.message}`);
81
+ }
82
+ }
83
+
84
+ async function classify({ expected, actual, expectedMessage, actualMessage }) {
85
+ const fields = await divergenceConfig.getFields();
86
+ const deterministic = deterministicDivergence(expected, actual, fields);
87
+ const stochastic = await stochasticDivergence(expectedMessage, actualMessage);
88
+ const result = OUTCOME_RULES.find(([applies]) => applies({ deterministic, stochastic }))?.[1] || 'pass';
89
+ return {
90
+ deterministic,
91
+ stochastic,
92
+ result,
93
+ isEmergencyReview: deterministic.divergences.some((d) => d.isEmergencyFlip),
94
+ };
95
+ }
96
+
97
+ module.exports = {
98
+ deterministicDivergence,
99
+ classify,
100
+ };
@@ -0,0 +1,62 @@
1
+ const { cleanForJson, isPlainObject } = require('../../utils/jsonUtils');
2
+
3
+ const SEED_FORMATS = {
4
+ '.json': {
5
+ isValid: (c) => isPlainObject(c) && Object.values(c).every(isPlainObject),
6
+ hint: 'an object keyed by id whose values are plain objects',
7
+ },
8
+ '.jsonl': {
9
+ isValid: (c) => Array.isArray(c) && c.every(isPlainObject),
10
+ hint: 'a list of plain objects',
11
+ },
12
+ };
13
+
14
+ // clinical-agent file-store shapes:
15
+ // keyedFile → KeyedJSONFile (`{pk: doc}` written as `.json`)
16
+ // jsonlFile → JSONLFileStore (list-of-dicts written as `.jsonl`)
17
+ function keyedFile(records, primaryKey) {
18
+ const out = {};
19
+ for (const record of records) {
20
+ const doc = cleanForJson(record);
21
+ const key = doc[primaryKey];
22
+ if (key === undefined || key === null || key === '') {
23
+ throw new Error(`keyed store record is missing primary key '${primaryKey}'`);
24
+ }
25
+ const skey = String(key);
26
+ if (Object.prototype.hasOwnProperty.call(out, skey)) {
27
+ throw new Error(`keyed store has duplicate primary key '${primaryKey}' value '${skey}'`);
28
+ }
29
+ out[skey] = doc;
30
+ }
31
+ return out;
32
+ }
33
+
34
+ function jsonlFile(records) {
35
+ return records.map(cleanForJson);
36
+ }
37
+
38
+ function validateSeed(seed) {
39
+ const basenames = new Set();
40
+ for (const [name, content] of Object.entries(seed)) {
41
+ const basename = name.split('/').pop();
42
+ if (!basename || basename === '.' || basename === '..') {
43
+ throw new Error(`invalid seed file name: '${name}'`);
44
+ }
45
+ if (basenames.has(basename)) {
46
+ throw new Error(`duplicate seed file basename: '${basename}'`);
47
+ }
48
+ basenames.add(basename);
49
+
50
+ const ext = Object.keys(SEED_FORMATS).find((extension) => basename.endsWith(extension));
51
+ const format = ext ? SEED_FORMATS[ext] : null;
52
+ if (!format) throw new Error(`seed file must end in .json or .jsonl: '${name}'`);
53
+ if (!format.isValid(content)) throw new Error(`'${name}' must be ${format.hint}`);
54
+ }
55
+ return seed;
56
+ }
57
+
58
+ module.exports = {
59
+ keyedFile,
60
+ jsonlFile,
61
+ validateSeed,
62
+ };
@@ -23,7 +23,14 @@ const llmConfigModule = require('./config/llmConfig');
23
23
 
24
24
  const { ToolRuntimeContext } = require('./context/ToolRuntimeContext');
25
25
 
26
+ const { getCtcaeCatalog } = require('./stores/CtcaeCatalog');
27
+
28
+ async function initialize() {
29
+ await getCtcaeCatalog().initialize();
30
+ }
31
+
26
32
  module.exports = {
33
+ initialize,
27
34
  createLLMProvider,
28
35
  PROVIDER_VARIANTS,
29
36
  OpenAIResponsesProvider,
@@ -11,6 +11,7 @@ const runtimeConfig = require('../../config/runtimeConfig');
11
11
  const { MemoryManager } = require('./MemoryManager');
12
12
 
13
13
  const DEFAULT_MAX_HISTORICAL_MESSAGES = 50;
14
+ const ALLOWED_SYSTEM_ROLES = new Set(['system', 'developer']);
14
15
 
15
16
  class DefaultMemoryManager extends MemoryManager {
16
17
  constructor(options = {}) {
@@ -37,7 +38,7 @@ class DefaultMemoryManager extends MemoryManager {
37
38
  const toolItems = getMessageTools(msg);
38
39
  const contentItems = formatMessage(msg)
39
40
  .map(content => ({
40
- role: msg.origin === 'patient' ? 'user' : 'assistant',
41
+ role: this._resolveContextRole(msg),
41
42
  content: content,
42
43
  type: 'message'
43
44
  }))
@@ -52,6 +53,17 @@ class DefaultMemoryManager extends MemoryManager {
52
53
  }
53
54
  }
54
55
 
56
+ _resolveContextRole(msg) {
57
+ if (msg.origin === 'patient') return 'user';
58
+ if (msg.origin === 'system' && msg.raw?.role) {
59
+ const role = String(msg.raw.role).trim().toLowerCase();
60
+ if (ALLOWED_SYSTEM_ROLES.has(role)) return role;
61
+ logger.warn('[DefaultMemoryManager] Unexpected system message role, using fallback', { role: msg.raw.role });
62
+ return 'system';
63
+ }
64
+ return 'assistant';
65
+ }
66
+
55
67
  async processResponse(response, thread) {
56
68
  this._logActivity('Processing response', { threadCode: thread.code, responseId: response.id });
57
69
  try {
@@ -0,0 +1,41 @@
1
+ const mongoose = require('mongoose');
2
+
3
+ const { clinicalConnection } = require('../config/connection');
4
+
5
+ const divergenceSchema = new mongoose.Schema({
6
+ turnId: { type: String, required: true },
7
+ code: { type: String, required: true },
8
+ request: { type: Object, default: null },
9
+ response: { type: Object, default: null },
10
+ expectedMessage: { type: String, default: null },
11
+ actualMessage: { type: String, default: null },
12
+ result: { type: String, default: null },
13
+ isEmergencyReview: { type: Boolean, default: false },
14
+ deterministic: { type: Object, default: null },
15
+ stochastic: { type: Object, default: null },
16
+ expected: { type: Object, default: null },
17
+ actual: { type: Object, default: null },
18
+ }, { timestamps: true });
19
+
20
+ divergenceSchema.index({ code: 1, createdAt: -1 });
21
+ divergenceSchema.index({ result: 1, createdAt: -1 });
22
+ divergenceSchema.index({ turnId: 1 }, { unique: true });
23
+
24
+ divergenceSchema.statics.record = function record({ shadow, request, report }) {
25
+ return this.create({
26
+ turnId: shadow?.turnId || null,
27
+ code: shadow?.context?.patientCode || null,
28
+ request,
29
+ ...report,
30
+ });
31
+ };
32
+
33
+ function getDivergenceModel() {
34
+ const conn = clinicalConnection();
35
+ return conn.models.Divergence || conn.model('Divergence', divergenceSchema, 'divergence');
36
+ }
37
+
38
+ module.exports = {
39
+ getDivergenceModel,
40
+ divergenceSchema,
41
+ };
@@ -44,6 +44,17 @@ turnTraceSchema.methods.addStep = function addStep(step) { this.steps.push({ ...
44
44
  turnTraceSchema.methods.setStatus = function setStatus(status, abort = null) { this.status = status; this.abort = abort; };
45
45
  turnTraceSchema.methods.setTimings = function setTimings(timings) { this.timings = { ...this.timings, ...timings }; };
46
46
 
47
+ turnTraceSchema.methods.toDecision = function toDecision() {
48
+ const decision = this.safetyDecision || {};
49
+ const signals = this.signals || {};
50
+ return {
51
+ escalation_kind: decision.escalationKind || null,
52
+ outbound_purpose: signals.outboundPurpose || null,
53
+ branch_taken: signals.branchTaken || null,
54
+ red_flags_matched: decision.redFlagsMatched || [],
55
+ };
56
+ };
57
+
47
58
  turnTraceSchema.methods.setPrompt = function setPrompt({ resolved, resolvedPresetId, resolvedPresetVersion }) {
48
59
  this.prompt = {
49
60
  resolved: resolved || null,
@@ -53,6 +53,22 @@ class BaseLLMProvider {
53
53
  const devContent = this._buildInstructions({ resolvedPrompt, toolSchemas, prePromptResult, additionalInstructions });
54
54
  const input = this._buildInput({ context, prePromptResult, additionalInstructions, promptVariables, modelConfig });
55
55
 
56
+ // Capture before _invokeModel: the divergence oracle replays this shadow request shape.
57
+ if (shadow && assistant?.toolRuntimeContext) {
58
+ assistant.toolRuntimeContext.divergenceInputs = {
59
+ clinicalData: {
60
+ clinicalContext: promptVariables?.clinical_context || '',
61
+ lastSymptoms: promptVariables?.last_symptoms || '',
62
+ patientMemories: promptVariables?.patient_memories || '',
63
+ conversationSummaries: promptVariables?.conversation_summaries || '',
64
+ },
65
+ input,
66
+ model: modelConfig?.model || null,
67
+ toolIds: Array.isArray(toolIds) ? toolIds : [],
68
+ additionalInstructions: additionalInstructions || null,
69
+ };
70
+ }
71
+
56
72
  trace?.setPrompt({ resolved: devContent, resolvedPresetId, resolvedPresetVersion });
57
73
 
58
74
  const { response, toolsExecuted, retries, usage } = await this._invokeModel({
@@ -0,0 +1,18 @@
1
+ const axios = require('axios');
2
+
3
+ async function runClinicalAgent(request, { baseUrl, apiKey, timeoutMs } = {}) {
4
+ if (!baseUrl) throw new Error('runClinicalAgent requires a baseUrl');
5
+ const res = await axios({
6
+ url: `${baseUrl}/api/oracle/turn`,
7
+ method: 'POST',
8
+ data: request,
9
+ timeout: timeoutMs,
10
+ headers: { 'content-type': 'application/json', 'x-api-key': apiKey },
11
+ validateStatus: () => true,
12
+ });
13
+ return { status: res.status, body: res.data ?? null };
14
+ }
15
+
16
+ module.exports = {
17
+ runClinicalAgent
18
+ };
@@ -0,0 +1,74 @@
1
+ const divergenceConfig = require('../config/divergenceConfig');
2
+ const { getDivergenceModel } = require('../models/divergenceModel');
3
+ const { classify } = require('../helpers/divergenceHelper');
4
+ const runtimeConfig = require('../../config/runtimeConfig');
5
+ const { logger } = require('../../utils/logger');
6
+
7
+ const { runClinicalAgent } = require('./clinicalAgentService');
8
+
9
+ async function runDivergence({ request, connection, trace, actualMessage }) {
10
+ let post;
11
+ try {
12
+ post = await runClinicalAgent(request, connection);
13
+ if (post.status !== 200) {
14
+ return { ok: false, status: post.status, response: post.body };
15
+ }
16
+ } catch (error) {
17
+ return { ok: false, status: 503, response: { error: error.message } };
18
+ }
19
+ const expected = post.body?.run?.sessions?.[0]?.turns?.[0] || {};
20
+ const expectedMessage = post.body?.response || '';
21
+
22
+ const actual = trace.toDecision();
23
+ const outcome = await classify({
24
+ expected, actual, expectedMessage, actualMessage,
25
+ });
26
+
27
+ return {
28
+ ok: true, ...outcome, expected, actual, expectedMessage, actualMessage, response: post.body,
29
+ };
30
+ }
31
+
32
+ async function diverge({ shadow, runId }) {
33
+ const inputs = shadow?.context?.divergenceInputs;
34
+ if (!inputs) return null;
35
+ if (!shadow.result?.completed) return null;
36
+
37
+ const { enabled, baseUrl, timeoutMs } = await divergenceConfig.getOracleConnection();
38
+ if (!enabled || !baseUrl) return null;
39
+
40
+ const request = {
41
+ runId,
42
+ clinicalData: inputs.clinicalData,
43
+ input: inputs.input || [],
44
+ model: inputs.model,
45
+ enabledTools: inputs.toolIds || [],
46
+ additionalInstructions: inputs.additionalInstructions || null,
47
+ };
48
+
49
+ const connection = {
50
+ baseUrl,
51
+ apiKey: runtimeConfig.get('CLINICAL_AGENT_BENCH_KEY', ''),
52
+ timeoutMs,
53
+ };
54
+
55
+ const report = await runDivergence({
56
+ request, connection, trace: shadow.context.trace, actualMessage: shadow.result?.output || '',
57
+ });
58
+
59
+ if (report.ok) {
60
+ try {
61
+ await getDivergenceModel().record({ shadow, request, report });
62
+ } catch (error) {
63
+ logger.warn('[divergence] persist failed', { runId, error: error.message });
64
+ }
65
+ } else {
66
+ logger.warn('[divergence] oracle failed', { runId, status: report.status });
67
+ }
68
+
69
+ return report;
70
+ }
71
+
72
+ module.exports = {
73
+ diverge,
74
+ };
@@ -178,7 +178,8 @@ async function resolveTools({ promptId, assistant, presetToolIds = null, status
178
178
  const hasAssistantTools = assistant?.tools?.size > 0;
179
179
 
180
180
  if (!hasRegistryTools && !hasAssistantTools) return { toolIds: [], filtered: false, descriptions: {} };
181
- if (!presetToolIds) return { toolIds: [], filtered: false, descriptions: {} };
181
+ if (presetToolIds == null) return { toolIds: [], filtered: false, descriptions: {} };
182
+ if (presetToolIds.length === 0) return { toolIds: [], filtered: true, descriptions: {} };
182
183
 
183
184
  const mappedTools = await fetchToolsByRecordIds(presetToolIds);
184
185
  if (!mappedTools.length) return { toolIds: [], filtered: false, descriptions: {} };
@@ -0,0 +1,21 @@
1
+ const { createProjectorRegistry } = require('../../helpers/projectorHelper');
2
+ const { validateSeed } = require('../helpers/seedHelper');
3
+
4
+ const registry = createProjectorRegistry({
5
+ label: 'seed projector',
6
+ assemble: (items) => {
7
+ const seed = {};
8
+ const seen = new Set();
9
+ for (const { name, content } of items) {
10
+ if (seen.has(name)) throw new Error(`duplicate seed file name: '${name}'`);
11
+ seen.add(name);
12
+ if (content !== undefined) seed[name] = content;
13
+ }
14
+ return validateSeed(seed);
15
+ },
16
+ });
17
+
18
+ module.exports = {
19
+ registerSeedProjector: registry.register,
20
+ projectSeed: registry.project,
21
+ };
@@ -0,0 +1,58 @@
1
+ const { getTurnTraceModel } = require('../models/turnTraceModel');
2
+ const runtimeConfig = require('../../config/runtimeConfig');
3
+ const { logger } = require('../../utils/logger');
4
+
5
+ const PAIRING_WINDOW_MS = 5 * 60 * 1000;
6
+
7
+ function isShowShadowRepliesEnabled() {
8
+ return runtimeConfig.get('SHOW_SHADOW_REPLIES', 'false') === 'true';
9
+ }
10
+
11
+ async function attachShadowReplies(messages, code) {
12
+ if (!isShowShadowRepliesEnabled()) return messages;
13
+
14
+ const assistantMessages = messages.filter((msg) => msg.origin === 'assistant' && msg.response_id);
15
+ if (!assistantMessages.length) return messages;
16
+
17
+ let traces;
18
+ try {
19
+ let min = Infinity;
20
+ let max = -Infinity;
21
+ messages.forEach((msg) => {
22
+ const at = new Date(msg.createdAt).getTime();
23
+ if (at < min) min = at;
24
+ if (at > max) max = at;
25
+ });
26
+ const gte = new Date(min - PAIRING_WINDOW_MS);
27
+ const lte = new Date(max + PAIRING_WINDOW_MS);
28
+ traces = await getTurnTraceModel()
29
+ .find({ patientCode: code, outbound: { $ne: null }, createdAt: { $gte: gte, $lte: lte } })
30
+ .select({ outbound: 1, createdAt: 1 })
31
+ .sort({ createdAt: 1 })
32
+ .lean();
33
+ } catch (error) {
34
+ logger.warn('[shadowReply] lookup skipped', { code, error: error.message });
35
+ return messages;
36
+ }
37
+ if (!traces.length) return messages;
38
+
39
+ const used = new Set();
40
+ assistantMessages.forEach((msg) => {
41
+ const at = new Date(msg.createdAt).getTime();
42
+ let bestIndex = -1;
43
+ let bestDelta = PAIRING_WINDOW_MS;
44
+ traces.forEach((trace, index) => {
45
+ if (used.has(index)) return;
46
+ const delta = Math.abs(new Date(trace.createdAt).getTime() - at);
47
+ if (delta <= bestDelta) { bestDelta = delta; bestIndex = index; }
48
+ });
49
+ if (bestIndex >= 0) {
50
+ used.add(bestIndex);
51
+ msg.shadowReply = traces[bestIndex].outbound;
52
+ }
53
+ });
54
+
55
+ return messages;
56
+ }
57
+
58
+ module.exports = { attachShadowReplies, isShowShadowRepliesEnabled };
@@ -11,6 +11,7 @@ async function runShadowTurn({
11
11
  sessionId = null,
12
12
  shouldContinue = () => true,
13
13
  runId,
14
+ additionalInstructions = null,
14
15
  resolveAssistant,
15
16
  executeLLM,
16
17
  }) {
@@ -23,7 +24,9 @@ async function runShadowTurn({
23
24
 
24
25
  const context = new ToolRuntimeContext({ patientCode: code, turnId: runId, sessionId, thread: shadowThread });
25
26
 
26
- const runOptions = { toolRuntimeContext: context, shadow: true, shouldContinue };
27
+ const runOptions = {
28
+ toolRuntimeContext: context, shadow: true, shouldContinue, additionalInstructions,
29
+ };
27
30
 
28
31
  const result = await runInShadowScope(() =>
29
32
  executeLLM(shadowThread, shadowAssistant, runOptions, message),
@@ -32,7 +35,9 @@ async function runShadowTurn({
32
35
  return { turnId: runId, context, result };
33
36
  }
34
37
 
35
- function forkShadowTurn({ thread, message, shouldContinue, runId, executeLLM }) {
38
+ function forkShadowTurn({
39
+ thread, message, shouldContinue, runId, additionalInstructions = null, executeLLM, onComplete = null,
40
+ }) {
36
41
  Promise.resolve()
37
42
  .then(() => {
38
43
  if (!isShadowEnabled()) return null;
@@ -41,10 +46,14 @@ function forkShadowTurn({ thread, message, shouldContinue, runId, executeLLM })
41
46
  message,
42
47
  shouldContinue,
43
48
  runId,
49
+ additionalInstructions,
44
50
  resolveAssistant: (shadowThread) => getAssistantById(shadowThread.getAssistantId(), shadowThread),
45
51
  executeLLM,
46
52
  });
47
53
  })
54
+ .then((shadow) => (shadow && onComplete
55
+ ? Promise.resolve(onComplete(shadow)).catch((error) => logger.warn('[divergence] failed', { code: thread?.code, error: error?.message }))
56
+ : null))
48
57
  .catch((error) => logger.error('[shadow] turn failed', { code: thread?.code, error: error?.message }));
49
58
  }
50
59