Spaces:
Running
Running
Jose Salazar Claude Opus 5 commited on
Commit ·
7dba92c
1
Parent(s): 1fec72e
Léxico clínico compartido y detección de analitos fabricados
Browse files`ai/lexico.py` centraliza el vocabulario clínico (variantes, abreviaturas,
nombres) que hasta ahora vivía disperso, y `ai/coherencia.py` lo usa para la
comprobación que faltaba: distinguir «no se midió» de «se midió y salió en
rango». Sin ese dato el modelo rellenaba el hueco —medido el 2026-08-04:
medGemma afirmó un leucograma con neutrofilia sobre un panel de calcio/fósforo/
BUN/creatinina, y llamó «trombocitopenia leve» a unas plaquetas en rango—.
De ahí `analitos_medidos` en `PeticionInterpretacion` y en el cliente (`ia.ts`):
el panel COMPLETO, no sólo lo alterado. Campo opcional, así que un cliente
antiguo que no lo mande se comporta como antes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- backend/app/ai/coherencia.py +218 -3
- backend/app/ai/lexico.py +192 -0
- backend/app/ai/prompt.py +54 -1
- backend/app/ai/service.py +103 -3
- backend/app/config.py +10 -1
- backend/app/schemas.py +7 -0
- backend/tests/conftest.py +8 -1
- backend/tests/test_coherencia.py +160 -0
- backend/tests/test_modelos_locales.py +11 -0
- backend/tests/test_prompt_y_rag.py +29 -0
- backend/tests/test_reintentos.py +92 -0
- backend/tests/test_schemas.py +14 -0
- frontend/src/ia.ts +5 -2
backend/app/ai/coherencia.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
Motivo: el 2026-08-01, con salida estructurada, medGemma devolvió en `hallazgos_clave` un
|
| 4 |
«Potasio (K+): 7.1 mEq/L, alto, grave» en un caso cuyos valores eran calcio, fósforo, BUN y
|
|
@@ -9,8 +9,25 @@ Por qué merece guarda propia: la decodificación restringida garantiza JSON bie
|
|
| 9 |
veracidad. Y un analito inventado DENTRO de un campo estructurado es peor que en prosa, porque
|
| 10 |
la interfaz lo pinta como un hallazgo con el mismo rango visual que los del motor determinista.
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
|
@@ -20,6 +37,7 @@ import re
|
|
| 20 |
import unicodedata
|
| 21 |
|
| 22 |
from ..schemas import InterpretacionClinica, PeticionInterpretacion
|
|
|
|
| 23 |
|
| 24 |
log = logging.getLogger("morphos.ia")
|
| 25 |
|
|
@@ -77,3 +95,200 @@ def descartar_fabricados(
|
|
| 77 |
h for h in resultado.hallazgos_clave if h.analito not in fabricados
|
| 78 |
]
|
| 79 |
return resultado
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""El modelo sólo puede hablar de analitos que se enviaron, y sólo alterarlos si lo estaban.
|
| 2 |
|
| 3 |
Motivo: el 2026-08-01, con salida estructurada, medGemma devolvió en `hallazgos_clave` un
|
| 4 |
«Potasio (K+): 7.1 mEq/L, alto, grave» en un caso cuyos valores eran calcio, fósforo, BUN y
|
|
|
|
| 9 |
veracidad. Y un analito inventado DENTRO de un campo estructurado es peor que en prosa, porque
|
| 10 |
la interfaz lo pinta como un hallazgo con el mismo rango visual que los del motor determinista.
|
| 11 |
|
| 12 |
+
Dos guardas, porque son dos superficies distintas:
|
| 13 |
+
|
| 14 |
+
- `descartar_fabricados` limpia `hallazgos_clave`. Aquí sí se BORRA (a diferencia de
|
| 15 |
+
`prescripcion.py`): un elemento de lista es una unidad independiente y quitarlo no deja una
|
| 16 |
+
frase a medias.
|
| 17 |
+
- `analitos_fabricados_en_prosa` mira la PROSA, que es donde va todo en la ruta desplegada en
|
| 18 |
+
producción —el Space devuelve texto libre y `hallazgos_clave` llega vacío por diseño, así que
|
| 19 |
+
la guarda de arriba no ve nada—. Aquí NO se borra: sólo detecta y devuelve la lista; quien
|
| 20 |
+
actúa es `service.py`, que vuelve a muestrear con una instrucción correctiva.
|
| 21 |
+
- `alterados_declarados_normales` es la simétrica de la anterior sobre la misma prosa: no que
|
| 22 |
+
se invente una alteración, sino que se dé por normal un valor que el motor determinista ve
|
| 23 |
+
fuera de rango. Se remedia igual, regenerando.
|
| 24 |
+
|
| 25 |
+
Lo que motivó la segunda, corrida del 2026-08-04 (juez_seguridad 0.79, umbral 0.90): sobre un
|
| 26 |
+
panel de calcio/fósforo/BUN/creatinina el modelo escribió «La leucograma muestra neutrofilia y
|
| 27 |
+
linfopenia» —única violación de seguridad de la corrida—, en otro caso afirmó «aumento de
|
| 28 |
+
reticulocitos» sin recuento reticulocitario, y en un tercero llamó «trombocitopenia leve» a unas
|
| 29 |
+
plaquetas de 190 que estaban en rango. Los tres son la misma carencia: el modelo no distinguía
|
| 30 |
+
«no se midió» de «se midió y salió normal».
|
| 31 |
"""
|
| 32 |
|
| 33 |
from __future__ import annotations
|
|
|
|
| 37 |
import unicodedata
|
| 38 |
|
| 39 |
from ..schemas import InterpretacionClinica, PeticionInterpretacion
|
| 40 |
+
from .lexico import abreviaturas_presentes, nombre_clinico, sin_tildes, terminos_especificos
|
| 41 |
|
| 42 |
log = logging.getLogger("morphos.ia")
|
| 43 |
|
|
|
|
| 95 |
h for h in resultado.hallazgos_clave if h.analito not in fabricados
|
| 96 |
]
|
| 97 |
return resultado
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
# --- Guarda sobre la prosa ---
|
| 101 |
+
|
| 102 |
+
# Pedir una prueba es exactamente lo que la herramienta DEBE hacer, así que una oración de
|
| 103 |
+
# recomendación queda exenta: sin esto, la propia frase correcta de `hipercalcemia-canino` («Se
|
| 104 |
+
# debe obtener un hemograma completo incluyendo plaquetas») se marcaría como invención del
|
| 105 |
+
# hemograma que la misma respuesta reconoce no tener. Se prefiere el falso negativo: un falso
|
| 106 |
+
# positivo gasta una llamada al Space y le mete al modelo una corrección que no procede.
|
| 107 |
+
_RECOMENDACION = re.compile(
|
| 108 |
+
r"\b(se recomienda|recomendable|recomiendo|se sugiere|sugiero|se aconseja|"
|
| 109 |
+
r"solicit\w+|obtener|obtenga|realiz\w+|efectu\w+|considerar|considere|evaluar|eval[uú]e|"
|
| 110 |
+
r"valorar|valore|determinar|determine|cuantific\w+|medicion|medir|mida|ampliar|amplie|"
|
| 111 |
+
r"complet\w+|incluir|incluya|pedir|pida|repetir|repita|monitoriz\w+|control\w+|"
|
| 112 |
+
r"seguimiento|descartar|descarte|confirmar|confirme|investigar|investigue|"
|
| 113 |
+
r"siguientes pruebas|pruebas (diagnosticas|adicionales|complementarias))\b"
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
# La prosa clínica explica MECANISMOS, y eso no es afirmar un dato del paciente: «el iCa puede
|
| 117 |
+
# estar alterado en presencia de alcalosis o hipoalbuminemia» habla de fisiología general, no
|
| 118 |
+
# dice que este gato tenga alcalosis. Sin esta exención, `hipomagnesemia-uci-felino` se marcaba
|
| 119 |
+
# por esa misma frase, que es correcta.
|
| 120 |
+
_HIPOTETICO = re.compile(
|
| 121 |
+
r"\b(puede|pueden|podria|podrian|pudiera|suele|suelen|si hay|si existe|si se|"
|
| 122 |
+
r"en presencia de|en ausencia de|en caso de|en casos de|cuando hay|cuando existe|"
|
| 123 |
+
r"posible|posibles|probable|probables|sospecha de|compatible con|sugiere|sugieren|"
|
| 124 |
+
r"sugestiv\w+|asociad\w+ a|secundari\w+ a|habitualmente|t[ií]picamente)\b"
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
# Nombrar un ESTADO fisiopatológico no es reportar un resultado de laboratorio: «indican una
|
| 128 |
+
# marcada deficiencia de insulina» en una cetoacidosis es la conclusión correcta, no la
|
| 129 |
+
# invención de una insulinemia que nadie midió. Sin esta exención, `cetoacidosis-felino-gases`
|
| 130 |
+
# —cuya salida el juez puntuó 1.00 en seguridad— gastaba un reintento contra el Space por una
|
| 131 |
+
# frase impecable.
|
| 132 |
+
_ESTADO_FISIOPATOLOGICO = re.compile(
|
| 133 |
+
r"\b(deficiencia|deficit|carencia|exceso|resistencia|insuficiencia|sobreproduccion|"
|
| 134 |
+
r"produccion excesiva|liberacion)\s+(de|a la|al|de la)\b"
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
# Marcadores de que la oración AFIRMA un dato, no lo pide ni lo hipotetiza.
|
| 138 |
+
_ASERCION = re.compile(
|
| 139 |
+
r"\b(muestra|muestran|presenta|presentan|se observa\w*|se aprecia\w*|se evidencia\w*|"
|
| 140 |
+
r"evidencia|revela|revelan|hay|existe|existen|aument\w+|disminu\w+|elevad\w+|"
|
| 141 |
+
r"descendid\w+|reducid\w+|increment\w+|alto|alta|altos|altas|bajo|baja|bajos|bajas|"
|
| 142 |
+
r"marcad\w+|sever\w+|acentuad\w+)\b"
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
# Un término que POR SÍ SOLO afirma una alteración: «trombocitopenia», «hipocalcemia»,
|
| 146 |
+
# «neutrofilia». Nombrar el analito («las plaquetas», «el calcio») no es afirmar nada; llamarlo
|
| 147 |
+
# por su alteración sí. Es lo que separa el caso del analito medido y en rango —donde sólo esto
|
| 148 |
+
# cuenta— del analito que nunca se midió, donde cualquier aserción vale.
|
| 149 |
+
_TERMINO_DE_ALTERACION = re.compile(
|
| 150 |
+
r"^(hiper|hipo|macro|micro|pan)\w+"
|
| 151 |
+
r"|(penia|citosis|filia|osis|uria|emia)$"
|
| 152 |
+
)
|
| 153 |
+
# Alteraciones cuya forma no delata el prefijo/sufijo de arriba. Se listan a mano, mismo
|
| 154 |
+
# criterio que `VARIANTES` en lexico.py: aquí un falso positivo es peor que un falso negativo.
|
| 155 |
+
_ALTERACIONES_IRREGULARES = frozenset({"regenerativa", "azotemia", "uremia", "transaminasas"})
|
| 156 |
+
|
| 157 |
+
_ORACIONES = re.compile(r"(?<=[.!?;:])\s+|\n+")
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _es_alteracion(termino: str) -> bool:
|
| 161 |
+
return termino in _ALTERACIONES_IRREGULARES or bool(_TERMINO_DE_ALTERACION.search(termino))
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def analitos_fabricados_en_prosa(
|
| 165 |
+
texto: str, pet: PeticionInterpretacion
|
| 166 |
+
) -> list[str]:
|
| 167 |
+
"""Nombres clínicos de analitos que la prosa afirma sin respaldo. Vacía si no hay ninguno.
|
| 168 |
+
|
| 169 |
+
Dos casos, con umbrales distintos a propósito:
|
| 170 |
+
|
| 171 |
+
- **No se midió**: cualquier oración que lo AFIRME (verbo de constatación o término de
|
| 172 |
+
dirección) o que lo nombre por su alteración es invención. Ejemplo real: «La leucograma
|
| 173 |
+
muestra neutrofilia y linfopenia» sobre un panel de bioquímica.
|
| 174 |
+
- **Se midió y salió en rango**: nombrarlo es legítimo —el modelo puede decir que las
|
| 175 |
+
plaquetas están conservadas—; lo que no vale es llamarlo por su alteración. Ejemplo real:
|
| 176 |
+
«trombocitopenia leve» con `plt` = 190, dentro de rango.
|
| 177 |
+
|
| 178 |
+
Sin `analitos_medidos` no se puede distinguir «no medido» de «medido y normal», así que la
|
| 179 |
+
guarda se desactiva entera: es lo que manda una petición de un cliente antiguo, y marcar a
|
| 180 |
+
ciegas ahí generaría reintentos sobre respuestas correctas.
|
| 181 |
+
"""
|
| 182 |
+
medidos = {c.strip() for c in pet.analitos_medidos if c.strip()}
|
| 183 |
+
if not medidos:
|
| 184 |
+
return []
|
| 185 |
+
|
| 186 |
+
lexico = terminos_especificos()
|
| 187 |
+
# `hallazgos` son los analitos FUERA de rango; los `parametros` de un patrón son los que el
|
| 188 |
+
# patrón involucra, estén alterados o no, así que cuentan como conocidos pero no como
|
| 189 |
+
# alterados. Mezclarlos absolvía el caso que motivó la guarda: en
|
| 190 |
+
# `hemolisis-regenerativa-canino` un patrón lista `plt` entre sus parámetros con las
|
| 191 |
+
# plaquetas en 190 —en rango—, y la «trombocitopenia leve» del modelo quedaba sin marcar.
|
| 192 |
+
alterados = {h.clave for h in pet.hallazgos}
|
| 193 |
+
conocidos = medidos | alterados
|
| 194 |
+
for patron in pet.patrones:
|
| 195 |
+
conocidos.update(patron.parametros)
|
| 196 |
+
en_rango = (conocidos - alterados) & lexico.keys()
|
| 197 |
+
no_medidos = lexico.keys() - conocidos
|
| 198 |
+
|
| 199 |
+
# Un término que también nombra a un analito realmente alterado no prueba nada: «azotemia»
|
| 200 |
+
# la sostiene un BUN alto aunque la creatinina esté en rango, y «eritrocitos» puede ser el
|
| 201 |
+
# RBC del hemograma y no los de la orina. Se exige que el término apunte SÓLO a lo que no
|
| 202 |
+
# se midió (o a lo que se midió y salió normal).
|
| 203 |
+
respaldados = {t for c in alterados for t in lexico.get(c, ())}
|
| 204 |
+
|
| 205 |
+
fabricados: dict[str, None] = {} # dict para deduplicar conservando el orden
|
| 206 |
+
for oracion in _ORACIONES.split(texto):
|
| 207 |
+
normalizada = sin_tildes(oracion)
|
| 208 |
+
if not normalizada.strip():
|
| 209 |
+
continue
|
| 210 |
+
if (
|
| 211 |
+
_RECOMENDACION.search(normalizada)
|
| 212 |
+
or _HIPOTETICO.search(normalizada)
|
| 213 |
+
or _ESTADO_FISIOPATOLOGICO.search(normalizada)
|
| 214 |
+
):
|
| 215 |
+
continue
|
| 216 |
+
afirma = bool(_ASERCION.search(normalizada))
|
| 217 |
+
# Las siglas ambiguas se cotejan sobre la oración SIN normalizar: es la mayúscula lo
|
| 218 |
+
# único que distingue el "UN" del BUN del artículo indeterminado.
|
| 219 |
+
siglas = abreviaturas_presentes(oracion)
|
| 220 |
+
for clave in no_medidos | en_rango:
|
| 221 |
+
presentes = [
|
| 222 |
+
t for t in lexico[clave]
|
| 223 |
+
if t not in respaldados and re.search(rf"\b{re.escape(t)}\b", normalizada)
|
| 224 |
+
]
|
| 225 |
+
# Una sigla nombra al analito, no lo altera: vale como mención, no como
|
| 226 |
+
# alteración, igual que decir «las plaquetas».
|
| 227 |
+
nombrado = bool(presentes) or clave in siglas
|
| 228 |
+
if not nombrado:
|
| 229 |
+
continue
|
| 230 |
+
if any(map(_es_alteracion, presentes)) or (clave in no_medidos and afirma):
|
| 231 |
+
fabricados[nombre_clinico(clave)] = None
|
| 232 |
+
return list(fabricados)
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
# --- Guarda simétrica: declarar normal lo que el motor vio alterado ---
|
| 236 |
+
|
| 237 |
+
# El copulativo es lo que separa afirmar de razonar: «la MCV aquí está normal» habla de ESTE
|
| 238 |
+
# paciente, mientras que «una creatinina normal no descarta ERC» enuncia un principio general y
|
| 239 |
+
# no debe marcarse. Por eso se exige el verbo, y no basta con que aparezca la palabra «normal».
|
| 240 |
+
_AFIRMA_NORMALIDAD = re.compile(
|
| 241 |
+
r"\b(esta|estan|es|son|se (encuentra|encuentran|mantiene|mantienen|situa|situan)|"
|
| 242 |
+
r"permanece\w*|resulta\w*|luce\w*|aparece\w*)\s+"
|
| 243 |
+
r"(dentro|en (el )?(rango|limites|intervalo)|normal\w*|conservad\w*|dentro de)"
|
| 244 |
+
r"|\b(dentro (de|del) (los |las )?(rango|limites|valores|intervalo)\w*|"
|
| 245 |
+
r"sin alteracion\w*|no muestra alteracion\w*)"
|
| 246 |
+
)
|
| 247 |
+
|
| 248 |
+
# «no está normal», «lejos de lo normal»: la negación invierte la frase y deja de ser una
|
| 249 |
+
# declaración de normalidad. Barata de comprobar y evita el falso positivo más obvio.
|
| 250 |
+
_NEGACION = re.compile(r"\b(no|nunca|tampoco|lejos de|salvo|excepto)\b")
|
| 251 |
+
|
| 252 |
+
# Esta guarda trocea MÁS fino que la de invención, y sólo ella. El caso real venía dentro de una
|
| 253 |
+
# oración larga: «…la literatura [2] menciona que la microcitosis no es típica en shunt
|
| 254 |
+
# portosistémico (aunque la MCV aquí está normal)», donde el «no» y el «posible» de la primera
|
| 255 |
+
# mitad eximían a la segunda. Una adversativa o un paréntesis abren una afirmación nueva, y aquí
|
| 256 |
+
# la afirmación que importa es corta y se sostiene sola. No se aplica al otro sentido de la
|
| 257 |
+
# guarda: allí trocear más deja frases sin el contexto que las exime y dispara falsos positivos.
|
| 258 |
+
_CLAUSULAS = re.compile(r"\b(aunque|si bien|mientras que|pese a que|a pesar de que)\b|[()]")
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
def alterados_declarados_normales(
|
| 262 |
+
texto: str, pet: PeticionInterpretacion
|
| 263 |
+
) -> list[str]:
|
| 264 |
+
"""Nombres clínicos de analitos ALTERADOS que la prosa declara normales.
|
| 265 |
+
|
| 266 |
+
Es la mitad que faltaba de `analitos_fabricados_en_prosa`: aquella detecta alterar lo que
|
| 267 |
+
está bien, y ésta declarar bien lo que está alterado. Clínicamente la segunda es la peor de
|
| 268 |
+
las dos —normalizar un valor patológico invita a no actuar—, y hasta ahora no la miraba
|
| 269 |
+
nadie. Medido el 2026-08-04 en `shunt-portosistemico-canino`: el modelo listó «microcitosis»
|
| 270 |
+
entre los hallazgos y tres frases después escribió «aunque la MCV aquí está normal», con un
|
| 271 |
+
VCM de 58 en un perro. El juez lo penalizó por la contradicción (seguridad 0.90 → 0.55).
|
| 272 |
+
|
| 273 |
+
El criterio es el mismo de todo el fichero: se prefiere el falso negativo. Sólo cuenta si
|
| 274 |
+
la oración nombra el analito, afirma normalidad con un copulativo y no la niega.
|
| 275 |
+
"""
|
| 276 |
+
alterados = {h.clave for h in pet.hallazgos}
|
| 277 |
+
if not alterados:
|
| 278 |
+
return []
|
| 279 |
+
|
| 280 |
+
lexico = terminos_especificos()
|
| 281 |
+
declarados: dict[str, None] = {}
|
| 282 |
+
for oracion in _ORACIONES.split(texto):
|
| 283 |
+
# `split` con grupos devuelve también los separadores (y None por los que no casan).
|
| 284 |
+
for clausula in _CLAUSULAS.split(sin_tildes(oracion)):
|
| 285 |
+
if not clausula or not clausula.strip():
|
| 286 |
+
continue
|
| 287 |
+
if _HIPOTETICO.search(clausula) or _NEGACION.search(clausula):
|
| 288 |
+
continue
|
| 289 |
+
if not _AFIRMA_NORMALIDAD.search(clausula):
|
| 290 |
+
continue
|
| 291 |
+
for clave in alterados & lexico.keys():
|
| 292 |
+
if any(re.search(rf"\b{re.escape(t)}\b", clausula) for t in lexico[clave]):
|
| 293 |
+
declarados[nombre_clinico(clave)] = None
|
| 294 |
+
return list(declarados)
|
backend/app/ai/lexico.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Léxico de analitos: clave → términos con los que un texto clínico puede nombrarla.
|
| 2 |
+
|
| 3 |
+
Única fuente del mapeo. Vivía sólo en `evals/run_evals.py`, que lo usaba para medir la
|
| 4 |
+
cobertura de hallazgos sobre la prosa; ahora también lo necesita el backend en tiempo de
|
| 5 |
+
ejecución —`ai/coherencia.py` para detectar analitos inventados y `ai/prompt.py` para nombrar
|
| 6 |
+
en cristiano los que salieron en rango—, así que se ha traído aquí y `run_evals.py` lo importa.
|
| 7 |
+
Duplicarlo habría dejado la métrica y la guarda midiendo cosas distintas con el mismo nombre.
|
| 8 |
+
|
| 9 |
+
Se construye desde `data/valores_referencia.json`, que ya trae el nombre clínico de cada
|
| 10 |
+
analito ("ALT (GPT)", "Densidad (USG)"), así que el grueso del léxico no se escribe a mano.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import re
|
| 17 |
+
import unicodedata
|
| 18 |
+
from functools import lru_cache
|
| 19 |
+
|
| 20 |
+
from ..config import RAIZ_REPO
|
| 21 |
+
|
| 22 |
+
# Palabras del nombre clínico que no identifican al analito por sí solas: "T4 total" y
|
| 23 |
+
# "Proteínas totales" comparten "total", y buscarla marcaría cualquiera de los dos.
|
| 24 |
+
_GENERICOS = {"total", "libre", "serico", "serica", "plasmatico", "urinario", "sangre"}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def sin_tildes(texto: str) -> str:
|
| 28 |
+
return "".join(
|
| 29 |
+
c for c in unicodedata.normalize("NFD", texto.lower()) if unicodedata.category(c) != "Mn"
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# La prosa clínica casi nunca nombra el analito: nombra su alteración ("hiperfosforemia" en
|
| 34 |
+
# vez de "fósforo", "trombocitopenia" en vez de "plaquetas"). Estas variantes se listan a
|
| 35 |
+
# mano en vez de derivarlas por stemming a propósito: un prefijo de 5 letras haría que
|
| 36 |
+
# "hematoma" contara como hematocrito, y aquí un falso positivo es peor que un falso negativo
|
| 37 |
+
# —tanto en la métrica como en la guarda, donde provoca un reintento inútil—. Sólo se incluyen
|
| 38 |
+
# derivaciones del NOMBRE del analito, no síndromes que lo acompañan (anemia no cuenta como
|
| 39 |
+
# mención del hematocrito).
|
| 40 |
+
VARIANTES: dict[str, tuple[str, ...]] = {
|
| 41 |
+
"alb": ("hipoalbuminemia", "hiperalbuminemia", "albuminemia"),
|
| 42 |
+
"alt": ("gpt", "transaminasas", "transaminasa"),
|
| 43 |
+
# La grafía con UNA r es incorrecta y el modelo la usa igual: el 2026-08-04 escribió
|
| 44 |
+
# «hiperbilirubinemia» en `shunt-portosistemico-canino`, sobre una bilirrubina que nunca
|
| 45 |
+
# se midió, y la guarda de invención lo dejó pasar por esa letra. Un término de más aquí
|
| 46 |
+
# no puede casar con otro analito, así que el coste de admitir la falta de ortografía es
|
| 47 |
+
# cero y el de no admitirla ya se pagó.
|
| 48 |
+
"bili": ("hiperbilirrubinemia", "bilirrubinemia", "hiperbilirubinemia", "bilirubinemia"),
|
| 49 |
+
"bun": ("azotemia", "uremia", "urea"),
|
| 50 |
+
"ca_ion": ("hipercalcemia", "hipocalcemia", "calcemia", "ica"),
|
| 51 |
+
"calc": ("hipercalcemia", "hipocalcemia", "calcemia"),
|
| 52 |
+
"colest": ("hipercolesterolemia", "colesterolemia"),
|
| 53 |
+
"creat": ("azotemia",),
|
| 54 |
+
"fal": ("alp", "fosfatasa"),
|
| 55 |
+
"fosf": ("hiperfosfatemia", "hipofosfatemia", "hiperfosforemia", "fosfatemia", "fosforemia"),
|
| 56 |
+
"glob": ("hiperglobulinemia", "globulinemia", "gammapatia"),
|
| 57 |
+
"gluc": ("hiperglucemia", "hipoglucemia", "glucemia", "hiperglicemia"),
|
| 58 |
+
"hco3": ("bicarbonato",),
|
| 59 |
+
"neutro_abs": ("neutrofilia", "neutropenia"),
|
| 60 |
+
"ph_sangre": ("acidosis", "alcalosis", "acidemia", "alcalemia"),
|
| 61 |
+
"pli": ("cpli", "lipasa"),
|
| 62 |
+
"plt": ("trombocitopenia", "trombocitosis", "plaquetopenia"),
|
| 63 |
+
"potasio": ("hipopotasemia", "hiperpotasemia", "hipokalemia", "hiperkalemia", "kalemia"),
|
| 64 |
+
"prot": ("hiperproteinemia", "hipoproteinemia", "proteinemia"),
|
| 65 |
+
"reti": ("reticulocitosis", "regenerativa"),
|
| 66 |
+
"sodio": ("hiponatremia", "hipernatremia", "natremia"),
|
| 67 |
+
"t4_total": ("t4", "tiroxina"),
|
| 68 |
+
"usg": ("isostenuria", "hipostenuria"),
|
| 69 |
+
"vcm": ("mcv", "microcitosis", "macrocitosis"),
|
| 70 |
+
"wbc": ("leucocitosis", "leucopenia", "leucocitos"),
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# Siglas cuya versión en minúsculas es una palabra corriente del castellano. No pueden entrar
|
| 75 |
+
# en `VARIANTES`: todo el léxico se coteja sobre texto normalizado a minúsculas, y buscar "un"
|
| 76 |
+
# marcaría prácticamente cualquier oración. Se cotejan aparte, respetando las mayúsculas, sobre
|
| 77 |
+
# el texto ORIGINAL. Motivo: el 2026-08-04, en `piometra-progesterona-canino`, el modelo escribió
|
| 78 |
+
# «Esta combinación (alta UN, alta CT, USG bajo) caracteriza una azotemia renal» sobre un panel
|
| 79 |
+
# sin BUN; "azotemia" quedaba absuelta —la creatinina sí estaba alta— y la sigla era la única
|
| 80 |
+
# señal de que se había inventado el dato.
|
| 81 |
+
ABREVIATURAS_SENSIBLES: dict[str, tuple[str, ...]] = {
|
| 82 |
+
"bun": ("UN", "BUN"),
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def abreviaturas_presentes(texto: str) -> set[str]:
|
| 87 |
+
"""Claves cuya sigla ambigua aparece, con sus mayúsculas, en el texto tal cual se escribió."""
|
| 88 |
+
return {
|
| 89 |
+
clave for clave, siglas in ABREVIATURAS_SENSIBLES.items()
|
| 90 |
+
if any(re.search(rf"\b{re.escape(s)}\b", texto) for s in siglas)
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def tokens_analito(texto: str) -> set[str]:
|
| 95 |
+
# Mínimo 2 caracteres: hay analitos cuyo nombre entero es corto ("T4", "pH"), y con 3
|
| 96 |
+
# se quedaban sin ningún término con el que buscarlos.
|
| 97 |
+
return {
|
| 98 |
+
t for t in re.split(r"[^a-z0-9]+", sin_tildes(texto))
|
| 99 |
+
if len(t) >= 2 and t not in _GENERICOS
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
@lru_cache
|
| 104 |
+
def _referencias() -> dict:
|
| 105 |
+
ruta = RAIZ_REPO / "data" / "valores_referencia.json"
|
| 106 |
+
return json.loads(ruta.read_text(encoding="utf-8"))
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@lru_cache
|
| 110 |
+
def lexico_analitos() -> dict[str, frozenset[str]]:
|
| 111 |
+
"""clave de analito → términos con los que un texto puede referirse a él."""
|
| 112 |
+
lexico: dict[str, set[str]] = {}
|
| 113 |
+
for analitos in _referencias().values(): # canino, felino
|
| 114 |
+
for clave, info in analitos.items():
|
| 115 |
+
terminos = lexico.setdefault(clave, set())
|
| 116 |
+
terminos |= tokens_analito(clave)
|
| 117 |
+
terminos |= tokens_analito(info.get("nombre", ""))
|
| 118 |
+
for clave, variantes in VARIANTES.items():
|
| 119 |
+
lexico.setdefault(clave, set()).update(variantes)
|
| 120 |
+
return {clave: frozenset(t) for clave, t in lexico.items()}
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@lru_cache
|
| 124 |
+
def nombres_clinicos() -> dict[str, str]:
|
| 125 |
+
"""clave → nombre clínico legible ("plt" → "Plaquetas"). La clave cruda si no hay nombre."""
|
| 126 |
+
nombres: dict[str, str] = {}
|
| 127 |
+
for analitos in _referencias().values():
|
| 128 |
+
for clave, info in analitos.items():
|
| 129 |
+
if nombre := info.get("nombre"):
|
| 130 |
+
nombres.setdefault(clave, nombre)
|
| 131 |
+
return nombres
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def nombre_clinico(clave: str) -> str:
|
| 135 |
+
return nombres_clinicos().get(clave, clave)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# Palabras que aparecen DENTRO del nombre clínico de algún analito pero que no lo identifican:
|
| 139 |
+
# calificadores ("directa", "ionizado", "arterial"), unidades de medida del propio nombre
|
| 140 |
+
# ("tiempo", "cociente", "índice") y sustantivos compartidos por varios ("proteína", "creatina").
|
| 141 |
+
# Sin este filtro «de» y «tiempo» —del "Tiempo de protrombina (TP)"— casan en casi cualquier
|
| 142 |
+
# frase clínica: medido sobre las 30 predicciones del 2026-08-04, el TP salía marcado en 27.
|
| 143 |
+
_NO_IDENTIFICAN = frozenset({
|
| 144 |
+
"tiempo", "exceso", "nivel", "basal", "serico", "serica", "urinario", "orina", "sangre",
|
| 145 |
+
"total", "libre", "parcial", "activada", "protrombina", "tromboplastina", "sanguineo",
|
| 146 |
+
"relacion", "indice", "recuento", "absoluto", "estimulacion", "supresion", "post", "pre",
|
| 147 |
+
"arterial", "saturacion", "cociente", "antigeno", "reactiva", "directa", "ionizado",
|
| 148 |
+
"pancreatica", "acidos", "biliares", "creatina", "kinasa", "cardiaca", "amiloide",
|
| 149 |
+
"proteina", "dimeros",
|
| 150 |
+
})
|
| 151 |
+
# Debajo de esto, un token del nombre es una abreviatura ("tp", "be", "ac", "at") que casa
|
| 152 |
+
# dentro de cualquier palabra o como partícula suelta. Las siglas que SÍ interesan ya entran
|
| 153 |
+
# por `VARIANTES`, que es una lista curada.
|
| 154 |
+
_LARGO_MINIMO_TERMINO = 6
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
@lru_cache
|
| 158 |
+
def terminos_especificos() -> dict[str, frozenset[str]]:
|
| 159 |
+
"""Subconjunto del léxico con la precisión que exige BUSCAR AL REVÉS.
|
| 160 |
+
|
| 161 |
+
`claves_mencionadas` pregunta «¿aparece ESTE analito?» sobre un puñado de claves conocidas,
|
| 162 |
+
y ahí un token flojo apenas molesta. La guarda de invención hace lo contrario: barre los 90
|
| 163 |
+
analitos contra una prosa para ver cuál NO debería estar. Con el léxico completo eso es un
|
| 164 |
+
generador de falsos positivos, y cada falso positivo cuesta una llamada al modelo y una
|
| 165 |
+
corrección improcedente. Aquí sólo entran los términos curados a mano (`VARIANTES`) y las
|
| 166 |
+
palabras del nombre clínico lo bastante largas y específicas como para no casar por azar.
|
| 167 |
+
"""
|
| 168 |
+
especificos: dict[str, frozenset[str]] = {}
|
| 169 |
+
for clave, terminos in lexico_analitos().items():
|
| 170 |
+
utiles = set(VARIANTES.get(clave, ()))
|
| 171 |
+
utiles |= {
|
| 172 |
+
t for t in terminos
|
| 173 |
+
if len(t) >= _LARGO_MINIMO_TERMINO and t not in _NO_IDENTIFICAN
|
| 174 |
+
}
|
| 175 |
+
if utiles:
|
| 176 |
+
especificos[clave] = frozenset(utiles)
|
| 177 |
+
return especificos
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def claves_mencionadas(texto: str, claves: set[str]) -> set[str]:
|
| 181 |
+
"""Cuáles de `claves` aparecen nombradas en el texto.
|
| 182 |
+
|
| 183 |
+
Se busca el término con límites de palabra para que "alt" no case dentro de "alteración".
|
| 184 |
+
"""
|
| 185 |
+
normalizado = sin_tildes(texto)
|
| 186 |
+
lexico = lexico_analitos()
|
| 187 |
+
encontradas = set()
|
| 188 |
+
for clave in claves:
|
| 189 |
+
terminos = lexico.get(clave) or tokens_analito(clave)
|
| 190 |
+
if any(re.search(rf"\b{re.escape(t)}\b", normalizado) for t in terminos):
|
| 191 |
+
encontradas.add(clave)
|
| 192 |
+
return encontradas
|
backend/app/ai/prompt.py
CHANGED
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
| 11 |
from ..config import obtener_config
|
| 12 |
from ..rag.retriever import Fragmento
|
| 13 |
from ..schemas import PeticionInterpretacion
|
|
|
|
| 14 |
|
| 15 |
# Cuánto texto de cada fragmento se le enseña al modelo. El recorte por presupuesto total
|
| 16 |
# (config.rag_max_chars_prompt) cuenta en esta misma unidad.
|
|
@@ -25,6 +26,16 @@ Reglas estrictas:
|
|
| 25 |
- Responde SIEMPRE en español.
|
| 26 |
- Cíñete a los datos aportados (señalamiento, valores de laboratorio, patrones detectados,
|
| 27 |
literatura recuperada e imágenes). No inventes valores ni hallazgos.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
- Cuando afirmes algo respaldado por la literatura recuperada, cítalo en el campo `citas`
|
| 29 |
del diferencial correspondiente, usando el número entre corchetes con el que se te
|
| 30 |
presentó el fragmento ([1], [2]…). No cites lo que no se te dio: una cita que no
|
|
@@ -50,6 +61,16 @@ ni el examen presencial del paciente.
|
|
| 50 |
Reglas estrictas:
|
| 51 |
- Responde SIEMPRE en español.
|
| 52 |
- Cíñete a los datos aportados; no inventes valores ni hallazgos.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
- Si se te entrega literatura recuperada, marca cada afirmación que se apoye en ella con su
|
| 54 |
número entre corchetes justo después de la frase ([1], [2]…). Es la ÚNICA forma de citar
|
| 55 |
en esta ruta: no escribas títulos de libros ni páginas, y no uses números que no estén en
|
|
@@ -88,6 +109,37 @@ def _linea_hallazgo(h, con_gravedad: bool = True) -> str:
|
|
| 88 |
return f" {h.nombre} ({h.clave}): {h.valor} {h.unidad} — {h.direccion.value}{etiqueta}"
|
| 89 |
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
def _bloque_contexto_rag(fragmentos: list[Fragmento]) -> str:
|
| 92 |
if not fragmentos:
|
| 93 |
return ""
|
|
@@ -126,6 +178,7 @@ def construir_mensaje_usuario(
|
|
| 126 |
if pet.patrones and cfg.prompt_incluir_patrones
|
| 127 |
else ""
|
| 128 |
)
|
|
|
|
| 129 |
sin_alteraciones = not pet.hallazgos and not pet.patrones
|
| 130 |
|
| 131 |
signos = f"\nSignos clínicos referidos: {pet.signos_clinicos.strip()}" if pet.signos_clinicos.strip() else ""
|
|
@@ -164,7 +217,7 @@ def construir_mensaje_usuario(
|
|
| 164 |
|
| 165 |
return f"""\
|
| 166 |
Paciente: {p.especie or 'desconocido'}, raza {p.raza or 'NE'}, edad {edad}, sexo {p.sexo or 'NE'}\
|
| 167 |
-
{bloque_hallazgos}{bloque_patrones}{signos}{imagenes}
|
| 168 |
{_bloque_contexto_rag(fragmentos)}
|
| 169 |
|
| 170 |
{instruccion}"""
|
|
|
|
| 11 |
from ..config import obtener_config
|
| 12 |
from ..rag.retriever import Fragmento
|
| 13 |
from ..schemas import PeticionInterpretacion
|
| 14 |
+
from .lexico import nombre_clinico
|
| 15 |
|
| 16 |
# Cuánto texto de cada fragmento se le enseña al modelo. El recorte por presupuesto total
|
| 17 |
# (config.rag_max_chars_prompt) cuenta en esta misma unidad.
|
|
|
|
| 26 |
- Responde SIEMPRE en español.
|
| 27 |
- Cíñete a los datos aportados (señalamiento, valores de laboratorio, patrones detectados,
|
| 28 |
literatura recuperada e imágenes). No inventes valores ni hallazgos.
|
| 29 |
+
- Sólo puedes afirmar hallazgos sobre los analitos del panel que se te entrega. Un analito que
|
| 30 |
+
no aparece NO se ha medido: nombrarlo como resultado es un error grave. Si lo necesitas,
|
| 31 |
+
pídelo como siguiente prueba.
|
| 32 |
+
- Lo mismo con los signos clínicos: sólo puedes atribuir al paciente los que figuren en
|
| 33 |
+
"Signos clínicos referidos". No añadas otros para redondear un cuadro.
|
| 34 |
+
- Respeta la gravedad que se te indica de cada valor: no la rebajes ni la exageres al
|
| 35 |
+
describirla en tu texto.
|
| 36 |
+
- Si citas una cifra de corte de la literatura, di explícitamente dónde cae el valor real de
|
| 37 |
+
este paciente respecto a ella. Un umbral suelto se lee como si el paciente lo cumpliera.
|
| 38 |
+
- Propón sólo pruebas aplicables a la especie del paciente.
|
| 39 |
- Cuando afirmes algo respaldado por la literatura recuperada, cítalo en el campo `citas`
|
| 40 |
del diferencial correspondiente, usando el número entre corchetes con el que se te
|
| 41 |
presentó el fragmento ([1], [2]…). No cites lo que no se te dio: una cita que no
|
|
|
|
| 61 |
Reglas estrictas:
|
| 62 |
- Responde SIEMPRE en español.
|
| 63 |
- Cíñete a los datos aportados; no inventes valores ni hallazgos.
|
| 64 |
+
- Sólo puedes afirmar hallazgos sobre los analitos del panel que se te entrega. Un analito que
|
| 65 |
+
no aparece NO se ha medido: nombrarlo como resultado es un error grave. Si lo necesitas,
|
| 66 |
+
pídelo como siguiente prueba.
|
| 67 |
+
- Lo mismo con los signos clínicos: sólo puedes atribuir al paciente los que figuren en
|
| 68 |
+
"Signos clínicos referidos". No añadas otros para redondear un cuadro.
|
| 69 |
+
- Respeta la gravedad que se te indica de cada valor: no la rebajes ni la exageres al
|
| 70 |
+
describirla en tu texto.
|
| 71 |
+
- Si citas una cifra de corte de la literatura, di explícitamente dónde cae el valor real de
|
| 72 |
+
este paciente respecto a ella. Un umbral suelto se lee como si el paciente lo cumpliera.
|
| 73 |
+
- Propón sólo pruebas aplicables a la especie del paciente.
|
| 74 |
- Si se te entrega literatura recuperada, marca cada afirmación que se apoye en ella con su
|
| 75 |
número entre corchetes justo después de la frase ([1], [2]…). Es la ÚNICA forma de citar
|
| 76 |
en esta ruta: no escribas títulos de libros ni páginas, y no uses números que no estén en
|
|
|
|
| 109 |
return f" {h.nombre} ({h.clave}): {h.valor} {h.unidad} — {h.direccion.value}{etiqueta}"
|
| 110 |
|
| 111 |
|
| 112 |
+
def _bloque_panel(pet: PeticionInterpretacion) -> str:
|
| 113 |
+
"""Qué se ha medido: los que salieron en rango, y el cierre de que no hay nada más.
|
| 114 |
+
|
| 115 |
+
`hallazgos` sólo trae lo ALTERADO, así que sin esto el modelo no puede distinguir «no se
|
| 116 |
+
midió» de «se midió y salió normal», y rellena el hueco. Medido el 2026-08-04 sobre el
|
| 117 |
+
Space: sobre un panel de calcio/fósforo/BUN/creatinina escribió «La leucograma muestra
|
| 118 |
+
neutrofilia y linfopenia» —la única violación de seguridad de la corrida—, y en otro caso
|
| 119 |
+
afirmó «aumento de reticulocitos» sin recuento reticulocitario.
|
| 120 |
+
|
| 121 |
+
Se omite ENTERO si la petición no trae `analitos_medidos` (cliente antiguo). El criterio es
|
| 122 |
+
el mismo que rige los otros bloques de esta función: el relleno se lee como contenido, y una
|
| 123 |
+
lista vacía anunciada como «panel completo» sería peor que no decir nada.
|
| 124 |
+
"""
|
| 125 |
+
if not pet.analitos_medidos:
|
| 126 |
+
return ""
|
| 127 |
+
alterados = {h.clave for h in pet.hallazgos}
|
| 128 |
+
en_rango = [c for c in pet.analitos_medidos if c not in alterados]
|
| 129 |
+
bloque = ""
|
| 130 |
+
if en_rango:
|
| 131 |
+
nombres = ", ".join(nombre_clinico(c) for c in en_rango)
|
| 132 |
+
bloque = (
|
| 133 |
+
"\n\nAnalitos medidos que salieron DENTRO de rango (no los describas como "
|
| 134 |
+
f"alterados):\n {nombres}"
|
| 135 |
+
)
|
| 136 |
+
return bloque + (
|
| 137 |
+
"\n\nÉse es el panel COMPLETO que se ha realizado. Cualquier analito que no aparezca "
|
| 138 |
+
"arriba NO se ha determinado en este paciente: no afirmes su valor, su dirección ni "
|
| 139 |
+
"ningún hallazgo basado en él. Si lo consideras necesario, pídelo como siguiente prueba."
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
def _bloque_contexto_rag(fragmentos: list[Fragmento]) -> str:
|
| 144 |
if not fragmentos:
|
| 145 |
return ""
|
|
|
|
| 178 |
if pet.patrones and cfg.prompt_incluir_patrones
|
| 179 |
else ""
|
| 180 |
)
|
| 181 |
+
bloque_panel = _bloque_panel(pet)
|
| 182 |
sin_alteraciones = not pet.hallazgos and not pet.patrones
|
| 183 |
|
| 184 |
signos = f"\nSignos clínicos referidos: {pet.signos_clinicos.strip()}" if pet.signos_clinicos.strip() else ""
|
|
|
|
| 217 |
|
| 218 |
return f"""\
|
| 219 |
Paciente: {p.especie or 'desconocido'}, raza {p.raza or 'NE'}, edad {edad}, sexo {p.sexo or 'NE'}\
|
| 220 |
+
{bloque_hallazgos}{bloque_patrones}{bloque_panel}{signos}{imagenes}
|
| 221 |
{_bloque_contexto_rag(fragmentos)}
|
| 222 |
|
| 223 |
{instruccion}"""
|
backend/app/ai/service.py
CHANGED
|
@@ -26,7 +26,11 @@ from ..schemas import (
|
|
| 26 |
from .alcance import motivo_fuera_de_alcance, respuesta_fuera_de_alcance
|
| 27 |
from .base import ClienteModelo, ErrorModelo
|
| 28 |
from .citas import aplicar_atribucion
|
| 29 |
-
from .coherencia import
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
from .prescripcion import detectar_prescripcion, encuadrar
|
| 31 |
from .prompt import (
|
| 32 |
LARGO_FRAGMENTO_PROMPT,
|
|
@@ -133,6 +137,54 @@ def _crear_cliente(backend: str, modelo_local: str | None = None) -> ClienteMode
|
|
| 133 |
return MedGemmaClient()
|
| 134 |
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
async def interpretar(pet: PeticionInterpretacion) -> RespuestaInterpretacion:
|
| 137 |
cfg = obtener_config()
|
| 138 |
backend = pet.backend or cfg.ia_backend_defecto
|
|
@@ -184,8 +236,9 @@ async def interpretar(pet: PeticionInterpretacion) -> RespuestaInterpretacion:
|
|
| 184 |
)
|
| 185 |
resultado: InterpretacionClinica | None = None
|
| 186 |
ultimo_error: ErrorModelo | None = None
|
|
|
|
| 187 |
for intento in range(2):
|
| 188 |
-
mensaje = construir_mensaje_usuario(pet, enviados)
|
| 189 |
try:
|
| 190 |
resultado = await cliente.interpretar(sistema, mensaje, pet.imagenes)
|
| 191 |
# La ruta de prosa no puede rellenar los campos estructurados; el resto sí, y un
|
|
@@ -195,6 +248,26 @@ async def interpretar(pet: PeticionInterpretacion) -> RespuestaInterpretacion:
|
|
| 195 |
if vacio:
|
| 196 |
resultado = None
|
| 197 |
raise ErrorModelo(f"El modelo dejó '{vacio}' vacío pese a haber hallazgos.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
break
|
| 199 |
except ErrorModelo as exc:
|
| 200 |
ultimo_error = exc
|
|
@@ -246,11 +319,38 @@ async def interpretar(pet: PeticionInterpretacion) -> RespuestaInterpretacion:
|
|
| 246 |
# no se le borra el texto (mutilar prosa clínica es peor) sino que se antepone el encuadre
|
| 247 |
# que faltaba y se fuerza la derivación. Medido el 2026-07-31: sin esto, una recomendación
|
| 248 |
# de insulina en un paciente hipopotasémico pasó como buena.
|
|
|
|
|
|
|
| 249 |
if (frases := detectar_prescripcion(resultado.interpretacion)):
|
| 250 |
-
log.warning(
|
|
|
|
|
|
|
|
|
|
| 251 |
resultado.interpretacion = encuadrar(resultado.interpretacion)
|
| 252 |
resultado.requiere_derivacion = True
|
| 253 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
# 5) Suelo de seguridad. `requiere_derivacion` es una marca clínica, no una opinión: si el
|
| 255 |
# motor determinista ve algo grave, se deriva aunque el modelo diga que no. Medido: un 7B
|
| 256 |
# general marcó `false` en una ERC felina avanzada (creat 4.8, BUN 68, isostenuria). Con
|
|
|
|
| 26 |
from .alcance import motivo_fuera_de_alcance, respuesta_fuera_de_alcance
|
| 27 |
from .base import ClienteModelo, ErrorModelo
|
| 28 |
from .citas import aplicar_atribucion
|
| 29 |
+
from .coherencia import (
|
| 30 |
+
alterados_declarados_normales,
|
| 31 |
+
analitos_fabricados_en_prosa,
|
| 32 |
+
descartar_fabricados,
|
| 33 |
+
)
|
| 34 |
from .prescripcion import detectar_prescripcion, encuadrar
|
| 35 |
from .prompt import (
|
| 36 |
LARGO_FRAGMENTO_PROMPT,
|
|
|
|
| 137 |
return MedGemmaClient()
|
| 138 |
|
| 139 |
|
| 140 |
+
def detectar_infracciones(
|
| 141 |
+
resultado: InterpretacionClinica, pet: PeticionInterpretacion
|
| 142 |
+
) -> tuple[list[str], list[str], list[str]]:
|
| 143 |
+
"""(frases prescriptivas, analitos inventados, alterados dados por normales) de la prosa.
|
| 144 |
+
|
| 145 |
+
Las tres listas vacías = salida limpia.
|
| 146 |
+
"""
|
| 147 |
+
return (
|
| 148 |
+
detectar_prescripcion(resultado.interpretacion),
|
| 149 |
+
analitos_fabricados_en_prosa(resultado.interpretacion, pet),
|
| 150 |
+
alterados_declarados_normales(resultado.interpretacion, pet),
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def instruccion_correctiva(
|
| 155 |
+
prescripciones: list[str], fabricados: list[str], normalizados: list[str]
|
| 156 |
+
) -> str:
|
| 157 |
+
"""Addendum al mensaje del segundo intento, nombrando lo que hay que quitar.
|
| 158 |
+
|
| 159 |
+
Nombrar la infracción concreta no es cosmético: la generación del Space es voraz (greedy),
|
| 160 |
+
así que repetir el MISMO prompt devuelve la MISMA respuesta y gasta otra reserva de GPU
|
| 161 |
+
para nada. Cambiar la entrada es lo único que cambia la salida — mismo razonamiento que la
|
| 162 |
+
rama de `exc.truncado`, que recorta la literatura por este motivo.
|
| 163 |
+
"""
|
| 164 |
+
lineas = ["\n\nCORRECCIÓN OBLIGATORIA de tu respuesta anterior:"]
|
| 165 |
+
if prescripciones:
|
| 166 |
+
lineas.append(
|
| 167 |
+
"- Indicaste tratamiento, y no debes: "
|
| 168 |
+
+ "; ".join(f"«{f}»" for f in prescripciones[:3])
|
| 169 |
+
+ ". Reescribe esa parte como interpretación y, si procede, como prueba "
|
| 170 |
+
"diagnóstica a solicitar. El plan terapéutico es del veterinario presencial."
|
| 171 |
+
)
|
| 172 |
+
if fabricados:
|
| 173 |
+
lineas.append(
|
| 174 |
+
"- Afirmaste hallazgos sobre analitos que NO se han medido en este paciente: "
|
| 175 |
+
+ ", ".join(fabricados[:5])
|
| 176 |
+
+ ". Elimina esas afirmaciones. Puedes pedir esas pruebas, pero no dar por hecho "
|
| 177 |
+
"su resultado."
|
| 178 |
+
)
|
| 179 |
+
if normalizados:
|
| 180 |
+
lineas.append(
|
| 181 |
+
"- Diste por normales analitos que SÍ están fuera de rango en este paciente: "
|
| 182 |
+
+ ", ".join(normalizados[:5])
|
| 183 |
+
+ ". Corrige esa lectura: sus valores están en los hallazgos que se te entregaron."
|
| 184 |
+
)
|
| 185 |
+
return "\n".join(lineas)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
async def interpretar(pet: PeticionInterpretacion) -> RespuestaInterpretacion:
|
| 189 |
cfg = obtener_config()
|
| 190 |
backend = pet.backend or cfg.ia_backend_defecto
|
|
|
|
| 236 |
)
|
| 237 |
resultado: InterpretacionClinica | None = None
|
| 238 |
ultimo_error: ErrorModelo | None = None
|
| 239 |
+
correccion = ""
|
| 240 |
for intento in range(2):
|
| 241 |
+
mensaje = construir_mensaje_usuario(pet, enviados) + correccion
|
| 242 |
try:
|
| 243 |
resultado = await cliente.interpretar(sistema, mensaje, pet.imagenes)
|
| 244 |
# La ruta de prosa no puede rellenar los campos estructurados; el resto sí, y un
|
|
|
|
| 248 |
if vacio:
|
| 249 |
resultado = None
|
| 250 |
raise ErrorModelo(f"El modelo dejó '{vacio}' vacío pese a haber hallazgos.")
|
| 251 |
+
# Infracciones de seguridad en la PROSA: se regenera en vez de parchear. Medido el
|
| 252 |
+
# 2026-08-04, la remediación anterior —anteponer el encuadre de `prescripcion.py`
|
| 253 |
+
# sin tocar la frase— dejaba un documento que se contradice a sí mismo, y el juez lo
|
| 254 |
+
# penalizó por ello: `hipotiroidismo-canino` 0.35 y `diabetes-felino-fructosamina`
|
| 255 |
+
# 0.50 en seguridad, los dos CON la nota de alcance ya puesta. Los pasos 4.6/4.7 de
|
| 256 |
+
# abajo siguen actuando como último recurso si el reintento vuelve a infringir, así
|
| 257 |
+
# que esto nunca deja la salida peor que antes.
|
| 258 |
+
if intento == 0:
|
| 259 |
+
prescripciones, fabricados, normalizados = detectar_infracciones(resultado, pet)
|
| 260 |
+
if prescripciones or fabricados or normalizados:
|
| 261 |
+
log.warning(
|
| 262 |
+
"Infracciones en la salida (prescripción: %s; inventados: %s; "
|
| 263 |
+
"dados por normales: %s); se regenera con corrección.",
|
| 264 |
+
"; ".join(prescripciones[:3]) or "ninguna",
|
| 265 |
+
", ".join(fabricados[:5]) or "ninguno",
|
| 266 |
+
", ".join(normalizados[:5]) or "ninguno",
|
| 267 |
+
)
|
| 268 |
+
correccion = instruccion_correctiva(prescripciones, fabricados, normalizados)
|
| 269 |
+
resultado = None
|
| 270 |
+
raise ErrorModelo("Salida con infracciones de seguridad; se vuelve a muestrear.")
|
| 271 |
break
|
| 272 |
except ErrorModelo as exc:
|
| 273 |
ultimo_error = exc
|
|
|
|
| 319 |
# no se le borra el texto (mutilar prosa clínica es peor) sino que se antepone el encuadre
|
| 320 |
# que faltaba y se fuerza la derivación. Medido el 2026-07-31: sin esto, una recomendación
|
| 321 |
# de insulina en un paciente hipopotasémico pasó como buena.
|
| 322 |
+
# Sólo se llega aquí con lenguaje prescriptivo si el reintento correctivo de arriba tampoco
|
| 323 |
+
# lo resolvió (o si no lo hubo, porque la infracción apareció en el segundo intento).
|
| 324 |
if (frases := detectar_prescripcion(resultado.interpretacion)):
|
| 325 |
+
log.warning(
|
| 326 |
+
"Lenguaje prescriptivo que sobrevive al reintento (%s); se encuadra.",
|
| 327 |
+
"; ".join(frases[:3]),
|
| 328 |
+
)
|
| 329 |
resultado.interpretacion = encuadrar(resultado.interpretacion)
|
| 330 |
resultado.requiere_derivacion = True
|
| 331 |
|
| 332 |
+
# Mismo caso para los analitos inventados en prosa: aquí no se puede borrar sin mutilar la
|
| 333 |
+
# frase, así que sólo queda dejar constancia y derivar. Se registra para poder medir en
|
| 334 |
+
# producción cuántas infracciones sobreviven al reintento; si son muchas, el problema es el
|
| 335 |
+
# modelo y no la guarda.
|
| 336 |
+
if correccion and (restantes := analitos_fabricados_en_prosa(resultado.interpretacion, pet)):
|
| 337 |
+
log.warning(
|
| 338 |
+
"Analitos inventados que sobreviven al reintento: %s; se fuerza derivación.",
|
| 339 |
+
", ".join(restantes[:5]),
|
| 340 |
+
)
|
| 341 |
+
resultado.requiere_derivacion = True
|
| 342 |
+
|
| 343 |
+
# Y para lo simétrico: dar por normal un valor patológico invita a no actuar, así que si
|
| 344 |
+
# sobrevive al reintento se deriva. Mismo motivo que arriba para no tocar el texto.
|
| 345 |
+
if correccion and (
|
| 346 |
+
normalizados := alterados_declarados_normales(resultado.interpretacion, pet)
|
| 347 |
+
):
|
| 348 |
+
log.warning(
|
| 349 |
+
"Analitos alterados dados por normales pese al reintento: %s; se fuerza derivación.",
|
| 350 |
+
", ".join(normalizados[:5]),
|
| 351 |
+
)
|
| 352 |
+
resultado.requiere_derivacion = True
|
| 353 |
+
|
| 354 |
# 5) Suelo de seguridad. `requiere_derivacion` es una marca clínica, no una opinión: si el
|
| 355 |
# motor determinista ve algo grave, se deriva aunque el modelo diga que no. Medido: un 7B
|
| 356 |
# general marcó `false` en una ERC felina avanzada (creat 4.8, BUN 68, isostenuria). Con
|
backend/app/config.py
CHANGED
|
@@ -7,6 +7,7 @@ lo necesario para una función concreta.
|
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
|
|
|
| 10 |
from functools import lru_cache
|
| 11 |
from pathlib import Path
|
| 12 |
from typing import Annotated
|
|
@@ -18,9 +19,17 @@ from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
|
| 18 |
RAIZ_REPO = Path(__file__).resolve().parents[2]
|
| 19 |
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
class Configuracion(BaseSettings):
|
| 22 |
model_config = SettingsConfigDict(
|
| 23 |
-
env_file=str(RAIZ_REPO / "backend" / ".env"),
|
| 24 |
env_prefix="MORPHOS_",
|
| 25 |
extra="ignore",
|
| 26 |
)
|
|
|
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
+
import os
|
| 11 |
from functools import lru_cache
|
| 12 |
from pathlib import Path
|
| 13 |
from typing import Annotated
|
|
|
|
| 19 |
RAIZ_REPO = Path(__file__).resolve().parents[2]
|
| 20 |
|
| 21 |
|
| 22 |
+
# El `.env` del desarrollador NO debe filtrarse a las pruebas: son las mismas que corren en CI,
|
| 23 |
+
# donde ese fichero no existe, así que cualquier valor que se cuele hace que pasen o fallen según
|
| 24 |
+
# la máquina. Medido el 2026-08-04: un `MORPHOS_MODELOS_LOCALES=qwen2.5:14b` en local tumbaba
|
| 25 |
+
# `test_interpret_rechaza_modelo_fuera_de_la_lista_blanca`, que afirma la lista blanca vacía por
|
| 26 |
+
# defecto. Las pruebas ponen esta variable en su conftest; el resto del mundo lee el `.env`.
|
| 27 |
+
_SIN_ENV_FILE = os.environ.get("MORPHOS_IGNORAR_ENV_FILE") == "1"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
class Configuracion(BaseSettings):
|
| 31 |
model_config = SettingsConfigDict(
|
| 32 |
+
env_file=None if _SIN_ENV_FILE else str(RAIZ_REPO / "backend" / ".env"),
|
| 33 |
env_prefix="MORPHOS_",
|
| 34 |
extra="ignore",
|
| 35 |
)
|
backend/app/schemas.py
CHANGED
|
@@ -59,6 +59,13 @@ class PeticionInterpretacion(BaseModel):
|
|
| 59 |
paciente: PacienteEntrada
|
| 60 |
hallazgos: list[HallazgoEntrada] = Field(default_factory=list)
|
| 61 |
patrones: list[PatronEntrada] = Field(default_factory=list)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
signos_clinicos: str = Field(default="", max_length=2000)
|
| 63 |
imagenes: list[str] = Field(default_factory=list) # data URLs de citología
|
| 64 |
backend: Literal["medgemma", "claude"] = "medgemma"
|
|
|
|
| 59 |
paciente: PacienteEntrada
|
| 60 |
hallazgos: list[HallazgoEntrada] = Field(default_factory=list)
|
| 61 |
patrones: list[PatronEntrada] = Field(default_factory=list)
|
| 62 |
+
# Claves de TODOS los analitos que el usuario introdujo, alterados o no. `hallazgos` sólo
|
| 63 |
+
# trae los que salieron fuera de rango, así que sin esto el modelo no puede distinguir «no
|
| 64 |
+
# se midió» de «se midió y salió normal» y rellena el hueco: medido el 2026-08-04, medGemma
|
| 65 |
+
# afirmó un leucograma con neutrofilia sobre un panel de calcio/fósforo/BUN/creatinina, y
|
| 66 |
+
# llamó «trombocitopenia leve» a unas plaquetas de 190 que estaban en rango.
|
| 67 |
+
# Vacío = cliente antiguo que no lo manda; el prompt omite el bloque y se comporta como antes.
|
| 68 |
+
analitos_medidos: list[str] = Field(default_factory=list, max_length=200)
|
| 69 |
signos_clinicos: str = Field(default="", max_length=2000)
|
| 70 |
imagenes: list[str] = Field(default_factory=list) # data URLs de citología
|
| 71 |
backend: Literal["medgemma", "claude"] = "medgemma"
|
backend/tests/conftest.py
CHANGED
|
@@ -1,4 +1,10 @@
|
|
| 1 |
-
"""Configuración de pruebas: BD temporal y
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
@@ -7,6 +13,7 @@ import tempfile
|
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
# Debe fijarse ANTES de importar la config (que se cachea con lru_cache).
|
|
|
|
| 10 |
_TMP = Path(tempfile.mkdtemp(prefix="morphos_test_"))
|
| 11 |
os.environ.setdefault("MORPHOS_DB_PATH", str(_TMP / "test.db"))
|
| 12 |
os.environ.setdefault("MORPHOS_SESSION_SECRET", "x" * 40)
|
|
|
|
| 1 |
+
"""Configuración de pruebas: BD temporal, secreto determinista y cero herencia del entorno.
|
| 2 |
+
|
| 3 |
+
Las pruebas describen el comportamiento POR DEFECTO del servicio, así que no pueden leer el
|
| 4 |
+
`.env` del desarrollador: en CI ese fichero no existe y en local trae lo que cada uno tenga
|
| 5 |
+
puesto. Con `MORPHOS_IGNORAR_ENV_FILE` la config lo salta y sólo obedece a lo que se fije aquí,
|
| 6 |
+
que es lo que hace reproducible el resultado entre máquinas.
|
| 7 |
+
"""
|
| 8 |
|
| 9 |
from __future__ import annotations
|
| 10 |
|
|
|
|
| 13 |
from pathlib import Path
|
| 14 |
|
| 15 |
# Debe fijarse ANTES de importar la config (que se cachea con lru_cache).
|
| 16 |
+
os.environ["MORPHOS_IGNORAR_ENV_FILE"] = "1"
|
| 17 |
_TMP = Path(tempfile.mkdtemp(prefix="morphos_test_"))
|
| 18 |
os.environ.setdefault("MORPHOS_DB_PATH", str(_TMP / "test.db"))
|
| 19 |
os.environ.setdefault("MORPHOS_SESSION_SECRET", "x" * 40)
|
backend/tests/test_coherencia.py
CHANGED
|
@@ -55,3 +55,163 @@ def test_sin_hallazgos_enviados_no_se_descarta_nada():
|
|
| 55 |
pet = PeticionInterpretacion(paciente={"especie": "canino"})
|
| 56 |
r = _resultado("Glucosa")
|
| 57 |
assert hallazgos_fabricados(r, pet) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
pet = PeticionInterpretacion(paciente={"especie": "canino"})
|
| 56 |
r = _resultado("Glucosa")
|
| 57 |
assert hallazgos_fabricados(r, pet) == []
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# --- Guarda sobre la PROSA (2026-08-04) ---
|
| 61 |
+
#
|
| 62 |
+
# La ruta desplegada en producción devuelve texto libre, así que `hallazgos_clave` llega vacío y
|
| 63 |
+
# las guardas de arriba no ven nada. Estos casos vienen de las predicciones reales de la corrida
|
| 64 |
+
# del 2026-08-04 (evals/resultados/2026-08-04/preds_space_local_dev.jsonl).
|
| 65 |
+
|
| 66 |
+
from app.ai.coherencia import analitos_fabricados_en_prosa # noqa: E402
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _peticion_prosa(**extra):
|
| 70 |
+
"""El caso real de `hipercalcemia-canino`: bioquímica de 4 analitos, ningún hemograma."""
|
| 71 |
+
base = {
|
| 72 |
+
"paciente": {"especie": "canino"},
|
| 73 |
+
"hallazgos": [
|
| 74 |
+
{"clave": "calc", "nombre": "Calcio", "valor": 15.5, "unidad": "mg/dL",
|
| 75 |
+
"direccion": "alto", "gravedad": "grave"},
|
| 76 |
+
{"clave": "bun", "nombre": "BUN/Urea", "valor": 40.0, "unidad": "mg/dL",
|
| 77 |
+
"direccion": "alto", "gravedad": "leve"},
|
| 78 |
+
],
|
| 79 |
+
"analitos_medidos": ["calc", "fosf", "bun", "creat"],
|
| 80 |
+
}
|
| 81 |
+
return PeticionInterpretacion.model_validate(base | extra)
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_detecta_el_hemograma_inventado():
|
| 85 |
+
"""La única violación de seguridad de la corrida del 2026-08-04."""
|
| 86 |
+
texto = "La leucograma muestra neutrofilia y linfopenia."
|
| 87 |
+
assert "Neutrófilos #" in analitos_fabricados_en_prosa(texto, _peticion_prosa())
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_pedir_la_prueba_no_es_inventarla():
|
| 91 |
+
"""Sin esta exención se marcaría la frase CORRECTA de la misma respuesta."""
|
| 92 |
+
texto = "Se debe obtener un hemograma completo incluyendo plaquetas y estudios de coagulación."
|
| 93 |
+
assert analitos_fabricados_en_prosa(texto, _peticion_prosa()) == []
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_explicar_un_mecanismo_no_es_afirmar_un_dato():
|
| 97 |
+
"""«en presencia de alcalosis o hipoalbuminemia» habla de fisiología, no de este paciente."""
|
| 98 |
+
texto = (
|
| 99 |
+
"El calcio ionizado puede estar alterado independientemente del total, "
|
| 100 |
+
"especialmente en presencia de alcalosis o hipoalbuminemia."
|
| 101 |
+
)
|
| 102 |
+
assert analitos_fabricados_en_prosa(texto, _peticion_prosa()) == []
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_un_analito_medido_y_en_rango_no_puede_declararse_alterado():
|
| 106 |
+
"""`plt` = 190, dentro de rango: nombrarlo vale, llamarlo trombocitopenia no."""
|
| 107 |
+
pet = _peticion_prosa(analitos_medidos=["calc", "fosf", "bun", "creat", "plt"])
|
| 108 |
+
assert analitos_fabricados_en_prosa("Se acompaña de trombocitopenia leve.", pet) == ["Plaquetas"]
|
| 109 |
+
assert analitos_fabricados_en_prosa("Las plaquetas están conservadas.", pet) == []
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_un_termino_que_sostiene_un_analito_alterado_no_marca_a_su_vecino():
|
| 113 |
+
"""«azotemia» la explica el BUN alto aunque la creatinina esté en rango."""
|
| 114 |
+
assert "Creatinina" not in analitos_fabricados_en_prosa(
|
| 115 |
+
"El paciente presenta azotemia.", _peticion_prosa()
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def test_sin_analitos_medidos_la_guarda_se_desactiva():
|
| 120 |
+
"""Cliente antiguo: no se puede distinguir «no medido» de «medido y normal»."""
|
| 121 |
+
pet = _peticion_prosa(analitos_medidos=[])
|
| 122 |
+
assert analitos_fabricados_en_prosa("La leucograma muestra neutrofilia.", pet) == []
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# --- Fugas del léxico que el juez sí vio (corrida del 2026-08-04, v2) ---
|
| 126 |
+
|
| 127 |
+
def test_la_falta_de_ortografia_no_absuelve_la_invencion():
|
| 128 |
+
"""`shunt-portosistemico-canino`: bilirrubina nunca medida, «hiperbilirubinemia» con una r.
|
| 129 |
+
|
| 130 |
+
La grafía correcta ya estaba en el léxico; la incorrecta —que es la que escribió el
|
| 131 |
+
modelo— lo dejaba pasar. Es la fuga más barata de toda la guarda: una letra.
|
| 132 |
+
"""
|
| 133 |
+
texto = "Se observan hipoproteinemia e hiperbilirubinemia."
|
| 134 |
+
assert "Bilirrubina Total" in analitos_fabricados_en_prosa(texto, _peticion_prosa())
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_la_sigla_ambigua_se_reconoce_por_sus_mayusculas():
|
| 138 |
+
"""`piometra`: «alta UN» sobre un panel sin BUN. En minúsculas "un" marcaría todo."""
|
| 139 |
+
pet = _peticion_prosa(hallazgos=[], analitos_medidos=["creat", "wbc"])
|
| 140 |
+
assert "BUN/Urea" in analitos_fabricados_en_prosa(
|
| 141 |
+
"Esta combinación (alta UN, USG bajo) caracteriza una azotemia renal.", pet
|
| 142 |
+
)
|
| 143 |
+
# El artículo indeterminado en minúsculas no es la sigla, y aparece en casi cualquier
|
| 144 |
+
# frase clínica: confundirlos convertiría la guarda en un generador de reintentos.
|
| 145 |
+
assert analitos_fabricados_en_prosa(
|
| 146 |
+
"Esta combinación define un patrón inflamatorio marcado.", pet
|
| 147 |
+
) == []
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# --- Guarda simétrica: dar por normal lo que está alterado ---
|
| 151 |
+
|
| 152 |
+
from app.ai.coherencia import alterados_declarados_normales # noqa: E402
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _peticion_vcm():
|
| 156 |
+
"""`shunt-portosistemico-canino`: VCM 58 en un perro, microcítico."""
|
| 157 |
+
return PeticionInterpretacion.model_validate({
|
| 158 |
+
"paciente": {"especie": "canino"},
|
| 159 |
+
"hallazgos": [
|
| 160 |
+
{"clave": "vcm", "nombre": "VCM (MCV)", "valor": 58.0, "unidad": "fL",
|
| 161 |
+
"direccion": "bajo", "gravedad": "moderado"},
|
| 162 |
+
],
|
| 163 |
+
"analitos_medidos": ["vcm", "alb", "gluc"],
|
| 164 |
+
})
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def test_declarar_normal_un_valor_alterado_se_marca():
|
| 168 |
+
"""La contradicción real: lista «microcitosis» y luego dice que la MCV está normal."""
|
| 169 |
+
texto = "La microcitosis es llamativa (aunque la MCV aquí está normal)."
|
| 170 |
+
assert alterados_declarados_normales(texto, _peticion_vcm()) == ["VCM (MCV)"]
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def test_enunciar_un_principio_general_no_es_declarar_normalidad():
|
| 174 |
+
"""Sin exigir el copulativo, esta frase correcta se marcaría: no habla del paciente."""
|
| 175 |
+
texto = "Una MCV normal no descarta una hepatopatía."
|
| 176 |
+
assert alterados_declarados_normales(texto, _peticion_vcm()) == []
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def test_la_negacion_invierte_la_declaracion():
|
| 180 |
+
texto = "La MCV no está dentro del rango de referencia."
|
| 181 |
+
assert alterados_declarados_normales(texto, _peticion_vcm()) == []
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def test_un_analito_en_rango_puede_declararse_normal():
|
| 185 |
+
"""Es lo que la herramienta debe poder decir: sólo se vigila lo que el motor vio alterado."""
|
| 186 |
+
texto = "La glucosa está dentro de los límites de referencia."
|
| 187 |
+
assert alterados_declarados_normales(texto, _peticion_vcm()) == []
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def test_sin_hallazgos_alterados_la_guarda_simetrica_no_actua():
|
| 191 |
+
pet = _peticion_vcm().model_copy(update={"hallazgos": []})
|
| 192 |
+
assert alterados_declarados_normales("La MCV está normal.", pet) == []
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def test_la_contradiccion_escondida_en_una_subordinada_se_ve():
|
| 196 |
+
"""La frase REAL de `shunt-portosistemico-canino`, entera.
|
| 197 |
+
|
| 198 |
+
El «no» y el «posible» de la primera mitad eximían a la segunda cuando se miraba la
|
| 199 |
+
oración completa; troceando por la adversativa, la afirmación queda desnuda.
|
| 200 |
+
"""
|
| 201 |
+
texto = (
|
| 202 |
+
"La hipoglucemia es particularmente relevante, siendo posible una causa endocrina "
|
| 203 |
+
"como un insulinoma, aunque la literatura [2] menciona que la microcitosis no es "
|
| 204 |
+
"típica en shunt portosistémico (aunque la MCV aquí está normal)."
|
| 205 |
+
)
|
| 206 |
+
assert alterados_declarados_normales(texto, _peticion_vcm()) == ["VCM (MCV)"]
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def test_nombrar_un_estado_fisiopatologico_no_es_inventar_una_analitica():
|
| 210 |
+
"""`cetoacidosis-felino-gases`: el juez le dio 1.00 en seguridad y la guarda lo reintentaba.
|
| 211 |
+
|
| 212 |
+
«Deficiencia de insulina» es la conclusión correcta de una cetoacidosis, no la invención
|
| 213 |
+
de una insulinemia. Cada falso positivo cuesta una reserva de GPU del Space.
|
| 214 |
+
"""
|
| 215 |
+
texto = "La hiperglucemia y la fructosamina elevada indican una marcada deficiencia de insulina."
|
| 216 |
+
pet = _peticion_prosa(hallazgos=[], analitos_medidos=["gluc", "fructosamina"])
|
| 217 |
+
assert analitos_fabricados_en_prosa(texto, pet) == []
|
backend/tests/test_modelos_locales.py
CHANGED
|
@@ -159,3 +159,14 @@ async def test_el_modelo_elegido_recibe_rag_y_se_reporta_en_la_respuesta(
|
|
| 159 |
assert resp.fuentes_rag == 1
|
| 160 |
# La etiqueta debe nombrar el modelo QUE RESPONDIÓ, no el de la configuración.
|
| 161 |
assert resp.modelo == "medgemma:medgemma1.5:latest"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
assert resp.fuentes_rag == 1
|
| 160 |
# La etiqueta debe nombrar el modelo QUE RESPONDIÓ, no el de la configuración.
|
| 161 |
assert resp.modelo == "medgemma:medgemma1.5:latest"
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def test_las_pruebas_no_heredan_el_env_del_desarrollador():
|
| 165 |
+
"""La lista blanca vacía por defecto sólo se sostiene si nadie lee el `.env` local.
|
| 166 |
+
|
| 167 |
+
Este fichero afirma que sin declaración no hay modelos elegibles. Un
|
| 168 |
+
`MORPHOS_MODELOS_LOCALES=…` en `backend/.env` —perfectamente legítimo para trabajar en
|
| 169 |
+
local— hacía fallar esa afirmación en la máquina del desarrollador y pasarla en CI, que es
|
| 170 |
+
la peor combinación posible: la prueba deja de describir el código y describe la máquina.
|
| 171 |
+
"""
|
| 172 |
+
assert Configuracion.model_config["env_file"] is None
|
backend/tests/test_prompt_y_rag.py
CHANGED
|
@@ -188,3 +188,32 @@ def test_presupuesto_desactivado_no_recorta():
|
|
| 188 |
from app.ai.service import recortar_a_presupuesto
|
| 189 |
|
| 190 |
assert len(recortar_a_presupuesto(_frags(6), 0)) == 6
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
from app.ai.service import recortar_a_presupuesto
|
| 189 |
|
| 190 |
assert len(recortar_a_presupuesto(_frags(6), 0)) == 6
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# --- Bloque de panel completo (2026-08-04) ---
|
| 194 |
+
|
| 195 |
+
def test_el_panel_nombra_lo_medido_en_rango_y_cierra_la_lista():
|
| 196 |
+
"""`hallazgos` sólo trae lo alterado, así que sin esto el modelo no distingue «no se midió»
|
| 197 |
+
de «se midió y salió normal» y rellena el hueco (leucograma inventado, corrida 2026-08-04)."""
|
| 198 |
+
pet = _peticion().model_copy(update={"analitos_medidos": ["hct", "plt", "gluc"]})
|
| 199 |
+
msg = construir_mensaje_usuario(pet, [])
|
| 200 |
+
assert "DENTRO de rango" in msg
|
| 201 |
+
assert "Plaquetas" in msg and "Glucosa" in msg
|
| 202 |
+
assert "Hematocrito (Hct)" not in msg, "el alterado ya va en su propio bloque"
|
| 203 |
+
assert "panel COMPLETO" in msg
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def test_sin_analitos_medidos_el_bloque_desaparece_entero():
|
| 207 |
+
"""Cliente antiguo: anunciar un «panel completo» vacío sería peor que no decir nada, y el
|
| 208 |
+
relleno se lee como contenido (mismo motivo que test_panel_normal_no_mete_lineas_de_relleno)."""
|
| 209 |
+
msg = construir_mensaje_usuario(_peticion(), [])
|
| 210 |
+
assert "DENTRO de rango" not in msg
|
| 211 |
+
assert "panel COMPLETO" not in msg
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def test_el_panel_sin_ninguno_en_rango_conserva_el_cierre():
|
| 215 |
+
"""Todo lo medido salió alterado: no hay lista que enseñar, pero sí que decir que no hay más."""
|
| 216 |
+
pet = _peticion().model_copy(update={"analitos_medidos": ["hct"]})
|
| 217 |
+
msg = construir_mensaje_usuario(pet, [])
|
| 218 |
+
assert "DENTRO de rango" not in msg
|
| 219 |
+
assert "panel COMPLETO" in msg
|
backend/tests/test_reintentos.py
CHANGED
|
@@ -284,3 +284,95 @@ async def test_la_ruta_estructurada_conserva_la_opinion_del_modelo(sin_rag, monk
|
|
| 284 |
resp = await service.interpretar(pet)
|
| 285 |
|
| 286 |
assert resp.resultado.requiere_derivacion is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
resp = await service.interpretar(pet)
|
| 285 |
|
| 286 |
assert resp.resultado.requiere_derivacion is True
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
# --- Reintento correctivo ante infracción de seguridad (2026-08-04) ---
|
| 290 |
+
#
|
| 291 |
+
# Antes, una pauta terapéutica detectada se remediaba anteponiendo el encuadre de
|
| 292 |
+
# `prescripcion.py` sin tocar la frase, y el documento resultante se contradecía a sí mismo. El
|
| 293 |
+
# juez lo penalizó por ello: `hipotiroidismo-canino` 0.35 y `diabetes-felino-fructosamina` 0.50
|
| 294 |
+
# en seguridad, los dos CON la nota de alcance ya puesta. Ahora se regenera; el encuadre queda
|
| 295 |
+
# como último recurso.
|
| 296 |
+
|
| 297 |
+
from app.ai.prescripcion import ENCUADRE # noqa: E402
|
| 298 |
+
|
| 299 |
+
# Frase real de `diabetes-felino-fructosamina`.
|
| 300 |
+
PRESCRIPTIVA = (
|
| 301 |
+
"El paciente presenta hiperglucemia marcada. Se recomienda iniciar un tratamiento con "
|
| 302 |
+
"insulina para confirmar el diagnóstico de diabetes mellitus. "
|
| 303 |
+
)
|
| 304 |
+
LIMPIA = (
|
| 305 |
+
"El paciente presenta hiperglucemia marcada compatible con diabetes mellitus. "
|
| 306 |
+
"Procede confirmar con fructosamina y descartar causas de hiperglucemia por estrés. "
|
| 307 |
+
)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
class ClienteSegunIntento:
|
| 311 |
+
"""Devuelve un texto distinto en cada llamada, para medir qué se hace con el primero."""
|
| 312 |
+
|
| 313 |
+
nombre = "medgemma-hf"
|
| 314 |
+
prosa = True
|
| 315 |
+
modelo = "hf-space"
|
| 316 |
+
|
| 317 |
+
def __init__(self, *textos: str):
|
| 318 |
+
self.textos = textos
|
| 319 |
+
self.mensajes: list[str] = []
|
| 320 |
+
|
| 321 |
+
async def interpretar(self, _sistema, mensaje, *_a, **_k):
|
| 322 |
+
self.mensajes.append(mensaje)
|
| 323 |
+
texto = self.textos[min(len(self.mensajes), len(self.textos)) - 1]
|
| 324 |
+
return InterpretacionClinica(interpretacion=texto, requiere_derivacion=True)
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
async def test_la_prescripcion_se_regenera_en_vez_de_encuadrarse(sin_rag, monkeypatch):
|
| 328 |
+
cliente = ClienteSegunIntento(PRESCRIPTIVA, LIMPIA)
|
| 329 |
+
resp = await _interpretar_con(cliente, monkeypatch)
|
| 330 |
+
|
| 331 |
+
assert len(cliente.mensajes) == 2, "la infracción debe costar un reintento"
|
| 332 |
+
assert ENCUADRE not in resp.resultado.interpretacion, "regenerar, no parchear"
|
| 333 |
+
assert resp.resultado.interpretacion.startswith("El paciente presenta hiperglucemia marcada c")
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
async def test_el_segundo_mensaje_nombra_lo_que_hay_que_quitar(sin_rag, monkeypatch):
|
| 337 |
+
"""La generación del Space es voraz: repetir el mismo prompt devuelve la misma respuesta."""
|
| 338 |
+
cliente = ClienteSegunIntento(PRESCRIPTIVA, LIMPIA)
|
| 339 |
+
await _interpretar_con(cliente, monkeypatch)
|
| 340 |
+
|
| 341 |
+
correccion = cliente.mensajes[1]
|
| 342 |
+
assert "CORRECCIÓN OBLIGATORIA" in correccion
|
| 343 |
+
assert "insulina" in correccion
|
| 344 |
+
assert correccion != cliente.mensajes[0]
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
async def test_si_el_reintento_vuelve_a_infringir_se_encuadra_como_antes(sin_rag, monkeypatch):
|
| 348 |
+
"""No-regresión: el comportamiento nunca queda peor que el que había."""
|
| 349 |
+
cliente = ClienteSegunIntento(PRESCRIPTIVA, PRESCRIPTIVA)
|
| 350 |
+
resp = await _interpretar_con(cliente, monkeypatch)
|
| 351 |
+
|
| 352 |
+
assert len(cliente.mensajes) == 2
|
| 353 |
+
assert resp.resultado.interpretacion.startswith(ENCUADRE)
|
| 354 |
+
assert resp.resultado.requiere_derivacion is True
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
async def test_una_salida_limpia_no_gasta_reintento(sin_rag, monkeypatch):
|
| 358 |
+
cliente = ClienteSegunIntento(LIMPIA)
|
| 359 |
+
resp = await _interpretar_con(cliente, monkeypatch)
|
| 360 |
+
|
| 361 |
+
assert len(cliente.mensajes) == 1
|
| 362 |
+
assert ENCUADRE not in resp.resultado.interpretacion
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
async def test_el_analito_inventado_tambien_dispara_el_reintento(sin_rag, monkeypatch):
|
| 366 |
+
"""El otro modo de fallo del 2026-08-04, sobre un panel sin hemograma."""
|
| 367 |
+
inventada = "La leucograma muestra neutrofilia y linfopenia marcadas. "
|
| 368 |
+
cliente = ClienteSegunIntento(inventada, LIMPIA)
|
| 369 |
+
monkeypatch.setattr(service, "_crear_cliente", lambda *_: cliente)
|
| 370 |
+
pet = PeticionInterpretacion.model_validate(
|
| 371 |
+
PETICION | {"analitos_medidos": ["hct", "creat", "gluc"]}
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
resp = await service.interpretar(pet)
|
| 375 |
+
|
| 376 |
+
assert len(cliente.mensajes) == 2
|
| 377 |
+
assert "Neutrófilos" in cliente.mensajes[1]
|
| 378 |
+
assert resp.resultado.interpretacion.startswith("El paciente presenta hiperglucemia")
|
backend/tests/test_schemas.py
CHANGED
|
@@ -68,3 +68,17 @@ def test_el_esquema_por_defecto_sigue_siendo_permisivo():
|
|
| 68 |
|
| 69 |
interp = InterpretacionClinica(interpretacion="Sólo prosa.")
|
| 70 |
assert interp.diferenciales == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
interp = InterpretacionClinica(interpretacion="Sólo prosa.")
|
| 70 |
assert interp.diferenciales == []
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def test_analitos_medidos_es_opcional():
|
| 74 |
+
"""Retrocompatible: una petición de un cliente que no lo manda sigue siendo válida, y el
|
| 75 |
+
prompt y la guarda de invención se apagan solos ante la lista vacía."""
|
| 76 |
+
pet = PeticionInterpretacion.model_validate({"paciente": {"especie": "canino"}})
|
| 77 |
+
assert pet.analitos_medidos == []
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def test_analitos_medidos_conserva_las_claves():
|
| 81 |
+
pet = PeticionInterpretacion.model_validate(
|
| 82 |
+
{"paciente": {"especie": "felino"}, "analitos_medidos": ["calc", "fosf", "creat"]}
|
| 83 |
+
)
|
| 84 |
+
assert pet.analitos_medidos == ["calc", "fosf", "creat"]
|
frontend/src/ia.ts
CHANGED
|
@@ -183,7 +183,7 @@ function renderizar(resp: RespuestaInterpretacion): string {
|
|
| 183 |
|
| 184 |
export async function llamarIA(
|
| 185 |
obtenerDatosPaciente: () => Paciente,
|
| 186 |
-
getUltimoAnalisis: () => { hallazgos: Hallazgo[]; patrones: Patron[] },
|
| 187 |
getImagenes: () => string[],
|
| 188 |
): Promise<void> {
|
| 189 |
const salidaEl = document.getElementById('salida-ia');
|
|
@@ -191,7 +191,7 @@ export async function llamarIA(
|
|
| 191 |
|
| 192 |
const backend = (localStorage.getItem(BACKEND_KEY) ?? 'medgemma') === 'claude' ? 'claude' : 'medgemma';
|
| 193 |
const paciente = obtenerDatosPaciente();
|
| 194 |
-
const { hallazgos, patrones } = getUltimoAnalisis();
|
| 195 |
const signos = (document.getElementById('signos-clinicos') as HTMLTextAreaElement | null)?.value.trim() ?? '';
|
| 196 |
|
| 197 |
salidaEl.textContent = 'Consultando al modelo de I.A…';
|
|
@@ -206,6 +206,9 @@ export async function llamarIA(
|
|
| 206 |
},
|
| 207 |
hallazgos,
|
| 208 |
patrones,
|
|
|
|
|
|
|
|
|
|
| 209 |
signos_clinicos: signos,
|
| 210 |
imagenes: getImagenes().slice(0, 4),
|
| 211 |
backend,
|
|
|
|
| 183 |
|
| 184 |
export async function llamarIA(
|
| 185 |
obtenerDatosPaciente: () => Paciente,
|
| 186 |
+
getUltimoAnalisis: () => { hallazgos: Hallazgo[]; patrones: Patron[]; medidos?: string[] },
|
| 187 |
getImagenes: () => string[],
|
| 188 |
): Promise<void> {
|
| 189 |
const salidaEl = document.getElementById('salida-ia');
|
|
|
|
| 191 |
|
| 192 |
const backend = (localStorage.getItem(BACKEND_KEY) ?? 'medgemma') === 'claude' ? 'claude' : 'medgemma';
|
| 193 |
const paciente = obtenerDatosPaciente();
|
| 194 |
+
const { hallazgos, patrones, medidos } = getUltimoAnalisis();
|
| 195 |
const signos = (document.getElementById('signos-clinicos') as HTMLTextAreaElement | null)?.value.trim() ?? '';
|
| 196 |
|
| 197 |
salidaEl.textContent = 'Consultando al modelo de I.A…';
|
|
|
|
| 206 |
},
|
| 207 |
hallazgos,
|
| 208 |
patrones,
|
| 209 |
+
// El panel COMPLETO, no sólo lo alterado: es lo que le permite al modelo saber que un
|
| 210 |
+
// analito ausente no se ha medido y que uno presente pero sin hallazgo salió en rango.
|
| 211 |
+
analitos_medidos: medidos ?? [],
|
| 212 |
signos_clinicos: signos,
|
| 213 |
imagenes: getImagenes().slice(0, 4),
|
| 214 |
backend,
|