File size: 10,326 Bytes
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
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;

    let lineasValores;
    if (hayImagenes) {
        lineasValores = hallazgos.length > 0
            ? hallazgos.map(h => `  ${h.nombre}: ${h.valor} ${h.unidad || ''} (${h.direccion} · ${h.gravedad})`).join('\n')
            : 'Todos los valores dentro de rangos de referencia.';
    } else {
        lineasValores = Object.entries(valores).map(([clave, valor]) => {
            const ref = refEspecie[clave];
            const nombre = ref?.nombre || clave;
            const unidad = ref?.unidad || '';
            return `  ${nombre}: ${valor} ${unidad}`;
        }).join('\n') || 'Sin valores ingresados';
    }

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

    let notaImagenes = '';
    let cierre = '';
    if (hayImagenes) {
        notaImagenes = `TAREA PRINCIPAL: Se adjuntan ${totalImagenes} imagen${totalImagenes > 1 ? 'es' : ''} de citología. DEBES analizarlas exhaustivamente ANTES de considerar los resultados de laboratorio. Describe morfología celular, lesiones, patrones anormales, hemoparásitos intracelulares y extracelulares (Anaplasma, Babesia, Ehrlichia, Hepatozoon, Piroplasma, Mycoplasma, etc.) e inclusiones citoplasmáticas. Luego integra los hallazgos citológicos con los resultados de laboratorio. NO omitas el análisis de las imágenes bajo ninguna circunstancia.`;
        cierre = `Estructura tu respuesta en dos partes claras: (1) Análisis citológico detallado de las imágenes adjuntas, y (2) Integración con los resultados de laboratorio y recomendaciones diagnósticas.`;
    } else {
        cierre = `Proporciona una interpretación clínica breve (8-10 oraciones) destacando los hallazgos más significativos y las recomendaciones diagnósticas inmediatas.`;
    }

    return `IMPORTANTE: Responde ÚNICAMENTE en español. Do not write in English under any circumstance.
    ${notaImagenes ? `\n    ${notaImagenes}\n` : ''}
    Eres un médico veterinario especialista en patología clínica. Sólo responderás consultas asociadas a ésta área de conocimiento y basado en la evidencia proporcionada.
    Analiza los resultados y proporciona una interpretación clínica concisa.

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

    Resultados de laboratorio:
    ${lineasValores}

    ${signosText ? `\nSignos clínicos: ${signosText}` : ''}
    ${cierre}`;
}

function limpiarRespuesta(text) {
    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 agotó tokens en el razonamiento — mostrar 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 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 líneas vacías múltiples
    text = text.replace(/\n{3,}/g, '\n\n').trim();

    // Cortar al primer párrafo 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];

    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) {
    const prompt = construirPrompt(obtenerDatosPaciente, obtenerValoresFormulario, getUltimoAnalisis, getReferencias);
    const imagenes = [...imagenesDataUrl.filter(Boolean), ...capturasMicroscopio]
        .filter(img => typeof img === 'string' && /^data:image\/(jpeg|png|gif|webp);base64,/.test(img))
        .slice(0, 4);

    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();
        if (!res.ok) {
            salidaEl.textContent = `Error: ${data?.error ?? `HTTP ${res.status}`}`;
        } else {
            salidaEl.textContent = limpiarRespuesta(data.text ?? 'Sin respuesta del modelo.');
        }
    } catch (e) {
        salidaEl.textContent = `Error de red: ${e.message}`;
    }
}