// Cliente tipado de /api/interpret. // Reemplaza a js/ia.js: ya no construye el prompt ni limpia la salida con regex — // eso vive ahora en el backend. Aquí sólo se envía la petición y se renderiza la // interpretación estructurada (hallazgos, diferenciales con citas, siguientes pruebas // y el aviso de derivación al veterinario). import type { Hallazgo, Paciente, Patron } from './tipos.js'; const BACKEND_KEY = 'mx-ia-backend'; const MODELO_LOCAL_KEY = 'mx-ia-modelo-local'; // Modelos locales que el servidor admite. Se pide una vez al arrancar; lista vacía = la // instancia no tiene Ollama alcanzable y el selector entero se queda oculto. let modelosLocales: string[] = []; // Modelo local elegido, o null si se usa la ruta que decide el servidor. Se lee al enviar la // petición: es lo único que el cliente puede decir sobre qué modelo responde, y aun así el // servidor lo revalida contra su lista blanca. function modeloLocalSeleccionado(): string | null { const radioLocal = document.getElementById('ia-backend-local') as HTMLInputElement | null; if (!radioLocal?.checked) return null; const guardado = localStorage.getItem(MODELO_LOCAL_KEY); return guardado && modelosLocales.includes(guardado) ? guardado : (modelosLocales[0] ?? null); } // Inicializa el panel de ajustes de backend. La ruta ('medgemma' | 'claude') la decide el // servidor; lo único que el usuario elige aquí es CUÁL de los modelos locales declarados // responde. No hay campo de URL: la de Ollama vive en la configuración del servidor. export async function inicializarConfigBackend(): Promise { const guardado = localStorage.getItem(BACKEND_KEY); if (guardado !== 'claude' && guardado !== 'medgemma') { localStorage.setItem(BACKEND_KEY, 'medgemma'); } const contenedor = document.getElementById('ia-backend-config') as HTMLElement | null; const radioLocal = document.getElementById('ia-backend-local') as HTMLInputElement | null; const radioHF = document.getElementById('ia-backend-hf') as HTMLInputElement | null; const camposOllama = document.getElementById('ia-ollama-fields') as HTMLElement | null; const selector = document.getElementById('ia-modelo-local') as HTMLSelectElement | null; try { const res = await fetch('/api/modelos', { credentials: 'same-origin' }); if (res.ok) modelosLocales = (await res.json()).locales ?? []; } catch { modelosLocales = []; // sin lista, el selector no aparece y todo sigue como antes } if (!modelosLocales.length) return; if (contenedor) contenedor.hidden = false; if (selector) { const previo = localStorage.getItem(MODELO_LOCAL_KEY); selector.replaceChildren( ...modelosLocales.map((m) => { const opcion = document.createElement('option'); opcion.value = m; opcion.textContent = m; opcion.selected = m === previo; return opcion; }), ); selector.addEventListener('change', () => { localStorage.setItem(MODELO_LOCAL_KEY, selector.value); }); } const aplicar = () => { if (camposOllama) camposOllama.hidden = !(radioLocal?.checked); }; aplicar(); [radioLocal, radioHF].forEach((r) => r?.addEventListener('change', aplicar)); } interface Diferencial { nombre: string; probabilidad: 'alta' | 'media' | 'baja'; evidencia: string[]; citas: string[]; } interface InterpretacionClinica { interpretacion: string; hallazgos_clave: Array<{ analito: string; direccion: string; gravedad: string; comentario: string }>; diferenciales: Diferencial[]; siguientes_pruebas: string[]; confianza: 'alta' | 'media' | 'baja'; requiere_derivacion: boolean; fuera_de_alcance: boolean; idioma: string; } // Las fuentes las construye el servidor a partir de lo que la recuperación entregó de // verdad (no las escribe el modelo), así que se pueden mostrar en las tres rutas —incluida // la del HF Space, que sólo devuelve prosa con marcadores [n]. interface Fuente { indice: number; libro: string; edicion: string; capitulo: string; pagina: string; cita: string; citada: boolean; } interface RespuestaInterpretacion { resultado: InterpretacionClinica; modelo: string; fuentes_rag: number; fuentes: Fuente[]; } function leerCookie(nombre: string): string | null { const par = document.cookie.split('; ').find((c) => c.startsWith(`${nombre}=`)); return par ? decodeURIComponent(par.split('=')[1]) : null; } // Escapado mínimo: los datos vienen del modelo, se insertan como texto. function esc(texto: string): string { const div = document.createElement('div'); div.textContent = texto; return div.innerHTML; } function renderizar(resp: RespuestaInterpretacion): string { const r = resp.resultado; // El rechazo por alcance lo decide el servidor antes de llamar al modelo, así que se anuncia // como tal en vez de dejarlo sólo en la prosa. const aviso = r.fuera_de_alcance ? '
⛔ Caso fuera del alcance de la herramienta (pacientes caninos y felinos)
' : r.requiere_derivacion ? '
⚠ Requiere valoración presencial del veterinario
' : ''; const hallazgos = r.hallazgos_clave.length ? `` : ''; const diferenciales = r.diferenciales.length ? `
    ${r.diferenciales .map( (d) => `
  1. ${esc(d.nombre)} ${d.probabilidad} ${d.evidencia.length ? `
    ${esc(d.evidencia.join('; '))}
    ` : ''} ${d.citas.length ? `
    ${d.citas.map((c) => `${esc(c)}`).join(' ')}
    ` : ''}
  2. `, ) .join('')}
