@peopl-health/nexus 5.44.0-dev.5437 → 5.44.0-dev.5439

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.
@@ -120,12 +120,21 @@ function hasGradeSignal(raw) {
120
120
  .some((key) => raw[key] !== null && raw[key] !== undefined);
121
121
  }
122
122
 
123
+ function malformedGradeEstimate(raw) {
124
+ if (raw === null || raw === undefined) return null;
125
+ if (typeof raw === 'object' && !Array.isArray(raw)) return null;
126
+ return `grade_estimate must be an object, not ${Array.isArray(raw) ? 'a list' : typeof raw}. `
127
+ + 'Send {best_estimate, confidence, possible_range, reasoning, functional_anchor, source, matched_grade} — '
128
+ + `a bare ${Array.isArray(raw) ? 'list' : typeof raw} carries no confidence or range and would be dropped.`;
129
+ }
130
+
123
131
  function isCarriedForward(raw) {
124
132
  return !!raw && typeof raw === 'object' && raw.source === CARRIED_FORWARD;
125
133
  }
126
134
 
127
135
  module.exports = {
128
136
  gradeEstimateSchema,
137
+ malformedGradeEstimate,
129
138
  projectGradeEstimate,
130
139
  projectGradeRead,
131
140
  hasGradeSignal,
@@ -0,0 +1,132 @@
1
+ const { readContingencies, readClusters, storeCluster } = require('../../fhir');
2
+ const { RESOLUTION_CLOSE_REASONS } = require('../../shared/dtos/ManagedSymptom');
3
+ const { RESOLVED_REASONS } = require('../../shared/dtos/ContingencySafetyNet');
4
+ const { ClusterImpression } = require('../../shared/dtos/ClusterImpression');
5
+ const { ClusterHistoryRecord } = require('../../shared/dtos/ClusterHistoryRecord');
6
+ const { autoResolveNets } = require('./contingencyDispatchService');
7
+
8
+ const DEACTIVATE_CLOSE_REASONS = ['escalated_to_team', 'patient_transferred', 'stale_no_activity'];
9
+ const CLUSTER_MEMBER_FLOOR = 2;
10
+ const ALL_CLOSE_REASONS = [...RESOLUTION_CLOSE_REASONS, ...DEACTIVATE_CLOSE_REASONS];
11
+
12
+ function isResolutionReason(reason) {
13
+ return RESOLUTION_CLOSE_REASONS.has((reason || '').trim().toLowerCase());
14
+ }
15
+
16
+ const INFER_HINT = 'A resolution reason records an observed end and stamps abatement_at; '
17
+ + 'a deactivation reason stops tracking without claiming one. Free text is not accepted for either.';
18
+
19
+ const closeError = (error, data) => ({ error: { error, data } });
20
+
21
+ function forResolve(closeReason, caseId) {
22
+ const reason = closeReason || 'resolved';
23
+ if (!RESOLUTION_CLOSE_REASONS.has(reason)) {
24
+ return closeError('invalid_close_reason_for_resolve', { case_id: caseId, valid_close_reasons: [...RESOLUTION_CLOSE_REASONS] });
25
+ }
26
+ return { closeReason: reason, resolving: true };
27
+ }
28
+
29
+ function forDeactivate(closeReason, caseId) {
30
+ if (!closeReason) return closeError('close_reason is required for deactivate', { valid_close_reasons: DEACTIVATE_CLOSE_REASONS });
31
+ if (!DEACTIVATE_CLOSE_REASONS.includes(closeReason)) {
32
+ return closeError('invalid_close_reason_for_deactivate', { case_id: caseId, valid_close_reasons: DEACTIVATE_CLOSE_REASONS });
33
+ }
34
+ return { closeReason, resolving: false };
35
+ }
36
+
37
+ function inferDirection(closeReason, caseId) {
38
+ if (!closeReason) return closeError('close_reason is required', { valid_close_reasons: ALL_CLOSE_REASONS });
39
+ if (isResolutionReason(closeReason)) return { closeReason, resolving: true };
40
+ if (!DEACTIVATE_CLOSE_REASONS.includes(closeReason)) {
41
+ return closeError('invalid_close_reason', { case_id: caseId, valid_close_reasons: ALL_CLOSE_REASONS, hint: INFER_HINT });
42
+ }
43
+ return { closeReason, resolving: false };
44
+ }
45
+
46
+ function resolveCloseReason(rawReason, { resolving = null, caseId = null } = {}) {
47
+ const closeReason = (typeof rawReason === 'string' ? rawReason : '').trim().toLowerCase();
48
+ if (resolving === null) return inferDirection(closeReason, caseId);
49
+ return resolving ? forResolve(closeReason, caseId) : forDeactivate(closeReason, caseId);
50
+ }
51
+
52
+ function closePatch({ closeReason, resolving, reasoning }) {
53
+ return {
54
+ clinicalStatus: 'closed',
55
+ abatementAt: resolving ? new Date().toISOString() : null,
56
+ closeReason,
57
+ ...(reasoning ? { lastTransitionReason: reasoning } : {}),
58
+ };
59
+ }
60
+
61
+ async function runCloseCascade(patientId, caseId, turnId, trace = null, sourceTool = 'updateConditionStatus') {
62
+ const autoResolvedPlans = [];
63
+ const prunedClusters = [];
64
+ const dissolvedClusters = [];
65
+ const cascadeErrors = [];
66
+ const now = new Date().toISOString();
67
+
68
+ try {
69
+ const nets = await readContingencies({ patientId, caseId });
70
+ const caseNets = await autoResolveNets({ patientCode: patientId, nets, resolvedReason: RESOLVED_REASONS.LINKED_CASE_CLOSED, trace });
71
+ autoResolvedPlans.push(...caseNets.autoResolvedPlans);
72
+ cascadeErrors.push(...caseNets.cascadeErrors);
73
+ } catch (error) {
74
+ cascadeErrors.push(`contingency_auto_resolve_failed: ${error?.message || error}`);
75
+ }
76
+
77
+ const clusters = await readClusters({ patientId, status: 'active' }).catch((error) => {
78
+ cascadeErrors.push(`cluster_cascade_failed: ${error?.message || error}`);
79
+ return [];
80
+ });
81
+ for (const cluster of clusters) {
82
+ if (!cluster.memberCaseIds.includes(caseId)) continue;
83
+ try {
84
+ const remaining = cluster.memberCaseIds.filter((id) => id !== caseId);
85
+ const isDissolving = remaining.length < CLUSTER_MEMBER_FLOOR;
86
+ const patch = { ...cluster, memberCaseIds: remaining.length ? remaining : cluster.memberCaseIds };
87
+ if (isDissolving) {
88
+ patch.status = 'dissolved';
89
+ patch.dissolvedAt = now;
90
+ patch.dissolveReason = `cascade: linked case ${caseId} closed`;
91
+ patch.dissolveCategory = 'linked_case_closed';
92
+ }
93
+ const action = isDissolving ? 'dissolved' : 'updated';
94
+ const changeItems = isDissolving
95
+ ? [`removed_members:${caseId}`, 'status:active→dissolved', 'dissolve_category:linked_case_closed']
96
+ : [`removed_members:${caseId}`];
97
+ const history = [new ClusterHistoryRecord({
98
+ clusterId: cluster.clusterId,
99
+ turnId: turnId || '',
100
+ action,
101
+ changes: { items: changeItems },
102
+ transitionReason: `cascade from ${sourceTool} on case ${caseId}`,
103
+ recordedAt: now,
104
+ })];
105
+ await storeCluster({ patientId, cluster: new ClusterImpression(patch), history });
106
+ (isDissolving ? dissolvedClusters : prunedClusters).push(cluster.clusterId);
107
+ if (isDissolving) {
108
+ try {
109
+ const clusterNets = await readContingencies({ patientId, clusterId: cluster.clusterId });
110
+ const dissolved = await autoResolveNets({ patientCode: patientId, nets: clusterNets, resolvedReason: RESOLVED_REASONS.LINKED_CLUSTER_DISSOLVED, trace });
111
+ autoResolvedPlans.push(...dissolved.autoResolvedPlans);
112
+ cascadeErrors.push(...dissolved.cascadeErrors);
113
+ } catch (error) {
114
+ cascadeErrors.push(`contingency_auto_resolve_failed: ${error?.message || error}`);
115
+ }
116
+ }
117
+ } catch (error) {
118
+ cascadeErrors.push(`cluster_cascade_failed: ${error?.message || error}`);
119
+ }
120
+ }
121
+
122
+ return { autoResolvedPlans, prunedClusters, dissolvedClusters, cascadeErrors };
123
+ }
124
+
125
+ module.exports = {
126
+ DEACTIVATE_CLOSE_REASONS,
127
+ CLUSTER_MEMBER_FLOOR,
128
+ isResolutionReason,
129
+ resolveCloseReason,
130
+ closePatch,
131
+ runCloseCascade,
132
+ };
@@ -1,19 +1,19 @@
1
1
  const crypto = require('node:crypto');
2
2
 
3
- const { GRADE_CONFIDENCE_LEVELS, GRADE_MIN, GRADE_MAX, gradeEstimateSchema, parseGradeEstimate, projectGradeEstimate, hasGradeSignal } = require('../helpers/gradeEstimateHelper');
3
+ const { GRADE_CONFIDENCE_LEVELS, GRADE_MIN, GRADE_MAX, gradeEstimateSchema, parseGradeEstimate, projectGradeEstimate, hasGradeSignal, malformedGradeEstimate } = require('../helpers/gradeEstimateHelper');
4
4
  const { ceilingGapError } = require('../helpers/ceilingGapHelper');
5
5
  const { mentionProvenanceError } = require('../helpers/evidenceAnchorHelper');
6
6
  const { ctcaeTermError } = require('../services/ctcaeTermService');
7
7
  const { gradeClaimError } = require('../services/gradeClaimService');
8
8
  const { armOpenCeilingNet } = require('../services/contingencyDispatchService');
9
9
  const { readSymptomCases, storeSymptomCase } = require('../../fhir');
10
- const { ManagedSymptom, OPEN_STATUSES, FUNCTIONAL_ANCHORS } = require('../../shared/dtos/ManagedSymptom');
10
+ const { ManagedSymptom, OPEN_STATUSES, CLOSED_STATUSES, FUNCTIONAL_ANCHORS } = require('../../shared/dtos/ManagedSymptom');
11
11
  const { logger } = require('../../utils/logger');
12
12
  const { isArmOnOpenCeilingLive } = require('../flags/contingencyFlags');
13
13
 
14
14
  const definition = {
15
15
  name: 'openCondition',
16
- description: '**Does:** Opens a NEW agent-tracked symptom case (Condition + initial ClinicalImpression) for a symptom term with no open case — the longitudinal expediente that groups 1..N episodes of the same `ctcae_term`.\n\n**Required inputs:** `ctcae_term` (English snake_case catalog key — e.g. `fever`, `pain`, `dyspnea`; use `other` only when no catalog term fits), `episode_id` (the intake episode this case wraps), `mention_id` (the intake mention that opened the case), `verbatim_quote` (exact substring of the patient message). Optional `temporality`, `grade_estimate` (`{best_estimate, confidence, possible_range, reasoning}`).\n\n**When to call:** when the turn surfaces a symptom with no open case on this `ctcae_term`. Use `recordClinicalImpression` if a case is already open — do NOT call `openCondition` twice for the same symptom.\n\n**When NOT to call:** for a new episode or subsequent assessment of an EXISTING case — use `recordClinicalImpression`.\n\n**Returns:** `case_id`, `action_taken` (`opened`), `status`, `ctcae_term`, `episode_ids`, `current_episode_id`, `grade_estimate`, `temporality`, `trajectory`. Fails hard with `case_already_open_for_term` + `existing_case_id` when an open case already exists for the term — recover via `recordClinicalImpression`.\n\n**Side effects:** writes a FHIR `Condition` + head `ClinicalImpression` + `Provenance`.',
16
+ description: '**Does:** Opens a NEW agent-tracked symptom case (Condition + initial ClinicalImpression) for a symptom term with no open case — the longitudinal expediente that groups 1..N episodes of the same `ctcae_term`.\n\n**Required inputs:** `ctcae_term` (English snake_case catalog key — e.g. `fever`, `pain`, `dyspnea`; use `other` only when no catalog term fits), `episode_id` (the intake episode this case wraps), `mention_id` (the intake mention that opened the case), `verbatim_quote` (exact substring of the patient message). Optional `temporality`, `grade_estimate` (`{best_estimate, confidence, possible_range, reasoning}`).\n\n**When to call:** when the turn surfaces a symptom with no open case on this `ctcae_term`. Use `recordClinicalImpression` if a case is already open — do NOT call `openCondition` twice for the same symptom.\n\n**When NOT to call:**\n- For a new episode or subsequent assessment of an EXISTING open case — use `recordClinicalImpression`.\n- For a symptom that already has a case which was closed, resolved or deactivated — the same symptom returning is a recurrence of that case, not a new one. Reopen it with `updateConditionStatus(transition=\'reactivate\')`. This tool refuses the duplicate with `DORMANT_CASE_EXISTS` and names the case.\n\n**Returns:** `case_id`, `action_taken` (`opened`), `status`, `ctcae_term`, `episode_ids`, `current_episode_id`, `grade_estimate`, `temporality`, `trajectory`. Fails hard with `case_already_open_for_term` + `existing_case_id` when an open case already exists for the term — recover via `recordClinicalImpression`.\n\n**Side effects:** writes a FHIR `Condition` + head `ClinicalImpression` + `Provenance`.',
17
17
  strict: false,
18
18
  parameters: {
19
19
  type: 'object',
@@ -91,12 +91,30 @@ async function handler(args = {}, context = {}) {
91
91
  });
92
92
  }
93
93
 
94
+ const dormantCase = casesForTerm.find((symptomCase) => CLOSED_STATUSES.includes(symptomCase.condition.clinicalStatus));
95
+ if (dormantCase) {
96
+ const { condition } = dormantCase;
97
+ return JSON.stringify({
98
+ success: false,
99
+ error: 'DORMANT_CASE_EXISTS',
100
+ data: {
101
+ existing_case_id: condition.conditionId,
102
+ existing_status: condition.clinicalStatus,
103
+ close_reason: condition.closeReason,
104
+ dormant_since: condition.abatementAt || condition.lastUpdatedAt || null,
105
+ hint: `this symptom already has a case — reopen it instead of forking the record: updateConditionStatus(case_id='${condition.conditionId}', transition='reactivate', episode_id='${episodeId}', reasoning=…), then recordClinicalImpression on the same case_id`,
106
+ },
107
+ });
108
+ }
109
+
94
110
  const provenance = mentionProvenanceError(mentionId, { trace: runtime?.trace || null });
95
111
  if (provenance) return JSON.stringify({ success: false, error: provenance, data: {} });
96
112
 
97
113
  const now = new Date().toISOString();
98
114
  const caseId = uid('case');
99
115
  const temporality = typeof args?.temporality === 'string' ? args.temporality : null;
116
+ const malformedGrade = malformedGradeEstimate(args?.grade_estimate);
117
+ if (malformedGrade) return JSON.stringify({ success: false, error: malformedGrade, data: {} });
100
118
  const grade = parseGradeEstimate(args?.grade_estimate);
101
119
  if (grade.error) {
102
120
  const data = { invalid_grade_confidence: { allowed: GRADE_CONFIDENCE_LEVELS },
@@ -1,21 +1,21 @@
1
1
  const crypto = require('node:crypto');
2
2
 
3
- const { CARRIED_FORWARD, GRADE_CONFIDENCE_LEVELS, GRADE_MIN, GRADE_MAX, gradeEstimateSchema, parseGradeEstimate, projectGradeEstimate, projectGradeRead, hasGradeSignal, isCarriedForward } = require('../helpers/gradeEstimateHelper');
3
+ const { CARRIED_FORWARD, GRADE_CONFIDENCE_LEVELS, GRADE_MIN, GRADE_MAX, gradeEstimateSchema, parseGradeEstimate, projectGradeEstimate, projectGradeRead, hasGradeSignal, isCarriedForward, malformedGradeEstimate } = require('../helpers/gradeEstimateHelper');
4
4
  const { ceilingGapError } = require('../helpers/ceilingGapHelper');
5
5
  const { mentionProvenanceError } = require('../helpers/evidenceAnchorHelper');
6
6
  const { MEDICATION_NAMING_RULE } = require('../helpers/medicationNamingRule');
7
+ const { resolveCloseReason, closePatch, runCloseCascade } = require('../services/conditionCloseService');
7
8
  const { gradeClaimError } = require('../services/gradeClaimService');
8
- const { autoResolveNets, armOpenCeilingNet } = require('../services/contingencyDispatchService');
9
+ const { armOpenCeilingNet } = require('../services/contingencyDispatchService');
9
10
  const { inputConsistency, statedReasoning } = require('../services/impressionValidationService');
10
11
  const { isArmOnOpenCeilingLive } = require('../flags/contingencyFlags');
11
- const { readContingencies, readSymptomCases, storeSymptomCase } = require('../../fhir');
12
+ const { readSymptomCases, storeSymptomCase } = require('../../fhir');
12
13
  const { ManagedSymptom, OPEN_STATUSES, FUNCTIONAL_ANCHORS } = require('../../shared/dtos/ManagedSymptom');
13
- const { RESOLVED_REASONS } = require('../../shared/dtos/ContingencySafetyNet');
14
14
  const { logger } = require('../../utils/logger');
15
15
 
16
16
  const definition = {
17
17
  name: 'recordClinicalImpression',
18
- description: '**Does:** Evolves an existing symptom case by `event`. `add_evidence`: new mention on the current episode (requires `characterizing` + a grade or trajectory). `new_episode`: a new episode for the same `ctcae_term` — attaches it and returns the case to `characterizing`. `resolve_episode`: the current episode resolved — case goes to `monitoring`. `close`: terminal — status `closed`, requires `close_reason`.\n\nAn impression is a grading act plus the reasoning behind it: `grade_estimate` carries your most likely grade with its confidence and range, and `reasoning` names, in one or two sentences, what you think is going on. `reasoning` is REQUIRED on every call and is the canonical per-case rationale — it lands on the clinical record, and the routing trace references it rather than restating it. Omitting it does not fail the write, but the impression is then a grade with no clinical reasoning behind it and the envelope says so in `input_consistency`. Grade-specific justification (the matched `grade_scale` clause) belongs inside `grade_estimate.reasoning`.\n\n**Required inputs:** `case_id` (from `openCondition` or `getActiveSymptomLandscape`), `event`, `reasoning`. Per-event: `add_evidence`/`new_episode` require `mention_id`, `missing_for_higher_grade`, plus at least one of `grade_estimate` or `trajectory_direction`; `new_episode` also requires `episode_id`; `close` requires `close_reason`.\n\n**Returns:** `case_id`, `action_taken`, `status`, `ctcae_term`, `episode_ids`, `current_episode_id`, `mention_ids`, `grade_estimate`, `temporality`, `trajectory`, `decision_posture`, `close_reason`, `abatement_at`, plus `input_consistency` when the call under-specified something. Fails hard on status guards (e.g. `add_evidence` on a non-`characterizing` case), duplicate `mention_id`, or an already-attached `episode_id`.\n\n**Side effects:** appends a FHIR `ClinicalImpression` to the case chain and repoints the head; updates the `Condition` lifecycle. `close` also retires any contingency net guarding the case (returned as `auto_resolved_contingency_plans`) — a closed case never keeps writing to the patient.',
18
+ description: '**Does:** Evolves an existing symptom case by `event`. `add_evidence`: new mention on the current episode (requires `characterizing` + a grade or trajectory). `new_episode`: a new episode for the same `ctcae_term` — attaches it and returns the case to `characterizing`. `resolve_episode`: the current episode resolved — case goes to `monitoring`. `close`: terminal — status `closed`, requires `close_reason` from the closed vocabulary.\n\nAn impression is a grading act plus the reasoning behind it: `grade_estimate` carries your most likely grade with its confidence and range, and `reasoning` names, in one or two sentences, what you think is going on. `reasoning` is REQUIRED on every call and is the canonical per-case rationale — it lands on the clinical record, and the routing trace references it rather than restating it. Omitting it does not fail the write, but the impression is then a grade with no clinical reasoning behind it and the envelope says so in `input_consistency`. Grade-specific justification (the matched `grade_scale` clause) belongs inside `grade_estimate.reasoning`.\n\n**Required inputs:** `case_id` (from `openCondition` or `getActiveSymptomLandscape`), `event`, `reasoning`. Per-event: `add_evidence`/`new_episode` require `mention_id`, `missing_for_higher_grade`, plus at least one of `grade_estimate` or `trajectory_direction`; `new_episode` also requires `episode_id`; `close` requires `close_reason`, and it must be one of `resolved` | `team_resolved` | `resolved_spontaneously` | `resolved_by_intervention` (the symptom ENDED — this stamps `abatement_at`) or `escalated_to_team` | `patient_transferred` | `stale_no_activity` (stop tracking WITHOUT claiming an end — `abatement_at` stays null). Never claim a resolution nobody observed; free text is rejected.\n\n**Returns:** `case_id`, `action_taken`, `status`, `ctcae_term`, `episode_ids`, `current_episode_id`, `mention_ids`, `grade_estimate`, `temporality`, `trajectory`, `decision_posture`, `close_reason`, `abatement_at`, plus `input_consistency` when the call under-specified something. Fails hard on status guards (e.g. `add_evidence` on a non-`characterizing` case), duplicate `mention_id`, or an already-attached `episode_id`.\n\n**Side effects:** appends a FHIR `ClinicalImpression` to the case chain and repoints the head; updates the `Condition` lifecycle. `close` also retires any contingency net guarding the case (returned as `auto_resolved_contingency_plans`) — a closed case never keeps writing to the patient — and prunes the case from its active clusters, dissolving one that drops below 2 members (`pruned_clusters`, `dissolved_clusters`).',
19
19
  strict: false,
20
20
  parameters: {
21
21
  type: 'object',
@@ -84,6 +84,8 @@ const normalisedEvent = (args) => (typeof args?.event === 'string' ? args.event
84
84
  const uid = (prefix) => `${prefix}_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`;
85
85
 
86
86
  function coerceGrade(raw, base, continuesEpisode = false) {
87
+ const malformed = malformedGradeEstimate(raw);
88
+ if (malformed) return { error: malformed };
87
89
  if (isCarriedForward(raw)) {
88
90
  const parsed = parseGradeEstimate(raw);
89
91
  if (parsed?.error) return parsed;
@@ -240,6 +242,8 @@ function applyNewEpisode(existing, args, runtime) {
240
242
  }
241
243
 
242
244
  function closingAssessment(existing, args, runtime, restatesPriorEvidence) {
245
+ const malformed = malformedGradeEstimate(args?.grade_estimate);
246
+ if (malformed) return { error: malformed };
243
247
  if (!hasGradeSignal(args?.grade_estimate) && !statesTrajectory(args)) return null;
244
248
  return buildAssessment(existing.condition, existing.latestAssessment, args, runtime, restatesPriorEvidence);
245
249
  }
@@ -270,11 +274,10 @@ function applyClose(existing, args, runtime) {
270
274
  if (!OPEN_STATUSES.includes(existing.condition.clinicalStatus)) {
271
275
  return fail('case_already_terminal', { case_id: existing.condition.conditionId, status: existing.condition.clinicalStatus, abatement_at: existing.condition.abatementAt, close_reason: existing.condition.closeReason });
272
276
  }
273
- const closeReason = (typeof args?.close_reason === 'string' ? args.close_reason : '').trim();
274
- if (!closeReason) return fail('close_reason is required');
275
- const patch = { clinicalStatus: 'closed', abatementAt: new Date().toISOString(), closeReason };
277
+ const close = resolveCloseReason(args?.close_reason, { caseId: existing.condition.conditionId });
278
+ if (close.error) return fail(close.error.error, close.error.data);
276
279
  const reasoning = (typeof args?.reasoning === 'string' ? args.reasoning : '').trim();
277
- if (reasoning) patch.lastTransitionReason = reasoning;
280
+ const patch = closePatch({ closeReason: close.closeReason, resolving: close.resolving, reasoning });
278
281
  const assessment = closingAssessment(existing, args, runtime, true);
279
282
  if (assessment?.error) return failAssessment(assessment.error, existing.condition.conditionId);
280
283
  return toManaged(existing, patch, assessment);
@@ -311,15 +314,16 @@ async function handler(args = {}, context = {}) {
311
314
  await storeSymptomCase({ patientId, managedSymptom: result });
312
315
 
313
316
  const autoResolvedPlans = [];
317
+ const prunedClusters = [];
318
+ const dissolvedClusters = [];
314
319
  if (event === 'close') {
315
320
  try {
316
- const nets = await readContingencies({ patientId, caseId });
317
- const cascade = await autoResolveNets({
318
- patientCode: patientId, nets, resolvedReason: RESOLVED_REASONS.LINKED_CASE_CLOSED, trace: runtime.trace || null,
319
- });
321
+ const cascade = await runCloseCascade(patientId, caseId, runtime.turnId, runtime.trace || null, 'recordClinicalImpression');
320
322
  autoResolvedPlans.push(...cascade.autoResolvedPlans);
323
+ prunedClusters.push(...cascade.prunedClusters);
324
+ dissolvedClusters.push(...cascade.dissolvedClusters);
321
325
  for (const failure of cascade.cascadeErrors) {
322
- logger.warn('[recordClinicalImpression] net left armed on a closed case', { caseId, error: failure });
326
+ logger.warn('[recordClinicalImpression] close cascade incomplete', { caseId, error: failure });
323
327
  }
324
328
  } catch (error) {
325
329
  // The close already succeeded, so failing the tool here would only invite a useless retry.
@@ -352,6 +356,8 @@ async function handler(args = {}, context = {}) {
352
356
  data: {
353
357
  case_id: condition.conditionId,
354
358
  ...(autoResolvedPlans.length ? { auto_resolved_contingency_plans: autoResolvedPlans } : {}),
359
+ ...(prunedClusters.length ? { pruned_clusters: prunedClusters } : {}),
360
+ ...(dissolvedClusters.length ? { dissolved_clusters: dissolvedClusters } : {}),
355
361
  action_taken: event,
356
362
  status: condition.clinicalStatus,
357
363
  ctcae_term: condition.ctcaeTerm,
@@ -8,7 +8,7 @@ const ACTIONS = ['create', 'record_outcome'];
8
8
 
9
9
  const definition = {
10
10
  name: 'recordIntervention',
11
- description: '**Does:** Records an agent-delivered intervention against an open symptom case (`action:\'create\'`) or patches the outcome of one already recorded (`action:\'record_outcome\'`). One intervention = one clinical action (OTC advice, self-care, education, a team escalation task, or a follow-up check).\n\n**Create inputs:** `case_id` (an OPEN case from `openCondition`/`getActiveSymptomLandscape`), `intervention_label`, `intervention_kind` (`otc` | `self_care` | `other` | `education` | `team_escalation` | `follow_up_check`); optional `rationale`. Fails hard with `case_not_found_or_wrong_patient` or `case_is_dormant` (recover via `recordClinicalImpression` reactivation), or `invalid intervention_kind`.\n\n**Outcome inputs (`action:\'record_outcome\'`):** `intervention_id` (the handle create returned), `outcome` (`pending` | `improved` | `no_relief` | `side_effects` | `worsened` | `unknown`); optional `outcome_notes`. Single-write: fails with `outcome_already_recorded` if the outcome is already non-pending.\n\n**When to call:** create when you deliver an intervention; record_outcome once the patient reports how it went.\n\n**Returns:** create → `{intervention_id, delivered_at}`; record_outcome → `{intervention_id, outcome}`.\n\n**Side effects:** writes a FHIR Procedure/Communication/Task/CommunicationRequest (per kind) + Provenance.',
11
+ description: '**Does:** Records an agent-delivered intervention against an open symptom case (`action:\'create\'`) or patches the outcome of one already recorded (`action:\'record_outcome\'`). One intervention = one clinical action (OTC advice, self-care, education, a team escalation task, or a follow-up check).\n\n**Create inputs:** `case_id` (an OPEN case from `openCondition`/`getActiveSymptomLandscape`), `intervention_label`, `intervention_kind` (`otc` | `self_care` | `other` | `education` | `team_escalation` | `follow_up_check`); optional `rationale`. Fails hard with `case_not_found_or_wrong_patient` or `case_is_dormant` (recover via `updateConditionStatus(transition=\'reactivate\')`), or `invalid intervention_kind`.\n\n**Outcome inputs (`action:\'record_outcome\'`):** `intervention_id` (the handle create returned), `outcome` (`pending` | `improved` | `no_relief` | `side_effects` | `worsened` | `unknown`); optional `outcome_notes`. Single-write: fails with `outcome_already_recorded` if the outcome is already non-pending.\n\n**Record vs actuate — this tool only WRITES A RECORD.** `intervention_kind=\'team_escalation\'` dispatches nothing: escalate with `reportMedicalEscalation` FIRST, then record it here. `intervention_kind=\'follow_up_check\'` schedules nothing: schedule with `schedulePatientReminder` FIRST, then record it here. **Recording an escalation is not escalating** — a `success` here never means anyone was notified.\n\n**When to call:** create when you deliver an intervention; record_outcome once the patient reports how it went.\n\n**Returns:** create → `{intervention_id, delivered_at}`; record_outcome → `{intervention_id, outcome}`.\n\n**Side effects:** writes a FHIR Procedure/Communication/Task/CommunicationRequest (per kind) + Provenance.',
12
12
  strict: false,
13
13
  parameters: {
14
14
  type: 'object',
@@ -87,7 +87,7 @@ async function create(patientId, args) {
87
87
  const targetCase = (await readSymptomCases({ patientId })).find((c) => c.condition.conditionId === caseId);
88
88
  if (!targetCase) return fail('case_not_found_or_wrong_patient', { case_id: caseId });
89
89
  if (!OPEN_STATUSES.includes(targetCase.condition.clinicalStatus)) {
90
- return fail('case_is_dormant — interventions attach to open cases; if the symptom recurred, reactivate the case via recordClinicalImpression first.', { case_id: caseId, status: targetCase.condition.clinicalStatus });
90
+ return fail('case_is_dormant — interventions attach to open cases; if the symptom recurred, reactivate the case with updateConditionStatus(transition=\'reactivate\') first.', { case_id: caseId, status: targetCase.condition.clinicalStatus });
91
91
  }
92
92
 
93
93
  const deliveredAt = new Date().toISOString();
@@ -1,17 +1,13 @@
1
- const { autoResolveNets } = require('../services/contingencyDispatchService');
2
- const { readSymptomCases, storeSymptomCase, readContingencies, readClusters, storeCluster } = require('../../fhir');
3
- const { ManagedSymptom, OPEN_STATUSES, RESOLUTION_CLOSE_REASONS } = require('../../shared/dtos/ManagedSymptom');
4
- const { RESOLVED_REASONS } = require('../../shared/dtos/ContingencySafetyNet');
5
- const { ClusterImpression } = require('../../shared/dtos/ClusterImpression');
6
- const { ClusterHistoryRecord } = require('../../shared/dtos/ClusterHistoryRecord');
1
+ const {
2
+ isResolutionReason, resolveCloseReason, closePatch, runCloseCascade,
3
+ } = require('../services/conditionCloseService');
4
+ const { readSymptomCases, storeSymptomCase } = require('../../fhir');
5
+ const { ManagedSymptom } = require('../../shared/dtos/ManagedSymptom');
7
6
  const { logger } = require('../../utils/logger');
8
7
 
9
- const DEACTIVATE_CLOSE_REASONS = ['escalated_to_team', 'patient_transferred', 'stale_no_activity'];
10
- const CLUSTER_MEMBER_FLOOR = 2;
11
-
12
8
  const definition = {
13
9
  name: 'updateConditionStatus',
14
- 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, which also auto-resolves that cluster\'s own safety net).',
10
+ description: '**Does:** Transitions the lifecycle of a symptom `Condition` (FHIR `Condition.clinicalStatus`), selected via `transition`.\n\n**Required inputs:** `case_id`, `transition` (`resolve_episode` | `resolve` | `deactivate` | `reactivate`), and `reasoning` (one short sentence justifying the transition — persisted to the Condition\'s audit record). Per transition:\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**When NOT to call:**\n- To start a new episode of the same term — that is `recordClinicalImpression` with `event=\'new_episode\'`, which gives the episode its own grade and arms its safety net. This tool refuses it.\n- To register a `ClinicalImpression` — that is `recordClinicalImpression`\'s job; call it separately, in an earlier round.\n- To open a case that does not exist yet — that is `openCondition`.\n\n**Parallelism with `recordClinicalImpression`:** `resolve_episode`, `resolve` and `deactivate` move the case out of `characterizing`, which is the status `recordClinicalImpression` requires. Record the impression carrying the closing read in an EARLIER round; a parallel impression in the same round will fail.\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, which also auto-resolves that cluster\'s own safety net).',
15
11
  strict: false,
16
12
  parameters: {
17
13
  type: 'object',
@@ -22,8 +18,8 @@ const definition = {
22
18
  },
23
19
  transition: {
24
20
  type: 'string',
25
- enum: ['new_episode', 'resolve_episode', 'resolve', 'deactivate', 'reactivate'],
26
- 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).',
21
+ enum: ['resolve_episode', 'resolve', 'deactivate', 'reactivate'],
22
+ description: '`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).',
27
23
  },
28
24
  reasoning: {
29
25
  type: 'string',
@@ -31,7 +27,7 @@ const definition = {
31
27
  },
32
28
  episode_id: {
33
29
  type: 'string',
34
- description: 'Required for new_episode (a genuinely-new episode) and reactivate (the new bout\'s intake episode).',
30
+ description: 'Required for reactivate (the new bout\'s intake episode).',
35
31
  },
36
32
  close_reason: {
37
33
  type: 'string',
@@ -52,10 +48,6 @@ function fail(error, data = {}) {
52
48
  return JSON.stringify({ success: false, error, data });
53
49
  }
54
50
 
55
- function isResolutionReason(reason) {
56
- return RESOLUTION_CLOSE_REASONS.has((reason || '').trim().toLowerCase());
57
- }
58
-
59
51
  function returnedStatus(condition) {
60
52
  const { clinicalStatus } = condition;
61
53
  if (clinicalStatus === 'closed') {
@@ -77,25 +69,6 @@ function toManaged(existing, conditionPatch) {
77
69
  });
78
70
  }
79
71
 
80
- function applyNewEpisode(existing, reasoning, args) {
81
- if (!OPEN_STATUSES.includes(existing.condition.clinicalStatus)) {
82
- return fail('new_episode requires an open case (characterizing or monitoring).', { case_id: existing.condition.conditionId, status: existing.condition.clinicalStatus });
83
- }
84
- const episodeId = (typeof args?.episode_id === 'string' ? args.episode_id : '').trim();
85
- if (!episodeId) return fail('new_episode requires episode_id');
86
- if (existing.condition.evidenceEpisodeIds.includes(episodeId)) {
87
- return fail('episode_already_attached_to_case', { case_id: existing.condition.conditionId, episode_id: episodeId, current_episode_id: existing.condition.currentEpisodeId });
88
- }
89
- const patch = {
90
- evidenceEpisodeIds: [...existing.condition.evidenceEpisodeIds, episodeId],
91
- currentEpisodeId: episodeId,
92
- clinicalStatus: 'characterizing',
93
- lastTransitionReason: reasoning,
94
- };
95
- if (typeof args?.temporality === 'string') patch.temporality = args.temporality;
96
- return toManaged(existing, patch);
97
- }
98
-
99
72
  function applyResolveEpisode(existing, reasoning, args) {
100
73
  if (existing.condition.clinicalStatus !== 'characterizing') {
101
74
  return fail('resolve_episode requires status=characterizing (there must be an active episode to resolve).', { case_id: existing.condition.conditionId, status: existing.condition.clinicalStatus });
@@ -109,31 +82,16 @@ function applyDormant(existing, reasoning, args, resolving) {
109
82
  if (isDormant(existing.condition)) {
110
83
  return fail('case_already_dormant', { case_id: existing.condition.conditionId, status: returnedStatus(existing.condition), close_reason: existing.condition.closeReason });
111
84
  }
112
- let closeReason = (typeof args?.close_reason === 'string' ? args.close_reason : '').trim().toLowerCase();
113
- if (resolving) {
114
- closeReason = closeReason || 'resolved';
115
- if (!RESOLUTION_CLOSE_REASONS.has(closeReason)) {
116
- return fail('invalid_close_reason_for_resolve', { case_id: existing.condition.conditionId, valid_close_reasons: [...RESOLUTION_CLOSE_REASONS] });
117
- }
118
- } else {
119
- if (!closeReason) return fail('close_reason is required for deactivate', { valid_close_reasons: DEACTIVATE_CLOSE_REASONS });
120
- if (!DEACTIVATE_CLOSE_REASONS.includes(closeReason)) {
121
- return fail('invalid_close_reason_for_deactivate', { case_id: existing.condition.conditionId, valid_close_reasons: DEACTIVATE_CLOSE_REASONS });
122
- }
123
- }
124
- const patch = {
125
- clinicalStatus: 'closed',
126
- abatementAt: resolving ? new Date().toISOString() : null,
127
- closeReason,
128
- lastTransitionReason: reasoning,
129
- };
85
+ const close = resolveCloseReason(args?.close_reason, { resolving, caseId: existing.condition.conditionId });
86
+ if (close.error) return fail(close.error.error, close.error.data);
87
+ const patch = closePatch({ closeReason: close.closeReason, resolving: close.resolving, reasoning });
130
88
  if (typeof args?.temporality === 'string') patch.temporality = args.temporality;
131
89
  return toManaged(existing, patch);
132
90
  }
133
91
 
134
92
  function applyReactivate(existing, reasoning, args) {
135
93
  if (!isDormant(existing.condition)) {
136
- 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 });
94
+ return fail('reactivate requires a dormant case (resolved/inactive). This case is already open — record the new bout with recordClinicalImpression(event=\'new_episode\').', { case_id: existing.condition.conditionId, status: existing.condition.clinicalStatus });
137
95
  }
138
96
  const episodeId = (typeof args?.episode_id === 'string' ? args.episode_id : '').trim();
139
97
  if (!episodeId) return fail('reactivate requires episode_id');
@@ -156,70 +114,6 @@ function applyReactivate(existing, reasoning, args) {
156
114
  return { managed: toManaged(existing, patch), wasResolved: hasBeenResolved };
157
115
  }
158
116
 
159
- async function runCloseCascade(patientId, caseId, turnId, trace = null) {
160
- const autoResolvedPlans = [];
161
- const prunedClusters = [];
162
- const dissolvedClusters = [];
163
- const cascadeErrors = [];
164
- const now = new Date().toISOString();
165
-
166
- try {
167
- const nets = await readContingencies({ patientId, caseId });
168
- const caseNets = await autoResolveNets({ patientCode: patientId, nets, resolvedReason: RESOLVED_REASONS.LINKED_CASE_CLOSED, trace });
169
- autoResolvedPlans.push(...caseNets.autoResolvedPlans);
170
- cascadeErrors.push(...caseNets.cascadeErrors);
171
- } catch (error) {
172
- cascadeErrors.push(`contingency_auto_resolve_failed: ${error?.message || error}`);
173
- }
174
-
175
- const clusters = await readClusters({ patientId, status: 'active' }).catch((error) => {
176
- cascadeErrors.push(`cluster_cascade_failed: ${error?.message || error}`);
177
- return [];
178
- });
179
- for (const cluster of clusters) {
180
- if (!cluster.memberCaseIds.includes(caseId)) continue;
181
- try {
182
- const remaining = cluster.memberCaseIds.filter((id) => id !== caseId);
183
- const isDissolving = remaining.length < CLUSTER_MEMBER_FLOOR;
184
- const patch = { ...cluster, memberCaseIds: remaining.length ? remaining : cluster.memberCaseIds };
185
- if (isDissolving) {
186
- patch.status = 'dissolved';
187
- patch.dissolvedAt = now;
188
- patch.dissolveReason = `cascade: linked case ${caseId} closed`;
189
- patch.dissolveCategory = 'linked_case_closed';
190
- }
191
- const action = isDissolving ? 'dissolved' : 'updated';
192
- const changeItems = isDissolving
193
- ? [`removed_members:${caseId}`, 'status:active→dissolved', 'dissolve_category:linked_case_closed']
194
- : [`removed_members:${caseId}`];
195
- const history = [new ClusterHistoryRecord({
196
- clusterId: cluster.clusterId,
197
- turnId: turnId || '',
198
- action,
199
- changes: { items: changeItems },
200
- transitionReason: `cascade from updateConditionStatus on case ${caseId}`,
201
- recordedAt: now,
202
- })];
203
- await storeCluster({ patientId, cluster: new ClusterImpression(patch), history });
204
- (isDissolving ? dissolvedClusters : prunedClusters).push(cluster.clusterId);
205
- if (isDissolving) {
206
- try {
207
- const clusterNets = await readContingencies({ patientId, clusterId: cluster.clusterId });
208
- const dissolved = await autoResolveNets({ patientCode: patientId, nets: clusterNets, resolvedReason: RESOLVED_REASONS.LINKED_CLUSTER_DISSOLVED, trace });
209
- autoResolvedPlans.push(...dissolved.autoResolvedPlans);
210
- cascadeErrors.push(...dissolved.cascadeErrors);
211
- } catch (error) {
212
- cascadeErrors.push(`contingency_auto_resolve_failed: ${error?.message || error}`);
213
- }
214
- }
215
- } catch (error) {
216
- cascadeErrors.push(`cluster_cascade_failed: ${error?.message || error}`);
217
- }
218
- }
219
-
220
- return { autoResolvedPlans, prunedClusters, dissolvedClusters, cascadeErrors };
221
- }
222
-
223
117
  async function handler(args = {}, context = {}) {
224
118
  try {
225
119
  const runtime = context?.toolRuntimeContext || null;
@@ -230,6 +124,12 @@ async function handler(args = {}, context = {}) {
230
124
  const transition = (typeof args?.transition === 'string' ? args.transition : '').trim().toLowerCase();
231
125
  const reasoning = (typeof args?.reasoning === 'string' ? args.reasoning : '').trim();
232
126
  if (!caseId) return fail('case_id is required');
127
+ if (transition === 'new_episode') {
128
+ return fail('new_episode is not a status transition — use recordClinicalImpression', {
129
+ case_id: caseId,
130
+ recovery: 'call recordClinicalImpression with event=\'new_episode\', episode_id, mention_id, missing_for_higher_grade and a grade_estimate or trajectory_direction, so the new episode carries its own grade',
131
+ });
132
+ }
233
133
  if (!TRANSITIONS.includes(transition)) {
234
134
  return fail(`unknown transition: '${transition}'. Must be one of ${TRANSITIONS.join(' | ')}.`);
235
135
  }
@@ -240,13 +140,6 @@ async function handler(args = {}, context = {}) {
240
140
  const existing = cases.find((symptomCase) => symptomCase.condition.conditionId === caseId);
241
141
  if (!existing) return fail('case_not_found_or_wrong_patient', { case_id: caseId });
242
142
 
243
- if (transition === 'new_episode') {
244
- const result = applyNewEpisode(existing, reasoning, args);
245
- if (typeof result === 'string') return result;
246
- await storeSymptomCase({ patientId, managedSymptom: result });
247
- return JSON.stringify({ success: true, data: { case_id: result.condition.conditionId, status: returnedStatus(result.condition) } });
248
- }
249
-
250
143
  if (transition === 'resolve_episode') {
251
144
  const result = applyResolveEpisode(existing, reasoning, args);
252
145
  if (typeof result === 'string') return result;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.44.0-dev.5437",
3
+ "version": "5.44.0-dev.5439",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",