@peopl-health/nexus 5.71.5 → 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.
- package/lib/clinical/helpers/gradeEstimateHelper.js +3 -3
- package/lib/clinical/services/gradeAnchorService.js +40 -18
- package/lib/clinical/services/gradeClaimService.js +13 -13
- package/lib/clinical/tools/openConditionTool.js +4 -2
- package/lib/clinical/tools/recordClinicalImpressionTool.js +5 -2
- package/package.json +1 -1
|
@@ -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
|
|
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'],
|
|
@@ -18,21 +18,37 @@ function hasCuratedLadder(ctcaeTerm) {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
function
|
|
22
|
-
if (!ctcaeTerm || !Number.isInteger(grade) || grade < 2) return null;
|
|
23
|
-
let clause = null;
|
|
21
|
+
function gradeClause(ctcaeTerm, grade) {
|
|
24
22
|
try {
|
|
25
|
-
|
|
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
|
-
|
|
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 (
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
+
|
|
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
|
|
2
|
-
const { gradeAnchorError,
|
|
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
|
-
({
|
|
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
|
|
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
|
-
|
|
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
|
|
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) {
|