@peopl-health/nexus 5.44.0-dev.6241 → 5.44.0-dev.6258
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.
|
@@ -13,7 +13,8 @@ const UNSUPPORTED_RESULT = {
|
|
|
13
13
|
medical_analysis: 'NOT_MEDICAL',
|
|
14
14
|
medical_relevance: false,
|
|
15
15
|
has_table: false,
|
|
16
|
-
table_data: null
|
|
16
|
+
table_data: null,
|
|
17
|
+
measurements: null
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
const MIME_MAP = {
|
|
@@ -23,22 +24,35 @@ const MIME_MAP = {
|
|
|
23
24
|
|
|
24
25
|
const SUPPORTED_MIMES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
|
25
26
|
|
|
26
|
-
const
|
|
27
|
+
const CONFIDENCE_LEVELS = ['high', 'medium', 'low'];
|
|
28
|
+
|
|
29
|
+
const DISPLAYED_NUMBER = /^-?\d+(?:[.,]\d+)?$/;
|
|
30
|
+
|
|
31
|
+
const ANALYSIS_MODEL = 'claude-sonnet-5';
|
|
27
32
|
const MAX_ANALYSIS_TOKENS = 4096;
|
|
28
33
|
|
|
29
|
-
const ANALYSIS_PROMPT = `Eres un oncólogo clínico con experiencia. Recibirás una imagen que un paciente envió por WhatsApp. Puede ser una receta, un resultado de laboratorio, un estudio de imagen, una nota clínica, la caja de un medicamento, o algo sin relación médica.
|
|
34
|
+
const ANALYSIS_PROMPT = `Eres un oncólogo clínico con experiencia. Recibirás una imagen que un paciente envió por WhatsApp. Puede ser una receta, un resultado de laboratorio, un estudio de imagen, una nota clínica, la pantalla de un aparato de medición casero, la caja de un medicamento, o algo sin relación médica.
|
|
30
35
|
|
|
31
36
|
Responde ÚNICAMENTE con un objeto JSON, sin texto antes ni después y sin bloque de código, con esta forma exacta:
|
|
32
37
|
|
|
33
38
|
{
|
|
34
|
-
"doc_type": "prescription" | "lab_result" | "imaging" | "clinical_note" | "other" | "not_medical",
|
|
39
|
+
"doc_type": "prescription" | "lab_result" | "imaging" | "clinical_note" | "device_reading" | "other" | "not_medical",
|
|
35
40
|
"quality": "clear" | "unclear" | "incomplete",
|
|
36
41
|
"medical_relevance": true | false,
|
|
37
42
|
"transcription": "<todo el texto legible de la imagen, transcrito tal cual, incluida la letra manuscrita; si la imagen no tiene texto legible, describe en su lugar lo que se ve>",
|
|
43
|
+
"measurements": [{ "label": "<la etiqueta impresa junto al número: SYS, DIA, PUL, mg/dL, SpO2, °C...>", "value": "<el número tal cual>", "unit": "<la unidad impresa, o null>", "confidence": "high" | "medium" | "low" }] | null,
|
|
38
44
|
"table_md": "<la tabla en markdown>" | null,
|
|
39
45
|
"reading": "<tu lectura clínica, en el formato que corresponde al doc_type>"
|
|
40
46
|
}
|
|
41
47
|
|
|
48
|
+
Usa "doc_type": "device_reading" cuando la imagen sea la pantalla de un aparato de medición casero (tensiómetro, glucómetro, oxímetro, termómetro, báscula).
|
|
49
|
+
|
|
50
|
+
Reglas para "measurements": inclúyelo solo cuando doc_type sea "device_reading"; en cualquier otro caso usa null.
|
|
51
|
+
- Es una pantalla LCD de segmentos: los segmentos apagados se ven como sombras grises tenues. Cuenta únicamente los segmentos oscuros y bien contrastados.
|
|
52
|
+
- Incluye una entrada por cada número que tenga una etiqueta impresa al lado. Copia la etiqueta tal como aparece en el aparato.
|
|
53
|
+
- NUNCA conviertas en medición la hora, la fecha, el número de memoria ni el índice de usuario. Esos datos van en "transcription", no aquí.
|
|
54
|
+
- Si no estás seguro de algún dígito, marca esa entrada con "confidence": "low". No adivines.
|
|
55
|
+
|
|
42
56
|
Reglas para "table_md": inclúyela solo si la imagen contiene una verdadera tabla de filas y columnas. Transcríbela completa, conservando el orden de las columnas, todas las unidades y todos los rangos de referencia tal como aparecen. Verifica la alineación vertical de cada valor con el nombre de su parámetro antes de escribirlo. Si no hay tabla, usa null; nunca expliques por qué no la hay.
|
|
43
57
|
|
|
44
58
|
Reglas para "reading" según "doc_type":
|
|
@@ -52,6 +66,7 @@ Reglas para "reading" según "doc_type":
|
|
|
52
66
|
- hallazgos clave y anomalías; estructuras normales relevantes; medidas críticas si aplican
|
|
53
67
|
3. Action Items:
|
|
54
68
|
- estudios o proyecciones adicionales recomendadas; limitaciones de la evaluación
|
|
69
|
+
- device_reading: escribe UNA sola línea que nombre el aparato y, si son legibles, la fecha y la hora que muestra la pantalla. NO repitas los valores medidos: esos ya van en "measurements". No los interpretes ni des recomendaciones; la lectura clínica la hace el agente. Ejemplo: "Tensiómetro digital de muñeca Neutek; la pantalla marca el 24/08 a las 5:54 PM."
|
|
55
70
|
- clinical_note / other: resume la información clínica que contiene el documento.
|
|
56
71
|
- not_medical: escribe exactamente NOT_MEDICAL.
|
|
57
72
|
|
|
@@ -109,6 +124,31 @@ const parseAnalysis = (text) => {
|
|
|
109
124
|
}
|
|
110
125
|
};
|
|
111
126
|
|
|
127
|
+
const isText = (value) => typeof value === 'string' && value.trim() !== '';
|
|
128
|
+
|
|
129
|
+
const toMeasurement = (entry) => {
|
|
130
|
+
if (!entry || typeof entry !== 'object') return null;
|
|
131
|
+
const { label, value, unit, confidence } = entry;
|
|
132
|
+
if (!isText(label)) return null;
|
|
133
|
+
if (unit !== null && unit !== undefined && typeof unit !== 'string') return null;
|
|
134
|
+
|
|
135
|
+
const displayed = String(value ?? '').trim();
|
|
136
|
+
if (!DISPLAYED_NUMBER.test(displayed)) return null;
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
label: label.trim(),
|
|
140
|
+
value: displayed,
|
|
141
|
+
unit: isText(unit) ? unit.trim() : null,
|
|
142
|
+
confidence: CONFIDENCE_LEVELS.includes(confidence) ? confidence : 'low'
|
|
143
|
+
};
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const toMeasurements = (raw) => {
|
|
147
|
+
if (!Array.isArray(raw) || !raw.length) return null;
|
|
148
|
+
const measurements = raw.map(toMeasurement);
|
|
149
|
+
return measurements.every(Boolean) ? measurements : null;
|
|
150
|
+
};
|
|
151
|
+
|
|
112
152
|
const toResult = (analysis, rawText) => {
|
|
113
153
|
if (!analysis) return { ...UNSUPPORTED_RESULT, description: rawText, medical_analysis: '' };
|
|
114
154
|
|
|
@@ -116,12 +156,20 @@ const toResult = (analysis, rawText) => {
|
|
|
116
156
|
const reading = typeof analysis.reading === 'string' ? analysis.reading.trim() : '';
|
|
117
157
|
const transcription = typeof analysis.transcription === 'string' ? analysis.transcription.trim() : '';
|
|
118
158
|
|
|
159
|
+
const isDevice = analysis.doc_type === 'device_reading';
|
|
160
|
+
const measurements = isDevice ? toMeasurements(analysis.measurements) : null;
|
|
161
|
+
|
|
162
|
+
let medicalAnalysis = reading;
|
|
163
|
+
if (analysis.doc_type === 'not_medical') medicalAnalysis = 'NOT_MEDICAL';
|
|
164
|
+
else if (isDevice && !measurements) medicalAnalysis = 'QUALITY_INSUFFICIENT';
|
|
165
|
+
|
|
119
166
|
return {
|
|
120
167
|
description: transcription,
|
|
121
|
-
medical_analysis:
|
|
168
|
+
medical_analysis: medicalAnalysis,
|
|
122
169
|
medical_relevance: analysis.medical_relevance === true,
|
|
123
170
|
has_table: table !== null,
|
|
124
|
-
table_data: table
|
|
171
|
+
table_data: table,
|
|
172
|
+
measurements
|
|
125
173
|
};
|
|
126
174
|
};
|
|
127
175
|
|
|
@@ -40,6 +40,16 @@ const processTextMessage = (reply) => {
|
|
|
40
40
|
return [{ type: 'text', text: reply.body }];
|
|
41
41
|
};
|
|
42
42
|
|
|
43
|
+
const renderMeasurements = (measurements) => {
|
|
44
|
+
const values = measurements
|
|
45
|
+
.map(({ label, value, unit }) => [label, value, unit].filter(Boolean).join(' '))
|
|
46
|
+
.join(' - ');
|
|
47
|
+
const unsure = measurements.filter(({ confidence }) => confidence !== 'high').map(({ label }) => label);
|
|
48
|
+
const line = `Valores leídos en pantalla: ${values}.`;
|
|
49
|
+
if (!unsure.length) return line;
|
|
50
|
+
return `${line}\nLectura dudosa en ${unsure.join(', ')}: pídele al paciente que confirme ese número antes de darlo por bueno.`;
|
|
51
|
+
};
|
|
52
|
+
|
|
43
53
|
const processImageFileCore = async (fileName, reply) => {
|
|
44
54
|
const messagesChat = [];
|
|
45
55
|
const timings = { analysis_ms: 0, url_generation_ms: 0 };
|
|
@@ -62,6 +72,7 @@ const processImageFileCore = async (fileName, reply) => {
|
|
|
62
72
|
timings.analysis_ms = analysisDuration;
|
|
63
73
|
|
|
64
74
|
const invalidAnalysis = ['NOT_MEDICAL', 'QUALITY_INSUFFICIENT'];
|
|
75
|
+
const unusableAnalysis = invalidAnalysis.some(tag => imageAnalysis?.medical_analysis?.includes(tag));
|
|
65
76
|
|
|
66
77
|
if (imageAnalysis?.medical_relevance && !isSticker) {
|
|
67
78
|
const { result: presignedUrl, duration: urlDuration } = await withTracing(
|
|
@@ -74,11 +85,13 @@ const processImageFileCore = async (fileName, reply) => {
|
|
|
74
85
|
}
|
|
75
86
|
|
|
76
87
|
const parts = [];
|
|
88
|
+
if (!isSticker && !unusableAnalysis && imageAnalysis?.measurements?.length) {
|
|
89
|
+
parts.push(renderMeasurements(imageAnalysis.measurements));
|
|
90
|
+
}
|
|
77
91
|
if (!isSticker && imageAnalysis?.has_table && imageAnalysis.table_data) {
|
|
78
92
|
parts.push(imageAnalysis.table_data);
|
|
79
93
|
}
|
|
80
|
-
if (!isSticker && imageAnalysis?.medical_analysis &&
|
|
81
|
-
!invalidAnalysis.some(tag => imageAnalysis.medical_analysis.includes(tag))) {
|
|
94
|
+
if (!isSticker && imageAnalysis?.medical_analysis && !unusableAnalysis) {
|
|
82
95
|
parts.push(imageAnalysis.medical_analysis);
|
|
83
96
|
}
|
|
84
97
|
|