// Chatbot.jsx — "Cynamoncita IA": escribe signos y síntomas en lenguaje natural y // Cynamoncita arma planes de cuidado candidatos (Diagnóstico + Objetivo + Intervención) // buscando coincidencias léxicas en la propia base de datos de la app (sin IA externa). // También extrae signos vitales del texto y los comparte con las calculadoras // (window.PatientStore, definido en Calculators.jsx) sugiriendo cuál usar. // // Todo el motor es determinista: nunca inventa códigos NANDA/NOC/NIC, solo reordena // y filtra lo que ya existe en /data (diagnosticos.json, objetivos.json, // intervenciones.json, relations.json). const { useState: useChS, useEffect: useChE, useRef: useChR } = React; // ─── Normalización de texto ───────────────────────────────── function stripAccents(s) { return String(s).normalize('NFD').replace(/[\u0300-\u036f]/g, ''); } function normWord(s) { return stripAccents(String(s || '').toLowerCase()).replace(/[^a-z0-9]+/g, ' ').trim(); } // Normaliza texto libre conservando solo letras y los separadores de cláusula // (. , ;) que se usan para detectar negaciones ("sin fiebre, con dolor…"). function normalizeFull(s) { return stripAccents(String(s || '').toLowerCase()).replace(/[^a-z.,;\n]+/g, ' '); } // Stemming ligero (español, acotado a vocabulario clínico) — se aplica igual a la // consulta del usuario y al índice construido desde la base de datos, así el // desajuste singular/plural no rompe el match ("náusea" ↔ "náuseas"). function stem(w) { if (!w || w.length <= 3) return w; if (/iones$/.test(w)) return w.slice(0, -4) + 'on'; if (/ades$/.test(w)) return w.slice(0, -3) + 'ad'; if (/es$/.test(w) && w.length > 5) return w.slice(0, -2); if (/s$/.test(w) && w.length > 4) return w.slice(0, -1); return w; } const STOPWORDS = new Set([ 'de','la','el','los','las','un','una','unos','unas','y','o','u','a','ante','con','contra', 'desde','en','entre','hacia','hasta','para','por','segun','sobre','tras','que','quien','cual', 'cuales','se','su','sus','al','del','es','esta','estan','esto','eso','ese','esa','estos','estas', 'tiene','tienen','presenta','presentan','tambien','muy','mas','pero','como','cuando','donde', 'porque','le','lo','les','me','mi','tu','nos','ya','este','refiere','refieren','paciente', 'pacientes','sin','no','niega','ausencia','negativo','nunca','ha','han','hay','fue','son','ser', // Etiquetas/unidades de signos vitales: se extraen aparte con extractVitals() y // NO deben alimentar el matching de síntomas (ej. la palabra "temperatura" sola // no implica fiebre — antes inflaba "Hipertermia" con solo mencionar el signo). 'peso','talla','estatura','altura','temperatura','temp','fc','fr','ta','pa','spo2','sat','sats', 'kg','kilo','kilos','cm','centimetros','mmhg','lpm','rpm', ]); const NEGATION_CUES = new Set(['sin', 'no', 'niega', 'ausencia', 'negativo', 'nunca']); function tokenizeContent(text) { return normWord(text).split(' ').filter(Boolean).filter(w => !STOPWORDS.has(w)).map(stem); } function hasOverlap(text, tokenSet) { if (!text || !tokenSet || !tokenSet.size) return false; return tokenizeContent(text).some(t => tokenSet.has(t)); } // ─── Diccionario de sinónimos (lenguaje coloquial → término clínico) ─────── // Agrupado por tema para poder ampliarlo con el tiempo. No reemplaza la palabra // original — la SUMA como token adicional, así nunca se pierde una coincidencia // literal aunque el sinónimo no aplique. const SYNONYMS = { // Dolor duele: 'dolor', dolorido: 'dolor', dolorida: 'dolor', molestia: 'dolor', molestias: 'dolor', punzada: 'dolor', ardor: 'dolor', escozor: 'dolor', calambre: 'dolor', punzante: 'dolor', // Respiración ahogo: 'disnea', asfixia: 'disnea', sofoco: 'disnea', jadeo: 'disnea', jadeante: 'disnea', tos: 'tos', flemas: 'secreciones', mocos: 'secreciones', silbido: 'sibilancias', // Piel / tejido llaga: 'herida', ulcera: 'herida', escara: 'herida', moretón: 'equimosis', moreton: 'equimosis', hinchazon: 'edema', hinchado: 'edema', hinchada: 'edema', comezon: 'prurito', picazon: 'prurito', resequedad: 'sequedad', reseca: 'sequedad', reseco: 'sequedad', // Eliminación estrenido: 'estreñimiento', estrenida: 'estreñimiento', estreñido: 'estreñimiento', suelta: 'diarrea', aguadas: 'diarrea', diarreicas: 'diarrea', escurre: 'incontinencia', moja: 'incontinencia', sangrado: 'hemorragia', sangra: 'hemorragia', sangrando: 'hemorragia', // Movilidad encamado: 'inmovilidad', encamada: 'inmovilidad', postrado: 'inmovilidad', postrada: 'inmovilidad', caida: 'caídas', caidas: 'caídas', tropieza: 'caídas', mareo: 'mareo', mareado: 'mareo', mareada: 'mareo', debil: 'debilidad', debilitado: 'debilidad', debilitada: 'debilidad', cansado: 'fatiga', cansada: 'fatiga', agotado: 'fatiga', agotada: 'fatiga', // Ánimo / ansiedad nervioso: 'ansiedad', nerviosa: 'ansiedad', angustiado: 'ansiedad', angustiada: 'ansiedad', preocupado: 'ansiedad', preocupada: 'ansiedad', miedo: 'temor', asustado: 'temor', asustada: 'temor', triste: 'ánimo', llorando: 'ánimo', llora: 'ánimo', decaido: 'ánimo', decaida: 'ánimo', confundido: 'confusión', confundida: 'confusión', desorientado: 'confusión', desorientada: 'confusión', // Nutrición vomita: 'vómito', vomitando: 'vómito', vomitos: 'vómito', nauseas: 'náuseas', arcadas: 'náuseas', inapetencia: 'apetito', anorexia: 'apetito', desnutrido: 'nutrición', desnutrida: 'nutrición', atragantado: 'deglución', atragantada: 'deglución', // Termorregulación fiebre: 'hipertermia', febril: 'hipertermia', escalofrios: 'hipotermia', escalofríos: 'hipotermia', sudoracion: 'diaforesis', sudoración: 'diaforesis', suda: 'diaforesis', sudando: 'diaforesis', // Sueño desvelado: 'insomnio', desvelada: 'insomnio', despierto: 'insomnio', trasnochado: 'insomnio', // Infección / heridas pus: 'infección', infectada: 'infección', infectado: 'infección', enrojecida: 'inflamación', enrojecido: 'inflamación', inflamado: 'inflamación', inflamada: 'inflamación', }; // Idiomas negativos que en español describen un hallazgo POSITIVO (no una // negación real) — se sustituyen ANTES de partir en cláusulas por negación, // para que "no puede respirar" no se lea como "sin problema respiratorio". const PHRASE_SUBS = [ [/\bno puede respirar( bien)?\b/g, ' disnea '], [/\ble cuesta( mucho)? respirar\b/g, ' disnea '], [/\bfalta(le)? (el )?aire\b/g, ' disnea '], [/\bdificultad (para|al) respirar\b/g, ' disnea '], [/\bno puede dormir\b/g, ' insomnio '], [/\bno concilia el sueno\b/g, ' insomnio '], [/\bno puede caminar\b/g, ' deterioro movilidad '], [/\bno se puede mover\b/g, ' deterioro movilidad '], [/\bno puede tragar\b/g, ' deterioro deglución '], [/\bno (puede )?orina\b/g, ' retención urinaria '], [/\bno ha orinado\b/g, ' retención urinaria '], [/\bno (puede )?evacua\b/g, ' estreñimiento '], [/\bno tolera (la )?via oral\b/g, ' nutrición deglución '], [/\bno tiene apetito\b/g, ' apetito nutrición '], [/\bno se levanta\b/g, ' inmovilidad '], [/\bno reconoce a nadie\b/g, ' confusión '], [/\bno responde bien\b/g, ' confusión '], ]; // Máximo de planes candidatos mostrados en el panel — deben seguir relacionados // con lo que escribió el usuario (el umbral de score abajo filtra el ruido); // esto solo sube el TECHO para cuando sí hay 10 diagnósticos relevantes. const MAX_CANDIDATES = 10; // ─── Índice invertido palabra → diagnóstico ───────────────── const FIELD_WEIGHTS = { etiqueta: 5, caracteristicas: 3, factores: 3, poblacion_riesgo: 1.5, condiciones_asociadas: 1.5, definicion: 1, }; let DX_INDEX = null; function getDxIndex() { if (DX_INDEX) return DX_INDEX; const items = (window.DATA && window.DATA.dxs && window.DATA.dxs.items) || []; const byToken = new Map(); const df = new Map(); items.forEach(dx => { const seenInDx = new Set(); const addField = (fieldName, text) => { if (!text) return; const uniq = new Set(tokenizeContent(text)); uniq.forEach(tok => { if (!tok) return; if (!byToken.has(tok)) byToken.set(tok, []); byToken.get(tok).push({ dxCodigo: dx.codigo, weight: FIELD_WEIGHTS[fieldName], field: fieldName }); seenInDx.add(tok); }); }; addField('etiqueta', dx.etiqueta); (dx.caracteristicas || []).forEach(c => addField('caracteristicas', c)); (dx.factores || []).forEach(f => addField('factores', f)); (dx.poblacion_riesgo || []).forEach(p => addField('poblacion_riesgo', p)); (dx.condiciones_asociadas || []).forEach(c => addField('condiciones_asociadas', c)); addField('definicion', dx.definicion); seenInDx.forEach(tok => df.set(tok, (df.get(tok) || 0) + 1)); }); const N = Math.max(1, items.length); const idf = new Map(); df.forEach((count, tok) => idf.set(tok, Math.log(1 + N / count))); DX_INDEX = { byToken, idf }; return DX_INDEX; } // ─── Extracción de signos vitales del texto libre ─────────── const VITAL_RANGES = { peso: [0.5, 400], talla: [20, 250], fc: [20, 300], fr: [4, 100], tas: [30, 300], tad: [10, 250], temp: [25, 45], spo2: [30, 100], volumen: [1, 5000], tiempo: [1, 1440], }; function num(s) { return parseFloat(String(s).replace(',', '.')); } function extractVitals(raw) { const t = ' ' + stripAccents(String(raw || '').toLowerCase()) + ' '; const patch = {}; let m; if ((m = t.match(/peso\s*(?:de|:)?\s*(\d{1,3}(?:[.,]\d{1,2})?)\s*(kg|kilos?)?/))) patch.peso = num(m[1]); if ((m = t.match(/(?:talla|estatura|altura)\s*(?:de|:)?\s*(\d{2,3}(?:[.,]\d{1,2})?)\s*(cm|centimetros?|m|mts?)?/))) { let v = num(m[1]); if ((m[2] && /^m/.test(m[2])) || v < 3) v *= 100; patch.talla = v; } if ((m = t.match(/(?:\bfc\b|frecuencia card[ií]aca|pulso)\s*(?:de|:)?\s*(\d{2,3})/))) patch.fc = num(m[1]); if ((m = t.match(/(?:\bfr\b|frecuencia respiratoria)\s*(?:de|:)?\s*(\d{1,2})/))) patch.fr = num(m[1]); if ((m = t.match(/(?:\bta\b|\bpa\b|presion(?:\s+arterial)?)\s*(?:de|:)?\s*(\d{2,3})\s*\/\s*(\d{2,3})/))) { patch.tas = num(m[1]); patch.tad = num(m[2]); } if ((m = t.match(/(?:temperatura|temp)\s*(?:de|:)?\s*(\d{2}(?:[.,]\d{1,2})?)/)) || (m = t.match(/(\d{2}(?:[.,]\d{1,2})?)\s*°\s*c\b/))) patch.temp = num(m[1]); if ((m = t.match(/(?:spo2|sat(?:uracion)?(?:\s+de\s+oxigeno)?|sats?)\s*(?:de|:)?\s*(\d{2,3})/))) patch.spo2 = num(m[1]); Object.keys(patch).forEach(k => { const r = VITAL_RANGES[k]; if (isNaN(patch[k]) || (r && (patch[k] < r[0] || patch[k] > r[1]))) delete patch[k]; }); if (/\b(neonato|recien nacido|\brn\b)\b/.test(t)) patch.poblacion = 'neonato'; else if (/\b(pediatr\w*|nin[oa]s?|lactante|bebe)\b/.test(t)) patch.poblacion = 'pediatra'; if (/ventilador|intubad/.test(t)) patch.apoyo = 'ventilador'; else if (/puntas nasales|canula nasal|oxigeno suplementario/.test(t)) patch.apoyo = 'puntas'; else if (/calor radiante|cuna de calor/.test(t)) patch.apoyo = 'calor'; else if (/fiebre|febril/.test(t)) patch.apoyo = 'fiebre'; return patch; } // Tokens "inferidos" a partir de signos vitales — no vienen del texto literal, // así que se ponderan más bajo (0.55) que una mención directa del síntoma. function vitalsToTokens(patch) { const toks = new Set(); const add = (...ws) => ws.forEach(w => toks.add(stem(normWord(w)))); if (patch.temp >= 38) add('hipertermia', 'termorregulacion'); if (patch.temp > 0 && patch.temp < 35) add('hipotermia', 'termorregulacion'); if (patch.spo2 && patch.spo2 < 92) add('disnea', 'gaseoso', 'intercambio', 'respiratorio'); if (patch.fr && patch.fr > 24) add('disnea', 'respiratorio', 'patron'); if (patch.fc && patch.tas && patch.fc / patch.tas > 0.9) add('choque', 'perfusion', 'volumen', 'circulatorio'); return toks; } function calcSuggestions(patch) { const s = []; if (patch.fc && patch.tas) s.push({ id: 'choque', label: 'Índice de choque' }); if (patch.tas && patch.tad) s.push({ id: 'pam', label: 'Presión arterial media' }); if (patch.peso && patch.talla) s.push({ id: 'imc', label: 'IMC' }); if (patch.peso && (patch.poblacion === 'pediatra' || patch.poblacion === 'neonato')) s.push({ id: 'ped', label: 'Pérdidas insensibles · Pediátrico' }); else if (patch.peso && patch.temp) s.push({ id: 'adult', label: 'Pérdidas insensibles · Adulto' }); return s.slice(0, 3); } function openCalculator(id) { window.__cynaToolsInitial = { cat: 'calc', calc: id }; if (window.__cynaGoTab) window.__cynaGoTab('tools'); } // ═══ Calculadoras conversacionales ═══════════════════════════ // Cynamoncita también puede tomar los datos por chat y calcular ahí mismo, // mostrando la operación paso a paso con decimales exactos (roundExact), // sin que el usuario tenga que ir a la pestaña Herramientas. const FIELD_META = { tas: { label: 'presión sistólica', unit: 'mmHg' }, tad: { label: 'presión diastólica', unit: 'mmHg' }, fc: { label: 'frecuencia cardiaca', unit: 'lpm' }, peso: { label: 'peso', unit: 'kg' }, talla: { label: 'talla', unit: 'cm' }, temp: { label: 'temperatura', unit: '°C' }, volumen: { label: 'volumen a infundir', unit: 'ml' }, tiempo: { label: 'tiempo total', unit: 'min' }, equipo: { label: 'tipo de equipo (macro / normo / micro / transfusión)', unit: '' }, apoyo: { label: 'tipo de apoyo (estable / puntas / fiebre / ventilador / calor)', unit: '' }, }; const CALC_CATALOG = [ { id: 'pam', label: 'Presión arterial media (PAM)', fields: ['tas', 'tad'], example: 'ej. 120/80' }, { id: 'choque', label: 'Índice de choque', fields: ['fc', 'tas'], example: 'ej. FC 96, sistólica 120' }, { id: 'imc', label: 'IMC (índice de masa corporal)', fields: ['peso', 'talla'], example: 'ej. 70 kg, 170 cm' }, { id: 'gotas', label: 'Factor de goteo (gotas por minuto)', fields: ['volumen', 'tiempo', 'equipo'], example: 'ej. 500 ml en 60 min, macrogotero' }, { id: 'adult', label: 'Pérdidas insensibles · Adulto', fields: ['peso', 'temp'], example: 'ej. 70 kg, temperatura 37.5' }, { id: 'ped', label: 'Pérdidas insensibles · Pediátrico', fields: ['peso', 'apoyo'], example: 'ej. 15 kg, estable' }, ]; const CALC_NAME_MATCHERS = [ { id: 'pam', re: /\bpam\b|presion arterial media/ }, { id: 'choque', re: /indice de choque|shock index/ }, { id: 'imc', re: /\bimc\b|masa corporal/ }, { id: 'gotas', re: /factor de goteo|gotas por minuto|velocidad de infusion|\bgoteo\b/ }, { id: 'adult', re: /perdidas insensibles.*adult|adult.*perdidas insensibles/ }, { id: 'ped', re: /perdidas insensibles.*(pediatr|neonat)|(pediatr|neonat).*perdidas insensibles/ }, ]; const CALC_MENU_RE = /\bcalculadora(s)?\b|quiero calcular|quiero medir|que puedo medir|ayuda con (el )?calculo/; const CALC_CANCEL_RE = /^(cancelar|cancela|olvidalo|ya no|mejor no|salir)\s*$/; function detectCalcIntent(rawText) { const t = stripAccents(String(rawText || '').toLowerCase()); for (const m of CALC_NAME_MATCHERS) if (m.re.test(t)) return { type: 'direct', id: m.id }; if (CALC_MENU_RE.test(t)) return { type: 'menu' }; return null; } function describeFields(ids) { return ids.map(id => { const meta = FIELD_META[id]; return meta.unit ? `${meta.label} (${meta.unit})` : meta.label; }).join(', '); } function parseNumbers(text) { const norm = String(text || '').replace(/,/g, '.'); return (norm.match(/\d+(?:\.\d+)?/g) || []).map(Number); } // Toma lo que el usuario haya escrito y extrae los campos que hagan falta para // la calculadora activa (peso, talla, FC, TA, temperatura, volumen, tiempo, // equipo, apoyo). Cada patrón que hace match se "borra" del texto de trabajo // antes de seguir, para que el respaldo posicional de más abajo (números // sueltos) nunca vuelva a tomar un número que ya se usó para otro campo — // ej. "FC 110, sistólica 90" no debe leer 110 dos veces. function extractCalcFields(text, neededIds) { let working = ' ' + stripAccents(String(text || '').toLowerCase()) + ' '; const out = {}; const take = (re, assign) => { const m = working.match(re); if (!m) return false; assign(m); working = working.slice(0, m.index) + ' '.repeat(m[0].length) + working.slice(m.index + m[0].length); return true; }; take(/peso\s*(?:de|:)?\s*(\d{1,3}(?:[.,]\d{1,2})?)\s*(kg|kilos?)?/, m => { out.peso = num(m[1]); }); take(/(?:talla|estatura|altura)\s*(?:de|:)?\s*(\d{2,3}(?:[.,]\d{1,2})?)\s*(cm|centimetros?|m|mts?)?/, m => { let v = num(m[1]); if ((m[2] && /^m/.test(m[2])) || v < 3) v *= 100; out.talla = v; }); take(/(?:\bfc\b|frecuencia card[ií]aca|pulso)\s*(?:de|:)?\s*(\d{2,3})/, m => { out.fc = num(m[1]); }); take(/(?:\bta\b|\bpa\b|presion(?:\s+arterial)?)\s*(?:de|:)?\s*(\d{2,3})\s*\/\s*(\d{2,3})/, m => { out.tas = num(m[1]); out.tad = num(m[2]); }); take(/sistolica\s*(?:de|:)?\s*(\d{2,3})/, m => { out.tas = num(m[1]); }); take(/diastolica\s*(?:de|:)?\s*(\d{2,3})/, m => { out.tad = num(m[1]); }); take(/(?:temperatura|temp)\s*(?:de|:)?\s*(\d{2}(?:[.,]\d{1,2})?)/, m => { out.temp = num(m[1]); }) || take(/(\d{2}(?:[.,]\d{1,2})?)\s*°\s*c\b/, m => { out.temp = num(m[1]); }); take(/(\d{1,5}(?:[.,]\d+)?)\s*(ml|cc)\b/, m => { out.volumen = num(m[1]); }); take(/(\d{1,4})\s*min(?:utos)?\b/, m => { out.tiempo = num(m[1]); }) || take(/(\d{1,3})\s*h(?:oras|rs?)?\b/, m => { out.tiempo = num(m[1]) * 60; }); if (/\bmicro/.test(working)) out.equipo = 'micro'; else if (/\bnormo/.test(working)) out.equipo = 'normo'; else if (/transfus/.test(working)) out.equipo = 'trans'; else if (/\bmacro/.test(working)) out.equipo = 'macro'; if (/ventilador|intubad/.test(working)) out.apoyo = 'ventilador'; else if (/puntas/.test(working)) out.apoyo = 'puntas'; else if (/calor/.test(working)) out.apoyo = 'calor'; else if (/fiebre|febril/.test(working)) out.apoyo = 'fiebre'; else if (/estable/.test(working)) out.apoyo = 'estable'; // Lo que siga faltando se completa con números "sueltos" que quedaron SIN // consumir, en el mismo orden en que se pidieron. let missing = neededIds.filter(id => out[id] == null && id !== 'equipo' && id !== 'apoyo'); if (missing.length) { if (missing.length === 2 && missing.includes('tas') && missing.includes('tad')) { const pair = working.match(/(\d{2,3})\s*\/\s*(\d{2,3})/); if (pair) { out.tas = num(pair[1]); out.tad = num(pair[2]); missing = missing.filter(id => id !== 'tas' && id !== 'tad'); } } const nums = parseNumbers(working); if (missing.length === 1 && nums.length >= 1) out[missing[0]] = nums[0]; else if (missing.length >= 2 && nums.length >= missing.length) missing.forEach((id, i) => { out[id] = nums[i]; }); } Object.keys(out).forEach(k => { const r = VITAL_RANGES[k]; if (r && (isNaN(out[k]) || out[k] < r[0] || out[k] > r[1])) delete out[k]; }); return out; } // Calcula con las mismas fórmulas que Calculators.jsx, mostrando cada paso y // usando roundExact() para decimales correctos (ver src/Calculators.jsx). function computeCalc(id, data) { const R = (n, d) => (window.Calc && window.Calc.roundExact) ? window.Calc.roundExact(n, d) : Math.round(n * Math.pow(10, d)) / Math.pow(10, d); if (id === 'pam') { const { tas, tad } = data; const pam = R((tas + 2 * tad) / 3, 1); const steps = [ 'PAM = (sistólica + 2 × diastólica) ÷ 3', `PAM = (${tas} + 2×${tad}) ÷ 3`, `PAM = ${R(tas + tad * 2, 1)} ÷ 3`, `PAM = ${pam} mmHg`, ]; const interp = pam < 60 ? 'Hipoperfusión · riesgo de daño orgánico' : pam < 70 ? 'Límite inferior' : pam <= 105 ? 'Perfusión adecuada' : 'Elevada · vigilar hipertensión'; return { label: 'Presión arterial media (PAM)', result: pam, unit: 'mmHg', steps, interp }; } if (id === 'choque') { const { fc, tas } = data; const si = R(fc / tas, 2); const steps = ['Índice de choque = FC ÷ presión sistólica', `IC = ${fc} ÷ ${tas}`, `IC = ${si}`]; const interp = si < 0.5 ? 'Volumen inadecuado / sobrecarga' : si <= 0.7 ? 'Normal' : si < 1.0 ? 'Choque leve / compensación' : si < 1.4 ? 'Choque moderado' : 'Choque severo · alerta'; return { label: 'Índice de choque', result: si, unit: '', steps, interp }; } if (id === 'imc') { const { peso, talla } = data; const tallaM = talla / 100; const imc = R(peso / (tallaM * tallaM), 2); const steps = [ 'IMC = peso ÷ talla²', `IMC = ${peso} ÷ (${R(tallaM, 2)} m)²`, `IMC = ${peso} ÷ ${R(tallaM * tallaM, 4)}`, `IMC = ${imc} kg/m²`, ]; const klass = (window.Calc && window.Calc.classifyImc) ? window.Calc.classifyImc(imc) : null; return { label: 'IMC', result: imc, unit: 'kg/m²', steps, interp: klass ? klass.label : '' }; } if (id === 'gotas') { const { volumen, tiempo } = data; const equipo = data.equipo || 'macro'; const gotasPorMl = { macro: 10, normo: 20, micro: 60, trans: 15 }[equipo]; const equipoNombre = { macro: 'Macrogotero', normo: 'Normogotero', micro: 'Microgotero', trans: 'Equipo de transfusión' }[equipo]; const gotasMin = R((volumen * gotasPorMl) / tiempo, 0); const mlHora = R(volumen / (tiempo / 60), 0); const steps = [ `Equipo: ${equipoNombre} (${gotasPorMl} gtt/ml)`, 'Gotas/min = (volumen × factor de goteo) ÷ tiempo', `Gotas/min = (${volumen} × ${gotasPorMl}) ÷ ${tiempo} = ${gotasMin} gtt/min`, `Velocidad = ${mlHora} ml/h`, ]; return { label: 'Factor de goteo', result: gotasMin, unit: 'gtt/min', steps, interp: `Equivalente a ${mlHora} ml/h` }; } if (id === 'adult') { const { peso, temp } = data; const factor = !temp || temp < 37 ? 0.5 : temp < 38 ? 0.6 : temp < 39 ? 0.7 : 1; const perHora = R(peso * factor, 1); const total24 = R(perHora * 24, 1); const steps = [ `Factor según temperatura (${Number.isFinite(temp) ? temp + '°C' : 'sin dato'}): ×${factor}`, `mL/h = peso × factor = ${peso} × ${factor} = ${perHora} mL/h`, `mL/24h = ${perHora} × 24 = ${total24} mL`, ]; return { label: 'Pérdidas insensibles · Adulto', result: perHora, unit: 'mL/h', steps, interp: `${total24} mL en 24 h` }; } if (id === 'ped') { const { peso } = data; const apoyo = data.apoyo || 'estable'; const constantes = { estable: 400, puntas: 500, fiebre: 600, ventilador: 700, calor: 300 }; const C = constantes[apoyo]; const sc = peso <= 10 ? (peso * 4 + 9) / 100 : (peso * 4 + 7) / (90 + peso); const scR = R(sc, 3); const total24 = R(sc * C, 1); const perHora = R(total24 / 24, 1); const steps = [ peso <= 10 ? 'SC = (peso×4 + 9) / 100' : 'SC = (peso×4 + 7) / (90 + peso)', `SC = ${scR} m²`, `Constante según apoyo (${apoyo}): ${C}`, `mL/24h = SC × constante = ${scR} × ${C} = ${total24} mL`, `mL/h = ${total24} ÷ 24 = ${perHora} mL/h`, ]; return { label: 'Pérdidas insensibles · Pediátrico', result: perHora, unit: 'mL/h', steps, interp: `${total24} mL en 24 h` }; } return null; } // ─── Tokenización de la consulta (con negación y sinónimos) ─ function tokenizeQuery(rawText) { let text = normalizeFull(rawText); PHRASE_SUBS.forEach(([re, rep]) => { text = text.replace(re, rep); }); const clauses = text.split(/[.,;]/).map(c => c.trim()).filter(Boolean); const positive = new Set(); const negated = new Set(); clauses.forEach(clause => { const words = clause.split(/\s+/).filter(Boolean); const isNegated = words.length > 0 && (NEGATION_CUES.has(words[0]) || (words.length > 1 && NEGATION_CUES.has(words[1]) && ['esta','sigue','se','continua'].includes(words[0]))); words.forEach(w => { if (NEGATION_CUES.has(w)) return; if (STOPWORDS.has(w)) return; const t = stem(w); if (!t) return; (isNegated ? negated : positive).add(t); const syn = SYNONYMS[w]; if (syn) { const st = stem(normWord(syn)); (isNegated ? negated : positive).add(st); } }); }); return { positive, negated }; } // ─── Selección de objetivo/intervención re-rankeando relations.json ───────── function pickBestFromRelations(dxCodigo, relMap, byCodeMap, tokenSet, textFieldsFn) { const rels = (relMap && relMap[dxCodigo]) || []; if (!rels.length) return null; const maxRel = rels.reduce((m, r) => Math.max(m, r.score), 1); let best = null, bestScore = -Infinity; rels.forEach(r => { const item = byCodeMap.get(r.code); if (!item) return; const toks = tokenizeContent(textFieldsFn(item)); let affinity = 0; toks.forEach(t => { if (tokenSet.has(t)) affinity++; }); const relNorm = r.score / maxRel; const affNorm = tokenSet.size ? affinity / tokenSet.size : 0; const combined = relNorm * 0.7 + Math.min(1, affNorm) * 0.3; if (combined > bestScore) { bestScore = combined; best = item; } }); return best; } // Selecciona hasta n elementos: primero los que coinciden con los tokens del // usuario, y si no alcanzan, completa con el resto del propio objetivo/ // intervención (nunca deja el bloque corto de contenido real). function selectTopN(items, tokenSet, n, textFn) { const matched = items.filter(it => hasOverlap(textFn(it), tokenSet)); if (matched.length >= n) return matched.slice(0, n); const matchedSet = new Set(matched); const rest = items.filter(it => !matchedSet.has(it)); return matched.concat(rest.slice(0, n - matched.length)); } // ─── Arma un candidato de plan con la MISMA forma que espera PlanStore ────── function buildCandidatePlan(dxItem, tokenSet) { const type = window.detectDxType(dxItem.etiqueta); const MAX_CARS = window.MAX_CARS, MAX_FACTORS = window.MAX_FACTORS; const INDICATOR_TARGET = window.MAX_INDICATORS || 5; // mínimo pedido: 5 indicadores const ACTIVITY_TARGET = 5; // mínimo pedido: 5 actividades (tope real: MAX_ACTIVITIES=10) let selChars = (dxItem.caracteristicas || []).filter(c => hasOverlap(c, tokenSet)).slice(0, MAX_CARS); if (!selChars.length) selChars = (dxItem.caracteristicas || []).slice(0, MAX_CARS); let selFactors = (dxItem.factores || []).filter(f => hasOverlap(f, tokenSet)).slice(0, MAX_FACTORS); if (!selFactors.length) selFactors = (dxItem.factores || []).slice(0, MAX_FACTORS); const dx = { codigo: dxItem.codigo, etiqueta: dxItem.etiqueta, definicion: dxItem.definicion, dominio: dxItem.dominio, dominio_num: dxItem.dominio_num, clase: dxItem.clase, clase_num: dxItem.clase_num, type, selectedDefinitorias: selChars, selectedFactores: selFactors, manifestaciones: '', }; const relations = (window.DATA && window.DATA.relations) || {}; const objItem = pickBestFromRelations( dxItem.codigo, relations.objByDx, window.DATA.objByCode, tokenSet, o => (o.definicion || '') + ' ' + (o.indicadores || []).map(i => i.descripcion).join(' ') ); let obj = null; if (objItem) { const selInd = selectTopN(objItem.indicadores || [], tokenSet, INDICATOR_TARGET, i => i.descripcion); const ds = window.defaultScores(objItem.escala); const indScores = {}; selInd.forEach(i => { indScores[i.codigo] = { ...ds }; }); obj = { codigo: objItem.codigo, titulo: objItem.titulo, definicion: objItem.definicion, dominio: objItem.dominio, dominio_num: objItem.dominio_num, clase: objItem.clase, clase_codigo: objItem.clase_codigo, escala: objItem.escala || [], indicadores: objItem.indicadores || [], selectedIndicadores: selInd.map(i => i.codigo), indScores, basal: ds.basal, meta: ds.meta, }; } const intItem = pickBestFromRelations( dxItem.codigo, relations.intByDx, window.DATA.intByCode, tokenSet, iv => (iv.definicion || '') + ' ' + (iv.actividades || []).join(' ') ); let intv = null; if (intItem) { const selAct = selectTopN(intItem.actividades || [], tokenSet, ACTIVITY_TARGET, a => a); intv = { codigo: intItem.codigo, nombre: intItem.nombre, dominio: intItem.dominio, dominio_num: intItem.dominio_num, clase: intItem.clase, clase_codigo: intItem.clase_codigo, definicion: intItem.definicion, actividades: intItem.actividades || [], selectedActividades: selAct, }; } return { dx, obj, int: intv }; } // ─── Motor completo: texto libre → candidatos + signos vitales ───────────── function runAutoMatch(rawText) { const vitalsPatch = extractVitals(rawText); const { positive, negated } = tokenizeQuery(rawText); const inferred = vitalsToTokens(vitalsPatch); const index = getDxIndex(); const scores = new Map(); // dxCodigo -> { score, fields: Set } const addTokenScore = (tok, dampen) => { const entries = index.byToken.get(tok); if (!entries) return; const idf = index.idf.get(tok) || 1; entries.forEach(e => { const cur = scores.get(e.dxCodigo) || { score: 0, fields: new Set() }; cur.score += e.weight * idf * dampen; cur.fields.add(e.field); scores.set(e.dxCodigo, cur); }); }; positive.forEach(t => { if (!negated.has(t)) addTokenScore(t, 1); }); inferred.forEach(t => addTokenScore(t, 0.55)); let ranked = Array.from(scores.entries()) .map(([codigo, v]) => ({ codigo, score: v.score * (1 + 0.15 * (v.fields.size - 1)) })) .sort((a, b) => b.score - a.score); if (!ranked.length) return { candidates: [], vitalsPatch, clarify: null }; const top = ranked[0].score; const minScore = Math.max(1.2, top * 0.15); ranked = ranked.filter(r => r.score >= minScore).slice(0, MAX_CANDIDATES); const clarify = (ranked.length >= 2 && ranked[1].score >= ranked[0].score * 0.82) ? { a: ranked[0], b: ranked[1] } : null; const highlight = new Set([...positive, ...inferred]); const candidates = ranked .map(r => { const dxItem = window.DATA.dxByCode.get(r.codigo); if (!dxItem) return null; return { ...buildCandidatePlan(dxItem, highlight), score: r.score }; }) .filter(Boolean); return { candidates, vitalsPatch, clarify }; } const INTRO_TEXT = 'Hola, soy Cynamoncita. Cuéntame los signos y síntomas del paciente — puedes incluir signos vitales (peso, talla, FC, TA, temperatura, SpO2) — y te propongo planes de cuidado listos para revisar en el panel. También puedo calcular por ti: solo dime qué quieres medir (ej. "quiero la PAM") o escribe "calculadora" para ver las opciones.'; // ─── Almacén del chat (persiste entre pestañas y recargas) ── // Vive fuera del componente para que cambiar de pestaña dentro de la app NO // borre la conversación (antes se perdía porque ChatbotSection se desmontaba). // Además se guarda en sessionStorage por si se recarga la página. const CHAT_KEY = 'cyna_chat_state_v1'; function loadChatState() { try { const raw = sessionStorage.getItem(CHAT_KEY); const parsed = raw ? JSON.parse(raw) : null; if (parsed && Array.isArray(parsed.messages) && parsed.messages.length) return parsed; } catch (e) {} return null; } const ChatStore = { state: loadChatState() || { messages: [{ id: 'intro', role: 'bot', text: INTRO_TEXT }], candidates: [], calc: [], calcFlow: null }, listeners: new Set(), set(updater) { this.state = typeof updater === 'function' ? updater(this.state) : { ...this.state, ...updater }; try { sessionStorage.setItem(CHAT_KEY, JSON.stringify(this.state)); } catch (e) {} this.listeners.forEach(fn => fn(this.state)); }, use() { const [, force] = useChS(0); useChE(() => { const fn = () => force(t => t + 1); this.listeners.add(fn); return () => this.listeners.delete(fn); }, []); return [this.state, (u) => this.set(u)]; }, reset() { this.set({ messages: [{ id: 'intro', role: 'bot', text: INTRO_TEXT }], candidates: [], calc: [], calcFlow: null }); }, }; // ─── UI: avatar animado (video) a la izquierda del chat ───── // Tres clips (720×1280, ~6s, en loop) que reflejan el estado de la conversación: // "waiting" en reposo, "writing" mientras el usuario escribe, "speaking" mientras // Cynamoncita procesa/entrega la respuesta. Todos van silenciados: es requisito de // los navegadores para autoreproducir en loop, y de paso nunca se escucha audio. // Duración real de los 3 clips (medida en el navegador: 6.016s) + margen chico, // para que el clip "speaking" siempre termine su vuelta completa. const SPEAKING_HOLD_MS = 6100; const AVATAR_VIDEOS = { waiting: 'assets/cynamoncita-waiting.mp4', writing: 'assets/cynamoncita-writing.mp4', speaking: 'assets/cynamoncita-speaking.mp4', }; function CynamoncitaAvatar({ state }) { return (
Cynamoncita IA de Cynamonurs
); } // ─── UI: mini-tabla de plan candidato (panel lateral) ─────── function MiniPlanTable({ candidate, onOpenTable, onDismiss, onEditManual }) { const { dx, obj, int: intv } = candidate; const sentence = window.buildDxSentence ? window.buildDxSentence(dx) : dx.etiqueta; const dominioName = (dx.dominio || '').replace(/^Dominio\s*\d+:?\s*/i, ''); const selectedIndicators = obj ? obj.selectedIndicadores.map(code => obj.indicadores.find(i => i.codigo === code)).filter(Boolean) : []; return (
{dx.dominio_num != null ? `Dominio ${dx.dominio_num}` : ''}{dominioName ? ` · ${dominioName.toLowerCase()}` : ''}
{dx.codigo}