` : ''; const pruebas = r.siguientes_pruebas.length ? `
Siguientes pruebas: ${esc(r.siguientes_pruebas.join(', '))}
` : ''; // Se listan todas las fuentes recuperadas y se distingue cuáles sostienen la respuesta: // en la ruta de prosa, los marcadores [n] del texto apuntan a esta numeración. const listaFuentes = resp.fuentes ?? []; const citadas = listaFuentes.filter((f) => f.citada).length; const fuentes = listaFuentes.length ? `
Literatura consultada (${citadas} de ${listaFuentes.length} citadas)
    ${listaFuentes .map( (f) => `
  1. ${esc(f.cita)}${f.capitulo ? ` — ${esc(f.capitulo)}` : ''}
  2. `, ) .join('')}
` : ''; const meta = `
Modelo: ${esc(resp.modelo)} · Fragmentos recuperados: ${resp.fuentes_rag} · Confianza: ${r.confianza}
`; return `${aviso}

${esc(r.interpretacion)}

${hallazgos} ${diferenciales ? `

Diagnósticos diferenciales

${diferenciales}` : ''} ${pruebas} ${fuentes} ${meta}`; } export async function llamarIA( obtenerDatosPaciente: () => Paciente, getUltimoAnalisis: () => { hallazgos: Hallazgo[]; patrones: Patron[] }, getImagenes: () => string[], ): Promise { const salidaEl = document.getElementById('salida-ia'); if (!salidaEl) return; const backend = (localStorage.getItem(BACKEND_KEY) ?? 'medgemma') === 'claude' ? 'claude' : 'medgemma'; const paciente = obtenerDatosPaciente(); const { hallazgos, patrones } = getUltimoAnalisis(); const signos = (document.getElementById('signos-clinicos') as HTMLTextAreaElement | null)?.value.trim() ?? ''; salidaEl.textContent = 'Consultando al modelo de I.A…'; salidaEl.classList.add('cargando'); const cuerpo = { paciente: { especie: paciente.especie, raza: paciente.raza, edad_meses: paciente.edadMeses, sexo: paciente.sexo, }, hallazgos, patrones, signos_clinicos: signos, imagenes: getImagenes().slice(0, 4), backend, modelo_local: modeloLocalSeleccionado(), }; try { const res = await fetch('/api/interpret', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': leerCookie('morphos_csrf') ?? '', }, body: JSON.stringify(cuerpo), }); const data = await res.json(); if (!res.ok) { salidaEl.textContent = data?.error ?? data?.detail ?? `Error HTTP ${res.status}`; } else { salidaEl.innerHTML = renderizar(data as RespuestaInterpretacion); } } catch (e) { salidaEl.textContent = `Error de red: ${(e as Error).message}`; } finally { salidaEl.classList.remove('cargando'); } }