@peopl-health/nexus 5.9.2 → 5.10.0-dev.1021

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.
Files changed (32) hide show
  1. package/lib/clinical/config/subAgentsConfig.js +1 -7
  2. package/lib/clinical/memory/DefaultMemoryManager.js +1 -1
  3. package/lib/clinical/providers/AnthropicProvider.js +0 -10
  4. package/lib/clinical/tools/{getPatientRiskAssessmentTool.js → getPatientRiskProfileTool.js} +4 -4
  5. package/lib/clinical/tools/getRouterContextBundleTool.js +1 -1
  6. package/lib/clinical/tools/registerClinicalTools.js +1 -1
  7. package/lib/fhir/constants/projectionSlugs.js +0 -12
  8. package/lib/fhir/index.js +1 -9
  9. package/lib/fhir/projections/registerProjectors.js +0 -6
  10. package/lib/fhir/resources/CarePlan.js +1 -23
  11. package/lib/fhir/resources/ClinicalImpression.js +1 -64
  12. package/lib/fhir/resources/Provenance.js +0 -20
  13. package/lib/fhir/resources/RiskAssessment.js +1 -40
  14. package/lib/fhir/resources/Task.js +0 -57
  15. package/lib/fhir/services/riskService.js +0 -19
  16. package/package.json +1 -1
  17. package/lib/clinical/helpers/palliativeAssessmentHelper.js +0 -62
  18. package/lib/clinical/models/patternConsultModel.js +0 -37
  19. package/lib/clinical/services/patternConsultService.js +0 -115
  20. package/lib/clinical/tools/requestPatternConsultTool.js +0 -70
  21. package/lib/clinical/tools/submitPalliativeAssessmentTool.js +0 -75
  22. package/lib/clinical/tools/submitRecommendationPlanTool.js +0 -242
  23. package/lib/clinical/tools/submitSafetyGateTool.js +0 -207
  24. package/lib/clinical/tools/updatePalliativeAssessmentTool.js +0 -74
  25. package/lib/fhir/projections/palliativeProjection.js +0 -37
  26. package/lib/fhir/projections/recommendationPlanProjection.js +0 -25
  27. package/lib/fhir/projections/safetyGateProjection.js +0 -16
  28. package/lib/fhir/services/palliativeService.js +0 -121
  29. package/lib/fhir/services/recommendationService.js +0 -27
  30. package/lib/shared/dtos/PalliativeAssessment.js +0 -62
  31. package/lib/shared/dtos/RecommendationPlan.js +0 -109
  32. package/lib/shared/dtos/RecommendationSafetyGate.js +0 -64
