@peopl-health/nexus 5.71.4 → 5.71.6

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.
@@ -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 — 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.',
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 — the grade then stands only on another part of its clause (regular laxatives, moderate pain, a measured value); with nothing else met, record the grade below. `not_established`: you have not asked. Grade from the whole clause and the whole picture — a non-functional part of the clause that is met, or intensity, regimen, cycle day and trajectory that make that grade the most likely one — name in reasoning what supports it, keep the grade below in possible_range, and ask the patient the one functional question that would confirm it in your reply. Missing ADL information is not evidence that function is preserved, and it never moves a grade more than one step above what the evidence strictly shows. When the clause names a specific finding — manual evacuation indicated, at rest, IV fluids, hospitalization — that grade needs that finding: intensity words alone do not reach it, so record the grade the evidence meets and keep the higher one in possible_range.',
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. Correcting an over-call is the same: name the finding that rules the higher grade out ("cocina y trabaja como siempre").',
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. `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.',
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. When what the patient tells you this turn contradicts that number (they can do today what the questionnaire said they could not), what they tell you now wins: grade it yourself as `agent_adjudicated`. `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,11 +19,13 @@ 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
+
22
24
  const trimmed = (value) => String(value ?? '').trim();
23
25
 
24
26
  function withholdReason(sections) {
25
27
  const { present } = flagsByStatus(sections);
26
- const modifiers = (Array.isArray(sections?.treatmentModifiers) ? sections.treatmentModifiers : []).map(trimmed).filter(Boolean);
28
+ const modifiers = (Array.isArray(sections?.treatmentModifiers) ? sections.treatmentModifiers : []).map((modifier) => trimmed(modifier).replace(/_/g, ' ')).filter(Boolean);
27
29
  const reasons = [];
28
30
  if (present.length) reasons.push(present.length === 1 ? 'hay una bandera roja presente' : 'hay banderas rojas presentes');
29
31
  if (modifiers.length) reasons.push(`condición que cambia el algoritmo (${modifiers.join(', ')})`);
@@ -172,16 +174,20 @@ async function generateEscalationDraft({
172
174
  return { completed: false, attempted: true, reason: `the treatment proposal broke the formulary rules: ${issues.join('; ')}` };
173
175
  }
174
176
 
175
- const doctorText = composeDoctorBody({
176
- alarm: alarms[alarms.length - 1] ?? null,
177
+ const draft = {
177
178
  shared: parsed,
178
179
  symptoms: settled.map(({ block, proposal, withheldReason, conduct }) => ({ sections: block, proposal, withheldReason, conduct })),
179
- });
180
+ };
181
+ const doctorText = composeDoctorBody({ ...draft, alarm: alarms[alarms.length - 1] ?? null });
180
182
 
181
183
  if (!doctorText || !patientMessage) {
182
184
  logger.warn('[escalationDraft] generation returned no usable text', { reviewId });
183
185
  return { completed: false, attempted: true, reason: 'the model returned no usable text' };
184
186
  }
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
+ }
185
191
 
186
192
  return {
187
193
  completed: true,
@@ -18,21 +18,37 @@ function hasCuratedLadder(ctcaeTerm) {
18
18
  }
19
19
  }
20
20
 
21
- function anchorLeanedOnBy(ctcaeTerm, grade) {
22
- if (!ctcaeTerm || !Number.isInteger(grade) || grade < 2) return null;
23
- let clause = null;
21
+ function gradeClause(ctcaeTerm, grade) {
24
22
  try {
25
- clause = getCtcaeCatalog().getGradeScale(ctcaeTerm)?.[String(grade)]?.description || null;
23
+ return getCtcaeCatalog().getGradeScale(ctcaeTerm)?.[String(grade)]?.description || null;
26
24
  } catch {
27
25
  return null;
28
26
  }
27
+ }
28
+
29
+ function anchorLeanedOnBy(ctcaeTerm, grade) {
30
+ if (!ctcaeTerm || !Number.isInteger(grade) || grade < 2) return null;
31
+ const clause = gradeClause(ctcaeTerm, grade);
29
32
  if (!clause) return null;
30
33
  if (SELF_CARE.test(clause)) return 'self_care';
31
34
  if (INSTRUMENTAL.test(clause)) return 'instrumental';
32
35
  return null;
33
36
  }
34
37
 
35
- function gradeAnchorError({ ctcaeTerm, grade, anchor, possibleRange, previous = null, continuesEpisode = false }) {
38
+ // CTCAE clauses list criteria separated by ';' — 'regular use of laxatives; limiting instrumental ADL'
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 }) {
36
52
  const leaned = anchorLeanedOnBy(ctcaeTerm, grade);
37
53
  if (!leaned) return null;
38
54
  const named = leaned === 'self_care' ? 'self-care' : 'instrumental';
@@ -40,24 +56,30 @@ function gradeAnchorError({ ctcaeTerm, grade, anchor, possibleRange, previous =
40
56
  return `functional_anchor is required for grade ${grade} of ${ctcaeTerm}: its rubric clause turns on ${named} ADL. `
41
57
  + `Say what you established — ${FUNCTIONAL_ANCHORS.join(', ')} — rather than restating the clause.`;
42
58
  }
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.`;
59
+ if (rulesOutFunctionalPart(leaned, anchor) && !clauseHasNonFunctionalCriterion(ctcaeTerm, grade)) {
60
+ return `functional_anchor is '${anchor}', which rules out the ${named} ADL limitation that is the only criterion of grade ${grade} of ${ctcaeTerm}. `
61
+ + 'Record the grade whose clause the evidence does meet.';
62
+ }
63
+ return null;
64
+ }
65
+
66
+ // Advice, not a verdict: the grade stands as recorded and the agent learns what would confirm it.
67
+ function gradeAnchorNotice({ ctcaeTerm, grade, anchor }) {
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.';
52
74
  }
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.`;
75
+ return `Grade ${grade} of ${ctcaeTerm} was recorded without the ${named} ADL limitation its clause mentions. `
76
+ + 'That is fine when another part of the clause is met or the whole picture makes it the most likely grade — `reasoning` must name which. '
77
+ + 'Ask the patient the one functional question that would confirm it in your reply, and keep the grade below in possible_range.';
57
78
  }
58
79
 
59
80
  module.exports = {
60
81
  hasCuratedLadder,
61
82
  anchorLeanedOnBy,
62
83
  gradeAnchorError,
84
+ gradeAnchorNotice,
63
85
  };
@@ -1,5 +1,5 @@
1
- const { CARRIED_FORWARD, parseGradeEstimate } = require('../helpers/gradeEstimateHelper');
2
- const { gradeAnchorError, hasCuratedLadder, anchorLeanedOnBy } = require('./gradeAnchorService');
1
+ const { CARRIED_FORWARD } = require('../helpers/gradeEstimateHelper');
2
+ const { gradeAnchorError, gradeAnchorNotice, hasCuratedLadder } = 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,16 +14,11 @@ const namesAResolution = (basis) => {
14
14
  };
15
15
 
16
16
  const GUARDS = [
17
- ({ ctcaeTerm, grade, previous, recordsOngoingRead }) => {
17
+ ({ 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;
27
22
  if (namesAResolution(grade.improvementBasis)) return null;
28
23
  return `best_estimate ${now} is below the grade ${before} this case already holds, so this call records an improvement. `
29
24
  + 'Name what resolved in `improvement_basis`: the finding that no longer holds, in the patient\'s own terms — a placeholder is not one. '
@@ -50,13 +45,10 @@ const GUARDS = [
50
45
  hasLadder: hasCuratedLadder(ctcaeTerm),
51
46
  trace,
52
47
  }),
53
- ({ ctcaeTerm, grade, previous, continuesEpisode }) => gradeAnchorError({
48
+ ({ ctcaeTerm, grade }) => gradeAnchorError({
54
49
  ctcaeTerm,
55
50
  grade: grade.bestEstimate ?? null,
56
51
  anchor: grade.functionalAnchor ?? null,
57
- possibleRange: grade.possibleRange ?? null,
58
- previous,
59
- continuesEpisode,
60
52
  }),
61
53
  ];
62
54
 
@@ -70,4 +62,12 @@ function gradeClaimError({ ctcaeTerm, grade, stated = null, previous = null, res
70
62
  return null;
71
63
  }
72
64
 
73
- module.exports = { gradeClaimError };
65
+ function gradeClaimNotice({ ctcaeTerm, grade }) {
66
+ return gradeAnchorNotice({
67
+ ctcaeTerm,
68
+ grade: grade?.bestEstimate ?? null,
69
+ anchor: grade?.functionalAnchor ?? null,
70
+ });
71
+ }
72
+
73
+ module.exports = { gradeClaimError, gradeClaimNotice };
@@ -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 } = require('../services/gradeClaimService');
7
+ const { gradeClaimError, gradeClaimNotice } = 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`. 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`, and `grade_notice` when the grade was recorded without the ADL limitation its clause mentions — advisory: the grade was stored as given. 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,6 +125,7 @@ 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;
128
129
  const ceilingGap = gradesTheCase ? ceilingGapError(args, 'openCondition') : null;
129
130
  if (ceilingGap) return JSON.stringify({ success: false, error: ceilingGap, data: {} });
130
131
  const managedSymptom = new ManagedSymptom({
@@ -183,6 +184,7 @@ async function handler(args = {}, context = {}) {
183
184
  grade_estimate: projectGradeEstimate(latestAssessment.grade),
184
185
  temporality: condition.temporality,
185
186
  trajectory: { direction: latestAssessment.trajectory || 'unknown' },
187
+ ...(gradeNotice ? { grade_notice: gradeNotice } : {}),
186
188
  },
187
189
  });
188
190
  } 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 } = require('../services/gradeClaimService');
8
+ const { gradeClaimError, gradeClaimNotice } = 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. 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`).',
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, and `grade_notice` when the grade was recorded without the ADL limitation its clause mentions — advisory: the grade was stored as given. 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,6 +351,8 @@ 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;
354
356
  return JSON.stringify({
355
357
  success: true,
356
358
  data: {
@@ -371,6 +373,7 @@ async function handler(args = {}, context = {}) {
371
373
  close_reason: condition.closeReason,
372
374
  abatement_at: condition.abatementAt,
373
375
  ...(consistency.length ? { input_consistency: consistency } : {}),
376
+ ...(gradeNotice ? { grade_notice: gradeNotice } : {}),
374
377
  },
375
378
  });
376
379
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.71.4",
3
+ "version": "5.71.6",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",