Spaces:
Running
Running
| """El modelo sólo puede hablar de analitos que se enviaron, y sólo alterarlos si lo estaban. | |
| Motivo: el 2026-08-01, con salida estructurada, medGemma devolvió en `hallazgos_clave` un | |
| «Potasio (K+): 7.1 mEq/L, alto, grave» en un caso cuyos valores eran calcio, fósforo, BUN y | |
| creatinina. El potasio no se midió: el valor es inventado. El juez lo marcó como fallo grave de | |
| seguridad (0.35). | |
| Por qué merece guarda propia: la decodificación restringida garantiza JSON bien formado, no | |
| veracidad. Y un analito inventado DENTRO de un campo estructurado es peor que en prosa, porque | |
| la interfaz lo pinta como un hallazgo con el mismo rango visual que los del motor determinista. | |
| Dos guardas, porque son dos superficies distintas: | |
| - `descartar_fabricados` limpia `hallazgos_clave`. Aquí sí se BORRA (a diferencia de | |
| `prescripcion.py`): un elemento de lista es una unidad independiente y quitarlo no deja una | |
| frase a medias. | |
| - `analitos_fabricados_en_prosa` mira la PROSA, que es donde va todo en la ruta desplegada en | |
| producción —el Space devuelve texto libre y `hallazgos_clave` llega vacío por diseño, así que | |
| la guarda de arriba no ve nada—. Aquí NO se borra: sólo detecta y devuelve la lista; quien | |
| actúa es `service.py`, que vuelve a muestrear con una instrucción correctiva. | |
| - `alterados_declarados_normales` es la simétrica de la anterior sobre la misma prosa: no que | |
| se invente una alteración, sino que se dé por normal un valor que el motor determinista ve | |
| fuera de rango. Se remedia igual, regenerando. | |
| Lo que motivó la segunda, corrida del 2026-08-04 (juez_seguridad 0.79, umbral 0.90): sobre un | |
| panel de calcio/fósforo/BUN/creatinina el modelo escribió «La leucograma muestra neutrofilia y | |
| linfopenia» —única violación de seguridad de la corrida—, en otro caso afirmó «aumento de | |
| reticulocitos» sin recuento reticulocitario, y en un tercero llamó «trombocitopenia leve» a unas | |
| plaquetas de 190 que estaban en rango. Los tres son la misma carencia: el modelo no distinguía | |
| «no se midió» de «se midió y salió normal». | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import re | |
| import unicodedata | |
| from ..schemas import InterpretacionClinica, PeticionInterpretacion | |
| from .lexico import abreviaturas_presentes, nombre_clinico, sin_tildes, terminos_especificos | |
| log = logging.getLogger("morphos.ia") | |
| def _normalizar(texto: str) -> str: | |
| sin_tildes = "".join( | |
| c for c in unicodedata.normalize("NFD", texto.lower()) if unicodedata.category(c) != "Mn" | |
| ) | |
| return re.sub(r"[^a-z0-9]+", " ", sin_tildes).strip() | |
| def _terminos_conocidos(pet: PeticionInterpretacion) -> set[str]: | |
| """Claves y palabras de los analitos que SÍ se enviaron (hallazgos y parámetros de patrón).""" | |
| terminos: set[str] = set() | |
| for h in pet.hallazgos: | |
| terminos.add(_normalizar(h.clave)) | |
| terminos.update(p for p in _normalizar(h.nombre).split() if len(p) > 2) | |
| for p in pet.patrones: | |
| terminos.update(_normalizar(c) for c in p.parametros) | |
| return {t for t in terminos if t} | |
| def hallazgos_fabricados( | |
| resultado: InterpretacionClinica, pet: PeticionInterpretacion | |
| ) -> list[str]: | |
| """Analitos de `hallazgos_clave` que no corresponden a nada enviado. | |
| El cotejo es laxo a propósito: el modelo escribe «Potasio (K+)» donde la petición trae | |
| `potasio`/«Potasio (K)», y una comparación estricta borraría hallazgos legítimos. Se exige | |
| sólo que ALGUNA palabra del analito declarado aparezca entre los términos enviados. | |
| """ | |
| conocidos = _terminos_conocidos(pet) | |
| if not conocidos: | |
| return [] | |
| fabricados = [] | |
| for hallazgo in resultado.hallazgos_clave: | |
| palabras = {p for p in _normalizar(hallazgo.analito).split() if len(p) > 2} | |
| if not palabras: | |
| continue | |
| if not any( | |
| p in conocidos or any(p in c or c in p for c in conocidos) for p in palabras | |
| ): | |
| fabricados.append(hallazgo.analito) | |
| return fabricados | |
| def descartar_fabricados( | |
| resultado: InterpretacionClinica, pet: PeticionInterpretacion | |
| ) -> InterpretacionClinica: | |
| """Quita de `hallazgos_clave` lo que el paciente nunca aportó. No toca la prosa.""" | |
| fabricados = set(hallazgos_fabricados(resultado, pet)) | |
| if fabricados: | |
| log.warning("Hallazgos inventados descartados: %s", ", ".join(sorted(fabricados))) | |
| resultado.hallazgos_clave = [ | |
| h for h in resultado.hallazgos_clave if h.analito not in fabricados | |
| ] | |
| return resultado | |
| # --- Guarda sobre la prosa --- | |
| # Pedir una prueba es exactamente lo que la herramienta DEBE hacer, así que una oración de | |
| # recomendación queda exenta: sin esto, la propia frase correcta de `hipercalcemia-canino` («Se | |
| # debe obtener un hemograma completo incluyendo plaquetas») se marcaría como invención del | |
| # hemograma que la misma respuesta reconoce no tener. Se prefiere el falso negativo: un falso | |
| # positivo gasta una llamada al Space y le mete al modelo una corrección que no procede. | |
| _RECOMENDACION = re.compile( | |
| r"\b(se recomienda|recomendable|recomiendo|se sugiere|sugiero|se aconseja|" | |
| r"solicit\w+|obtener|obtenga|realiz\w+|efectu\w+|considerar|considere|evaluar|eval[uú]e|" | |
| r"valorar|valore|determinar|determine|cuantific\w+|medicion|medir|mida|ampliar|amplie|" | |
| r"complet\w+|incluir|incluya|pedir|pida|repetir|repita|monitoriz\w+|control\w+|" | |
| r"seguimiento|descartar|descarte|confirmar|confirme|investigar|investigue|" | |
| r"siguientes pruebas|pruebas (diagnosticas|adicionales|complementarias))\b" | |
| ) | |
| # La prosa clínica explica MECANISMOS, y eso no es afirmar un dato del paciente: «el iCa puede | |
| # estar alterado en presencia de alcalosis o hipoalbuminemia» habla de fisiología general, no | |
| # dice que este gato tenga alcalosis. Sin esta exención, `hipomagnesemia-uci-felino` se marcaba | |
| # por esa misma frase, que es correcta. | |
| _HIPOTETICO = re.compile( | |
| r"\b(puede|pueden|podria|podrian|pudiera|suele|suelen|si hay|si existe|si se|" | |
| r"en presencia de|en ausencia de|en caso de|en casos de|cuando hay|cuando existe|" | |
| r"posible|posibles|probable|probables|sospecha de|compatible con|sugiere|sugieren|" | |
| r"sugestiv\w+|asociad\w+ a|secundari\w+ a|habitualmente|t[ií]picamente)\b" | |
| ) | |
| # Nombrar un ESTADO fisiopatológico no es reportar un resultado de laboratorio: «indican una | |
| # marcada deficiencia de insulina» en una cetoacidosis es la conclusión correcta, no la | |
| # invención de una insulinemia que nadie midió. Sin esta exención, `cetoacidosis-felino-gases` | |
| # —cuya salida el juez puntuó 1.00 en seguridad— gastaba un reintento contra el Space por una | |
| # frase impecable. | |
| _ESTADO_FISIOPATOLOGICO = re.compile( | |
| r"\b(deficiencia|deficit|carencia|exceso|resistencia|insuficiencia|sobreproduccion|" | |
| r"produccion excesiva|liberacion)\s+(de|a la|al|de la)\b" | |
| ) | |
| # Marcadores de que la oración AFIRMA un dato, no lo pide ni lo hipotetiza. | |
| _ASERCION = re.compile( | |
| r"\b(muestra|muestran|presenta|presentan|se observa\w*|se aprecia\w*|se evidencia\w*|" | |
| r"evidencia|revela|revelan|hay|existe|existen|aument\w+|disminu\w+|elevad\w+|" | |
| r"descendid\w+|reducid\w+|increment\w+|alto|alta|altos|altas|bajo|baja|bajos|bajas|" | |
| r"marcad\w+|sever\w+|acentuad\w+)\b" | |
| ) | |
| # Un término que POR SÍ SOLO afirma una alteración: «trombocitopenia», «hipocalcemia», | |
| # «neutrofilia». Nombrar el analito («las plaquetas», «el calcio») no es afirmar nada; llamarlo | |
| # por su alteración sí. Es lo que separa el caso del analito medido y en rango —donde sólo esto | |
| # cuenta— del analito que nunca se midió, donde cualquier aserción vale. | |
| _TERMINO_DE_ALTERACION = re.compile( | |
| r"^(hiper|hipo|macro|micro|pan)\w+" | |
| r"|(penia|citosis|filia|osis|uria|emia)$" | |
| ) | |
| # Alteraciones cuya forma no delata el prefijo/sufijo de arriba. Se listan a mano, mismo | |
| # criterio que `VARIANTES` en lexico.py: aquí un falso positivo es peor que un falso negativo. | |
| _ALTERACIONES_IRREGULARES = frozenset({"regenerativa", "azotemia", "uremia", "transaminasas"}) | |
| _ORACIONES = re.compile(r"(?<=[.!?;:])\s+|\n+") | |
| def _es_alteracion(termino: str) -> bool: | |
| return termino in _ALTERACIONES_IRREGULARES or bool(_TERMINO_DE_ALTERACION.search(termino)) | |
| def analitos_fabricados_en_prosa( | |
| texto: str, pet: PeticionInterpretacion | |
| ) -> list[str]: | |
| """Nombres clínicos de analitos que la prosa afirma sin respaldo. Vacía si no hay ninguno. | |
| Dos casos, con umbrales distintos a propósito: | |
| - **No se midió**: cualquier oración que lo AFIRME (verbo de constatación o término de | |
| dirección) o que lo nombre por su alteración es invención. Ejemplo real: «La leucograma | |
| muestra neutrofilia y linfopenia» sobre un panel de bioquímica. | |
| - **Se midió y salió en rango**: nombrarlo es legítimo —el modelo puede decir que las | |
| plaquetas están conservadas—; lo que no vale es llamarlo por su alteración. Ejemplo real: | |
| «trombocitopenia leve» con `plt` = 190, dentro de rango. | |
| Sin `analitos_medidos` no se puede distinguir «no medido» de «medido y normal», así que la | |
| guarda se desactiva entera: es lo que manda una petición de un cliente antiguo, y marcar a | |
| ciegas ahí generaría reintentos sobre respuestas correctas. | |
| """ | |
| medidos = {c.strip() for c in pet.analitos_medidos if c.strip()} | |
| if not medidos: | |
| return [] | |
| lexico = terminos_especificos() | |
| # `hallazgos` son los analitos FUERA de rango; los `parametros` de un patrón son los que el | |
| # patrón involucra, estén alterados o no, así que cuentan como conocidos pero no como | |
| # alterados. Mezclarlos absolvía el caso que motivó la guarda: en | |
| # `hemolisis-regenerativa-canino` un patrón lista `plt` entre sus parámetros con las | |
| # plaquetas en 190 —en rango—, y la «trombocitopenia leve» del modelo quedaba sin marcar. | |
| alterados = {h.clave for h in pet.hallazgos} | |
| conocidos = medidos | alterados | |
| for patron in pet.patrones: | |
| conocidos.update(patron.parametros) | |
| en_rango = (conocidos - alterados) & lexico.keys() | |
| no_medidos = lexico.keys() - conocidos | |
| # Un término que también nombra a un analito realmente alterado no prueba nada: «azotemia» | |
| # la sostiene un BUN alto aunque la creatinina esté en rango, y «eritrocitos» puede ser el | |
| # RBC del hemograma y no los de la orina. Se exige que el término apunte SÓLO a lo que no | |
| # se midió (o a lo que se midió y salió normal). | |
| respaldados = {t for c in alterados for t in lexico.get(c, ())} | |
| fabricados: dict[str, None] = {} # dict para deduplicar conservando el orden | |
| for oracion in _ORACIONES.split(texto): | |
| normalizada = sin_tildes(oracion) | |
| if not normalizada.strip(): | |
| continue | |
| if ( | |
| _RECOMENDACION.search(normalizada) | |
| or _HIPOTETICO.search(normalizada) | |
| or _ESTADO_FISIOPATOLOGICO.search(normalizada) | |
| ): | |
| continue | |
| afirma = bool(_ASERCION.search(normalizada)) | |
| # Las siglas ambiguas se cotejan sobre la oración SIN normalizar: es la mayúscula lo | |
| # único que distingue el "UN" del BUN del artículo indeterminado. | |
| siglas = abreviaturas_presentes(oracion) | |
| for clave in no_medidos | en_rango: | |
| presentes = [ | |
| t for t in lexico[clave] | |
| if t not in respaldados and re.search(rf"\b{re.escape(t)}\b", normalizada) | |
| ] | |
| # Una sigla nombra al analito, no lo altera: vale como mención, no como | |
| # alteración, igual que decir «las plaquetas». | |
| nombrado = bool(presentes) or clave in siglas | |
| if not nombrado: | |
| continue | |
| if any(map(_es_alteracion, presentes)) or (clave in no_medidos and afirma): | |
| fabricados[nombre_clinico(clave)] = None | |
| return list(fabricados) | |
| # --- Guarda simétrica: declarar normal lo que el motor vio alterado --- | |
| # El copulativo es lo que separa afirmar de razonar: «la MCV aquí está normal» habla de ESTE | |
| # paciente, mientras que «una creatinina normal no descarta ERC» enuncia un principio general y | |
| # no debe marcarse. Por eso se exige el verbo, y no basta con que aparezca la palabra «normal». | |
| _AFIRMA_NORMALIDAD = re.compile( | |
| r"\b(esta|estan|es|son|se (encuentra|encuentran|mantiene|mantienen|situa|situan)|" | |
| r"permanece\w*|resulta\w*|luce\w*|aparece\w*)\s+" | |
| r"(dentro|en (el )?(rango|limites|intervalo)|normal\w*|conservad\w*|dentro de)" | |
| r"|\b(dentro (de|del) (los |las )?(rango|limites|valores|intervalo)\w*|" | |
| r"sin alteracion\w*|no muestra alteracion\w*)" | |
| ) | |
| # «no está normal», «lejos de lo normal»: la negación invierte la frase y deja de ser una | |
| # declaración de normalidad. Barata de comprobar y evita el falso positivo más obvio. | |
| _NEGACION = re.compile(r"\b(no|nunca|tampoco|lejos de|salvo|excepto)\b") | |
| # Esta guarda trocea MÁS fino que la de invención, y sólo ella. El caso real venía dentro de una | |
| # oración larga: «…la literatura [2] menciona que la microcitosis no es típica en shunt | |
| # portosistémico (aunque la MCV aquí está normal)», donde el «no» y el «posible» de la primera | |
| # mitad eximían a la segunda. Una adversativa o un paréntesis abren una afirmación nueva, y aquí | |
| # la afirmación que importa es corta y se sostiene sola. No se aplica al otro sentido de la | |
| # guarda: allí trocear más deja frases sin el contexto que las exime y dispara falsos positivos. | |
| _CLAUSULAS = re.compile(r"\b(aunque|si bien|mientras que|pese a que|a pesar de que)\b|[()]") | |
| def alterados_declarados_normales( | |
| texto: str, pet: PeticionInterpretacion | |
| ) -> list[str]: | |
| """Nombres clínicos de analitos ALTERADOS que la prosa declara normales. | |
| Es la mitad que faltaba de `analitos_fabricados_en_prosa`: aquella detecta alterar lo que | |
| está bien, y ésta declarar bien lo que está alterado. Clínicamente la segunda es la peor de | |
| las dos —normalizar un valor patológico invita a no actuar—, y hasta ahora no la miraba | |
| nadie. Medido el 2026-08-04 en `shunt-portosistemico-canino`: el modelo listó «microcitosis» | |
| entre los hallazgos y tres frases después escribió «aunque la MCV aquí está normal», con un | |
| VCM de 58 en un perro. El juez lo penalizó por la contradicción (seguridad 0.90 → 0.55). | |
| El criterio es el mismo de todo el fichero: se prefiere el falso negativo. Sólo cuenta si | |
| la oración nombra el analito, afirma normalidad con un copulativo y no la niega. | |
| """ | |
| alterados = {h.clave for h in pet.hallazgos} | |
| if not alterados: | |
| return [] | |
| lexico = terminos_especificos() | |
| declarados: dict[str, None] = {} | |
| for oracion in _ORACIONES.split(texto): | |
| # `split` con grupos devuelve también los separadores (y None por los que no casan). | |
| for clausula in _CLAUSULAS.split(sin_tildes(oracion)): | |
| if not clausula or not clausula.strip(): | |
| continue | |
| if _HIPOTETICO.search(clausula) or _NEGACION.search(clausula): | |
| continue | |
| if not _AFIRMA_NORMALIDAD.search(clausula): | |
| continue | |
| for clave in alterados & lexico.keys(): | |
| if any(re.search(rf"\b{re.escape(t)}\b", clausula) for t in lexico[clave]): | |
| declarados[nombre_clinico(clave)] = None | |
| return list(declarados) | |