@@ -1,115 +0,0 @@
1
- const crypto = require('node:crypto');
2
-
3
- const { getPatternConsult } = require('../config/subAgentsConfig');
4
- const { patternConsultStore } = require('../models/patternConsultModel');
5
- const { readOutputText } = require('../helpers/clinicalMentionHelper');
6
- const { assembleLandscape } = require('../tools/getActiveSymptomLandscapeTool');
7
- const { safeParse } = require('../../utils/jsonUtils');
8
- const { logger } = require('../../utils/logger');
9
-
10
- const PLAUSIBILITY = new Set(['low', 'moderate', 'high']);
11
-
12
- function asStringArray(value) {
13
- return Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
14
- }
15
-
16
- function shapeImpressions(raw) {
17
- if (!Array.isArray(raw)) return [];
18
- return raw
19
- .filter((item) => item && typeof item === 'object')
20
- .map((item) => ({
21
- label: typeof item.label === 'string' ? item.label : '',
22
- plausibility: PLAUSIBILITY.has(item.plausibility) ? item.plausibility : 'low',
23
- supporting: asStringArray(item.supporting),
24
- against: asStringArray(item.against),
25
- missing_discriminators: asStringArray(item.missing_discriminators),
26
- }));
27
- }
28
-
29
- function shapeEscalation(raw) {
30
- if (!raw || typeof raw !== 'object') return null;
31
- return {
32
- indicated_now: raw.indicated_now === true,
33
- if_then: typeof raw.if_then === 'string' ? raw.if_then : '',
34
- };
35
- }
36
-
37
- function degradedEnvelope(consultationId, degradedReasons) {
38
- return { consultationId, impressions: [], recommendedProbes: [], watchFor: [], escalationAdvice: null, degradedReasons, degraded: true };
39
- }
40
-
41
- function buildConsultInput(concern, bundle) {
42
- return [
43
- `S/B/A/R concern: ${concern}`,
44
- `evidence_bundle: ${JSON.stringify(bundle)}`,
45
- ].join('\n');
46
- }
47
-
48
- async function persist(store, record) {
49
- if (!store) return false;
50
- try {
51
- await store.put(record);
52
- return true;
53
- } catch (err) {
54
- logger.warn('[patternConsult] persist failed', { turnId: record.turnId, error: err?.message });
55
- return false;
56
- }
57
- }
58
-
59
- async function requestPatternConsult({ concern, patientId, turnId, provider }) {
60
- const consultationId = `pc_${turnId}_${crypto.randomUUID().replace(/-/g, '').slice(0, 8)}`;
61
- let store = null;
62
-
63
- try {
64
- store = patternConsultStore();
65
- const { presetId } = await getPatternConsult();
66
- const bundle = await assembleLandscape(patientId);
67
-
68
- if (!bundle.active_cases?.length) {
69
- await persist(store, { consultationId, patientCode: patientId, turnId, concern, degraded: true, degradedReasons: ['no_open_cases_to_consult_on'] });
70
- return degradedEnvelope(consultationId, ['no_open_cases_to_consult_on']);
71
- }
72
-
73
- if (!presetId || !provider?.runStructured) {
74
- await persist(store, { consultationId, patientCode: patientId, turnId, concern, degraded: true, degradedReasons: ['consultant_unavailable'] });
75
- return degradedEnvelope(consultationId, ['consultant_unavailable']);
76
- }
77
-
78
- const result = await provider.runStructured({
79
- presetId,
80
- input: [{ role: 'user', content: buildConsultInput(concern, bundle) }],
81
- metadata: { subAgent: 'pattern-consult', turnId },
82
- });
83
-
84
- if (result?.status && result.status !== 'completed') throw new Error(`pattern consult incomplete: status=${result.status}`);
85
-
86
- const outputText = readOutputText(result);
87
- const parsed = safeParse(outputText, null);
88
- if (outputText && outputText.trim() && parsed === null) {
89
- logger.warn('[patternConsult] model output did not parse as JSON; degraded', { turnId });
90
- await persist(store, { consultationId, patientCode: patientId, turnId, concern, degraded: true, degradedReasons: ['malformed_model_output'] });
91
- return degradedEnvelope(consultationId, ['malformed_model_output']);
92
- }
93
-
94
- const impressions = shapeImpressions(parsed?.impressions);
95
- const recommendedProbes = asStringArray(parsed?.recommended_probes);
96
- const watchFor = asStringArray(parsed?.watch_for);
97
- const escalationAdvice = shapeEscalation(parsed?.escalation_advice);
98
-
99
- const persisted = await persist(store, {
100
- consultationId, patientCode: patientId, turnId, concern,
101
- impressions, recommendedProbes, watchFor, escalationAdvice,
102
- });
103
- if (!persisted) return degradedEnvelope(consultationId, ['persist_failed']);
104
-
105
- return { consultationId, impressions, recommendedProbes, watchFor, escalationAdvice, degradedReasons: [], degraded: false };
106
- } catch (err) {
107
- logger.error('[patternConsult] consult failed; degraded', { turnId, error: err?.message });
108
- await persist(store, { consultationId, patientCode: patientId, turnId, concern, degraded: true, degradedReasons: ['consultant_unavailable'] });
109
- return degradedEnvelope(consultationId, ['consultant_unavailable']);
110
- }
111
- }
112
-
113
- module.exports = {
114
- requestPatternConsult,
115
- };
@@ -1,70 +0,0 @@
1
- const { requireGatewayProvider } = require('../config/llmConfig');
2
- const { requestPatternConsult } = require('../services/patternConsultService');
3
-
4
- const definition = {
5
- name: 'requestPatternConsult',
6
- description: '**Does:** Consults a symptom-pattern specialist sub-agent that reviews the patient\'s active symptom landscape and returns RANKED differential impressions, each with supporting AND against evidence plus the discriminators still missing. Read-only and NON-TERMINAL — it informs your reasoning; it does not resolve the turn.\n\n**Required inputs:** `concern` (SBAR-style, 1-2 sentences framing what to reason about).\n\n**When to call:** when several concurrent symptoms, an ambiguous presentation, or a possible cluster warrant an expert second read before you route or probe.\n\n**Returns:** `data.impressions[]` (each: `label`, `plausibility` low|moderate|high, `supporting[]`, `against[]`, `missing_discriminators[]`), `data.recommended_probes[]`, `data.watch_for[]`, `data.escalation_advice` ({ indicated_now, if_then } or null), `data.degraded` (true when the consult could not run — treat as advisory-absent, not as a clear result) with `data.degraded_reasons[]`.',
7
- strict: false,
8
- parameters: {
9
- type: 'object',
10
- properties: {
11
- concern: {
12
- type: 'string',
13
- description: 'SBAR-style framing of the clinical question, 1-2 sentences: what the patient presents and what you want the specialist to reason about.',
14
- },
15
- },
16
- required: ['concern'],
17
- },
18
- };
19
-
20
- async function handler(args = {}, context = {}) {
21
- try {
22
- const runtime = context?.toolRuntimeContext || null;
23
- const trace = runtime?.trace || null;
24
-
25
- if (!runtime?.turnId || !runtime?.patientCode) {
26
- return JSON.stringify({ success: false, error: 'requestPatternConsult requires an active turn context (turnId, patientCode).', data: {} });
27
- }
28
-
29
- const concern = (args?.concern || '').trim();
30
- if (!concern) {
31
- return JSON.stringify({ success: false, error: 'requestPatternConsult requires `concern`.', data: {} });
32
- }
33
-
34
- const r = await requestPatternConsult({
35
- concern,
36
- patientId: runtime.patientCode,
37
- turnId: runtime.turnId,
38
- provider: requireGatewayProvider(),
39
- });
40
-
41
- trace?.setSignals?.({
42
- patternConsult: {
43
- consultationId: r.consultationId,
44
- impressions: r.impressions.length,
45
- escalationIndicated: !!r.escalationAdvice?.indicated_now,
46
- degraded: r.degraded,
47
- },
48
- });
49
-
50
- return JSON.stringify({
51
- success: true,
52
- data: {
53
- consultation_id: r.consultationId,
54
- impressions: r.impressions,
55
- recommended_probes: r.recommendedProbes,
56
- watch_for: r.watchFor,
57
- escalation_advice: r.escalationAdvice,
58
- degraded: r.degraded,
59
- degraded_reasons: r.degradedReasons,
60
- },
61
- });
62
- } catch (err) {
63
- return JSON.stringify({ success: false, error: err?.message || 'requestPatternConsult failed', data: {} });
64
- }
65
- }
66
-
67
- module.exports = {
68
- definition,
69
- handler,
70
- };
@@ -1,75 +0,0 @@
1
- const { assessmentUid, buildAssessment } = require('../helpers/palliativeAssessmentHelper');
2
- const { storePalliativeAssessment, readPalliativeByTurn } = require('../../fhir');
3
- const { fhirId } = require('../../fhir/helpers/fhirHelper');
4
-
5
- const definition = {
6
- name: 'submitPalliativeAssessment',
7
- description: '**Does:** Terminal Phase-1 commit of the `accompaniment` skill — persists a structured palliative assessment with ranked `findings[]` covering symptoms, QoL impact, improvement capacity, need characterization, and urgent-under-24h escalation.\n\n**Required inputs:** `assessment` (one object) with `findings[]` — each `{rank, element_type (cluster|individual), name_short, time_window, symptom_terms[], clinical_description, why_relevant, qol_impact (Low|Medium|High), qol_criteria, improvement_capacity (Low|Medium|High), improvement_criteria, are_meds_needed, needs_characterization, characterization_criteria, probe_questions[], audit_notes, cluster_note, escalation}` — plus `evaluation_summary`, `ranking_rationale`, `escalation_reason`. An `escalation` (`{max_action_time_hours 1-24, escalation_routes[], escalation_reason}`) is present only for a finding urgent under 24h.\n\n**When NOT to call:**\n- Twice in the same turn — fail-hard dedup, returns `assessment_already_committed_for_turn`.\n- To CORRECT a committed assessment — use `updatePalliativeAssessment` (full-replacement) instead.\n\n**Returns:** `accepted`, `assessment_id`, `turn_id`, `committed_at`, `element_count`.\n\n**Side effects:** persists the assessment as a FHIR `ClinicalImpression` (+ a `Task` per urgent finding) + `Provenance`.',
8
- strict: false,
9
- parameters: {
10
- type: 'object',
11
- properties: {
12
- assessment: {
13
- type: 'object',
14
- description: 'The full palliative assessment. `findings[]` (ranked, contiguous): each finding carries its clinical shape and an optional `escalation` for urgent-under-24h items. Plus `evaluation_summary`, `ranking_rationale`, `escalation_reason`.',
15
- },
16
- },
17
- required: ['assessment'],
18
- },
19
- };
20
-
21
- function fail(error, data = {}) {
22
- return JSON.stringify({ success: false, error, data });
23
- }
24
-
25
- async function handler(args = {}, context = {}) {
26
- try {
27
- const runtime = context?.toolRuntimeContext || null;
28
- if (!runtime?.turnId || !runtime?.patientCode) {
29
- return fail('submitPalliativeAssessment requires an active turn context (turnId, patientCode).');
30
- }
31
- const raw = args?.assessment;
32
- if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
33
- return fail('assessment is required (non-empty object)');
34
- }
35
-
36
- const patientId = runtime.patientCode;
37
- const turnId = runtime.turnId;
38
-
39
- const existing = await readPalliativeByTurn({ patientId, turnId });
40
- if (existing) {
41
- return fail('assessment_already_committed_for_turn', { existing_assessment_id: fhirId(existing.assessmentId), turn_id: turnId });
42
- }
43
-
44
- const assessment = buildAssessment({
45
- raw,
46
- assessmentId: assessmentUid(),
47
- patientId,
48
- turnId,
49
- committedAt: new Date().toISOString(),
50
- });
51
- await storePalliativeAssessment({ patientId, assessment });
52
-
53
- const assessmentId = fhirId(assessment.assessmentId);
54
- runtime.setPalliativeAssessment({ assessmentId, turnId });
55
- runtime.trace.setSignals({ palliativeAssessmentId: assessmentId });
56
-
57
- return JSON.stringify({
58
- success: true,
59
- data: {
60
- accepted: true,
61
- assessment_id: assessmentId,
62
- turn_id: turnId,
63
- committed_at: assessment.committedAt,
64
- element_count: assessment.findings.length,
65
- },
66
- });
67
- } catch (err) {
68
- return JSON.stringify({ success: false, error: err?.message || 'submitPalliativeAssessment failed', data: {} });
69
- }
70
- }
71
-
72
- module.exports = {
73
- definition,
74
- handler,
75
- };
@@ -1,242 +0,0 @@
1
- const { ZodError } = require('zod');
2
-
3
- const { storeRecommendationPlan, recommendationPlanExists } = require('../../fhir');
4
- const { RecommendationPlan, recommendationId } = require('../../shared/dtos/RecommendationPlan');
5
- const { logger } = require('../../utils/logger');
6
-
7
- const definition = {
8
- name: 'submitRecommendationPlan',
9
- description: '**Does:** Commits the symptom plan for THIS turn: ranked elements, each carrying its assessment fields AND its recommendation inline (rank/name/targets cannot diverge — one nested unit). Call ONCE per turn, AFTER submitSafetyGate on a triage turn (a chat turn may plan without a gate). Server-validated: contiguous ranks 1..N; denied symptoms excluded; main_recommendation 2-4 sentences; when_to_escalate ≤90 chars (auto-trimmed at a `;` boundary; required when the element escalates); every element delivers a patient-facing action (main_recommendation, otc_offer, or followup=team_referral); otc_offer only on chat turns and only complete (dose, frequency, duration, warnings) with non-null contraindications. Meds-need is carried solely by `recommendation.is_pharmacological_med_needed`; any element-level `are_meds_needed` is ignored. Repairs are disclosed in `auto_repairs`.\n\n**Required inputs:** `takeaways` (`safety_confirmation_needed`, `vital_risk_possible_now`, `summary_patient_facing`, `summary_internal`) and `elements[]` (each: `rank`, `type`, `name_short`, `targeted_ctcae_terms`, `why_relevant`, `escalation`, `recommendation`).\n\n**Returns:** `accepted`, `run_id`, `element_count`, `recommendation_ids` (rank order — reference these, never invent ids), `palliative_escalation`, `auto_repairs`.\n\n**Side effects:** persists the plan as a run-scoped FHIR ClinicalImpression + CarePlan + one Task per recommendation.',
10
- strict: false,
11
- parameters: {
12
- type: 'object',
13
- properties: {
14
- takeaways: {
15
- type: 'object',
16
- description: '`{safety_confirmation_needed, safety_confirmation_question, vital_risk_possible_now, summary_patient_facing, summary_internal}`. `safety_confirmation_question` is exactly one closed yes/no question when needed; null otherwise.',
17
- },
18
- elements: {
19
- type: 'array',
20
- description: 'Ranked plan elements (contiguous ranks 1..N). Each: `{rank, type(cluster|individual), name_short, time_window, symptoms[{ctcae_term, grade, verification_status}], targeted_ctcae_terms[], why_relevant, qol_impact, improvement_capacity, escalation{urgent_under_24h, max_action_time_hours, routes[], clinical_criteria}, recommendation{symptoms_patient_facing[], is_pharmacological_med_needed, patient{main_recommendation, when_to_escalate, contraindications, expected_effectiveness}, otc_offer?, followup?, followup_urgency?}, grounded_in[]}`.',
21
- items: { type: 'object' },
22
- },
23
- },
24
- required: ['takeaways', 'elements'],
25
- },
26
- };
27
-
28
- const str = (value) => (typeof value === 'string' ? value : '');
29
- const arr = (value) => (Array.isArray(value) ? value : []);
30
- const strList = (value) => arr(value).map(str).filter(Boolean);
31
- const num = (value) => (Number.isInteger(value) ? value : null);
32
- const nullableStr = (value) => str(value).trim() || null;
33
- const inEnum = (value, values) => (values.includes(str(value).trim()) ? str(value).trim() : null);
34
-
35
- const SENTENCE_END = /[.!?](?=\s|$)/g;
36
- const SENTENCE_PROTECT = /\b(?:\d+\.\d+|p\.\s?ej\.|Dr\.|Dra\.|etc\.)/gi;
37
-
38
- function sentenceCount(text) {
39
- const protectedText = text.trim().replace(SENTENCE_PROTECT, (match) => match.replace(/\./g, '·'));
40
- return (protectedText.match(SENTENCE_END) || []).length;
41
- }
42
-
43
- function trimEscalationText(text) {
44
- if (text.length <= 90) return text;
45
- let out = '';
46
- for (const part of text.split(';').map((piece) => piece.trim()).filter(Boolean)) {
47
- const candidate = out ? `${out}; ${part}` : part;
48
- if (candidate.length > 90) break;
49
- out = candidate;
50
- }
51
- return out || `${text.slice(0, 89).trimEnd()}…`;
52
- }
53
-
54
- function buildSymptom(raw) {
55
- return {
56
- ctcaeTerm: str(raw?.ctcae_term).trim(),
57
- grade: num(raw?.grade),
58
- verificationStatus: str(raw?.verification_status).trim() || 'suspected',
59
- };
60
- }
61
-
62
- function buildOtcOffer(raw) {
63
- if (!raw || typeof raw !== 'object') return null;
64
- return {
65
- label: str(raw?.label).trim(),
66
- dose: nullableStr(raw?.dose),
67
- frequency: nullableStr(raw?.frequency),
68
- duration: nullableStr(raw?.duration),
69
- warnings: nullableStr(raw?.warnings),
70
- };
71
- }
72
-
73
- function buildElement(raw, repairs) {
74
- const rawRec = raw?.recommendation || {};
75
- const rawPatient = rawRec?.patient || {};
76
- const patient = {
77
- mainRecommendation: str(rawPatient?.main_recommendation).trim(),
78
- whenToEscalate: nullableStr(rawPatient?.when_to_escalate),
79
- contraindications: nullableStr(rawPatient?.contraindications),
80
- expectedEffectiveness: inEnum(rawPatient?.expected_effectiveness, ['low', 'medium', 'high']),
81
- };
82
- if (patient.whenToEscalate && patient.whenToEscalate.length > 90) {
83
- const trimmed = trimEscalationText(patient.whenToEscalate);
84
- repairs.push(`when_to_escalate (rank ${raw?.rank ?? '?'}) recortado de ${patient.whenToEscalate.length} a ≤90 caracteres en límite de criterio`);
85
- patient.whenToEscalate = trimmed;
86
- }
87
- const recommendation = {
88
- symptomsPatientFacing: strList(rawRec?.symptoms_patient_facing),
89
- isPharmacologicalMedNeeded: rawRec?.is_pharmacological_med_needed === true,
90
- patient,
91
- otcOffer: buildOtcOffer(rawRec?.otc_offer),
92
- followup: inEnum(rawRec?.followup, ['checkin_48h', 'team_referral', 'none']),
93
- followupUrgency: nullableStr(rawRec?.followup_urgency),
94
- };
95
- const rawEsc = raw?.escalation || {};
96
- return {
97
- rank: num(raw?.rank) || 0,
98
- type: str(raw?.type).trim(),
99
- nameShort: str(raw?.name_short).trim(),
100
- timeWindow: nullableStr(raw?.time_window),
101
- symptoms: arr(raw?.symptoms).map(buildSymptom),
102
- targetedCtcaeTerms: strList(raw?.targeted_ctcae_terms),
103
- whyRelevant: str(raw?.why_relevant).trim(),
104
- qolImpact: inEnum(raw?.qol_impact, ['low', 'moderate', 'high']),
105
- improvementCapacity: inEnum(raw?.improvement_capacity, ['low', 'moderate', 'high']),
106
- escalation: {
107
- urgentUnder24h: rawEsc?.urgent_under_24h === true,
108
- maxActionTimeHours: num(rawEsc?.max_action_time_hours),
109
- routes: strList(rawEsc?.routes),
110
- clinicalCriteria: nullableStr(rawEsc?.clinical_criteria),
111
- },
112
- recommendation,
113
- groundedIn: strList(raw?.grounded_in),
114
- };
115
- }
116
-
117
- function validate(elements) {
118
- const issues = [];
119
- const ranks = elements.map((element) => element.rank).sort((a, b) => a - b);
120
- const contiguous = ranks.every((rank, index) => rank === index + 1);
121
- if (!contiguous) {
122
- issues.push({ path: 'elements[].rank', problem: `ranks deben ser contiguos 1..${ranks.length}; recibidos ${ranks.join(', ')}` });
123
- }
124
- elements.forEach((element, index) => {
125
- const path = `elements[${index}]`;
126
- const { recommendation } = element;
127
- const { patient } = recommendation;
128
- for (const symptom of element.symptoms) {
129
- if (symptom.verificationStatus === 'denied') {
130
- issues.push({ path: `${path}.symptoms`, problem: `síntoma negado (${symptom.ctcaeTerm}) no se incluye` });
131
- }
132
- }
133
- const count = patient.mainRecommendation ? sentenceCount(patient.mainRecommendation) : 0;
134
- if (patient.mainRecommendation && !(count >= 2 && count <= 4)) {
135
- issues.push({ path: `${path}.recommendation.patient.main_recommendation`, problem: `2-4 oraciones; detectadas: ${count}` });
136
- }
137
- if (element.escalation.urgentUnder24h && !patient.whenToEscalate) {
138
- issues.push({ path: `${path}.recommendation.patient.when_to_escalate`, problem: 'elemento con escalamiento <24h ⇒ when_to_escalate no puede ser null' });
139
- }
140
- if (!patient.mainRecommendation && recommendation.otcOffer === null && recommendation.followup !== 'team_referral') {
141
- issues.push({ path: `${path}.recommendation`, problem: 'sin main_recommendation, sin otc_offer y sin followup=team_referral — el paciente no recibe nada' });
142
- }
143
- if (recommendation.otcOffer !== null) {
144
- for (const field of ['dose', 'frequency', 'duration', 'warnings']) {
145
- if (!recommendation.otcOffer[field]) {
146
- issues.push({ path: `${path}.recommendation.otc_offer.${field}`, problem: 'los 4 elementos del contrato OTC son obligatorios' });
147
- }
148
- }
149
- if (!patient.contraindications) {
150
- issues.push({ path: `${path}.recommendation.patient.contraindications`, problem: 'contraindications no puede ser null con una oferta OTC' });
151
- }
152
- }
153
- });
154
- return issues;
155
- }
156
-
157
- async function handler(args = {}, context = {}) {
158
- try {
159
- const runtime = context?.toolRuntimeContext || null;
160
- if (!runtime?.turnId || !runtime?.patientCode) {
161
- return JSON.stringify({ success: false, error: 'submitRecommendationPlan requires an active turn context (turnId, patientCode).', data: {} });
162
- }
163
- const takeaways = args?.takeaways;
164
- if (!takeaways || typeof takeaways !== 'object') {
165
- return JSON.stringify({ success: false, error: 'takeaways is required.', data: {} });
166
- }
167
- const rawElements = arr(args?.elements);
168
- if (!rawElements.length) {
169
- return JSON.stringify({ success: false, error: 'elements is required and must be non-empty.', data: {} });
170
- }
171
-
172
- // Nexus is chat-lane-only; the triage safety-gate precondition is Python-deferred.
173
- const runId = `recrun-chat-${runtime.turnId}`;
174
-
175
- if (await recommendationPlanExists({ runId })) {
176
- return JSON.stringify({ success: false, error: 'plan_already_committed_for_turn', data: { run_id: runId } });
177
- }
178
-
179
- const repairs = [];
180
- const elements = rawElements.map((raw) => buildElement(raw, repairs));
181
- const issues = validate(elements);
182
- if (issues.length) {
183
- return JSON.stringify({ success: false, error: 'plan_invalid', data: { issues } });
184
- }
185
-
186
- const plan = new RecommendationPlan({
187
- runId,
188
- patientId: runtime.patientCode,
189
- turnId: runtime.turnId,
190
- takeaways: {
191
- safetyConfirmationNeeded: takeaways.safety_confirmation_needed === true,
192
- safetyConfirmationQuestion: nullableStr(takeaways.safety_confirmation_question),
193
- vitalRiskPossibleNow: takeaways.vital_risk_possible_now === true,
194
- summaryPatientFacing: str(takeaways.summary_patient_facing).trim(),
195
- summaryInternal: str(takeaways.summary_internal).trim(),
196
- },
197
- elements,
198
- createdAt: new Date().toISOString(),
199
- });
200
-
201
- await storeRecommendationPlan({ patientId: runtime.patientCode, plan });
202
-
203
- const ranked = [...plan.elements].sort((a, b) => a.rank - b.rank);
204
- const recommendationIds = ranked.map((element) => recommendationId(runId, element.rank));
205
- const palliativeEscalation = plan.elements.some((element) => element.escalation.urgentUnder24h);
206
-
207
- const trace = runtime.trace || null;
208
- if (trace) {
209
- trace.setSignals({
210
- recommendationPlan: {
211
- runId,
212
- elementCount: plan.elements.length,
213
- palliativeEscalation,
214
- },
215
- });
216
- } else {
217
- logger.warn('[submitRecommendationPlan] no trace on runtime context; plan signal not recorded', { turnId: runtime.turnId });
218
- }
219
-
220
- return JSON.stringify({
221
- success: true,
222
- data: {
223
- accepted: true,
224
- run_id: runId,
225
- element_count: plan.elements.length,
226
- recommendation_ids: recommendationIds,
227
- palliative_escalation: palliativeEscalation,
228
- auto_repairs: repairs,
229
- },
230
- });
231
- } catch (err) {
232
- if (err instanceof ZodError) {
233
- return JSON.stringify({ success: false, error: 'plan_schema_invalid', data: { issues: err.issues.map((issue) => ({ path: issue.path.join('.'), problem: issue.message })) } });
234
- }
235
- return JSON.stringify({ success: false, error: err?.message || 'submitRecommendationPlan failed', data: {} });
236
- }
237
- }
238
-
239
- module.exports = {
240
- definition,
241
- handler,
242
- };