@peopl-health/nexus 5.10.0-dev.1064 → 5.10.0-dev.1066

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.
@@ -16,6 +16,7 @@ const TOOLS = [
16
16
  require('./submitRoutingDecisionTool'),
17
17
  require('./openConditionTool'),
18
18
  require('./recordClinicalImpressionTool'),
19
+ require('./updateConditionStatusTool'),
19
20
  require('./getActiveSymptomLandscapeTool'),
20
21
  require('./recordInterventionTool'),
21
22
  require('./setContingencyPlanTool'),
@@ -0,0 +1,294 @@
1
+ const { readSymptomCases, storeSymptomCase, readContingencies, storeContingency, readClusters, storeCluster } = require('../../fhir');
2
+ const { ManagedSymptom } = require('../../shared/dtos/ManagedSymptom');
3
+ const { RESOLUTION_CLOSE_REASONS } = require('../../fhir/resources/Condition');
4
+ const { ContingencySafetyNet, ACTIVE_CARE_PLAN_STATUSES } = require('../../shared/dtos/ContingencySafetyNet');
5
+ const { ClusterImpression } = require('../../shared/dtos/ClusterImpression');
6
+ const { ClusterHistoryRecord } = require('../../shared/dtos/ClusterHistoryRecord');
7
+ const { logger } = require('../../utils/logger');
8
+
9
+ const DEACTIVATE_CLOSE_REASONS = ['escalated_to_team', 'patient_transferred', 'stale_no_activity'];
10
+ const OPEN_STATUSES = ['characterizing', 'monitoring'];
11
+ const CLUSTER_MEMBER_FLOOR = 2;
12
+
13
+ const definition = {
14
+ name: 'updateConditionStatus',
15
+ description: '**Does:** Transitions the lifecycle of a symptom `Condition` (FHIR `Condition.clinicalStatus`), selected via `transition`.\n\n**Required inputs:** `case_id`, `transition` (`new_episode` | `resolve_episode` | `resolve` | `deactivate` | `reactivate`), and `reasoning` (one short sentence justifying the transition — persisted to the Condition\'s audit record). Per transition:\n- `new_episode`: requires `episode_id` for a GENUINELY-NEW episode of the same term. Attaches it and returns the case to `characterizing`.\n- `resolve_episode`: no extra args. Current episode resolved; case → `monitoring`, current episode cleared.\n- `resolve`: the symptom ENDED (patient-confirmed or clinically established) → `resolved` + `abatement_at`.\n- `deactivate`: stop tracking WITHOUT asserting resolution. `close_reason` REQUIRED (`escalated_to_team` | `patient_transferred` | `stale_no_activity`) → `inactive`, no abatement.\n- `reactivate`: a dormant case\'s symptom RETURNED. Requires `episode_id`. Reopens the SAME Condition (→ `characterizing`; FHIR `recurrence` when it had been resolved).\n\n**Returns:** `case_id` + new `status`; `resolve`/`deactivate` add `abatement_at` (null on deactivate) and cascade outcomes — `auto_resolved_plans[]`, `pruned_clusters[]`, `dissolved_clusters[]`, `cascade_errors[]` (each present only when non-empty); `reactivate` adds `is_recurrence` + `recurrence_count`.\n\n**Side effects:** writes the lifecycle patch to the `Condition`. On `resolve`/`deactivate`, auto-resolves any active contingency safety net linked to the case and prunes the case from any active cluster (auto-dissolving a cluster that drops below 2 members).',
16
+ strict: false,
17
+ parameters: {
18
+ type: 'object',
19
+ properties: {
20
+ case_id: {
21
+ type: 'string',
22
+ description: 'The case_id whose Condition lifecycle changes (from openCondition or getActiveSymptomLandscape).',
23
+ },
24
+ transition: {
25
+ type: 'string',
26
+ enum: ['new_episode', 'resolve_episode', 'resolve', 'deactivate', 'reactivate'],
27
+ description: '`new_episode`: attach a genuinely-new episode (→ characterizing). `resolve_episode`: current episode resolved (→ monitoring). `resolve`: symptom ended (→ resolved + abatement). `deactivate`: stop tracking without resolution (→ inactive, requires close_reason). `reactivate`: dormant case returned (→ characterizing).',
28
+ },
29
+ reasoning: {
30
+ type: 'string',
31
+ description: 'Required. One short sentence justifying this lifecycle transition — persisted to the Condition\'s audit record.',
32
+ },
33
+ episode_id: {
34
+ type: 'string',
35
+ description: 'Required for new_episode (a genuinely-new episode) and reactivate (the new bout\'s intake episode).',
36
+ },
37
+ close_reason: {
38
+ type: 'string',
39
+ description: 'Required for deactivate. One of escalated_to_team | patient_transferred | stale_no_activity.',
40
+ },
41
+ temporality: {
42
+ type: 'string',
43
+ description: 'Optional. Pass only when temporality changed with this transition.',
44
+ },
45
+ },
46
+ required: ['case_id', 'transition', 'reasoning'],
47
+ },
48
+ };
49
+
50
+ const TRANSITIONS = definition.parameters.properties.transition.enum;
51
+
52
+ function fail(error, data = {}) {
53
+ return JSON.stringify({ success: false, error, data });
54
+ }
55
+
56
+ function isResolutionReason(reason) {
57
+ return RESOLUTION_CLOSE_REASONS.has((reason || '').trim().toLowerCase());
58
+ }
59
+
60
+ function returnedStatus(condition) {
61
+ const { clinicalStatus } = condition;
62
+ if (clinicalStatus === 'closed') {
63
+ return isResolutionReason(condition.closeReason) ? 'resolved' : 'inactive';
64
+ }
65
+ return clinicalStatus;
66
+ }
67
+
68
+ function isDormant(condition) {
69
+ return ['closed', 'resolved', 'inactive'].includes(condition.clinicalStatus);
70
+ }
71
+
72
+ function toManaged(existing, conditionPatch) {
73
+ const condition = { ...existing.condition, ...conditionPatch };
74
+ return new ManagedSymptom({
75
+ condition,
76
+ latestAssessment: existing.latestAssessment,
77
+ priorAssessments: existing.priorAssessments,
78
+ });
79
+ }
80
+
81
+ function applyNewEpisode(existing, reasoning, args) {
82
+ if (!OPEN_STATUSES.includes(existing.condition.clinicalStatus)) {
83
+ return fail('new_episode requires an open case (characterizing or monitoring).', { case_id: existing.condition.conditionId, status: existing.condition.clinicalStatus });
84
+ }
85
+ const episodeId = (typeof args?.episode_id === 'string' ? args.episode_id : '').trim();
86
+ if (!episodeId) return fail('new_episode requires episode_id');
87
+ if (existing.condition.evidenceEpisodeIds.includes(episodeId)) {
88
+ return fail('episode_already_attached_to_case', { case_id: existing.condition.conditionId, episode_id: episodeId, current_episode_id: existing.condition.currentEpisodeId });
89
+ }
90
+ const patch = {
91
+ evidenceEpisodeIds: [...existing.condition.evidenceEpisodeIds, episodeId],
92
+ currentEpisodeId: episodeId,
93
+ clinicalStatus: 'characterizing',
94
+ lastTransitionReason: reasoning,
95
+ };
96
+ if (typeof args?.temporality === 'string') patch.temporality = args.temporality;
97
+ return toManaged(existing, patch);
98
+ }
99
+
100
+ function applyResolveEpisode(existing, reasoning, args) {
101
+ if (existing.condition.clinicalStatus !== 'characterizing') {
102
+ return fail('resolve_episode requires status=characterizing (there must be an active episode to resolve).', { case_id: existing.condition.conditionId, status: existing.condition.clinicalStatus });
103
+ }
104
+ const patch = { clinicalStatus: 'monitoring', currentEpisodeId: null, lastTransitionReason: reasoning };
105
+ if (typeof args?.temporality === 'string') patch.temporality = args.temporality;
106
+ return toManaged(existing, patch);
107
+ }
108
+
109
+ function applyDormant(existing, reasoning, args, resolving) {
110
+ if (isDormant(existing.condition)) {
111
+ return fail('case_already_dormant', { case_id: existing.condition.conditionId, status: returnedStatus(existing.condition), close_reason: existing.condition.closeReason });
112
+ }
113
+ let closeReason = (typeof args?.close_reason === 'string' ? args.close_reason : '').trim().toLowerCase();
114
+ if (resolving) {
115
+ closeReason = closeReason || 'resolved';
116
+ if (!RESOLUTION_CLOSE_REASONS.has(closeReason)) {
117
+ return fail('invalid_close_reason_for_resolve', { case_id: existing.condition.conditionId, valid_close_reasons: [...RESOLUTION_CLOSE_REASONS] });
118
+ }
119
+ } else {
120
+ if (!closeReason) return fail('close_reason is required for deactivate', { valid_close_reasons: DEACTIVATE_CLOSE_REASONS });
121
+ if (!DEACTIVATE_CLOSE_REASONS.includes(closeReason)) {
122
+ return fail('invalid_close_reason_for_deactivate', { case_id: existing.condition.conditionId, valid_close_reasons: DEACTIVATE_CLOSE_REASONS });
123
+ }
124
+ }
125
+ const patch = {
126
+ clinicalStatus: 'closed',
127
+ abatementAt: resolving ? new Date().toISOString() : null,
128
+ closeReason,
129
+ lastTransitionReason: reasoning,
130
+ };
131
+ if (typeof args?.temporality === 'string') patch.temporality = args.temporality;
132
+ return toManaged(existing, patch);
133
+ }
134
+
135
+ function applyReactivate(existing, reasoning, args) {
136
+ if (!isDormant(existing.condition)) {
137
+ return fail('reactivate requires a dormant case (resolved/inactive). This case is already open — use transition=new_episode.', { case_id: existing.condition.conditionId, status: existing.condition.clinicalStatus });
138
+ }
139
+ const episodeId = (typeof args?.episode_id === 'string' ? args.episode_id : '').trim();
140
+ if (!episodeId) return fail('reactivate requires episode_id');
141
+ const wasResolved = existing.condition.clinicalStatus === 'resolved'
142
+ || (existing.condition.clinicalStatus === 'closed' && isResolutionReason(existing.condition.closeReason));
143
+ const evidenceEpisodeIds = existing.condition.evidenceEpisodeIds.includes(episodeId)
144
+ ? [...existing.condition.evidenceEpisodeIds]
145
+ : [...existing.condition.evidenceEpisodeIds, episodeId];
146
+ const patch = {
147
+ clinicalStatus: 'characterizing',
148
+ abatementAt: null,
149
+ closeReason: null,
150
+ currentEpisodeId: episodeId,
151
+ evidenceEpisodeIds,
152
+ reactivatedAt: new Date().toISOString(),
153
+ lastTransitionReason: reasoning,
154
+ };
155
+ if (wasResolved) patch.recurrenceCount = (existing.condition.recurrenceCount || 0) + 1;
156
+ if (typeof args?.temporality === 'string') patch.temporality = args.temporality;
157
+ return { managed: toManaged(existing, patch), wasResolved };
158
+ }
159
+
160
+ async function runCloseCascade(patientId, caseId, turnId) {
161
+ const autoResolvedPlans = [];
162
+ const prunedClusters = [];
163
+ const dissolvedClusters = [];
164
+ const cascadeErrors = [];
165
+ const now = new Date().toISOString();
166
+
167
+ try {
168
+ const nets = await readContingencies({ patientId, caseId });
169
+ const active = nets.find((net) => ACTIVE_CARE_PLAN_STATUSES.includes(net.carePlan.status));
170
+ if (active) {
171
+ const contingency = new ContingencySafetyNet({
172
+ ...active,
173
+ carePlan: { ...active.carePlan, status: 'auto_resolved', resolvedAt: now, resolvedReason: 'linked_case_closed' },
174
+ });
175
+ await storeContingency({ patientId, contingency });
176
+ autoResolvedPlans.push(active.carePlan.planId);
177
+ }
178
+ } catch (error) {
179
+ cascadeErrors.push(`contingency_auto_resolve_failed: ${error?.message || error}`);
180
+ }
181
+
182
+ const clusters = await readClusters({ patientId, status: 'active' }).catch((error) => {
183
+ cascadeErrors.push(`cluster_cascade_failed: ${error?.message || error}`);
184
+ return [];
185
+ });
186
+ for (const cluster of clusters) {
187
+ if (!cluster.memberCaseIds.includes(caseId)) continue;
188
+ try {
189
+ const remaining = cluster.memberCaseIds.filter((id) => id !== caseId);
190
+ const dissolving = remaining.length < CLUSTER_MEMBER_FLOOR;
191
+ const patch = { ...cluster, memberCaseIds: remaining.length ? remaining : cluster.memberCaseIds };
192
+ if (dissolving) {
193
+ patch.status = 'dissolved';
194
+ patch.dissolvedAt = now;
195
+ patch.dissolveReason = `cascade: linked case ${caseId} closed`;
196
+ patch.dissolveCategory = 'linked_case_closed';
197
+ }
198
+ const action = dissolving ? 'dissolved' : 'updated';
199
+ const changeItems = dissolving
200
+ ? [`removed_members:${caseId}`, 'status:active→dissolved', 'dissolve_category:linked_case_closed']
201
+ : [`removed_members:${caseId}`];
202
+ const history = [new ClusterHistoryRecord({
203
+ clusterId: cluster.clusterId,
204
+ turnId: turnId || '',
205
+ action,
206
+ changes: { items: changeItems },
207
+ transitionReason: `cascade from updateConditionStatus on case ${caseId}`,
208
+ recordedAt: now,
209
+ })];
210
+ await storeCluster({ patientId, cluster: new ClusterImpression(patch), history });
211
+ (dissolving ? dissolvedClusters : prunedClusters).push(cluster.clusterId);
212
+ } catch (error) {
213
+ cascadeErrors.push(`cluster_cascade_failed: ${error?.message || error}`);
214
+ }
215
+ }
216
+
217
+ return { autoResolvedPlans, prunedClusters, dissolvedClusters, cascadeErrors };
218
+ }
219
+
220
+ async function handler(args = {}, context = {}) {
221
+ try {
222
+ const runtime = context?.toolRuntimeContext || null;
223
+ if (!runtime?.turnId || !runtime?.patientCode) {
224
+ return JSON.stringify({ success: false, error: 'updateConditionStatus requires an active turn context (turnId, patientCode).', data: {} });
225
+ }
226
+ const caseId = (typeof args?.case_id === 'string' ? args.case_id : '').trim();
227
+ const transition = (typeof args?.transition === 'string' ? args.transition : '').trim().toLowerCase();
228
+ const reasoning = (typeof args?.reasoning === 'string' ? args.reasoning : '').trim();
229
+ if (!caseId) return fail('case_id is required');
230
+ if (!TRANSITIONS.includes(transition)) {
231
+ return fail(`unknown transition: '${transition}'. Must be one of ${TRANSITIONS.join(' | ')}.`);
232
+ }
233
+ if (!reasoning) return fail('reasoning is required — one short sentence justifying this lifecycle transition.');
234
+
235
+ const patientId = runtime.patientCode;
236
+ const cases = await readSymptomCases({ patientId });
237
+ const existing = cases.find((symptomCase) => symptomCase.condition.conditionId === caseId);
238
+ if (!existing) return fail('case_not_found_or_wrong_patient', { case_id: caseId });
239
+
240
+ if (transition === 'new_episode') {
241
+ const result = applyNewEpisode(existing, reasoning, args);
242
+ if (typeof result === 'string') return result;
243
+ await storeSymptomCase({ patientId, managedSymptom: result });
244
+ return JSON.stringify({ success: true, data: { case_id: result.condition.conditionId, status: returnedStatus(result.condition) } });
245
+ }
246
+
247
+ if (transition === 'resolve_episode') {
248
+ const result = applyResolveEpisode(existing, reasoning, args);
249
+ if (typeof result === 'string') return result;
250
+ await storeSymptomCase({ patientId, managedSymptom: result });
251
+ return JSON.stringify({ success: true, data: { case_id: result.condition.conditionId, status: returnedStatus(result.condition) } });
252
+ }
253
+
254
+ if (transition === 'reactivate') {
255
+ const result = applyReactivate(existing, reasoning, args);
256
+ if (typeof result === 'string') return result;
257
+ await storeSymptomCase({ patientId, managedSymptom: result.managed });
258
+ return JSON.stringify({
259
+ success: true,
260
+ data: {
261
+ case_id: result.managed.condition.conditionId,
262
+ status: returnedStatus(result.managed.condition),
263
+ is_recurrence: result.wasResolved,
264
+ recurrence_count: result.managed.condition.recurrenceCount || 0,
265
+ },
266
+ });
267
+ }
268
+
269
+ const result = applyDormant(existing, reasoning, args, transition === 'resolve');
270
+ if (typeof result === 'string') return result;
271
+ await storeSymptomCase({ patientId, managedSymptom: result });
272
+ const cascade = await runCloseCascade(patientId, result.condition.conditionId, runtime.turnId);
273
+ const data = {
274
+ case_id: result.condition.conditionId,
275
+ status: returnedStatus(result.condition),
276
+ abatement_at: result.condition.abatementAt,
277
+ };
278
+ if (cascade.autoResolvedPlans.length) data.auto_resolved_plans = cascade.autoResolvedPlans;
279
+ if (cascade.prunedClusters.length) data.pruned_clusters = cascade.prunedClusters;
280
+ if (cascade.dissolvedClusters.length) data.dissolved_clusters = cascade.dissolvedClusters;
281
+ if (cascade.cascadeErrors.length) {
282
+ data.cascade_errors = cascade.cascadeErrors;
283
+ logger.warn({ caseId: result.condition.conditionId, cascadeErrors: cascade.cascadeErrors }, 'updateConditionStatus close cascade errors');
284
+ }
285
+ return JSON.stringify({ success: true, data });
286
+ } catch (err) {
287
+ return JSON.stringify({ success: false, error: err?.message || 'updateConditionStatus failed', data: {} });
288
+ }
289
+ }
290
+
291
+ module.exports = {
292
+ definition,
293
+ handler,
294
+ };
@@ -136,4 +136,4 @@ class Condition {
136
136
  }
137
137
  }
