@peopl-health/nexus 5.44.0-dev.5374 → 5.44.0-dev.5377
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 +17 -1
- package/lib/clinical/monitoring/contingencyMonitorSpec.js +4 -1
- package/lib/clinical/services/gradeClaimService.js +29 -3
- package/lib/clinical/tools/recordClinicalImpressionTool.js +5 -2
- package/lib/fhir/constants/extensionSlugs.js +1 -0
- package/lib/fhir/projections/symptomCaseProjection.js +4 -2
- package/lib/shared/dtos/ManagedSymptom.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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(
|
|
177
|
+
return JSON.stringify(projectGradeRead(a)) === JSON.stringify(projectGradeRead(b));
|
|
175
178
|
}
|
|
176
179
|
|
|
177
180
|
function movesTheRead(previous, next) {
|
|
@@ -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], {
|