File size: 13,226 Bytes
445de93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4486d90
 
 
3a64e85
 
 
 
 
 
 
 
 
 
 
 
 
445de93
 
 
 
 
4486d90
c51b22a
 
 
4486d90
d90e9a6
c51b22a
4486d90
c51b22a
4486d90
c51b22a
 
4486d90
 
c51b22a
 
 
4486d90
 
d8aaa23
 
 
 
4486d90
 
 
445de93
4486d90
42cdd91
d8aaa23
 
4486d90
42cdd91
4486d90
445de93
 
 
d90e9a6
445de93
 
 
 
 
 
 
 
 
 
d90e9a6
445de93
 
 
 
 
 
 
 
b819476
 
 
d90e9a6
b819476
 
 
 
 
 
 
 
d90e9a6
b819476
 
 
 
 
 
 
 
 
 
 
 
 
 
445de93
 
 
 
 
 
d90e9a6
445de93
 
d90e9a6
445de93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d90e9a6
445de93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d8aaa23
 
d90e9a6
d8aaa23
 
 
 
d90e9a6
445de93
 
 
 
d8aaa23
 
 
 
 
 
445de93
 
 
 
 
 
 
 
d8aaa23
 
 
 
 
 
445de93
 
 
 
 
 
d8aaa23
445de93
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
import { imagenesDataUrl, capturasMicroscopio } from './ui.js';

const BACKEND_KEY = 'mx-ia-backend';
const OLLAMA_URL_KEY = 'mx-ia-ollama-url';
const OLLAMA_MOD_KEY = 'mx-ia-ollama-model';

export function inicializarConfigBackend() {
    const radioLocal = document.getElementById('ia-backend-local');
    const radioHF = document.getElementById('ia-backend-hf');
    const urlInput = document.getElementById('ia-ollama-url');
    const modelInput = document.getElementById('ia-ollama-model');
    const camposOllama = document.getElementById('ia-ollama-fields');

    const backendGuardado = localStorage.getItem(BACKEND_KEY) ?? 'hf';
    if (backendGuardado === 'local') radioLocal.checked = true;
    else radioHF.checked = true;

    const urlGuardada = localStorage.getItem(OLLAMA_URL_KEY);
    let modeloGuardado = localStorage.getItem(OLLAMA_MOD_KEY);
    if (modeloGuardado === 'medgemma:latest') {
        modeloGuardado = 'medgemma1.5:latest';
        localStorage.setItem(OLLAMA_MOD_KEY, modeloGuardado);
    }
    if (urlGuardada) urlInput.value = urlGuardada;
    if (modeloGuardado) modelInput.value = modeloGuardado;

    function aplicarBackend(val) {
        camposOllama.hidden = val !== 'local';
    }
    aplicarBackend(backendGuardado);

    [radioLocal, radioHF].forEach(r => r.addEventListener('change', () => {
        const val = document.querySelector('input[name="ia-backend"]:checked').value;
        localStorage.setItem(BACKEND_KEY, val);
        aplicarBackend(val);
    }));

    urlInput.addEventListener('input', () => localStorage.setItem(OLLAMA_URL_KEY, urlInput.value.trim()));
    modelInput.addEventListener('input', () => localStorage.setItem(OLLAMA_MOD_KEY, modelInput.value.trim()));
}

// Prompt

