@peopl-health/nexus 5.71.6 → 5.72.0-dev.12918
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 +3 -3
- package/lib/clinical/services/escalationDraftService.js +4 -10
- package/lib/clinical/services/gradeAnchorService.js +18 -40
- package/lib/clinical/services/gradeClaimService.js +13 -13
- package/lib/clinical/tools/openConditionTool.js +2 -4
- package/lib/clinical/tools/recordClinicalImpressionTool.js +2 -5
- package/package.json +1 -1
|
@@ -68,16 +68,16 @@ function gradeEstimateSchema(description) {
|
|
|
68
68
|
functional_anchor: {
|
|
69
69
|
type: 'string',
|
|
70
70
|
enum: FUNCTIONAL_ANCHORS,
|
|
71
|
-
description: 'What you ESTABLISHED about function, not what the clause says. Required whenever the rubric clause for your grade turns on ADL — grade 2 on instrumental ADL, grade 3 on self-care. `instrumental_adl_limited`: they told you the symptom stops them cooking, working, shopping, driving. `self_care_adl_limited`: it stops them bathing, dressing, feeding themselves. `not_limited`: you asked and it does NOT limit them —
|
|
71
|
+
description: 'What you ESTABLISHED about function, not what the clause says. Required whenever the rubric clause for your grade turns on ADL — grade 2 on instrumental ADL, grade 3 on self-care. `instrumental_adl_limited`: they told you the symptom stops them cooking, working, shopping, driving. `self_care_adl_limited`: it stops them bathing, dressing, feeding themselves. `not_limited`: you asked and it does NOT limit them — then that grade is ruled out, so assign the grade below rather than widening the range. `not_established`: you have not asked — then that grade is not yours to assert yet: put the grade below in best_estimate and leave the higher one live in possible_range. The exception is a grade the case already holds on a limitation established earlier in this same episode: there `not_established` just means you did not re-ask, and the grade stands.',
|
|
72
72
|
},
|
|
73
73
|
improvement_basis: {
|
|
74
74
|
type: ['string', 'null'],
|
|
75
|
-
description: 'REQUIRED when best_estimate is lower than the grade this case already holds. Name the finding that no longer holds, in the patient\'s own terms — what they can do again, or the symptom that stopped. A grade only comes down when something specific resolved; "mejoró un poco" is not that, and neither is the absence of new complaints.
|
|
75
|
+
description: 'REQUIRED when best_estimate is lower than the grade this case already holds. Name the finding that no longer holds, in the patient\'s own terms — what they can do again, or the symptom that stopped. A grade only comes down when something specific resolved; "mejoró un poco" is not that, and neither is the absence of new complaints.',
|
|
76
76
|
},
|
|
77
77
|
source: {
|
|
78
78
|
type: 'string',
|
|
79
79
|
enum: GRADE_SOURCES,
|
|
80
|
-
description: 'Where this grade came from. Required whenever you set best_estimate. `proctcae_composite`: you are carrying over the number the PRO-CTCAE triage already computed — say this even when you agree with it.
|
|
80
|
+
description: 'Where this grade came from. Required whenever you set best_estimate. `proctcae_composite`: you are carrying over the number the PRO-CTCAE triage already computed — say this even when you agree with it. `agent_adjudicated`: you read the grade_scale clauses yourself against what the patient said. `carried_forward`: the case\'s previous grade, unchanged, with no new evidence this turn.',
|
|
81
81
|
},
|
|
82
82
|
matched_grade: {
|
|
83
83
|
type: ['integer', 'null'],
|
|
@@ -19,13 +19,11 @@ const CONVERSATION_TURNS = 40;
|
|
|
19
19
|
const TRIAGE_LIMIT = 3;
|
|
20
20
|
const CALL_LOG_LIMIT = 10;
|
|
21
21
|
|
|
22
|
-
const TECHNICAL_TOKEN = /\b(urgentIfPositive|blocksDrug|null)\b|\b[a-z]+_\w+|\\n/i;
|
|
23
|
-
|
|
24
22
|
const trimmed = (value) => String(value ?? '').trim();
|
|
25
23
|
|
|
26
24
|
function withholdReason(sections) {
|
|
27
25
|
const { present } = flagsByStatus(sections);
|
|
28
|
-
const modifiers = (Array.isArray(sections?.treatmentModifiers) ? sections.treatmentModifiers : []).map(
|
|
26
|
+
const modifiers = (Array.isArray(sections?.treatmentModifiers) ? sections.treatmentModifiers : []).map(trimmed).filter(Boolean);
|
|
29
27
|
const reasons = [];
|
|
30
28
|
if (present.length) reasons.push(present.length === 1 ? 'hay una bandera roja presente' : 'hay banderas rojas presentes');
|
|
31
29
|
if (modifiers.length) reasons.push(`condición que cambia el algoritmo (${modifiers.join(', ')})`);
|
|
@@ -174,20 +172,16 @@ async function generateEscalationDraft({
|
|
|
174
172
|
return { completed: false, attempted: true, reason: `the treatment proposal broke the formulary rules: ${issues.join('; ')}` };
|
|
175
173
|
}
|
|
176
174
|
|
|
177
|
-
const
|
|
175
|
+
const doctorText = composeDoctorBody({
|
|
176
|
+
alarm: alarms[alarms.length - 1] ?? null,
|
|
178
177
|
shared: parsed,
|
|
179
178
|
symptoms: settled.map(({ block, proposal, withheldReason, conduct }) => ({ sections: block, proposal, withheldReason, conduct })),
|
|
180
|
-
};
|
|
181
|
-
const doctorText = composeDoctorBody({ ...draft, alarm: alarms[alarms.length - 1] ?? null });
|
|
179
|
+
});
|
|
182
180
|
|
|
183
181
|
if (!doctorText || !patientMessage) {
|
|
184
182
|
logger.warn('[escalationDraft] generation returned no usable text', { reviewId });
|
|
185
183
|
return { completed: false, attempted: true, reason: 'the model returned no usable text' };
|
|
186
184
|
}
|
|
187
|
-
if (TECHNICAL_TOKEN.test(`${composeDoctorBody({ ...draft, alarm: null })}\n${patientMessage}`)) {
|
|
188
|
-
logger.warn('[escalationDraft] generation leaked a technical token into the text', { reviewId });
|
|
189
|
-
return { completed: false, attempted: true, reason: 'the model wrote a field name or technical token into the doctor or patient text' };
|
|
190
|
-
}
|
|
191
185
|
|
|
192
186
|
return {
|
|
193
187
|
completed: true,
|
|
@@ -18,37 +18,21 @@ function hasCuratedLadder(ctcaeTerm) {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function
|
|
21
|
+
function anchorLeanedOnBy(ctcaeTerm, grade) {
|
|
22
|
+
if (!ctcaeTerm || !Number.isInteger(grade) || grade < 2) return null;
|
|
23
|
+
let clause = null;
|
|
22
24
|
try {
|
|
23
|
-
|
|
25
|
+
clause = getCtcaeCatalog().getGradeScale(ctcaeTerm)?.[String(grade)]?.description || null;
|
|
24
26
|
} catch {
|
|
25
27
|
return null;
|
|
26
28
|
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function anchorLeanedOnBy(ctcaeTerm, grade) {
|
|
30
|
-
if (!ctcaeTerm || !Number.isInteger(grade) || grade < 2) return null;
|
|
31
|
-
const clause = gradeClause(ctcaeTerm, grade);
|
|
32
29
|
if (!clause) return null;
|
|
33
30
|
if (SELF_CARE.test(clause)) return 'self_care';
|
|
34
31
|
if (INSTRUMENTAL.test(clause)) return 'instrumental';
|
|
35
32
|
return null;
|
|
36
33
|
}
|
|
37
34
|
|
|
38
|
-
|
|
39
|
-
// is met by either part. Only a clause with nothing but the ADL part is ruled out by 'not_limited'.
|
|
40
|
-
function clauseHasNonFunctionalCriterion(ctcaeTerm, grade) {
|
|
41
|
-
const clause = gradeClause(ctcaeTerm, grade) || '';
|
|
42
|
-
return clause.split(';').some((part) => part.trim() && !SELF_CARE.test(part) && !INSTRUMENTAL.test(part));
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// 'not_limited' rules the functional part out; so does an instrumental-only limitation for a grade
|
|
46
|
-
// whose clause turns on self-care.
|
|
47
|
-
function rulesOutFunctionalPart(leaned, anchor) {
|
|
48
|
-
return anchor === 'not_limited' || (leaned === 'self_care' && anchor === 'instrumental_adl_limited');
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function gradeAnchorError({ ctcaeTerm, grade, anchor }) {
|
|
35
|
+
function gradeAnchorError({ ctcaeTerm, grade, anchor, possibleRange, previous = null, continuesEpisode = false }) {
|
|
52
36
|
const leaned = anchorLeanedOnBy(ctcaeTerm, grade);
|
|
53
37
|
if (!leaned) return null;
|
|
54
38
|
const named = leaned === 'self_care' ? 'self-care' : 'instrumental';
|
|
@@ -56,30 +40,24 @@ function gradeAnchorError({ ctcaeTerm, grade, anchor }) {
|
|
|
56
40
|
return `functional_anchor is required for grade ${grade} of ${ctcaeTerm}: its rubric clause turns on ${named} ADL. `
|
|
57
41
|
+ `Say what you established — ${FUNCTIONAL_ANCHORS.join(', ')} — rather than restating the clause.`;
|
|
58
42
|
}
|
|
59
|
-
if (
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const leaned = anchorLeanedOnBy(ctcaeTerm, grade);
|
|
69
|
-
if (!leaned || !anchor || SATISFIED_BY[leaned].has(anchor)) return null;
|
|
70
|
-
const named = leaned === 'self_care' ? 'self-care' : 'instrumental';
|
|
71
|
-
if (rulesOutFunctionalPart(leaned, anchor)) {
|
|
72
|
-
return `Grade ${grade} of ${ctcaeTerm} can only stand on the non-functional part of its clause: the patient is not limited in ${named} ADL. `
|
|
73
|
-
+ '`reasoning` must name that criterion; if it is not met, record the grade below.';
|
|
43
|
+
if (SATISFIED_BY[leaned].has(anchor)) return null;
|
|
44
|
+
// The case already holds this grade on an anchor established earlier. 'not_established' here
|
|
45
|
+
// says "I did not re-ask this turn", not "nobody ever established it" — holding is honest.
|
|
46
|
+
if (anchor === 'not_established' && continuesEpisode && previous
|
|
47
|
+
&& previous.bestEstimate === grade && SATISFIED_BY[leaned].has(previous.functionalAnchor)) return null;
|
|
48
|
+
const below = grade - 1;
|
|
49
|
+
if (anchor === 'not_limited') {
|
|
50
|
+
return `functional_anchor is 'not_limited', so the ${named} ADL limitation that grade ${grade} of ${ctcaeTerm} turns on is ruled OUT, not merely unknown. `
|
|
51
|
+
+ `Assign grade ${below} — the highest grade whose clause the evidence actually satisfies.`;
|
|
74
52
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
+
|
|
53
|
+
const top = Array.isArray(possibleRange) ? possibleRange[1] : grade;
|
|
54
|
+
return `functional_anchor is '${anchor}', so the ${named} ADL limitation that grade ${grade} of ${ctcaeTerm} turns on is not established. `
|
|
55
|
+
+ `best_estimate is the grade the evidence already carries, not the one it might reach: assign ${below} and keep ${grade} live as possible_range [${below}, ${top}]. `
|
|
56
|
+
+ `Establish the ${named} ADL limitation and ${grade} becomes assertable.`;
|
|
78
57
|
}
|
|
79
58
|
|
|
80
59
|
module.exports = {
|
|
81
60
|
hasCuratedLadder,
|
|
82
61
|
anchorLeanedOnBy,
|
|
83
62
|
gradeAnchorError,
|
|
84
|
-
gradeAnchorNotice,
|
|
85
63
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
const { CARRIED_FORWARD } = require('../helpers/gradeEstimateHelper');
|
|
2
|
-
const { gradeAnchorError,
|
|
1
|
+
const { CARRIED_FORWARD, parseGradeEstimate } = require('../helpers/gradeEstimateHelper');
|
|
2
|
+
const { gradeAnchorError, hasCuratedLadder, anchorLeanedOnBy } = require('./gradeAnchorService');
|
|
3
3
|
const { gradeSourceError, carriedForwardError } = require('./gradeSourceService');
|
|
4
4
|
|
|
5
5
|
// A resolution names a finding. Length is not the test: 'ya come' names one and 'mejoró un poco'
|
|
@@ -14,11 +14,16 @@ const namesAResolution = (basis) => {
|
|
|
14
14
|
};
|
|
15
15
|
|
|
16
16
|
const GUARDS = [
|
|
17
|
-
({ grade, previous, recordsOngoingRead }) => {
|
|
17
|
+
({ ctcaeTerm, grade, previous, recordsOngoingRead }) => {
|
|
18
18
|
if (!recordsOngoingRead) return null;
|
|
19
19
|
const now = grade.bestEstimate;
|
|
20
20
|
const before = previous && previous.bestEstimate;
|
|
21
21
|
if (!Number.isInteger(now) || !Number.isInteger(before) || now >= before) return null;
|
|
22
|
+
// Only 'not_established' is exempt. It means the question was not asked, so the drop is the
|
|
23
|
+
// anchor guard's own remediation rather than a claim that anything got better. 'not_limited'
|
|
24
|
+
// is the opposite: the question WAS asked and the limitation is gone — a finding, and the
|
|
25
|
+
// thing this field exists to record.
|
|
26
|
+
if (grade.functionalAnchor === 'not_established' && now === before - 1 && anchorLeanedOnBy(ctcaeTerm, before)) return null;
|
|
22
27
|
if (namesAResolution(grade.improvementBasis)) return null;
|
|
23
28
|
return `best_estimate ${now} is below the grade ${before} this case already holds, so this call records an improvement. `
|
|
24
29
|
+ 'Name what resolved in `improvement_basis`: the finding that no longer holds, in the patient\'s own terms — a placeholder is not one. '
|
|
@@ -45,10 +50,13 @@ const GUARDS = [
|
|
|
45
50
|
hasLadder: hasCuratedLadder(ctcaeTerm),
|
|
46
51
|
trace,
|
|
47
52
|
}),
|
|
48
|
-
({ ctcaeTerm, grade }) => gradeAnchorError({
|
|
53
|
+
({ ctcaeTerm, grade, previous, continuesEpisode }) => gradeAnchorError({
|
|
49
54
|
ctcaeTerm,
|
|
50
55
|
grade: grade.bestEstimate ?? null,
|
|
51
56
|
anchor: grade.functionalAnchor ?? null,
|
|
57
|
+
possibleRange: grade.possibleRange ?? null,
|
|
58
|
+
previous,
|
|
59
|
+
continuesEpisode,
|
|
52
60
|
}),
|
|
53
61
|
];
|
|
54
62
|
|
|
@@ -62,12 +70,4 @@ function gradeClaimError({ ctcaeTerm, grade, stated = null, previous = null, res
|
|
|
62
70
|
return null;
|
|
63
71
|
}
|
|
64
72
|
|
|
65
|
-
|
|
66
|
-
return gradeAnchorNotice({
|
|
67
|
-
ctcaeTerm,
|
|
68
|
-
grade: grade?.bestEstimate ?? null,
|
|
69
|
-
anchor: grade?.functionalAnchor ?? null,
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
module.exports = { gradeClaimError, gradeClaimNotice };
|
|
73
|
+
module.exports = { gradeClaimError };
|
|
@@ -4,7 +4,7 @@ const { GRADE_CONFIDENCE_LEVELS, GRADE_MIN, GRADE_MAX, gradeEstimateSchema, pars
|
|
|
4
4
|
const { ceilingGapError } = require('../helpers/ceilingGapHelper');
|
|
5
5
|
const { mentionProvenanceError } = require('../helpers/evidenceAnchorHelper');
|
|
6
6
|
const { ctcaeTermError } = require('../services/ctcaeTermService');
|
|
7
|
-
const { gradeClaimError
|
|
7
|
+
const { gradeClaimError } = require('../services/gradeClaimService');
|
|
8
8
|
const { armOpenCeilingNet } = require('../services/contingencyDispatchService');
|
|
9
9
|
const { readSymptomCases, storeSymptomCase } = require('../../fhir');
|
|
10
10
|
const { ManagedSymptom, OPEN_STATUSES, CLOSED_STATUSES, FUNCTIONAL_ANCHORS } = require('../../shared/dtos/ManagedSymptom');
|
|
@@ -13,7 +13,7 @@ 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:**\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
|
|
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',
|
|
@@ -125,7 +125,6 @@ async function handler(args = {}, context = {}) {
|
|
|
125
125
|
const gradesTheCase = hasGradeSignal(args?.grade_estimate);
|
|
126
126
|
const claimGap = gradesTheCase ? gradeClaimError({ ctcaeTerm, grade, trace: runtime?.trace || null }) : null;
|
|
127
127
|
if (claimGap) return JSON.stringify({ success: false, error: claimGap, data: {} });
|
|
128
|
-
const gradeNotice = gradesTheCase ? gradeClaimNotice({ ctcaeTerm, grade }) : null;
|
|
129
128
|
const ceilingGap = gradesTheCase ? ceilingGapError(args, 'openCondition') : null;
|
|
130
129
|
if (ceilingGap) return JSON.stringify({ success: false, error: ceilingGap, data: {} });
|
|
131
130
|
const managedSymptom = new ManagedSymptom({
|
|
@@ -184,7 +183,6 @@ async function handler(args = {}, context = {}) {
|
|
|
184
183
|
grade_estimate: projectGradeEstimate(latestAssessment.grade),
|
|
185
184
|
temporality: condition.temporality,
|
|
186
185
|
trajectory: { direction: latestAssessment.trajectory || 'unknown' },
|
|
187
|
-
...(gradeNotice ? { grade_notice: gradeNotice } : {}),
|
|
188
186
|
},
|
|
189
187
|
});
|
|
190
188
|
} catch (err) {
|
|
@@ -5,7 +5,7 @@ const { ceilingGapError } = require('../helpers/ceilingGapHelper');
|
|
|
5
5
|
const { mentionProvenanceError } = require('../helpers/evidenceAnchorHelper');
|
|
6
6
|
const { MEDICATION_NAMING_RULE } = require('../helpers/medicationNamingRule');
|
|
7
7
|
const { resolveCloseReason, closePatch, runCloseCascade } = require('../services/conditionCloseService');
|
|
8
|
-
const { gradeClaimError
|
|
8
|
+
const { gradeClaimError } = require('../services/gradeClaimService');
|
|
9
9
|
const { armOpenCeilingNet } = require('../services/contingencyDispatchService');
|
|
10
10
|
const { inputConsistency, statedReasoning } = require('../services/impressionValidationService');
|
|
11
11
|
const { isArmOnOpenCeilingLive } = require('../flags/contingencyFlags');
|
|
@@ -15,7 +15,7 @@ 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` 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
|
|
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',
|
|
@@ -351,8 +351,6 @@ async function handler(args = {}, context = {}) {
|
|
|
351
351
|
|
|
352
352
|
const { condition, latestAssessment } = result;
|
|
353
353
|
const consistency = inputConsistency(args);
|
|
354
|
-
const freshRead = ONGOING_READ_EVENTS.includes(event) && hasGradeSignal(args?.grade_estimate) && !isCarriedForward(args.grade_estimate);
|
|
355
|
-
const gradeNotice = freshRead ? gradeClaimNotice({ ctcaeTerm: condition.ctcaeTerm, grade: latestAssessment.grade }) : null;
|
|
356
354
|
return JSON.stringify({
|
|
357
355
|
success: true,
|
|
358
356
|
data: {
|
|
@@ -373,7 +371,6 @@ async function handler(args = {}, context = {}) {
|
|
|
373
371
|
close_reason: condition.closeReason,
|
|
374
372
|
abatement_at: condition.abatementAt,
|
|
375
373
|
...(consistency.length ? { input_consistency: consistency } : {}),
|
|
376
|
-
...(gradeNotice ? { grade_notice: gradeNotice } : {}),
|
|
377
374
|
},
|
|
378
375
|
});
|
|
379
376
|
} catch (err) {
|