@peopl-health/nexus 5.45.0-dev.7144 → 5.45.0-dev.7159
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/README.md +32 -0
- package/lib/clinical/config/divergenceConfig.js +81 -18
- package/lib/clinical/helpers/recommendationGateHelper.js +90 -0
- package/lib/clinical/services/triageRecommendationService.js +55 -12
- package/lib/controllers/patientController.js +0 -16
- package/lib/core/NexusMessaging.js +5 -0
- package/lib/core/WorkflowRunner.js +265 -0
- package/lib/index.d.ts +6 -4
- package/lib/index.js +0 -2
- package/lib/models/deferredWorkModel.js +24 -0
- package/lib/routes/index.js +0 -2
- package/lib/services/airtableService.js +7 -0
- package/lib/utils/sanitizerUtils.js +0 -1
- package/package.json +1 -1
- package/lib/core/workflowRunner.js +0 -35
- package/lib/services/escalationContextService.js +0 -81
package/README.md
CHANGED
|
@@ -116,6 +116,38 @@ const bus = nexus.getMessaging().getEventBus();
|
|
|
116
116
|
bus.on('message:received', (m) => console.log('rx', m.id));
|
|
117
117
|
```
|
|
118
118
|
|
|
119
|
+
## Workflows (Optional)
|
|
120
|
+
|
|
121
|
+
Register a named unit of work, deduplicated by a key you derive from the trigger.
|
|
122
|
+
|
|
123
|
+
```js
|
|
124
|
+
const runner = nexus.getMessaging().getWorkflowRunner();
|
|
125
|
+
|
|
126
|
+
await runner.register({
|
|
127
|
+
kind: 'triage-projection',
|
|
128
|
+
dedupeKey: (trigger) => String(trigger.submissionId),
|
|
129
|
+
prepare: async (trigger, checkpoints, save) => { /* ... */ },
|
|
130
|
+
retrySchedule: [5, 10, 20, 40], // minutes; omit for fire-and-forget
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await runner.enqueue('triage-projection', { submissionId });
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
With `retrySchedule` the trigger and a checkpoint journal are stored in Mongo and the schedule runs
|
|
137
|
+
on an in-process timer — deferred workflows never touch the queue adapter, so they add no Redis load.
|
|
138
|
+
`prepare` resumes from `checkpoints`; call `save(patch)` before each external call so a retry does
|
|
139
|
+
not repeat completed work. Throw an error carrying `permanent: true` to give up immediately instead
|
|
140
|
+
of walking the schedule; the Airtable helpers tag their own permanent 4xx errors that way, in
|
|
141
|
+
`withAirtableRetry`, which all five of them route through. They rethrow on failure too, so an
|
|
142
|
+
Airtable outage reaches `prepare` as an exception and walks the retry schedule on its own rather
|
|
143
|
+
than reading as success.
|
|
144
|
+
|
|
145
|
+
Recovery is automatic: registering sweeps for work left behind by a previous process, and the runner
|
|
146
|
+
keeps sweeping so work whose worker died mid-`prepare` is reclaimed once its claim goes stale.
|
|
147
|
+
`enqueue` on an abandoned key revives it, keeping its checkpoints. Work is retained for 24 hours
|
|
148
|
+
after its last update and then expires — the dedupe key expires with it, so idempotency has the
|
|
149
|
+
same horizon as retention.
|
|
150
|
+
|
|
119
151
|
## Assistants (Optional)
|
|
120
152
|
|
|
121
153
|
Register assistant classes and (optionally) a custom resolver. OpenAI is supported via `llm: 'openai'`.
|
|
@@ -2,6 +2,8 @@ const { Config_ID } = require('../../config/airtableConfig');
|
|
|
2
2
|
|
|
3
3
|
const { getRecordByFilter } = require('../../services/airtableService');
|
|
4
4
|
|
|
5
|
+
const { ESCALATION_ROUTES } = require('../../shared/dtos/RecommendationSafetyGate');
|
|
6
|
+
|
|
5
7
|
const { MapCache } = require('../../utils/MapCache');
|
|
6
8
|
const { safeParse, safeParseArray, isPlainObject } = require('../../utils/jsonUtils');
|
|
7
9
|
|
|
@@ -14,6 +16,70 @@ const STOCHASTIC_DEFAULTS = { enabled: false, presetId: '' };
|
|
|
14
16
|
|
|
15
17
|
const cache = new MapCache({ maxSize: 1, ttl: CACHE_TTL });
|
|
16
18
|
|
|
19
|
+
function parseSafetyFlagPatterns(value) {
|
|
20
|
+
if (!value) return [];
|
|
21
|
+
const parsedPatterns = safeParseArray(value);
|
|
22
|
+
if (!parsedPatterns) throw new Error('divergence config SAFETY_FLAG_PATTERNS is not a JSON array');
|
|
23
|
+
return parsedPatterns.map((row) => {
|
|
24
|
+
if (!isPlainObject(row)) throw new Error('divergence config SAFETY_FLAG_PATTERNS row is not an object');
|
|
25
|
+
const { flag, pattern } = row;
|
|
26
|
+
if (!flag || !pattern) throw new Error('divergence config SAFETY_FLAG_PATTERNS row missing flag or pattern');
|
|
27
|
+
try {
|
|
28
|
+
return { flag, re: new RegExp(pattern, 'i') };
|
|
29
|
+
} catch (err) {
|
|
30
|
+
throw new Error(`divergence config SAFETY_FLAG_PATTERNS pattern for ${flag} is not a valid regex: ${err.message}`);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseRedFlagMinRoute(row) {
|
|
36
|
+
if (!isPlainObject(row)) throw new Error('divergence config RED_FLAG_MIN_ROUTES row is not an object');
|
|
37
|
+
const {
|
|
38
|
+
flag,
|
|
39
|
+
ctcae_terms: ctcaeTerms,
|
|
40
|
+
min_route: minRoute,
|
|
41
|
+
max_action_time_hours: maxActionTimeHours,
|
|
42
|
+
requires_risk: requiresRisk = true,
|
|
43
|
+
} = row;
|
|
44
|
+
if (typeof flag !== 'string' || !flag.trim()) throw new Error('divergence config RED_FLAG_MIN_ROUTES row missing flag');
|
|
45
|
+
if (!Array.isArray(ctcaeTerms) || !ctcaeTerms.length || !ctcaeTerms.every((term) => typeof term === 'string' && term.trim())) {
|
|
46
|
+
throw new Error(`divergence config RED_FLAG_MIN_ROUTES ctcae_terms for ${flag} is not a non-empty list of terms`);
|
|
47
|
+
}
|
|
48
|
+
if (!ESCALATION_ROUTES.includes(minRoute)) {
|
|
49
|
+
throw new Error(`divergence config RED_FLAG_MIN_ROUTES min_route for ${flag} is not one of ${ESCALATION_ROUTES.join(', ')}`);
|
|
50
|
+
}
|
|
51
|
+
if (!Number.isInteger(maxActionTimeHours) || maxActionTimeHours < 1 || maxActionTimeHours > 24) {
|
|
52
|
+
throw new Error(`divergence config RED_FLAG_MIN_ROUTES max_action_time_hours for ${flag} is not an integer from 1 to 24`);
|
|
53
|
+
}
|
|
54
|
+
if (typeof requiresRisk !== 'boolean') throw new Error(`divergence config RED_FLAG_MIN_ROUTES requires_risk for ${flag} is not a boolean`);
|
|
55
|
+
return {
|
|
56
|
+
flag, ctcaeTerms, minRoute, maxActionTimeHours, requiresRisk,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseRedFlagMinRoutes(value) {
|
|
61
|
+
const routes = { rows: [], invalidRows: [] };
|
|
62
|
+
if (!value) return routes;
|
|
63
|
+
const parsedRoutes = safeParseArray(value);
|
|
64
|
+
if (!parsedRoutes) throw new Error('divergence config RED_FLAG_MIN_ROUTES is not a JSON array');
|
|
65
|
+
for (const row of parsedRoutes) {
|
|
66
|
+
try {
|
|
67
|
+
routes.rows.push(parseRedFlagMinRoute(row));
|
|
68
|
+
} catch (err) {
|
|
69
|
+
routes.invalidRows.push(err.message);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return routes;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseIsolated(parse) {
|
|
76
|
+
try {
|
|
77
|
+
return { value: parse(), error: null };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
return { value: null, error };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
17
83
|
async function load() {
|
|
18
84
|
const cached = cache.get(CACHE_KEY);
|
|
19
85
|
if (cached) return cached;
|
|
@@ -46,23 +112,13 @@ async function load() {
|
|
|
46
112
|
const stochastic = { ...STOCHASTIC_DEFAULTS, ...(parsedStochastic || {}) };
|
|
47
113
|
if (stochastic.enabled && !stochastic.presetId) throw new Error('divergence config STOCHASTIC.presetId is required when enabled');
|
|
48
114
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if (!flag || !pattern) throw new Error('divergence config SAFETY_FLAG_PATTERNS row missing flag or pattern');
|
|
57
|
-
try {
|
|
58
|
-
return { flag, re: new RegExp(pattern, 'i') };
|
|
59
|
-
} catch (err) {
|
|
60
|
-
throw new Error(`divergence config SAFETY_FLAG_PATTERNS pattern for ${flag} is not a valid regex: ${err.message}`);
|
|
61
|
-
}
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const config = { FIELDS: fields, ORACLE_CONNECTION: oracleConnection, STOCHASTIC: stochastic, SAFETY_FLAG_PATTERNS: safetyFlagPatterns };
|
|
115
|
+
const config = {
|
|
116
|
+
FIELDS: fields,
|
|
117
|
+
ORACLE_CONNECTION: oracleConnection,
|
|
118
|
+
STOCHASTIC: stochastic,
|
|
119
|
+
SAFETY_FLAG_PATTERNS: parseIsolated(() => parseSafetyFlagPatterns(byKey.SAFETY_FLAG_PATTERNS)),
|
|
120
|
+
RED_FLAG_MIN_ROUTES: parseIsolated(() => parseRedFlagMinRoutes(byKey.RED_FLAG_MIN_ROUTES)),
|
|
121
|
+
};
|
|
66
122
|
cache.set(CACHE_KEY, config);
|
|
67
123
|
return config;
|
|
68
124
|
}
|
|
@@ -72,9 +128,16 @@ async function get(key) {
|
|
|
72
128
|
return config[key];
|
|
73
129
|
}
|
|
74
130
|
|
|
131
|
+
async function getIsolated(key) {
|
|
132
|
+
const { value, error } = await get(key);
|
|
133
|
+
if (error) throw error;
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
|
|
75
137
|
module.exports = {
|
|
76
138
|
getFields: () => get('FIELDS'),
|
|
77
139
|
getOracleConnection: () => get('ORACLE_CONNECTION'),
|
|
78
140
|
getStochastic: () => get('STOCHASTIC'),
|
|
79
|
-
getSafetyFlagPatterns: () =>
|
|
141
|
+
getSafetyFlagPatterns: () => getIsolated('SAFETY_FLAG_PATTERNS'),
|
|
142
|
+
getRedFlagMinRoutes: () => getIsolated('RED_FLAG_MIN_ROUTES'),
|
|
80
143
|
};
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
const { ESCALATION_ROUTES, RED_FLAG_STATUSES } = require('../../shared/dtos/RecommendationSafetyGate');
|
|
2
|
+
const { aliasClosure } = require('../../fhir/helpers/symptomAliasHelper');
|
|
2
3
|
const { str, arr, strList } = require('../../utils/coercionUtils');
|
|
3
4
|
const { foldAccents } = require('../../utils/formatUtils');
|
|
4
5
|
|
|
5
6
|
const ECHO_SHARE = 0.5;
|
|
7
|
+
const ROUTE_FLOOR_UNVERIFIABLE_CODE = 'route_floor_unverifiable';
|
|
6
8
|
|
|
7
9
|
const tokensOf = (text) => foldAccents(str(text)).toLowerCase().match(/[a-z0-9]{4,}/g) || [];
|
|
8
10
|
|
|
@@ -107,6 +109,93 @@ function normalizeGateEscalation(raw, repairs) {
|
|
|
107
109
|
return { urgent, maxActionTimeHours, escalationRoute, reasonForEscalation };
|
|
108
110
|
}
|
|
109
111
|
|
|
112
|
+
const routeUrgency = (route) => ESCALATION_ROUTES.indexOf(route);
|
|
113
|
+
|
|
114
|
+
const aliasKeySet = (catalog, terms) => new Set(terms.flatMap((term) => aliasClosure(catalog, term)));
|
|
115
|
+
|
|
116
|
+
function riskSymptomKeys(catalog, riskAssessment) {
|
|
117
|
+
return aliasKeySet(catalog, [
|
|
118
|
+
...arr(riskAssessment?.predictedAes)
|
|
119
|
+
.filter((ae) => ae?.severityIfConfirmed === 'emergency')
|
|
120
|
+
.flatMap((ae) => arr(ae.relatedSymptomTerms)),
|
|
121
|
+
...arr(riskAssessment?.diseaseRisks).flatMap((risk) => arr(risk?.relatedSymptomTerms)),
|
|
122
|
+
]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const unverifiableFloor = (subject, reason) => ({
|
|
126
|
+
path: 'escalation_route',
|
|
127
|
+
code: ROUTE_FLOOR_UNVERIFIABLE_CODE,
|
|
128
|
+
warning: true,
|
|
129
|
+
problem: `${subject}: ${reason}; el gate no se elevó`,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
function applyRouteFloor(escalation, {
|
|
133
|
+
catalog,
|
|
134
|
+
rows = [],
|
|
135
|
+
invalidRows = [],
|
|
136
|
+
affirmedKeys = new Set(),
|
|
137
|
+
riskAssessment = null,
|
|
138
|
+
riskReadFailed = false,
|
|
139
|
+
freeTextPending = false,
|
|
140
|
+
configError = null,
|
|
141
|
+
} = {}, repairs = [], warnings = []) {
|
|
142
|
+
if (configError) {
|
|
143
|
+
warnings.push(unverifiableFloor('rutas mínimas no verificables', `no se pudo leer RED_FLAG_MIN_ROUTES (${configError})`));
|
|
144
|
+
return escalation;
|
|
145
|
+
}
|
|
146
|
+
if (invalidRows.length) {
|
|
147
|
+
warnings.push({
|
|
148
|
+
path: 'escalation_route',
|
|
149
|
+
code: ROUTE_FLOOR_UNVERIFIABLE_CODE,
|
|
150
|
+
warning: true,
|
|
151
|
+
problem: `rutas mínimas incompletas: se ignoraron filas inválidas de RED_FLAG_MIN_ROUTES (${invalidRows.join('; ')}); esas filas no pueden elevar el gate`,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const riskKeys = riskSymptomKeys(catalog, riskAssessment);
|
|
155
|
+
const fired = [];
|
|
156
|
+
for (const row of rows) {
|
|
157
|
+
const termKeys = [...aliasKeySet(catalog, row.ctcaeTerms)];
|
|
158
|
+
const eligibleKeys = termKeys.filter((key) => !row.requiresRisk || riskKeys.has(key));
|
|
159
|
+
if (eligibleKeys.some((key) => affirmedKeys.has(key))) {
|
|
160
|
+
fired.push(row);
|
|
161
|
+
} else if (row.requiresRisk && !riskAssessment && termKeys.some((key) => affirmedKeys.has(key))) {
|
|
162
|
+
const reason = riskReadFailed ? 'no se pudo leer el perfil de riesgo' : 'no hay perfil de riesgo';
|
|
163
|
+
warnings.push(unverifiableFloor(`ruta mínima ${row.minRoute} de ${row.flag} no verificable`, reason));
|
|
164
|
+
} else if (freeTextPending && eligibleKeys.length) {
|
|
165
|
+
warnings.push(unverifiableFloor(`ruta mínima ${row.minRoute} de ${row.flag} no verificable`, 'el clasificador del texto libre no ha terminado'));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (!fired.length) return escalation;
|
|
169
|
+
const currentRoute = escalation.urgent ? escalation.escalationRoute : null;
|
|
170
|
+
const currentHours = escalation.urgent ? escalation.maxActionTimeHours : null;
|
|
171
|
+
const floorRoute = fired.map((row) => row.minRoute).sort((left, right) => routeUrgency(left) - routeUrgency(right))[0];
|
|
172
|
+
const floorHours = Math.min(...fired.map((row) => row.maxActionTimeHours));
|
|
173
|
+
const route = currentRoute && routeUrgency(currentRoute) <= routeUrgency(floorRoute) ? currentRoute : floorRoute;
|
|
174
|
+
const hours = Number.isInteger(currentHours) ? Math.min(currentHours, floorHours) : floorHours;
|
|
175
|
+
const routeRaised = route !== currentRoute;
|
|
176
|
+
const windowTightened = hours !== currentHours;
|
|
177
|
+
if (!routeRaised && !windowTightened) return escalation;
|
|
178
|
+
const flags = [...new Set(fired
|
|
179
|
+
.filter((row) => (routeRaised && row.minRoute === route) || (windowTightened && row.maxActionTimeHours === hours))
|
|
180
|
+
.map((row) => row.flag))].join(', ');
|
|
181
|
+
const changes = [
|
|
182
|
+
routeRaised ? `escalation_route ${currentRoute ? `'${currentRoute}'` : 'null'} → '${route}'` : null,
|
|
183
|
+
windowTightened ? `max_action_time_hours ${currentHours ?? 'null'} → ${hours}` : null,
|
|
184
|
+
escalation.urgent ? null : 'urgent_under_24h false → true',
|
|
185
|
+
].filter(Boolean);
|
|
186
|
+
repairs.push(`ruta mínima por término reportado (${flags}): ${changes.join(', ')}`);
|
|
187
|
+
return {
|
|
188
|
+
...escalation,
|
|
189
|
+
urgent: true,
|
|
190
|
+
maxActionTimeHours: hours,
|
|
191
|
+
escalationRoute: route,
|
|
192
|
+
reasonForEscalation: escalation.reasonForEscalation || `Término reportado en este triaje (${flags}): ruta mínima ${route}, máximo ${hours} h.`,
|
|
193
|
+
summary: [str(escalation.summary).trim(), `Ruta mínima aplicada por un término reportado en este triaje (${flags}): ${route}, máximo ${hours} h.`]
|
|
194
|
+
.filter(Boolean)
|
|
195
|
+
.join(' '),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
110
199
|
module.exports = {
|
|
111
200
|
str,
|
|
112
201
|
arr,
|
|
@@ -115,4 +204,5 @@ module.exports = {
|
|
|
115
204
|
normalizeStatus,
|
|
116
205
|
buildRedFlag,
|
|
117
206
|
normalizeGateEscalation,
|
|
207
|
+
applyRouteFloor,
|
|
118
208
|
};
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
const { getTriageGate, getTriagePlan } = require('../config/subAgentsConfig');
|
|
2
|
-
const { getSafetyFlagPatterns } = require('../config/divergenceConfig');
|
|
2
|
+
const { getSafetyFlagPatterns, getRedFlagMinRoutes } = require('../config/divergenceConfig');
|
|
3
3
|
const { requireGatewayProvider } = require('../config/llmConfig');
|
|
4
4
|
const { getTriageRecommendationEventModel } = require('../models/triageRecommendationEventModel');
|
|
5
5
|
const { isShadowRun } = require('../helpers/shadowHelper');
|
|
6
6
|
const { readOutputText } = require('../helpers/clinicalMentionHelper');
|
|
7
|
-
const {
|
|
7
|
+
const {
|
|
8
|
+
str, arr, buildRedFlag, normalizeGateEscalation, applyRouteFloor,
|
|
9
|
+
} = require('../helpers/recommendationGateHelper');
|
|
8
10
|
const { buildElement, nullableStr } = require('../helpers/recommendationPlanHelper');
|
|
9
11
|
const { readClinicalProfile } = require('../helpers/clinicalProfileHelper');
|
|
10
12
|
const {
|
|
@@ -265,7 +267,13 @@ async function generateTriageRecommendations({ payload, provider, runId = null }
|
|
|
265
267
|
if (profile === undefined) return { ...settle, status: 'failed', stage: 'assemble', reason: 'profile_read_failed' };
|
|
266
268
|
if (!profile) return { ...settle, status: 'failed', stage: 'assemble', reason: 'no_clinical_profile' };
|
|
267
269
|
|
|
268
|
-
const
|
|
270
|
+
const riskRead = await readPatientRiskAssessments({ patientId: code, limit: 1 })
|
|
271
|
+
.then((assessments) => ({ assessments, failed: false }))
|
|
272
|
+
.catch((err) => {
|
|
273
|
+
logger.warn('[triageRecommendation] risk assessment unavailable; section degraded', { code, error: err?.message });
|
|
274
|
+
return { assessments: [], failed: true };
|
|
275
|
+
});
|
|
276
|
+
const [riskAssessment] = riskRead.assessments;
|
|
269
277
|
// A failed read degrades the section; it must never fail the run.
|
|
270
278
|
const [openCases, escalations] = await Promise.all([
|
|
271
279
|
readSymptomCases({ patientId: code })
|
|
@@ -315,16 +323,40 @@ async function generateTriageRecommendations({ payload, provider, runId = null }
|
|
|
315
323
|
...freeTextDerivedSymptoms,
|
|
316
324
|
];
|
|
317
325
|
const reportedKeys = symptomKeySet(catalog, reportedTerms);
|
|
326
|
+
const floorConfig = await getRedFlagMinRoutes()
|
|
327
|
+
.then(({ rows, invalidRows }) => ({ rows, invalidRows, configError: null }))
|
|
328
|
+
.catch((err) => {
|
|
329
|
+
logger.warn('[triageRecommendation] red-flag minimum routes unavailable; no route floor', { code, error: err?.message });
|
|
330
|
+
return { rows: [], invalidRows: [], configError: err?.message || 'desconocido' };
|
|
331
|
+
});
|
|
332
|
+
const routeFloor = {
|
|
333
|
+
catalog,
|
|
334
|
+
...floorConfig,
|
|
335
|
+
affirmedKeys: reportedKeys,
|
|
336
|
+
riskAssessment,
|
|
337
|
+
riskReadFailed: riskRead.failed,
|
|
338
|
+
freeTextPending: settle.classifierPending === true && freeTextBits.length > 0,
|
|
339
|
+
};
|
|
318
340
|
|
|
319
341
|
const gateStart = Date.now();
|
|
320
|
-
const gateOutcome = await runGate({
|
|
342
|
+
const gateOutcome = await runGate({
|
|
343
|
+
provider, presetId: gatePresetId, casoBase: casoBaseFor('gate'), routeFloor, rid, code, payload,
|
|
344
|
+
});
|
|
321
345
|
settle.gateLatencyMs = Date.now() - gateStart;
|
|
322
346
|
settle.gateAttempts = gateOutcome.attempts;
|
|
323
|
-
if (gateOutcome.failure)
|
|
324
|
-
|
|
347
|
+
if (gateOutcome.failure) {
|
|
348
|
+
return {
|
|
349
|
+
...settle,
|
|
350
|
+
...gateOutcome.failure,
|
|
351
|
+
autoRepairs: gateOutcome.gateRepairs || [],
|
|
352
|
+
qualityWarnings: gateOutcome.gateWarnings || settle.qualityWarnings,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
const { gate, gateRepairs, gateWarnings } = gateOutcome;
|
|
325
356
|
settle.gateVerdict = gate.urgentUnder24h ? 'urgent' : 'clear';
|
|
326
357
|
settle.urgentUnder24h = gate.urgentUnder24h;
|
|
327
358
|
settle.escalationRoute = gate.escalationRoute;
|
|
359
|
+
settle.qualityWarnings = gateWarnings;
|
|
328
360
|
|
|
329
361
|
const planStart = Date.now();
|
|
330
362
|
const boundary = {
|
|
@@ -344,7 +376,7 @@ async function generateTriageRecommendations({ payload, provider, runId = null }
|
|
|
344
376
|
const { plan, planRepairs, qualityWarnings } = planOutcome;
|
|
345
377
|
settle.elementCount = plan.elements.length;
|
|
346
378
|
settle.autoRepairs = [...gateRepairs, ...planRepairs];
|
|
347
|
-
settle.qualityWarnings = qualityWarnings;
|
|
379
|
+
settle.qualityWarnings = [...gateWarnings, ...qualityWarnings];
|
|
348
380
|
settle.rankReordered = planOutcome.rankReordered;
|
|
349
381
|
settle.deniedCardsDropped = planOutcome.deniedCardsDropped;
|
|
350
382
|
settle.deniedTargetsStripped = planOutcome.deniedTargetsStripped;
|
|
@@ -404,7 +436,9 @@ function rejectedGateShape(rawGate, redFlags) {
|
|
|
404
436
|
};
|
|
405
437
|
}
|
|
406
438
|
|
|
407
|
-
async function runGate({
|
|
439
|
+
async function runGate({
|
|
440
|
+
provider, presetId, casoBase, routeFloor, rid, code, payload,
|
|
441
|
+
}) {
|
|
408
442
|
let attempts = 0;
|
|
409
443
|
let gateRepairs = [];
|
|
410
444
|
let accepted = null;
|
|
@@ -468,7 +502,10 @@ async function runGate({ provider, presetId, casoBase, rid, code, payload }) {
|
|
|
468
502
|
break;
|
|
469
503
|
}
|
|
470
504
|
if (!accepted) return { attempts, gateRepairs, failure: { status: 'failed', stage: 'safety_gate', reason: 'gate_malformed' } };
|
|
471
|
-
const
|
|
505
|
+
const gateWarnings = [];
|
|
506
|
+
const {
|
|
507
|
+
summary, redFlags, urgent, maxActionTimeHours, escalationRoute, reasonForEscalation,
|
|
508
|
+
} = applyRouteFloor(accepted, routeFloor, gateRepairs, gateWarnings);
|
|
472
509
|
|
|
473
510
|
let gate;
|
|
474
511
|
try {
|
|
@@ -487,15 +524,21 @@ async function runGate({ provider, presetId, casoBase, rid, code, payload }) {
|
|
|
487
524
|
});
|
|
488
525
|
} catch (err) {
|
|
489
526
|
logger.warn('[triageRecommendation] gate failed DTO validation', { code, runId: rid, error: err?.message });
|
|
490
|
-
return {
|
|
527
|
+
return {
|
|
528
|
+
attempts, gateRepairs, gateWarnings, failure: { status: 'failed', stage: 'safety_gate', reason: 'gate_malformed' },
|
|
529
|
+
};
|
|
491
530
|
}
|
|
492
531
|
try {
|
|
493
532
|
await storeSafetyGate({ patientId: code, safetyGate: gate });
|
|
494
533
|
} catch (err) {
|
|
495
534
|
logger.error('[triageRecommendation] gate store failed', { code, runId: rid, error: err?.message });
|
|
496
|
-
return {
|
|
535
|
+
return {
|
|
536
|
+
attempts, gateRepairs, gateWarnings, failure: { status: 'failed', stage: 'fhir', reason: 'gate_store_failed' },
|
|
537
|
+
};
|
|
497
538
|
}
|
|
498
|
-
return {
|
|
539
|
+
return {
|
|
540
|
+
attempts, gate, gateRepairs, gateWarnings,
|
|
541
|
+
};
|
|
499
542
|
}
|
|
500
543
|
|
|
501
544
|
async function runPlan({ provider, presetId, casoBase, gate, boundary = null, rid, code, payload }) {
|
|
@@ -6,7 +6,6 @@ const { isAllowedUrl, redactDirectIdentifiers } = require('../utils/sanitizerUti
|
|
|
6
6
|
const { getPatientInformation, updatePatientInformation, logPatientChange } = require('../services/patientService');
|
|
7
7
|
const { getTriageHistory } = require('../services/triageHistoryService');
|
|
8
8
|
const { getCarePlanHistory } = require('../services/carePlanHistoryService');
|
|
9
|
-
const { getEscalationContext } = require('../services/escalationContextService');
|
|
10
9
|
const { getPatientTasks, updatePatientTask } = require('../services/patientTaskService');
|
|
11
10
|
const {
|
|
12
11
|
getRecommendationCarousels,
|
|
@@ -242,25 +241,10 @@ const logPatientChangeController = async (req, res) => {
|
|
|
242
241
|
}
|
|
243
242
|
};
|
|
244
243
|
|
|
245
|
-
const getPatientEscalationContextController = async (req, res) => {
|
|
246
|
-
try {
|
|
247
|
-
const context = await getEscalationContext({ code: req.params.code });
|
|
248
|
-
res.status(200).json({ success: true, context });
|
|
249
|
-
} catch (error) {
|
|
250
|
-
logger.error('Error fetching patient escalation context:', {
|
|
251
|
-
error: redactDirectIdentifiers(String(error.message || '')),
|
|
252
|
-
stack: redactDirectIdentifiers(String(error.stack || '')),
|
|
253
|
-
code: redactDirectIdentifiers(String(req.params?.code || '')),
|
|
254
|
-
});
|
|
255
|
-
res.status(error.statusCode || 500).json({ success: false, error: error.message });
|
|
256
|
-
}
|
|
257
|
-
};
|
|
258
|
-
|
|
259
244
|
module.exports = {
|
|
260
245
|
getPatientInfoController,
|
|
261
246
|
getPatientTriageInfoController,
|
|
262
247
|
getPatientCarePlanController,
|
|
263
|
-
getPatientEscalationContextController,
|
|
264
248
|
getPatientTasksController,
|
|
265
249
|
getPatientRecommendationCarouselsController,
|
|
266
250
|
getPatientRecommendationCarouselImageController,
|
|
@@ -43,6 +43,7 @@ const { isLlmMonitorEnabled } = require('../clinical/flags/llmMonitorConfig');
|
|
|
43
43
|
const { createQueueAdapter } = require('../queue');
|
|
44
44
|
const { ScheduledMessageJob } = require('../jobs/ScheduledMessageJob');
|
|
45
45
|
const { TemplateApprovalJob } = require('../jobs/TemplateApprovalJob');
|
|
46
|
+
const { WorkflowRunner } = require('./WorkflowRunner');
|
|
46
47
|
|
|
47
48
|
const { PhiProcessor } = require('./PhiProcessor');
|
|
48
49
|
|
|
@@ -127,6 +128,8 @@ class NexusMessaging {
|
|
|
127
128
|
queueAdapter: this.queueAdapter,
|
|
128
129
|
});
|
|
129
130
|
|
|
131
|
+
this.workflowRunner = new WorkflowRunner({ queueAdapter: this.queueAdapter });
|
|
132
|
+
|
|
130
133
|
this.phiProcessor = new PhiProcessor({
|
|
131
134
|
encode: config.phi?.encode || false,
|
|
132
135
|
ner: config.phi?.ner || null,
|
|
@@ -310,6 +313,7 @@ class NexusMessaging {
|
|
|
310
313
|
getAssistantProcessor() { return this.assistantProcessor; }
|
|
311
314
|
getLlmMonitor() { return this.llmMonitor; }
|
|
312
315
|
getQueueAdapter() { return this.queueAdapter; }
|
|
316
|
+
getWorkflowRunner() { return this.workflowRunner; }
|
|
313
317
|
getPhiProcessor() { return this.phiProcessor; }
|
|
314
318
|
isConnected() { return this.provider?.getConnectionStatus() ?? false; }
|
|
315
319
|
isProcessing(chatId) { return this.batchingManager.isProcessing(chatId); }
|
|
@@ -1012,6 +1016,7 @@ class NexusMessaging {
|
|
|
1012
1016
|
async disconnect() {
|
|
1013
1017
|
this.queueReconciliation?.stop();
|
|
1014
1018
|
if (this.provider) await this.provider.disconnect();
|
|
1019
|
+
if (this.workflowRunner) this.workflowRunner.stop();
|
|
1015
1020
|
if (this.queueAdapter) await this.queueAdapter.shutdown();
|
|
1016
1021
|
this.events.removeAllListeners();
|
|
1017
1022
|
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
const { describeThrown } = require('../utils/errorUtils');
|
|
2
|
+
const { logger } = require('../utils/logger');
|
|
3
|
+
|
|
4
|
+
const { DeferredWork, RETENTION_MS } = require('../models/deferredWorkModel');
|
|
5
|
+
|
|
6
|
+
const STALE_CLAIM_MS = 5 * 60 * 1000;
|
|
7
|
+
const MINUTE_MS = 60 * 1000;
|
|
8
|
+
const INDEX_TIMEOUT_MS = 1000;
|
|
9
|
+
const SWEEP_INTERVAL_MS = 60 * 1000;
|
|
10
|
+
|
|
11
|
+
let indexesReady = null;
|
|
12
|
+
|
|
13
|
+
function ensureIndexes() {
|
|
14
|
+
if (!indexesReady) {
|
|
15
|
+
indexesReady = DeferredWork.init().catch((error) => {
|
|
16
|
+
indexesReady = null;
|
|
17
|
+
logger.error('[WorkflowRunner] Index build failed, dedupe and retention are not guaranteed', { error: error.message });
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return indexesReady;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function withIndexTimeout(promise) {
|
|
24
|
+
let timer;
|
|
25
|
+
const timeout = new Promise((resolve) => {
|
|
26
|
+
timer = setTimeout(resolve, INDEX_TIMEOUT_MS);
|
|
27
|
+
timer.unref();
|
|
28
|
+
});
|
|
29
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class WorkflowRunner {
|
|
33
|
+
constructor({ queueAdapter, sweepIntervalMs = SWEEP_INTERVAL_MS } = {}) {
|
|
34
|
+
if (!queueAdapter) throw new Error('WorkflowRunner requires a queueAdapter');
|
|
35
|
+
this.queueAdapter = queueAdapter;
|
|
36
|
+
this.sweepIntervalMs = sweepIntervalMs;
|
|
37
|
+
this.sweepTimer = null;
|
|
38
|
+
this.timers = new Map();
|
|
39
|
+
this.workflows = new Map();
|
|
40
|
+
this.stopped = false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async register(workflow = {}) {
|
|
44
|
+
if (!workflow.kind || typeof workflow.kind !== 'string') throw new Error('workflow requires a kind');
|
|
45
|
+
if (workflow.kind.includes('__')) throw new Error(`workflow '${workflow.kind}' must not contain '__' (reserved as the jobId delimiter)`);
|
|
46
|
+
if (typeof workflow.dedupeKey !== 'function') throw new Error(`workflow '${workflow.kind}' requires a dedupeKey function`);
|
|
47
|
+
if (typeof workflow.prepare !== 'function') throw new Error(`workflow '${workflow.kind}' requires a prepare function`);
|
|
48
|
+
if (this.workflows.has(workflow.kind)) throw new Error(`workflow '${workflow.kind}' is already registered`);
|
|
49
|
+
if (workflow.retrySchedule !== undefined) {
|
|
50
|
+
if (!Array.isArray(workflow.retrySchedule) || !workflow.retrySchedule.length) {
|
|
51
|
+
throw new Error(`workflow '${workflow.kind}' requires a non-empty retrySchedule array`);
|
|
52
|
+
}
|
|
53
|
+
if (workflow.retrySchedule.some((minutes) => !Number.isFinite(minutes) || minutes <= 0)) {
|
|
54
|
+
throw new Error(`workflow '${workflow.kind}' requires retrySchedule entries to be positive minutes`);
|
|
55
|
+
}
|
|
56
|
+
if (workflow.retrySchedule.some((minutes) => minutes * MINUTE_MS >= RETENTION_MS)) {
|
|
57
|
+
throw new Error(`workflow '${workflow.kind}' requires retrySchedule entries under the ${RETENTION_MS / MINUTE_MS}m retention, or the record expires before the attempt fires`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
this.workflows.set(workflow.kind, workflow);
|
|
62
|
+
if (workflow.retrySchedule) {
|
|
63
|
+
await this.sweep([workflow.kind]).catch((error) => {
|
|
64
|
+
logger.error('[WorkflowRunner] Sweep on register failed', { kind: workflow.kind, error: error.message });
|
|
65
|
+
});
|
|
66
|
+
this._startSweeping();
|
|
67
|
+
return workflow;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
await this.queueAdapter.process(workflow.kind, (trigger) => workflow.prepare(trigger));
|
|
72
|
+
} catch (error) {
|
|
73
|
+
this.workflows.delete(workflow.kind);
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
return workflow;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
enqueue(kind, trigger, options = {}) {
|
|
80
|
+
const workflow = this.workflows.get(kind);
|
|
81
|
+
if (!workflow) throw new Error(`no workflow registered for '${kind}'`);
|
|
82
|
+
const dedupeKey = workflow.dedupeKey(trigger);
|
|
83
|
+
if (dedupeKey == null || (typeof dedupeKey === 'string' && !dedupeKey.trim())) {
|
|
84
|
+
throw new Error(`workflow '${kind}' produced an empty dedupe key`);
|
|
85
|
+
}
|
|
86
|
+
if (typeof dedupeKey !== 'string') throw new Error(`workflow '${kind}' produced a non-string dedupe key`);
|
|
87
|
+
if (!workflow.retrySchedule) {
|
|
88
|
+
return this.queueAdapter.enqueue(kind, trigger, { ...options, jobId: `${kind}__${dedupeKey}` });
|
|
89
|
+
}
|
|
90
|
+
return this._armDeferred(workflow, trigger, dedupeKey);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async sweep(only = null) {
|
|
94
|
+
const deferredKinds = [...this.workflows.values()].filter((w) => w.retrySchedule).map((w) => w.kind);
|
|
95
|
+
const kinds = only ? deferredKinds.filter((kind) => only.includes(kind)) : deferredKinds;
|
|
96
|
+
if (!kinds.length) return [];
|
|
97
|
+
|
|
98
|
+
const orphaned = await DeferredWork.find({
|
|
99
|
+
kind: { $in: kinds },
|
|
100
|
+
$or: [
|
|
101
|
+
{ status: 'pending' },
|
|
102
|
+
{ status: 'processing', claimedAt: { $lt: new Date(Date.now() - STALE_CLAIM_MS) } }
|
|
103
|
+
]
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const rearmed = orphaned.map((work) => this._arm(
|
|
107
|
+
this.workflows.get(work.kind),
|
|
108
|
+
work._id,
|
|
109
|
+
this._delayUntilDue(work),
|
|
110
|
+
));
|
|
111
|
+
if (rearmed.length) logger.info('[WorkflowRunner] Swept', { kinds, rearmed: rearmed.length });
|
|
112
|
+
return rearmed;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_startSweeping() {
|
|
116
|
+
if (this.stopped || this.sweepTimer || !this.sweepIntervalMs) return;
|
|
117
|
+
this.sweepTimer = setInterval(() => {
|
|
118
|
+
this.sweep().catch((error) => logger.error('[WorkflowRunner] Periodic sweep failed', { error: error.message }));
|
|
119
|
+
}, this.sweepIntervalMs);
|
|
120
|
+
this.sweepTimer.unref();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
stop() {
|
|
124
|
+
this.stopped = true;
|
|
125
|
+
if (this.sweepTimer) clearInterval(this.sweepTimer);
|
|
126
|
+
this.sweepTimer = null;
|
|
127
|
+
for (const timer of this.timers.values()) clearTimeout(timer);
|
|
128
|
+
this.timers.clear();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async _armDeferred(workflow, trigger, dedupeKey) {
|
|
132
|
+
const { kind } = workflow;
|
|
133
|
+
await withIndexTimeout(ensureIndexes());
|
|
134
|
+
const work = await DeferredWork.findOne({ kind, dedupeKey })
|
|
135
|
+
|| await DeferredWork.create({ kind, dedupeKey, trigger }).catch(async (error) => {
|
|
136
|
+
if (error.code !== 11000) throw error;
|
|
137
|
+
return await DeferredWork.findOne({ kind, dedupeKey });
|
|
138
|
+
});
|
|
139
|
+
if (work.status === 'abandoned') {
|
|
140
|
+
const { modifiedCount } = await DeferredWork.updateOne(
|
|
141
|
+
{ _id: work._id, status: 'abandoned' },
|
|
142
|
+
{ $set: { status: 'pending', attempt: 0, nextAttemptAt: new Date(), lastError: null, claimedAt: null } }
|
|
143
|
+
);
|
|
144
|
+
if (!modifiedCount) return null;
|
|
145
|
+
logger.info('[WorkflowRunner] Re-arming abandoned work', { kind, dedupeKey });
|
|
146
|
+
return this._arm(workflow, work._id, 0);
|
|
147
|
+
}
|
|
148
|
+
if (work.status !== 'pending') {
|
|
149
|
+
logger.info('[WorkflowRunner] Skipping, work is in flight or complete', { kind, dedupeKey, status: work.status });
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
return this._arm(workflow, work._id, this._delayUntilDue(work));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
_delayUntilDue(work) {
|
|
156
|
+
return Math.max(0, new Date(work.nextAttemptAt).getTime() - Date.now());
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
_arm(workflow, workId, delayMs) {
|
|
160
|
+
const id = String(workId);
|
|
161
|
+
// An attempt still in flight when stop() lands must not re-arm after shutdown. The
|
|
162
|
+
// record stays pending in Mongo, so the next process recovers it on its sweep.
|
|
163
|
+
if (this.stopped) return id;
|
|
164
|
+
clearTimeout(this.timers.get(id));
|
|
165
|
+
const timer = setTimeout(() => {
|
|
166
|
+
this.timers.delete(id);
|
|
167
|
+
this._runDeferred(workflow, id).catch((error) => {
|
|
168
|
+
logger.error('[WorkflowRunner] Deferred run failed', { kind: workflow.kind, deferredWorkId: id, error: error.message });
|
|
169
|
+
});
|
|
170
|
+
}, delayMs);
|
|
171
|
+
timer.unref();
|
|
172
|
+
this.timers.set(id, timer);
|
|
173
|
+
return id;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async _runDeferred(workflow, deferredWorkId) {
|
|
177
|
+
const claimedAt = new Date();
|
|
178
|
+
const prior = await DeferredWork.findOneAndUpdate(
|
|
179
|
+
{
|
|
180
|
+
_id: deferredWorkId,
|
|
181
|
+
$or: [
|
|
182
|
+
{ status: 'pending', nextAttemptAt: { $lte: claimedAt } },
|
|
183
|
+
{ status: 'processing', claimedAt: { $lt: new Date(claimedAt.getTime() - STALE_CLAIM_MS) } }
|
|
184
|
+
]
|
|
185
|
+
},
|
|
186
|
+
{ $set: { status: 'processing', claimedAt } },
|
|
187
|
+
{ new: false }
|
|
188
|
+
);
|
|
189
|
+
if (!prior) {
|
|
190
|
+
logger.info('[WorkflowRunner] Claimed elsewhere or not due', { kind: workflow.kind, deferredWorkId });
|
|
191
|
+
return { claimed: false };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const reclaimed = prior.status === 'processing';
|
|
195
|
+
const work = { ...prior.toObject(), attempt: prior.attempt + (reclaimed ? 1 : 0) };
|
|
196
|
+
if (reclaimed) await this._write(work, claimedAt, { attempt: work.attempt });
|
|
197
|
+
|
|
198
|
+
const checkpoints = { ...(work.checkpoints || {}) };
|
|
199
|
+
const save = async (patch) => {
|
|
200
|
+
Object.assign(checkpoints, patch);
|
|
201
|
+
const $set = {};
|
|
202
|
+
for (const [key, value] of Object.entries(patch || {})) $set[`checkpoints.${key}`] = value;
|
|
203
|
+
if (Object.keys($set).length) await this._write(work, claimedAt, $set);
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
await workflow.prepare(work.trigger, checkpoints, save);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
return await this._rescheduleOrAbandon(workflow, work, claimedAt, error);
|
|
210
|
+
}
|
|
211
|
+
await this._write(work, claimedAt, { status: 'done', claimedAt: null });
|
|
212
|
+
return { status: 'done' };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// matchedCount, not modifiedCount: the fence is "do we still hold the claim", and
|
|
216
|
+
// modifiedCount is also 0 for a write that matched but set an identical value - a
|
|
217
|
+
// save() rewriting an unchanged checkpoint is not a lost claim.
|
|
218
|
+
async _write(work, claimedAt, $set) {
|
|
219
|
+
const { matchedCount } = await DeferredWork.updateOne({ _id: work._id, claimedAt }, { $set });
|
|
220
|
+
if (!matchedCount) logger.warn('[WorkflowRunner] Claim lost, write discarded', { deferredWorkId: String(work._id) });
|
|
221
|
+
return matchedCount > 0;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async _rescheduleOrAbandon(workflow, work, claimedAt, error) {
|
|
225
|
+
// prepare() is consumer code and can throw anything, including null. Dereferencing
|
|
226
|
+
// it here used to raise a TypeError out of this handler, so neither branch ran and
|
|
227
|
+
// the record stayed `processing` until stale recovery hit the same failure again.
|
|
228
|
+
const lastError = describeThrown(error);
|
|
229
|
+
const delayMinutes = workflow.retrySchedule[work.attempt];
|
|
230
|
+
if (error?.permanent === true || delayMinutes === undefined) {
|
|
231
|
+
await this._write(work, claimedAt, { status: 'abandoned', lastError, claimedAt: null });
|
|
232
|
+
logger.error('[WorkflowRunner] Abandoned', {
|
|
233
|
+
kind: workflow.kind,
|
|
234
|
+
dedupeKey: work.dedupeKey,
|
|
235
|
+
attempt: work.attempt,
|
|
236
|
+
permanent: error?.permanent === true,
|
|
237
|
+
error: lastError
|
|
238
|
+
});
|
|
239
|
+
return { status: 'abandoned' };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const attempt = work.attempt + 1;
|
|
243
|
+
const delayMs = delayMinutes * MINUTE_MS;
|
|
244
|
+
const kept = await this._write(work, claimedAt, {
|
|
245
|
+
status: 'pending',
|
|
246
|
+
attempt,
|
|
247
|
+
nextAttemptAt: new Date(Date.now() + delayMs),
|
|
248
|
+
lastError,
|
|
249
|
+
claimedAt: null
|
|
250
|
+
});
|
|
251
|
+
if (!kept) return { claimed: false };
|
|
252
|
+
|
|
253
|
+
logger.warn('[WorkflowRunner] Rescheduled', {
|
|
254
|
+
kind: workflow.kind,
|
|
255
|
+
dedupeKey: work.dedupeKey,
|
|
256
|
+
attempt,
|
|
257
|
+
delayMinutes,
|
|
258
|
+
error: lastError
|
|
259
|
+
});
|
|
260
|
+
this._arm(workflow, work._id, delayMs);
|
|
261
|
+
return { status: 'pending', attempt };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = { WorkflowRunner };
|
package/lib/index.d.ts
CHANGED
|
@@ -243,6 +243,7 @@ declare module '@peopl-health/nexus' {
|
|
|
243
243
|
getAssistantProcessor(): AssistantProcessor;
|
|
244
244
|
getLlmMonitor(): { start(options?: { scheduleDaily?: boolean }): Promise<{ enabled: boolean }> } | null;
|
|
245
245
|
initializeLlmMonitor(): Promise<{ enabled: boolean; error?: string } | undefined>;
|
|
246
|
+
getWorkflowRunner(): WorkflowRunner;
|
|
246
247
|
processInstruction(code: string, instruction: string, role?: string, options?: { triggeredBy?: string }): Promise<void>;
|
|
247
248
|
processSystemMessage(code: string, messages: string | string[], role?: string, options?: { triggeredBy?: string; reply?: boolean }): Promise<void>;
|
|
248
249
|
processOutreach(code: string, options?: { brief?: OutreachBrief | null; triggeredBy?: string | null; reason?: string | null; firstName?: string | null; dryRun?: boolean }): Promise<OutreachDecision>;
|
|
@@ -520,16 +521,17 @@ declare module '@peopl-health/nexus' {
|
|
|
520
521
|
export interface Workflow {
|
|
521
522
|
kind: string;
|
|
522
523
|
dedupeKey: (trigger: any) => string;
|
|
523
|
-
prepare: (trigger: any) => Promise<any>;
|
|
524
|
+
prepare: (trigger: any, checkpoints?: any, save?: (patch: any) => Promise<void>) => Promise<any>;
|
|
525
|
+
retrySchedule?: number[];
|
|
524
526
|
}
|
|
525
527
|
|
|
526
528
|
export interface WorkflowRunner {
|
|
527
529
|
register(workflow: Workflow): Promise<Workflow>;
|
|
528
|
-
enqueue(kind: string, trigger: any, options?: QueueJobOptions): Promise<string>;
|
|
530
|
+
enqueue(kind: string, trigger: any, options?: QueueJobOptions): Promise<string | null>;
|
|
531
|
+
sweep(only?: string[]): Promise<string[]>;
|
|
532
|
+
stop(): void;
|
|
529
533
|
}
|
|
530
534
|
|
|
531
|
-
export function createWorkflowRunner(options: { queueAdapter: QueueAdapter }): WorkflowRunner;
|
|
532
|
-
|
|
533
535
|
// Memory System
|
|
534
536
|
export interface PatientMemoryDocument {
|
|
535
537
|
_id: any;
|
package/lib/index.js
CHANGED
|
@@ -27,7 +27,6 @@ const { BaileysProvider } = require('./adapters/BaileysProvider');
|
|
|
27
27
|
const { setPreprocessingHandler, hasPreprocessingHandler, invokePreprocessingHandler } = require('./services/preprocessingService');
|
|
28
28
|
const { requestIdMiddleware, getRequestId } = require('./middleware/requestId');
|
|
29
29
|
const { QueueAdapter, LocalQueueAdapter, RedisQueueAdapter, createQueueAdapter, registerQueueAdapter } = require('./queue');
|
|
30
|
-
const { createWorkflowRunner } = require('./core/workflowRunner');
|
|
31
30
|
const routes = require('./routes');
|
|
32
31
|
const { resetAll } = require('./config/lifecycle');
|
|
33
32
|
const { EvalProvider } = require('./eval/EvalProvider');
|
|
@@ -229,7 +228,6 @@ class Nexus {
|
|
|
229
228
|
}
|
|
230
229
|
|
|
231
230
|
module.exports = {
|
|
232
|
-
createWorkflowRunner,
|
|
233
231
|
Nexus,
|
|
234
232
|
TwilioProvider,
|
|
235
233
|
BaileysProvider,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const mongoose = require('mongoose');
|
|
2
|
+
|
|
3
|
+
const STATUS_VALUES = ['pending', 'processing', 'done', 'abandoned'];
|
|
4
|
+
const RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
const deferredWorkSchema = new mongoose.Schema({
|
|
7
|
+
kind: { type: String, required: true },
|
|
8
|
+
dedupeKey: { type: String, required: true },
|
|
9
|
+
trigger: { type: mongoose.Schema.Types.Mixed, default: null },
|
|
10
|
+
checkpoints: { type: mongoose.Schema.Types.Mixed, default: () => ({}) },
|
|
11
|
+
status: { type: String, enum: STATUS_VALUES, default: 'pending' },
|
|
12
|
+
attempt: { type: Number, default: 0 },
|
|
13
|
+
nextAttemptAt: { type: Date, default: Date.now },
|
|
14
|
+
claimedAt: { type: Date, default: null },
|
|
15
|
+
lastError: { type: String, default: null }
|
|
16
|
+
}, { timestamps: true });
|
|
17
|
+
|
|
18
|
+
deferredWorkSchema.index({ kind: 1, dedupeKey: 1 }, { unique: true, name: 'dedupe_idx' });
|
|
19
|
+
deferredWorkSchema.index({ status: 1, nextAttemptAt: 1 }, { name: 'due_idx' });
|
|
20
|
+
deferredWorkSchema.index({ updatedAt: 1 }, { expireAfterSeconds: RETENTION_MS / 1000, name: 'ttl_idx' });
|
|
21
|
+
|
|
22
|
+
const DeferredWork = mongoose.model('DeferredWork', deferredWorkSchema);
|
|
23
|
+
|
|
24
|
+
module.exports = { DeferredWork, RETENTION_MS };
|
package/lib/routes/index.js
CHANGED
|
@@ -70,7 +70,6 @@ const patientRouteDefinitions = {
|
|
|
70
70
|
'GET /:code': 'getPatientInfoController',
|
|
71
71
|
'GET /triage/:code': 'getPatientTriageInfoController',
|
|
72
72
|
'GET /careplan/:code': 'getPatientCarePlanController',
|
|
73
|
-
'GET /escalation-context/:code': 'getPatientEscalationContextController',
|
|
74
73
|
'GET /tasks/:code': 'getPatientTasksController',
|
|
75
74
|
'PATCH /:code': 'updatePatientController',
|
|
76
75
|
'PATCH /tasks/:code/:kind/:recordId': 'updatePatientTaskController',
|
|
@@ -215,7 +214,6 @@ const builtInControllers = {
|
|
|
215
214
|
getPatientInfoController: patientController.getPatientInfoController,
|
|
216
215
|
getPatientTriageInfoController: patientController.getPatientTriageInfoController,
|
|
217
216
|
getPatientCarePlanController: patientController.getPatientCarePlanController,
|
|
218
|
-
getPatientEscalationContextController: patientController.getPatientEscalationContextController,
|
|
219
217
|
getPatientTasksController: patientController.getPatientTasksController,
|
|
220
218
|
getPatientRecommendationCarouselsController: patientController.getPatientRecommendationCarouselsController,
|
|
221
219
|
getPatientRecommendationCarouselImageController: patientController.getPatientRecommendationCarouselImageController,
|
|
@@ -15,6 +15,11 @@ const AIRTABLE_API_URL = 'https://api.airtable.com/v0';
|
|
|
15
15
|
const AXIOS_TIMEOUT_CODE = 'ECONNABORTED';
|
|
16
16
|
const RETRY_BASE_DELAY_MS = 1000;
|
|
17
17
|
|
|
18
|
+
function isPermanentAirtableError(error) {
|
|
19
|
+
const status = error?.statusCode;
|
|
20
|
+
return typeof status === 'number' && status >= 400 && status < 500 && status !== 429;
|
|
21
|
+
}
|
|
22
|
+
|
|
18
23
|
let isEvalMode = false;
|
|
19
24
|
|
|
20
25
|
function setEvalMode(enabled) {
|
|
@@ -42,6 +47,7 @@ async function withAirtableRetry(operation, label, { retryNetworkErrors = true }
|
|
|
42
47
|
const isTransient = TRANSIENT_STATUS_CODES.includes(error.statusCode)
|
|
43
48
|
|| (retryNetworkErrors && isTransientNetworkError(error));
|
|
44
49
|
if (!isTransient || attempt === MAX_ATTEMPTS) {
|
|
50
|
+
if (isPermanentAirtableError(error)) error.permanent = true;
|
|
45
51
|
throw error;
|
|
46
52
|
}
|
|
47
53
|
const baseDelay = error.statusCode === RATE_LIMITED_STATUS ? RATE_LIMIT_DELAY_MS : RETRY_BASE_DELAY_MS;
|
|
@@ -229,6 +235,7 @@ async function upsertRecord(baseID, tableName, fields, { mergeOn } = {}, context
|
|
|
229
235
|
|
|
230
236
|
module.exports = {
|
|
231
237
|
setEvalMode,
|
|
238
|
+
isPermanentAirtableError,
|
|
232
239
|
addRecord,
|
|
233
240
|
getRecords,
|
|
234
241
|
getRecordByFilter,
|
package/package.json
CHANGED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
function createWorkflowRunner({ queueAdapter } = {}) {
|
|
2
|
-
if (!queueAdapter) throw new Error('createWorkflowRunner requires a queueAdapter');
|
|
3
|
-
const workflows = new Map();
|
|
4
|
-
|
|
5
|
-
async function register(workflow = {}) {
|
|
6
|
-
if (!workflow.kind || typeof workflow.kind !== 'string') throw new Error('workflow requires a kind');
|
|
7
|
-
if (workflow.kind.includes('__')) throw new Error(`workflow '${workflow.kind}' must not contain '__' (reserved as the jobId delimiter)`);
|
|
8
|
-
if (typeof workflow.dedupeKey !== 'function') throw new Error(`workflow '${workflow.kind}' requires a dedupeKey function`);
|
|
9
|
-
if (typeof workflow.prepare !== 'function') throw new Error(`workflow '${workflow.kind}' requires a prepare function`);
|
|
10
|
-
if (workflows.has(workflow.kind)) throw new Error(`workflow '${workflow.kind}' is already registered`);
|
|
11
|
-
workflows.set(workflow.kind, workflow);
|
|
12
|
-
try {
|
|
13
|
-
await queueAdapter.process(workflow.kind, (trigger) => workflow.prepare(trigger));
|
|
14
|
-
} catch (error) {
|
|
15
|
-
workflows.delete(workflow.kind);
|
|
16
|
-
throw error;
|
|
17
|
-
}
|
|
18
|
-
return workflow;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function enqueue(kind, trigger, options = {}) {
|
|
22
|
-
const workflow = workflows.get(kind);
|
|
23
|
-
if (!workflow) throw new Error(`no workflow registered for '${kind}'`);
|
|
24
|
-
const dedupeKey = workflow.dedupeKey(trigger);
|
|
25
|
-
if (dedupeKey == null || (typeof dedupeKey === 'string' && !dedupeKey.trim())) {
|
|
26
|
-
throw new Error(`workflow '${kind}' produced an empty dedupe key`);
|
|
27
|
-
}
|
|
28
|
-
if (typeof dedupeKey !== 'string') throw new Error(`workflow '${kind}' produced a non-string dedupe key`);
|
|
29
|
-
return queueAdapter.enqueue(kind, trigger, { ...options, jobId: `${kind}__${dedupeKey}` });
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
return { register, enqueue };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
module.exports = { createWorkflowRunner };
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
const { isoDay } = require('../utils/dateUtils');
|
|
2
|
-
const { logger } = require('../utils/logger');
|
|
3
|
-
const { PATIENT_CODE, redactDirectIdentifiers } = require('../utils/sanitizerUtils');
|
|
4
|
-
const { withTimeout } = require('../utils/timeoutUtils');
|
|
5
|
-
const { ensureWhatsAppFormat } = require('../helpers/twilioHelper');
|
|
6
|
-
const { assembleHistory } = require('../clinical/services/patientHistoryService');
|
|
7
|
-
const { DefaultMemoryManager } = require('../clinical/memory/DefaultMemoryManager');
|
|
8
|
-
|
|
9
|
-
const { getCarePlanHistory } = require('./carePlanHistoryService');
|
|
10
|
-
const { getPatientInformation } = require('./patientService');
|
|
11
|
-
const { getTriageHistory } = require('./triageHistoryService');
|
|
12
|
-
|
|
13
|
-
const SECTION_TIMEOUT_MS = 10000;
|
|
14
|
-
const TRIAGE_WINDOW_DAYS = 30;
|
|
15
|
-
const CARE_PLAN_LIMIT = 5;
|
|
16
|
-
const HISTORY_SCOPE = ['closed_cases_90d', 'recurrence_patterns', 'recent_medications', 'patient_reported_procedures'];
|
|
17
|
-
|
|
18
|
-
const httpError = (statusCode, message) => Object.assign(new Error(message), { statusCode });
|
|
19
|
-
|
|
20
|
-
function sectionReaders(code, now) {
|
|
21
|
-
const memoryManager = new DefaultMemoryManager();
|
|
22
|
-
const triageStart = isoDay(new Date(now - TRIAGE_WINDOW_DAYS * 86400000).toISOString());
|
|
23
|
-
return {
|
|
24
|
-
agentContext: () => memoryManager.getClinicalData(code),
|
|
25
|
-
conversation: () => memoryManager.buildContext({ thread: { code } }),
|
|
26
|
-
triages: () => getTriageHistory({ code, startDate: triageStart }),
|
|
27
|
-
carePlan: () => getCarePlanHistory({ code, limit: CARE_PLAN_LIMIT }),
|
|
28
|
-
history: () => assembleHistory(code, { scope: HISTORY_SCOPE }),
|
|
29
|
-
};
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
async function getEscalationContext({ code, now = Date.now(), timeoutMs = SECTION_TIMEOUT_MS } = {}) {
|
|
33
|
-
const patientCode = ensureWhatsAppFormat(String(code ?? '').trim());
|
|
34
|
-
if (!PATIENT_CODE.test(patientCode)) throw httpError(400, 'A valid WhatsApp ID is required');
|
|
35
|
-
|
|
36
|
-
const patient = await withTimeout(getPatientInformation(patientCode), timeoutMs, 'patient lookup');
|
|
37
|
-
if (!patient) throw httpError(404, 'Patient not found');
|
|
38
|
-
|
|
39
|
-
const readers = sectionReaders(patientCode, now);
|
|
40
|
-
const names = Object.keys(readers);
|
|
41
|
-
const outcomes = await Promise.allSettled(names.map((name) => withTimeout(readers[name](), timeoutMs, name)));
|
|
42
|
-
|
|
43
|
-
const context = { patientCode, asOf: new Date(now).toISOString(), degradedSections: [] };
|
|
44
|
-
outcomes.forEach((outcome, index) => {
|
|
45
|
-
const name = names[index];
|
|
46
|
-
if (outcome.status === 'fulfilled' && outcome.value != null) {
|
|
47
|
-
context[name] = outcome.value;
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
context[name] = null;
|
|
51
|
-
context.degradedSections.push(name);
|
|
52
|
-
if (outcome.status === 'rejected') {
|
|
53
|
-
logger.error('[escalationContextService] Section reader failed', {
|
|
54
|
-
code: patientCode,
|
|
55
|
-
section: name,
|
|
56
|
-
error: redactDirectIdentifiers(String(outcome.reason?.message ?? outcome.reason)),
|
|
57
|
-
stack: redactDirectIdentifiers(String(outcome.reason?.stack ?? '')),
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
if (context.conversation) {
|
|
63
|
-
context.conversation = context.conversation
|
|
64
|
-
.filter((item) => item.type === 'message')
|
|
65
|
-
.map(({ role, content }) => ({ role, content }));
|
|
66
|
-
}
|
|
67
|
-
if (context.triages) {
|
|
68
|
-
if (context.triages.pagination?.hasNext) context.degradedSections.push('triages');
|
|
69
|
-
context.triages = context.triages.records;
|
|
70
|
-
}
|
|
71
|
-
if (context.carePlan) {
|
|
72
|
-
const { records, pagination = null, currentState = null } = context.carePlan;
|
|
73
|
-
const { degradedSections: carePlanDegraded = [], ...state } = currentState || {};
|
|
74
|
-
context.carePlan = { records, pagination, currentState: currentState ? state : null };
|
|
75
|
-
for (const section of carePlanDegraded) context.degradedSections.push(`carePlan.${section}`);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
return context;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
module.exports = { getEscalationContext };
|