function construirPrompt(obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias) {
    const paciente = obtenerDatosPaciente();
    const valores = obtenerValoresFormulario();
    const { hallazgos, patrones } = getUltimoAnalisis();
    const signosText = document.getElementById('signos-clinicos').value.trim();
    const refEspecie = paciente.especie ? (getReferencias()[paciente.especie] || {}) : {};

    const totalImagenes = imagenesDataUrl.filter(Boolean).length + capturasMicroscopio.length;
    const hayImagenes = totalImagenes > 0;

    const lineasValores = Object.entries(valores).map(([clave, valor]) => {
        const ref = refEspecie[clave];
        const nombre = ref?.nombre || clave;
        const unidad = ref?.unidad || '';
        const rango = ref ? ` [ref: ${ref.inferior}-${ref.superior}]` : '';
        const h = hallazgos.find(h => h.clave === clave);
        const flag = h ? ` ← ${h.direccion === 'alto' ? 'ELEVADO' : 'BAJO'} (${h.gravedad})` : '';
        return `  ${nombre}: ${valor} ${unidad}${rango}${flag}`;
    }).join('\n') || 'Sin valores ingresados';

    const lineasPatrones = patrones.length > 0
        ? patrones.map(p => `  - ${p.nombre}: ${p.descripcion}`).join('\n')
        : '  Ninguno detectado';

    const edadTexto = paciente.edadMeses != null
        ? (paciente.edadMeses < 24 ? `${Math.round(paciente.edadMeses)} meses` : `${(paciente.edadMeses / 12).toFixed(1)} años`)
        : 'desconocida';

    if (hayImagenes) {
        const lineasHallazgos = hallazgos.length > 0
            ? hallazgos.map(h => `  ${h.nombre}: ${h.valor} ${h.unidad || ''} (${h.direccion} · ${h.gravedad})`).join('\n')
            : '  Todos los valores normales';

        // Prompt enfocado en citologia cuando hay imagenes adjuntas
        return `Eres médico veterinario especialista en patología clínica.

Paciente: ${paciente.especie || 'desconocido'}, ${paciente.raza || 'raza desconocida'}, ${edadTexto}, ${paciente.sexo || 'sexo desconocido'}

Hallazgos de laboratorio:
${lineasHallazgos}
${signosText ? `\nSignos clínicos: ${signosText}` : ''}

DATOS ADJUNTOS: ${totalImagenes} imagen${totalImagenes > 1 ? 'es' : ''} de citología.

¿Qué observas en las imágenes? Describe la morfología celular, identifica lesiones, patrones anormales, hemoparásitos (Anaplasma, Babesia, Ehrlichia, Hepatozoon, Piroplasma, Mycoplasma) e inclusiones citoplasmáticas. Luego integra con los datos de laboratorio. Responde en español.`;
    }

    const lineasHallazgos = hallazgos.length > 0
        ? hallazgos.map(h => `  ${h.nombre}: ${h.valor} ${h.unidad || ''} (${h.direccion} · ${h.gravedad})`).join('\n')
        : '  Todos los valores dentro de rangos normales';

    return `Responde en español.

Eres médico veterinario especialista en patología clínica.

Paciente: ${paciente.especie || 'desconocido'}, raza: ${paciente.raza || 'NE'}, edad: ${edadTexto}, sexo: ${paciente.sexo || 'NE'}

Hallazgos de laboratorio:
${lineasHallazgos}
${signosText ? `\nSignos clínicos: ${signosText}` : ''}

Proporciona una interpretación clínica breve (6-8 oraciones) destacando los hallazgos más significativos y las recomendaciones diagnósticas inmediatas.`;
}

function limpiarRespuesta(text) {
    // Elimina tokens especiales del modelo medGemma y otros artefactos de generacion
    if (text.includes('<start_of_turn>model')) {
        text = text.split('<start_of_turn>model').pop();
    }
    if (text.includes('<end_of_turn>')) {
        text = text.slice(0, text.indexOf('<end_of_turn>'));
    }
    if (text.includes('<unused95>')) {
        // Formato normal: <unused94>pensamiento<unused95>respuesta
        text = text.split('<unused95>').pop();
    } else if (text.includes('<unused94>')) {
        // El modelo agoto tokens en el razonamiento; muestra el pensamiento como respuesta
        text = text.split('<unused94>').slice(1).join('').trim();
    }
    text = text.replace(/<unused\d+>/g, '');
    text = text.replace(/<start_of_turn>\w+\n?/g, '');

    // Quitar prefijos de rol que el modelo a veces antepone
    text = text.replace(/^\d+\s+(medical assistant|assistant|model)\s*/i, '');

    // Quitar bloques de razonamiento / thinking process
    text = text.replace(/^thought\s*\n?/i, '');

    // Detectar si el modelo solo genero razonamiento en ingles sin respuesta clinica
    const tieneRazonamiento = /Here'?s a thinking process|Understand the Role|Analyze the Request|Review the Lab Results|Synthesize Findings|Formulate Clinical Interpretation/i.test(text);
    const tieneEspanol = /[áéíóúñÁÉÍÓÚÑ]{2,}/.test(text) || /\b(paciente|hallazgos|interpretación|recomendaciones|análisis|resultados|clínica|diagnóstico|evaluación|hepatopatía|nefropatía|anemia|leucocitosis|neutrofilia|linfopenia|hiperglucemia|hipoglucemia|pancreatitis|hepatitis|cirrosis|insuficiencia)\b/i.test(text);
    if (tieneRazonamiento && !tieneEspanol) {
        return 'El modelo generó un proceso de razonamiento interno en lugar de una interpretación clínica. Esto suele deberse a que el modelo está configurado en modo "pensamiento". Intenta nuevamente o contacta al administrador del espacio para desactivar el modo de razonamiento.';
    }

    // Si hay razonamiento mezclado con español, intentar extraer solo la respuesta
    if (tieneRazonamiento) {
        // Buscar la primera linea que parezca español clinico
        const lineas = text.split('\n');
        let inicioRespuesta = -1;
        for (let i = 0; i < lineas.length; i++) {
            const linea = lineas[i].trim();
            if (linea.length > 20 && /[áéíóúñÁÉÍÓÚÑ]/.test(linea) && !/\*\*[^*]+\*\*/.test(linea) && !/^\d+\./.test(linea) && !/Here'?s a thinking process/i.test(linea)) {
                inicioRespuesta = i;
                break;
            }
        }
        if (inicioRespuesta > 0) {
            text = lineas.slice(inicioRespuesta).join('\n');
        }
    }

    // Quitar LaTeX
    text = text.replace(/\$\\boxed\{[^}]*\}\$/g, '');
    text = text.replace(/\\begin\{[^}]+\}[\s\S]*?\\end\{[^}]+\}/g, '');
    text = text.replace(/\\[a-zA-Z]+(\{[^}]*\})?/g, '');
    text = text.replace(/\$[^$]*\$/g, '');

    // Colapsar lineas vacias multiples
    text = text.replace(/\n{3,}/g, '\n\n').trim();

    // Cortar al primer parrafo que se repite (loop del modelo)
    const parrafos = text.split(/\n\n+/);
    const vistos = new Set();
    const sinRepetidos = [];
    for (const p of parrafos) {
        const clave = p.trim().slice(0, 80);
        if (vistos.has(clave)) break;
        vistos.add(clave);
        sinRepetidos.push(p);
    }
    text = sinRepetidos.join('\n\n');

    return text.trim() || 'Sin respuesta del modelo.';
}

