@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.
- package/lib/clinical/helpers/gradeEstimateHelper.js +9 -0
- package/lib/clinical/services/conditionCloseService.js +132 -0
- package/lib/clinical/tools/openConditionTool.js +21 -3
- package/lib/clinical/tools/recordClinicalImpressionTool.js +20 -14
- package/lib/clinical/tools/recordInterventionTool.js +2 -2
- package/lib/clinical/tools/updateConditionStatusTool.js +19 -126
- package/package.json +1 -1
|
@@ -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
|
|
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 {
|
|
9
|
+
const { armOpenCeilingNet } = require('../services/contingencyDispatchService');
|
|
9
10
|
const { inputConsistency, statedReasoning } = require('../services/impressionValidationService');
|
|
10
11
|
const { isArmOnOpenCeilingLive } = require('../flags/contingencyFlags');
|
|
11
|
-
const {
|
|
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
|
|
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
|
|
274
|
-
if (
|
|
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
|
-
|
|
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
|
|
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]
|
|
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 `
|
|
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
|
|
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 {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
const {
|
|
5
|
-
const {
|
|
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` (`
|
|
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: ['
|
|
26
|
-
description: '`
|
|
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
|
|
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
|
-
|
|
113
|
-
if (
|
|
114
|
-
|
|
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 —
|
|
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;
|