File size: 10,137 Bytes
70e641d
 
 
 
 
 
c205c6b
70e641d
 
6ab7946
70e641d
6ab7946
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70e641d
 
 
 
 
6ab7946
70e641d
 
 
6ab7946
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf9f7d1
70e641d
 
 
1e215c9
 
 
 
 
 
 
 
 
 
 
 
 
70e641d
 
 
 
1e215c9
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf9f7d1
 
 
 
 
 
 
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1e215c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70e641d
 
 
 
 
 
1e215c9
70e641d
 
 
 
 
c205c6b
70e641d
 
 
 
 
 
 
c205c6b
 
 
 
 
 
 
 
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
 
7dba92c
 
c205c6b
 
 
 
 
70e641d
 
 
6ab7946
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
// 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, ValoresFormulario } 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[]; valores?: ValoresFormulario },
  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, valores } = getUltimoAnalisis();
  // El formulario entrega texto; el backend espera números. Se descarta lo que no lo sea en vez
  // de mandarlo: un campo vacío no es un valor medido, y colarlo como 0 sería inventar un dato.
  const numericos: Record<string, number> = {};
  for (const [clave, crudo] of Object.entries(valores ?? {})) {
    const n = typeof crudo === 'number' ? crudo : parseFloat(String(crudo ?? ''));
    if (Number.isFinite(n)) numericos[clave] = n;
  }
  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,
    // El panel COMPLETO, no sólo lo alterado: es lo que le permite al modelo saber que un
    // analito ausente no se ha medido y que uno presente pero sin hallazgo salió en rango.
    // Valores CRUDOS: es lo que permite al backend recalcular hallazgos y gravedad por su
    // cuenta en vez de fiarse de los de aquí (ARCHITECTURE_REVIEW §1.1). `analitos_medidos` se
    // sigue enviando por compatibilidad, pero el servidor lo deriva de `valores`.
    valores: numericos,
    analitos_medidos: Object.keys(numericos),
    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');
  }
}