// Llamado a IA

export async function llamarIA(obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias) {
    const salidaEl = document.getElementById('salida-ia');
    const backend = document.querySelector('input[name="ia-backend"]:checked')?.value ?? 'hf';

    salidaEl.textContent = 'Consultando al modelo de I.A…';
    salidaEl.classList.add('cargando');

    try {
        if (backend === 'local') {
            await _llamarOllama(salidaEl, obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias);
        } else {
            await _llamarSpace(salidaEl, obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias);
        }
    } finally {
        salidaEl.classList.remove('cargando');
    }
}

// Ollama

async function _llamarOllama(salidaEl, obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias) {
    const urlBase = (document.getElementById('ia-ollama-url')?.value ?? 'http://localhost:11434').replace(/\/$/, '');
    const model = document.getElementById('ia-ollama-model')?.value?.trim() || 'medgemma1.5:latest';
    const prompt = construirPrompt(obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias);
    const imagenes = [...imagenesDataUrl.filter(Boolean), ...capturasMicroscopio];

    // Construye el payload compatible con OpenAI vision: imagenes primero, luego el texto
    const contenido = [];
    for (const img of imagenes) {
        if (typeof img === 'string' && img.startsWith('data:image/'))
            contenido.push({ type: 'image_url', image_url: { url: img } });
    }
    contenido.push({ type: 'text', text: prompt });

    try {
        const res = await fetch(`${urlBase}/v1/chat/completions`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                model,
                messages: [{ role: 'user', content: contenido.length === 1 ? prompt : contenido }],
                max_tokens: imagenes.length > 0 ? 1200 : 600,
                stream: false,
                think: false,
            }),
        });

        let data;
        try { data = await res.json(); } catch {
            salidaEl.textContent = `Error del servidor Ollama (HTTP ${res.status}). Verifica que esté ejecutándose.`;
            return;
        }

        if (!res.ok) {
            salidaEl.textContent = `Error Ollama: ${data?.error?.message ?? data?.error ?? `HTTP ${res.status}`}`;
        } else {
            salidaEl.textContent = limpiarRespuesta(data?.choices?.[0]?.message?.content ?? 'Sin respuesta del modelo.');
        }
    } catch {
        salidaEl.textContent = `No se pudo conectar con Ollama en ${urlBase}. Verifica que esté ejecutándose con "ollama serve".`;
    }
}

// Morphos AI Space

async function _llamarSpace(salidaEl, obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias) {
    let prompt = construirPrompt(obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias);
    
    // El modelo medGemma espera el token <unused95> al inicio del prompt para modo respuesta directa
    if (!prompt.includes('<unused95>')) {
        prompt = '<unused95>' + prompt;
    }
    
    // Filtra y limita a 4 imagenes por restriccion del backend de HuggingFace
    const imagenes = [...imagenesDataUrl.filter(Boolean), ...capturasMicroscopio]
        .filter(img => typeof img === 'string' && /^data:image\/(jpeg|png|gif|webp);base64,/.test(img))
        .slice(0, 4);

    console.log('=== MORPHOS AI REQUEST ===');
    console.log('Images count:', imagenes.length);
    console.log('Prompt length:', prompt.length);
    console.log('Prompt preview:', prompt.substring(0, 200) + '...');
    console.log('==========================');

    try {
        const res = await fetch('api/hf_proxy.php', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ images: imagenes, prompt }),
        });

        const data = await res.json();
        
        console.log('=== MORPHOS AI RESPONSE ===');
        console.log('Status:', res.status);
        console.log('Raw text preview:', (data.text ?? 'NO TEXT').substring(0, 300));
        console.log('===========================');
        
        if (!res.ok) {
            salidaEl.textContent = `Error: ${data?.error ?? `HTTP ${res.status}`}`;
        } else {
            salidaEl.textContent = limpiarRespuesta(data.text ?? 'Sin respuesta del modelo.');
        }
    } catch (e) {
        console.error('Network error:', e);
        salidaEl.textContent = `Error de red: ${e.message}`;
    }
}