138
138
 
139
- module.exports = { Condition };
139
+ module.exports = { Condition, RESOLUTION_CLOSE_REASONS };
@@ -35,14 +35,14 @@ const symptomConditionSchema = z.strictObject({
35
35
  recurrenceCount: z.number().int().nonnegative().default(0),
36
36
  reactivatedAt: dateTime.nullable().default(null),
37
37
  })
38
- .refine((c) => !c.closeReason || c.abatementAt !== null, {
39
- message: 'closeReason requires abatementAt',
40
- })
41
38
  .refine((c) => c.abatementAt === null || ['resolved', 'inactive', 'closed'].includes(c.clinicalStatus), {
42
39
  message: 'abatementAt requires a terminal clinicalStatus (resolved/inactive/closed)',
43
40
  })
44
41
  .refine((c) => c.abatementAt === null || new Date(c.abatementAt) >= new Date(c.onsetAt), {
45
42
  message: 'abatementAt must be >= onsetAt',
43
+ })
44
+ .refine((c) => !c.closeReason || c.clinicalStatus === 'closed', {
45
+ message: 'closeReason requires clinicalStatus=closed',
46
46
  });
47
47
 
48
48
  const symptomAssessmentSchema = z.strictObject({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.10.0-dev.1064",
3
+ "version": "5.10.0-dev.1066",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",