@peopl-health/nexus 5.44.0-dev.5328 → 5.44.0-dev.5345

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.
@@ -67,7 +67,7 @@ function gradeEstimateSchema(description) {
67
67
  functional_anchor: {
68
68
  type: 'string',
69
69
  enum: FUNCTIONAL_ANCHORS,
70
- 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 the grade below stays inside possible_range.',
70
+ 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
71
  },
72
72
  source: {
73
73
  type: 'string',
@@ -32,7 +32,7 @@ function anchorLeanedOnBy(ctcaeTerm, grade) {
32
32
  return null;
33
33
  }
34
34
 
35
- function gradeAnchorError({ ctcaeTerm, grade, anchor, possibleRange }) {
35
+ function gradeAnchorError({ ctcaeTerm, grade, anchor, possibleRange, previous = null, continuesEpisode = false }) {
36
36
  const leaned = anchorLeanedOnBy(ctcaeTerm, grade);
37
37
  if (!leaned) return null;
38
38
  const named = leaned === 'self_care' ? 'self-care' : 'instrumental';
@@ -41,14 +41,19 @@ function gradeAnchorError({ ctcaeTerm, grade, anchor, possibleRange }) {
41
41
  + `Say what you established — ${FUNCTIONAL_ANCHORS.join(', ')} — rather than restating the clause.`;
42
42
  }
43
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;
44
48
  const below = grade - 1;
45
49
  if (anchor === 'not_limited') {
46
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. `
47
51
  + `Assign grade ${below} — the highest grade whose clause the evidence actually satisfies.`;
48
52
  }
49
- if (Array.isArray(possibleRange) && possibleRange[0] <= below) return null;
50
- return `functional_anchor is '${anchor}', but grade ${grade} of ${ctcaeTerm} turns on ${named} ADL, `
51
- + `so grade ${below} is still live: set possible_range to [${below}, ${Array.isArray(possibleRange) ? possibleRange[1] : grade}], or establish the ${named} ADL limitation before asserting ${grade}.`;
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.`;
52
57
  }
53
58
 
54
59
  module.exports = {
@@ -24,17 +24,19 @@ const GUARDS = [
24
24
  hasLadder: hasCuratedLadder(ctcaeTerm),
25
25
  trace,
26
26
  }),
27
- ({ ctcaeTerm, grade }) => gradeAnchorError({
27
+ ({ ctcaeTerm, grade, previous, continuesEpisode }) => gradeAnchorError({
28
28
  ctcaeTerm,
29
29
  grade: grade.bestEstimate ?? null,
30
30
  anchor: grade.functionalAnchor ?? null,
31
31
  possibleRange: grade.possibleRange ?? null,
32
+ previous,
33
+ continuesEpisode,
32
34
  }),
33
35
  ];
34
36
 
35
- function gradeClaimError({ ctcaeTerm, grade, stated = null, previous = null, restatesPriorEvidence = true, trace = null }) {
37
+ function gradeClaimError({ ctcaeTerm, grade, stated = null, previous = null, restatesPriorEvidence = true, continuesEpisode = false, trace = null }) {
36
38
  const resolved = grade || {};
37
- const claim = { ctcaeTerm, grade: resolved, stated: stated || resolved, previous, restatesPriorEvidence, trace };
39
+ const claim = { ctcaeTerm, grade: resolved, stated: stated || resolved, previous, restatesPriorEvidence, continuesEpisode, trace };
38
40
  for (const guard of GUARDS) {
39
41
  const error = guard(claim);
40
42
  if (error) return error;
@@ -78,9 +78,12 @@ const EVENTS = definition.parameters.properties.event.enum;
78
78
  const TRAJECTORIES = definition.parameters.properties.trajectory_direction.enum;
79
79
  const POSTURES = definition.parameters.properties.decision_posture.enum;
80
80
 
81
+ // Only new_episode starts a fresh episode; every other event acts on the one the case holds.
82
+ const normalisedEvent = (args) => (typeof args?.event === 'string' ? args.event : '').trim().toLowerCase();
83
+
81
84
  const uid = (prefix) => `${prefix}_${crypto.randomUUID().replace(/-/g, '').slice(0, 12)}`;
82
85
 
83
- function coerceGrade(raw, base) {
86
+ function coerceGrade(raw, base, continuesEpisode = false) {
84
87
  if (isCarriedForward(raw)) {
85
88
  const parsed = parseGradeEstimate(raw);
86
89
  if (parsed?.error) return parsed;
@@ -89,7 +92,13 @@ function coerceGrade(raw, base) {
89
92
  if (!hasGradeSignal(raw)) return base;
90
93
  const parsed = parseGradeEstimate(raw);
91
94
  if (parsed?.error) return parsed;
92
- if (parsed.functionalAnchor === null && parsed.bestEstimate === base?.bestEstimate) {
95
+ // The anchor records the case's functional state, not whether this turn re-asked. An unchanged
96
+ // grade keeps the anchor already established, so the finding survives more than one quiet turn.
97
+ const holdsGrade = parsed.bestEstimate === base?.bestEstimate;
98
+ const notReasked = parsed.functionalAnchor === null || parsed.functionalAnchor === 'not_established';
99
+ if (continuesEpisode && holdsGrade && notReasked && base?.functionalAnchor) {
100
+ parsed.functionalAnchor = base.functionalAnchor;
101
+ } else if (parsed.functionalAnchor === null && holdsGrade) {
93
102
  parsed.functionalAnchor = base?.functionalAnchor ?? null;
94
103
  }
95
104
  return parsed;
@@ -98,7 +107,8 @@ function coerceGrade(raw, base) {
98
107
  const statesTrajectory = (args) => args?.trajectory_direction !== null && args?.trajectory_direction !== undefined;
99
108
 
100
109
  function buildAssessment(condition, previous, args, runtime, restatesPriorEvidence = false) {
101
- const grade = coerceGrade(args?.grade_estimate, previous.grade);
110
+ const continuesEpisode = normalisedEvent(args) !== 'new_episode';
111
+ const grade = coerceGrade(args?.grade_estimate, previous.grade, continuesEpisode);
102
112
  if (grade?.error) return grade;
103
113
  if (hasGradeSignal(args?.grade_estimate)) {
104
114
  const claimGap = gradeClaimError({
@@ -107,6 +117,7 @@ function buildAssessment(condition, previous, args, runtime, restatesPriorEviden
107
117
  stated: parseGradeEstimate(args.grade_estimate),
108
118
  previous: previous.grade,
109
119
  restatesPriorEvidence,
120
+ continuesEpisode,
110
121
  trace: runtime?.trace || null,
111
122
  });
112
123
  if (claimGap) return { error: claimGap };
@@ -280,7 +291,7 @@ async function handler(args = {}, context = {}) {
280
291
  return JSON.stringify({ success: false, error: 'recordClinicalImpression requires an active turn context (turnId, patientCode).', data: {} });
281
292
  }
282
293
  const caseId = (typeof args?.case_id === 'string' ? args.case_id : '').trim();
283
- const event = (typeof args?.event === 'string' ? args.event : '').trim().toLowerCase();
294
+ const event = normalisedEvent(args);
284
295
  if (!caseId) return fail('case_id is required');
285
296
  if (!EVENTS.includes(event)) {
286
297
  return fail(`unknown event: '${event}'. Must be one of ${EVENTS.join(' | ')}.`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.44.0-dev.5328",
3
+ "version": "5.44.0-dev.5345",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",