@peopl-health/nexus 5.71.5 → 5.71.7

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: when the term has a grade_scale, 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'],
@@ -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 and name in `improvement_basis` what rules this grade out.';
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,19 +45,16 @@ 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
 
63
- function gradeClaimError({ ctcaeTerm, grade, stated = null, previous = null, restatesPriorEvidence = true, continuesEpisode = false, recordsOngoingRead = false, trace = null }) {
55
+ function gradeClaimError({ ctcaeTerm, grade, stated = null, previous = null, restatesPriorEvidence = true, recordsOngoingRead = false, trace = null }) {
64
56
  const resolved = grade || {};
65
- const claim = { ctcaeTerm, grade: resolved, stated: stated || resolved, previous, restatesPriorEvidence, continuesEpisode, recordsOngoingRead, trace };
57
+ const claim = { ctcaeTerm, grade: resolved, stated: stated || resolved, previous, restatesPriorEvidence, recordsOngoingRead, trace };
66
58
  for (const guard of GUARDS) {
67
59
  const error = guard(claim);
68
60
  if (error) return error;
@@ -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',
@@ -121,7 +121,6 @@ function buildAssessment(condition, previous, args, runtime, restatesPriorEviden
121
121
  stated: parseGradeEstimate(args.grade_estimate),
122
122
  previous: previous.grade,
123
123
  restatesPriorEvidence,
124
- continuesEpisode,
125
124
  recordsOngoingRead: ONGOING_READ_EVENTS.includes(normalisedEvent(args)),
126
125
  trace: runtime?.trace || null,
127
126
  });
@@ -175,6 +174,10 @@ function requireCeilingGap(args, event) {
175
174
  return error ? fail(error) : null;
176
175
  }
177
176
 
177
+ function sameGradeAndAnchor(a, b) {
178
+ return !!a && !!b && a.bestEstimate === b.bestEstimate && a.functionalAnchor === b.functionalAnchor;
179
+ }
180
+
178
181
  function sameGrade(a, b) {
179
182
  return JSON.stringify(projectGradeRead(a)) === JSON.stringify(projectGradeRead(b));
180
183
  }
@@ -351,6 +354,10 @@ async function handler(args = {}, context = {}) {
351
354
 
352
355
  const { condition, latestAssessment } = result;
353
356
  const consistency = inputConsistency(args);
357
+ // A hold of the same grade on the same anchor was already advised; repeating it re-asks the patient every turn.
358
+ const heldAsAdvised = event === 'add_evidence' && sameGradeAndAnchor(existing.latestAssessment?.grade, latestAssessment.grade);
359
+ const freshRead = ONGOING_READ_EVENTS.includes(event) && hasGradeSignal(args?.grade_estimate) && !isCarriedForward(args.grade_estimate) && !heldAsAdvised;
360
+ const gradeNotice = freshRead ? gradeClaimNotice({ ctcaeTerm: condition.ctcaeTerm, grade: latestAssessment.grade }) : null;
354
361
  return JSON.stringify({
355
362
  success: true,
356
363
  data: {
@@ -371,6 +378,7 @@ async function handler(args = {}, context = {}) {
371
378
  close_reason: condition.closeReason,
372
379
  abatement_at: condition.abatementAt,
373
380
  ...(consistency.length ? { input_consistency: consistency } : {}),
381
+ ...(gradeNotice ? { grade_notice: gradeNotice } : {}),
374
382
  },
375
383
  });
376
384
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.71.5",
3
+ "version": "5.71.7",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",