{dx.etiqueta}

{sentence}

Objetivo (NOC)
{obj ? ( <>
{obj.codigo} {obj.titulo}
) :
Sin objetivo sugerido
}
Intervención (NIC)
{intv ? ( <>
{intv.codigo} {intv.nombre}
) :
Sin intervención sugerida
}
); } // ─── UI: panel lateral de resultados ───────────────────────── function CandidatesPanel({ candidates, calc, onOpenTable, onDismiss, onEditManual }) { return (
Planes candidatos {candidates.length > 0 && {candidates.length}}
{calc && calc.length > 0 && (
Con esos datos también puedes usar: {calc.map(c => ( ))}
)} {candidates.length === 0 ? (

Aquí van a aparecer hasta 10 planes candidatos completos — diagnóstico, objetivo e intervención — relacionados con los signos y síntomas que describas, listos para revisar e imprimir.

) : (
{candidates.map(c => ( onOpenTable(c)} onDismiss={() => onDismiss(c.dx.codigo)} onEditManual={() => onEditManual(c)}/> ))}
)}
); } // ─── ChatbotSection — pestaña "Cynamoncita IA" ────────────── function ChatbotSection() { const [state] = ChatStore.use(); const { messages, candidates, calc } = state; const [input, setInput] = useChS(''); const [thinking, setThinking] = useChS(false); const [talking, setTalking] = useChS(false); const logRef = useChR(null); const talkTimer = useChR(null); useChE(() => { if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight; }, [messages, thinking]); // El clip "speaking" dura ~6s (720×1280, ver AVATAR_VIDEOS) — se mantiene el // estado "hablando" ese tiempo completo antes de volver a "espera", para que // la animación nunca se corte a la mitad. const pulseTalk = () => { setTalking(true); clearTimeout(talkTimer.current); talkTimer.current = setTimeout(() => setTalking(false), SPEAKING_HOLD_MS); }; useChE(() => () => clearTimeout(talkTimer.current), []); // Acepta un string simple o un objeto ({ text, calcMenu, calcResult, … }) para // mensajes con contenido enriquecido (menú de calculadoras, resultado de un // cálculo con sus pasos). const pushBot = (payload) => { const extra = typeof payload === 'string' ? { text: payload } : payload; ChatStore.set(s => ({ ...s, messages: [...s.messages, { id: 'b' + Date.now() + Math.random().toString(36).slice(2), role: 'bot', ...extra }] })); pulseTalk(); }; const pushUser = (text) => { ChatStore.set(s => ({ ...s, messages: [...s.messages, { id: 'u' + Date.now() + Math.random().toString(36).slice(2), role: 'user', text }] })); }; // Publica el resultado de una calculadora y, de paso, deja los valores usados // en PatientStore (mismo store que Herramientas) por si luego quiere revisarlo ahí. const pushCalcResult = (calcId, collected) => { const out = computeCalc(calcId, collected); if (!out) return; if (window.PatientStore) { const patch = {}; ['peso', 'talla', 'fc', 'tas', 'tad', 'temp'].forEach(k => { if (collected[k] != null) patch[k] = collected[k]; }); if (Object.keys(patch).length) window.PatientStore.setPatient(patch); } pushBot({ text: `Listo, aquí está ${out.label}:`, calcResult: out }); // Solo el identificador de la calculadora — nunca los valores del paciente. if (window.cynaTrack) window.cynaTrack('calculo', { calc: calcId }); }; // Inicia una calculadora elegida desde el menú (chip) en vez de por texto. const startCalcFlowFromMenu = (id) => { const meta = CALC_CATALOG.find(c => c.id === id); if (!meta) return; pushUser(meta.label); ChatStore.set(s => ({ ...s, calcFlow: { id: meta.id, fields: meta.fields, collected: {} } })); pushBot(`Perfecto, vamos a calcular ${meta.label}. Dame: ${describeFields(meta.fields)}. (${meta.example})`); }; const send = () => { const text = input.trim(); if (!text || thinking) return; if (!window.DATA || !window.DATA.loaded) { pushUser(text); setInput(''); pushBot('Estoy cargando la base de datos — inténtalo de nuevo en un segundo.'); return; } // Límite del plan gratuito. El contador vive en Supabase (ver Limites.jsx), // así que no se reinicia borrando el navegador. const lim = window.revisarLimite ? window.revisarLimite('bot') : { ok: true }; if (!lim.ok) { pushUser(text); setInput(''); pushBot(lim.motivo === 'sesion' ? 'Para usar Cynamoncita IA necesitas iniciar sesión. El plan gratuito incluye 3 consultas.' : 'Has usado tus 3 consultas gratuitas a Cynamoncita IA. Con Enfermería Premium son ilimitadas — pulsa «Ver suscripción» aquí arriba.'); return; } pushUser(text); setInput(''); setThinking(true); setTimeout(() => { setThinking(false); // 0) ¿Hay una calculadora en curso pidiendo datos? Prioridad sobre todo // lo demás — este mensaje es la respuesta a lo que se preguntó. const flow = ChatStore.state.calcFlow; if (flow) { if (CALC_CANCEL_RE.test(stripAccents(text.trim().toLowerCase()))) { ChatStore.set(s => ({ ...s, calcFlow: null })); pushBot('Sin problema, cancelé ese cálculo. Cuéntame los signos y síntomas cuando quieras, o pide otra calculadora.'); return; } const parsed = extractCalcFields(text, flow.fields); const collected = { ...flow.collected, ...parsed }; const missing = flow.fields.filter(f => collected[f] == null); if (missing.length) { ChatStore.set(s => ({ ...s, calcFlow: { ...flow, collected } })); pushBot(`Me falta: ${describeFields(missing)}. ¿Me los pasas? (o escribe "cancelar")`); return; } ChatStore.set(s => ({ ...s, calcFlow: null })); pushCalcResult(flow.id, collected); return; } // 1) ¿Pide una calculadora nueva (por nombre o genérico "quiero calcular")? const intent = detectCalcIntent(text); if (intent) { if (intent.type === 'menu') { pushBot({ text: '¿Qué quieres medir? Elige una calculadora:', calcMenu: CALC_CATALOG }); return; } const meta = CALC_CATALOG.find(c => c.id === intent.id); const parsed = extractCalcFields(text, meta.fields); const missing = meta.fields.filter(f => parsed[f] == null); if (!missing.length) { pushCalcResult(meta.id, parsed); return; } // ya mandó todos los datos junto con el pedido ChatStore.set(s => ({ ...s, calcFlow: { id: meta.id, fields: meta.fields, collected: parsed } })); pushBot(`Perfecto, vamos a calcular ${meta.label}. Dame: ${describeFields(missing)}. (${meta.example})`); return; } // 2) Flujo normal: relacionar signos y síntomas con diagnósticos. let result; try { result = runAutoMatch(text); } catch (e) { result = { candidates: [], vitalsPatch: {} }; } if (result.vitalsPatch && Object.keys(result.vitalsPatch).length && window.PatientStore) { window.PatientStore.setPatient(result.vitalsPatch); } const calcS = calcSuggestions(result.vitalsPatch || {}); // Solo el número de resultados — nunca el texto de síntomas que escribió. if (window.cynaTrack) window.cynaTrack('consulta_bot', { n: result.candidates.length }); if (!result.candidates.length) { ChatStore.set(s => ({ ...s, candidates: [], calc: calcS })); pushBot('No logré relacionar signos claros con un diagnóstico todavía. Cuéntame más — por ejemplo: dolor, dificultad para respirar, fiebre, piel afectada, ansiedad, movilidad, sueño…'); return; } ChatStore.set(s => ({ ...s, candidates: result.candidates, calc: calcS })); const n = result.candidates.length; pushBot( result.clarify ? `Encontré ${n} opciones con síntomas parecidos entre sí — revisa las tablas del panel y elige la que se ajuste mejor.` : `Con lo que describes, encontré ${n} ${n === 1 ? 'plan candidato' : 'planes candidatos'}. Revísalos en el panel y confirma el que corresponda.` ); }, 600 + Math.random() * 300); }; const dismissCandidate = (code) => { ChatStore.set(s => ({ ...s, candidates: s.candidates.filter(c => c.dx.codigo !== code) })); }; const openTableFor = (candidate) => { if (!window.PlanStore) return; if (window.abrirSmartLinkUnaVez) window.abrirSmartLinkUnaVez(); window.PlanStore.set({ dx: candidate.dx, obj: candidate.obj, int: candidate.int }); if (window.__openFinalTable) window.__openFinalTable(); }; // Por si algún punto de lo que armó Cynamoncita no convence: carga ese mismo // candidato en el explorador manual (Diagnósticos · Objetivos · Intervenciones) // para poder cambiar características, indicadores, actividades, etc. a mano. const editManualFor = (candidate) => { if (!window.PlanStore) return; if (window.abrirSmartLinkUnaVez) window.abrirSmartLinkUnaVez(); window.PlanStore.set({ dx: candidate.dx, obj: candidate.obj, int: candidate.int }); if (window.__cynaGoTab) window.__cynaGoTab('dx'); }; const resetChat = () => ChatStore.reset(); // Estado del avatar animado: hablando mientras procesa/entrega la respuesta, // escribiendo mientras el usuario teclea, en espera el resto del tiempo. const avatarState = (thinking || talking) ? 'speaking' : (input.trim().length > 0 ? 'writing' : 'waiting'); return (
Escribe los signos y síntomas del paciente con tus propias palabras — Cynamoncita arma los planes candidatos. También puedes pedirle una calculadora (ej. "quiero la PAM").
{window.LimiteAviso ? React.createElement(window.LimiteAviso, { clave: 'bot', etiqueta: 'consultas a Cynamoncita IA' }) : null}
{messages.map(m => (
{m.role === 'bot' &&
}

{m.text}

{m.calcMenu && (
{m.calcMenu.map(c => ( ))}
)} {m.calcResult && (
{m.calcResult.label}
    {m.calcResult.steps.map((s, i) =>
  1. {s}
  2. )}
{m.calcResult.result}{m.calcResult.unit ? ' ' + m.calcResult.unit : ''} {m.calcResult.interp && — {m.calcResult.interp}}
)}
))} {thinking && (
)}