@peopl-health/nexus 5.44.0-dev.5374 → 5.44.0-dev.5376

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.
@@ -4,7 +4,7 @@ const { MEDICATION_NAMING_RULE } = require('./medicationNamingRule');
4
4
  const CARRIED_FORWARD = 'carried_forward';
5
5
 
6
6
  function parseGradeEstimate(raw) {
7
- const grade = { bestEstimate: null, confidence: null, possibleRange: null, functionalAnchor: null, source: null, matchedGrade: null, reasoning: '' };
7
+ const grade = { bestEstimate: null, confidence: null, possibleRange: null, functionalAnchor: null, source: null, matchedGrade: null, improvementBasis: null, reasoning: '' };
8
8
  if (!raw || typeof raw !== 'object') return grade;
9
9
  if (raw.best_estimate !== null && raw.best_estimate !== undefined) {
10
10
  if (!inGradeRange(raw.best_estimate)) return { error: 'grade_out_of_range' };
@@ -37,6 +37,7 @@ function parseGradeEstimate(raw) {
37
37
  && (grade.bestEstimate < grade.possibleRange[0] || grade.bestEstimate > grade.possibleRange[1])) {
38
38
  return { error: 'grade_out_of_range' };
39
39
  }
40
+ if (typeof raw.improvement_basis === 'string' && raw.improvement_basis.trim()) grade.improvementBasis = raw.improvement_basis.trim();
40
41
  if (typeof raw.reasoning === 'string') grade.reasoning = raw.reasoning;
41
42
  return grade;
42
43
  }
@@ -69,6 +70,10 @@ function gradeEstimateSchema(description) {
69
70
  enum: FUNCTIONAL_ANCHORS,
70
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
72
  },
73
+ improvement_basis: {
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.',
76
+ },
72
77
  source: {
73
78
  type: 'string',
74
79
  enum: GRADE_SOURCES,
@@ -88,6 +93,10 @@ function gradeEstimateSchema(description) {
88
93
  };
89
94
  }
90
95
 
96
+ // improvement_basis explains a downgrade in prose. It renders and it persists, but it is not part
97
+ // of the clinical read, so it never decides whether a repeated mention moved anything.
98
+ const GRADE_READ_FIELDS = ['best_estimate', 'confidence', 'possible_range', 'functional_anchor', 'source', 'matched_grade'];
99
+
91
100
  function projectGradeEstimate(grade) {
92
101
  return {
93
102
  best_estimate: grade?.bestEstimate ?? null,
@@ -96,9 +105,15 @@ function projectGradeEstimate(grade) {
96
105
  functional_anchor: grade?.functionalAnchor ?? null,
97
106
  source: grade?.source ?? null,
98
107
  matched_grade: grade?.matchedGrade ?? null,
108
+ improvement_basis: grade?.improvementBasis ?? null,
99
109
  };
100
110
  }
101
111
 
112
+ function projectGradeRead(grade) {
113
+ const projected = projectGradeEstimate(grade);
114
+ return GRADE_READ_FIELDS.reduce((out, key) => Object.assign(out, { [key]: projected[key] }), {});
115
+ }
116
+
102
117
  function hasGradeSignal(raw) {
103
118
  return !!raw && typeof raw === 'object'
104
119
  && ['best_estimate', 'possible_range', 'confidence', 'functional_anchor', 'source', 'matched_grade']
@@ -112,6 +127,7 @@ function isCarriedForward(raw) {
112
127
  module.exports = {
113
128
  gradeEstimateSchema,
114
129
  projectGradeEstimate,
130
+ projectGradeRead,
115
131
  hasGradeSignal,
116
132
  isCarriedForward,
117
133
  CARRIED_FORWARD,
@@ -224,11 +224,14 @@ function timelineStep(step, callIndex) {
224
224
  };
225
225
  }
226
226
 
227
+ const MONITOR_GRADE_FIELDS = ['best_estimate', 'confidence', 'possible_range', 'functional_anchor', 'source', 'matched_grade'];
228
+
227
229
  function gradedNumbers(raw) {
228
230
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
229
231
  const parsed = parseGradeEstimate(raw);
230
232
  if (parsed.error) return null;
231
- return projectGradeEstimate(parsed);
233
+ const projected = projectGradeEstimate(parsed);
234
+ return MONITOR_GRADE_FIELDS.reduce((out, key) => Object.assign(out, { [key]: projected[key] ?? null }), {});
232
235
  }
233
236
 
234
237
  function gradingStep(step) {
@@ -1,8 +1,34 @@
1
1
  const { CARRIED_FORWARD, parseGradeEstimate } = require('../helpers/gradeEstimateHelper');
2
- const { gradeAnchorError, hasCuratedLadder } = require('./gradeAnchorService');
2
+ const { gradeAnchorError, hasCuratedLadder, anchorLeanedOnBy } = require('./gradeAnchorService');
3
3
  const { gradeSourceError, carriedForwardError } = require('./gradeSourceService');
4
4
 
5
+ // A resolution names a finding. Length is not the test: 'ya come' names one and 'mejoró un poco'
6
+ // does not, however long it runs. Reject the placeholders and the bare comparatives instead.
7
+ const EMPTY_BASIS = new Set(['n/a', 'na', 'none', 'ninguno', 'ninguna', 'nada', '-', '--', '?', 'null', 'n/d', 'ok']);
8
+ const VAGUE_BASIS = /^(algo |un poco |bastante |ya )?(mejor|mejoría|mejoria|mejorando|mejor[oó])( un poco| algo| bastante)?$|^(sin cambios|va mejor|menos|menos molestias?|todo bien|estable)$/i;
9
+ const namesAResolution = (basis) => {
10
+ const text = String(basis || '').trim().replace(/[.!]+$/, '');
11
+ if (text.length < 4) return false;
12
+ if (EMPTY_BASIS.has(text.toLowerCase())) return false;
13
+ return !VAGUE_BASIS.test(text);
14
+ };
15
+
5
16
  const GUARDS = [
17
+ ({ ctcaeTerm, grade, previous, recordsOngoingRead }) => {
18
+ if (!recordsOngoingRead) return null;
19
+ const now = grade.bestEstimate;
20
+ const before = previous && previous.bestEstimate;
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
+ if (namesAResolution(grade.improvementBasis)) return null;
28
+ return `best_estimate ${now} is below the grade ${before} this case already holds, so this call records an improvement. `
29
+ + 'Name what resolved in `improvement_basis`: the finding that no longer holds, in the patient\'s own terms — a placeholder is not one. '
30
+ + `If nothing specific resolved, the case still carries grade ${before} — keep it, and put ${now} in possible_range as where it may be heading.`;
31
+ },
6
32
  ({ stated, previous, restatesPriorEvidence }) => {
7
33
  if (stated.source !== CARRIED_FORWARD) return null;
8
34
  if (!previous) {
@@ -34,9 +60,9 @@ const GUARDS = [
34
60
  }),
35
61
  ];
36
62
 
37
- function gradeClaimError({ ctcaeTerm, grade, stated = null, previous = null, restatesPriorEvidence = true, continuesEpisode = false, trace = null }) {
63
+ function gradeClaimError({ ctcaeTerm, grade, stated = null, previous = null, restatesPriorEvidence = true, continuesEpisode = false, recordsOngoingRead = false, trace = null }) {
38
64
  const resolved = grade || {};
39
- const claim = { ctcaeTerm, grade: resolved, stated: stated || resolved, previous, restatesPriorEvidence, continuesEpisode, trace };
65
+ const claim = { ctcaeTerm, grade: resolved, stated: stated || resolved, previous, restatesPriorEvidence, continuesEpisode, recordsOngoingRead, trace };
40
66
  for (const guard of GUARDS) {
41
67
  const error = guard(claim);
42
68
  if (error) return error;
@@ -1,6 +1,6 @@
1
1
  const crypto = require('node:crypto');
2
2
 
3
- const { CARRIED_FORWARD, GRADE_CONFIDENCE_LEVELS, GRADE_MIN, GRADE_MAX, gradeEstimateSchema, parseGradeEstimate, projectGradeEstimate, hasGradeSignal, isCarriedForward } = require('../helpers/gradeEstimateHelper');
3
+ const { CARRIED_FORWARD, GRADE_CONFIDENCE_LEVELS, GRADE_MIN, GRADE_MAX, gradeEstimateSchema, parseGradeEstimate, projectGradeEstimate, projectGradeRead, hasGradeSignal, isCarriedForward } = 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');
@@ -106,6 +106,8 @@ function coerceGrade(raw, base, continuesEpisode = false) {
106
106
 
107
107
  const statesTrajectory = (args) => args?.trajectory_direction !== null && args?.trajectory_direction !== undefined;
108
108
 
109
+ const ONGOING_READ_EVENTS = ['add_evidence', 'new_episode'];
110
+
109
111
  function buildAssessment(condition, previous, args, runtime, restatesPriorEvidence = false) {
110
112
  const continuesEpisode = normalisedEvent(args) !== 'new_episode';
111
113
  const grade = coerceGrade(args?.grade_estimate, previous.grade, continuesEpisode);
@@ -118,6 +120,7 @@ function buildAssessment(condition, previous, args, runtime, restatesPriorEviden
118
120
  previous: previous.grade,
119
121
  restatesPriorEvidence,
120
122
  continuesEpisode,
123
+ recordsOngoingRead: ONGOING_READ_EVENTS.includes(normalisedEvent(args)),
121
124
  trace: runtime?.trace || null,
122
125
  });
123
126
  if (claimGap) return { error: claimGap };
@@ -171,7 +174,7 @@ function requireCeilingGap(args, event) {
171
174
  }
172
175
 
173
176
  function sameGrade(a, b) {
174
- return JSON.stringify(projectGradeEstimate(a)) === JSON.stringify(projectGradeEstimate(b));
177
+ return JSON.stringify(projectGradeRead(a)) === JSON.stringify(projectGradeRead(b));
175
178
  }
176
179
 
177
180
  function movesTheRead(previous, next) {
@@ -40,6 +40,7 @@ const GRADE_EXT = {
40
40
  THRESHOLD_CLAUSE: 'grade-threshold-clause',
41
41
  FUNCTIONAL_ANCHOR: 'grade-functional-anchor',
42
42
  MATCHED_GRADE: 'grade-matched-clause',
43
+ IMPROVEMENT_BASIS: 'grade-improvement-basis',
43
44
  };
44
45
 
45
46
  const MENTION_EXT = {
@@ -48,7 +48,7 @@ function caseProvenance(anchorId, recordedAt, targetRefs) {
48
48
  function assessmentResources(assessment, placeholderIds) {
49
49
  const grade = assessment.grade;
50
50
  const hasGradeSignal = grade.bestEstimate !== null || grade.possibleRange !== null
51
- || grade.matchedGrade !== null || grade.functionalAnchor !== null
51
+ || grade.matchedGrade !== null || grade.functionalAnchor !== null || Boolean(grade.improvementBasis)
52
52
  || Boolean(grade.confidence) || Boolean(grade.reasoning) || Boolean(assessment.gradeThresholdClause);
53
53
  const out = [impressionResource(assessment, placeholderIds, hasGradeSignal)];
54
54
  if (hasGradeSignal) out.push(gradeResource(assessment));
@@ -170,6 +170,7 @@ function gradeExtensions(assessment) {
170
170
  if (grade.reasoning) out.push(extension(GRADE_EXT.REASONING, { valueString: grade.reasoning }));
171
171
  if (grade.functionalAnchor !== null) out.push(extension(GRADE_EXT.FUNCTIONAL_ANCHOR, { valueString: grade.functionalAnchor }));
172
172
  if (grade.matchedGrade !== null) out.push(extension(GRADE_EXT.MATCHED_GRADE, { valueString: String(grade.matchedGrade) }));
173
+ if (grade.improvementBasis) out.push(extension(GRADE_EXT.IMPROVEMENT_BASIS, { valueString: grade.improvementBasis }));
173
174
  if (assessment.gradeVerification) out.push(extension(GRADE_EXT.VERIFICATION, { valueString: assessment.gradeVerification }));
174
175
  if (assessment.gradeThresholdClause) out.push(extension(GRADE_EXT.THRESHOLD_CLAUSE, { valueString: assessment.gradeThresholdClause }));
175
176
  return out;
@@ -251,7 +252,7 @@ function reconstructAssessment(impression, { patientId, conditionId, previousId
251
252
  }
252
253
 
253
254
  function reconstructGrade(obs) {
254
- const grade = { bestEstimate: null, confidence: null, possibleRange: null, functionalAnchor: null, source: null, matchedGrade: null, reasoning: '' };
255
+ const grade = { bestEstimate: null, confidence: null, possibleRange: null, functionalAnchor: null, source: null, matchedGrade: null, improvementBasis: null, reasoning: '' };
255
256
  if (!obs) return grade;
256
257
  const ext = extMap(obs);
257
258
  if (typeof obs.valueInteger === 'number') grade.bestEstimate = obs.valueInteger;
@@ -261,6 +262,7 @@ function reconstructGrade(obs) {
261
262
  if (Number.isInteger(lo) && Number.isInteger(hi)) grade.possibleRange = [lo, hi];
262
263
  }
263
264
  if (ext[GRADE_EXT.FUNCTIONAL_ANCHOR]) grade.functionalAnchor = ext[GRADE_EXT.FUNCTIONAL_ANCHOR];
265
+ if (typeof ext[GRADE_EXT.IMPROVEMENT_BASIS] === 'string') grade.improvementBasis = ext[GRADE_EXT.IMPROVEMENT_BASIS];
264
266
  if (ext[GRADE_EXT.MATCHED_GRADE]) {
265
267
  const matched = Number(ext[GRADE_EXT.MATCHED_GRADE]);
266
268
  if (inGradeRange(matched)) grade.matchedGrade = matched;
@@ -25,6 +25,7 @@ const gradeEstimateSchema = z.strictObject({
25
25
  functionalAnchor: z.enum(FUNCTIONAL_ANCHORS).nullable().default(null),
26
26
  source: z.enum(GRADE_SOURCES).nullable().default(null),
27
27
  matchedGrade: gradeNumber.nullable().default(null),
28
+ improvementBasis: z.string().nullable().default(null),
28
29
  reasoning: z.string().default(''),
29
30
  })
30
31
  .refine((g) => g.possibleRange === null || g.possibleRange[0] <= g.possibleRange[1], {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peopl-health/nexus",
3
- "version": "5.44.0-dev.5374",
3
+ "version": "5.44.0-dev.5376",
4
4
  "description": "Core messaging and assistant library for WhatsApp communication platforms",
5
5
  "keywords": [
6
6
  "whatsapp",