morphos / frontend /src /ia.ts
Jose Salazar
Allow list para modelos locales configurables por el usuario
6ab7946
Raw
History Blame Contribute Delete
9.11 kB
// 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<void> {
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
? '<div class="ia-aviso-alcance">⛔ Caso fuera del alcance de la herramienta (pacientes caninos y felinos)</div>'
: r.requiere_derivacion
? '<div class="ia-aviso-derivacion">⚠ Requiere valoración presencial del veterinario</div>'
: '';
const hallazgos = r.hallazgos_clave.length
? `<ul class="ia-hallazgos">${r.hallazgos_clave
.map((h) => `<li>${esc(h.analito)}: ${esc(h.direccion)} · ${esc(h.gravedad)}${h.comentario ? ` — ${esc(h.comentario)}` : ''}</li>`)
.join('')}</ul>`
: '';
const diferenciales = r.diferenciales.length
? `<ol class="ia-diferenciales">${r.diferenciales
.map(
(d) => `<li>
<span class="ia-dif-nombre">${esc(d.nombre)}</span>
<span class="ia-dif-prob ia-prob-${d.probabilidad}">${d.probabilidad}</span>
${d.evidencia.length ? `<div class="ia-dif-evidencia">${esc(d.evidencia.join('; '))}</div>` : ''}
${d.citas.length ? `<div class="ia-dif-citas">${d.citas.map((c) => `<cite>${esc(c)}</cite>`).join(' ')}</div>` : ''}
</li>`,
)
.join('')}</ol>`
: '';
const pruebas = r.siguientes_pruebas.length
? `<div class="ia-pruebas"><strong>Siguientes pruebas:</strong> ${esc(r.siguientes_pruebas.join(', '))}</div>`
: '';
// 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
? `<details class="ia-fuentes"><summary>Literatura consultada (${citadas} de ${listaFuentes.length} citadas)</summary>
<ol class="ia-fuentes-lista">${listaFuentes
.map(
(f) =>
`<li value="${f.indice}" class="${f.citada ? 'ia-fuente-citada' : 'ia-fuente-no-citada'}">
<cite>${esc(f.cita)}</cite>${f.capitulo ? ` — ${esc(f.capitulo)}` : ''}
</li>`,
)
.join('')}</ol></details>`
: '';
const meta = `<div class="ia-meta">Modelo: ${esc(resp.modelo)} · Fragmentos recuperados: ${resp.fuentes_rag} · Confianza: ${r.confianza}</div>`;
return `${aviso}
<p class="ia-interpretacion">${esc(r.interpretacion)}</p>
${hallazgos}
${diferenciales ? `<h4>Diagnósticos diferenciales</h4>${diferenciales}` : ''}
${pruebas}
${fuentes}
${meta}`;
}
export async function llamarIA(
obtenerDatosPaciente: () => Paciente,
getUltimoAnalisis: () => { hallazgos: Hallazgo[]; patrones: Patron[] },
getImagenes: () => string[],
): Promise<void> {
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');
}
}