"""Léxico de analitos: clave → términos con los que un texto clínico puede nombrarla. Única fuente del mapeo. Vivía sólo en `evals/run_evals.py`, que lo usaba para medir la cobertura de hallazgos sobre la prosa; ahora también lo necesita el backend en tiempo de ejecución —`ai/coherencia.py` para detectar analitos inventados y `ai/prompt.py` para nombrar en cristiano los que salieron en rango—, así que se ha traído aquí y `run_evals.py` lo importa. Duplicarlo habría dejado la métrica y la guarda midiendo cosas distintas con el mismo nombre. Se construye desde `data/valores_referencia.json`, que ya trae el nombre clínico de cada analito ("ALT (GPT)", "Densidad (USG)"), así que el grueso del léxico no se escribe a mano. """ from __future__ import annotations import json import re import unicodedata from functools import lru_cache from ..config import RAIZ_REPO # Palabras del nombre clínico que no identifican al analito por sí solas: "T4 total" y # "Proteínas totales" comparten "total", y buscarla marcaría cualquiera de los dos. _GENERICOS = {"total", "libre", "serico", "serica", "plasmatico", "urinario", "sangre"} def sin_tildes(texto: str) -> str: return "".join( c for c in unicodedata.normalize("NFD", texto.lower()) if unicodedata.category(c) != "Mn" ) # La prosa clínica casi nunca nombra el analito: nombra su alteración ("hiperfosforemia" en # vez de "fósforo", "trombocitopenia" en vez de "plaquetas"). Estas variantes se listan a # mano en vez de derivarlas por stemming a propósito: un prefijo de 5 letras haría que # "hematoma" contara como hematocrito, y aquí un falso positivo es peor que un falso negativo # —tanto en la métrica como en la guarda, donde provoca un reintento inútil—. Sólo se incluyen # derivaciones del NOMBRE del analito, no síndromes que lo acompañan (anemia no cuenta como # mención del hematocrito). VARIANTES: dict[str, tuple[str, ...]] = { "alb": ("hipoalbuminemia", "hiperalbuminemia", "albuminemia"), "alt": ("gpt", "transaminasas", "transaminasa"), # La grafía con UNA r es incorrecta y el modelo la usa igual: el 2026-08-04 escribió # «hiperbilirubinemia» en `shunt-portosistemico-canino`, sobre una bilirrubina que nunca # se midió, y la guarda de invención lo dejó pasar por esa letra. Un término de más aquí # no puede casar con otro analito, así que el coste de admitir la falta de ortografía es # cero y el de no admitirla ya se pagó. "bili": ("hiperbilirrubinemia", "bilirrubinemia", "hiperbilirubinemia", "bilirubinemia"), "bun": ("azotemia", "uremia", "urea"), "ca_ion": ("hipercalcemia", "hipocalcemia", "calcemia", "ica"), "calc": ("hipercalcemia", "hipocalcemia", "calcemia"), "colest": ("hipercolesterolemia", "colesterolemia"), "creat": ("azotemia",), "fal": ("alp", "fosfatasa"), "fosf": ("hiperfosfatemia", "hipofosfatemia", "hiperfosforemia", "fosfatemia", "fosforemia"), "glob": ("hiperglobulinemia", "globulinemia", "gammapatia"), "gluc": ("hiperglucemia", "hipoglucemia", "glucemia", "hiperglicemia"), "hco3": ("bicarbonato",), "neutro_abs": ("neutrofilia", "neutropenia"), "ph_sangre": ("acidosis", "alcalosis", "acidemia", "alcalemia"), "pli": ("cpli", "lipasa"), "plt": ("trombocitopenia", "trombocitosis", "plaquetopenia"), "potasio": ("hipopotasemia", "hiperpotasemia", "hipokalemia", "hiperkalemia", "kalemia"), "prot": ("hiperproteinemia", "hipoproteinemia", "proteinemia"), "reti": ("reticulocitosis", "regenerativa"), "sodio": ("hiponatremia", "hipernatremia", "natremia"), "t4_total": ("t4", "tiroxina"), "usg": ("isostenuria", "hipostenuria"), "vcm": ("mcv", "microcitosis", "macrocitosis"), "wbc": ("leucocitosis", "leucopenia", "leucocitos"), } # Siglas cuya versión en minúsculas es una palabra corriente del castellano. No pueden entrar # en `VARIANTES`: todo el léxico se coteja sobre texto normalizado a minúsculas, y buscar "un" # marcaría prácticamente cualquier oración. Se cotejan aparte, respetando las mayúsculas, sobre # el texto ORIGINAL. Motivo: el 2026-08-04, en `piometra-progesterona-canino`, el modelo escribió # «Esta combinación (alta UN, alta CT, USG bajo) caracteriza una azotemia renal» sobre un panel # sin BUN; "azotemia" quedaba absuelta —la creatinina sí estaba alta— y la sigla era la única # señal de que se había inventado el dato. ABREVIATURAS_SENSIBLES: dict[str, tuple[str, ...]] = { "bun": ("UN", "BUN"), } def abreviaturas_presentes(texto: str) -> set[str]: """Claves cuya sigla ambigua aparece, con sus mayúsculas, en el texto tal cual se escribió.""" return { clave for clave, siglas in ABREVIATURAS_SENSIBLES.items() if any(re.search(rf"\b{re.escape(s)}\b", texto) for s in siglas) } def tokens_analito(texto: str) -> set[str]: # Mínimo 2 caracteres: hay analitos cuyo nombre entero es corto ("T4", "pH"), y con 3 # se quedaban sin ningún término con el que buscarlos. return { t for t in re.split(r"[^a-z0-9]+", sin_tildes(texto)) if len(t) >= 2 and t not in _GENERICOS } @lru_cache def _referencias() -> dict: ruta = RAIZ_REPO / "data" / "valores_referencia.json" return json.loads(ruta.read_text(encoding="utf-8")) @lru_cache def lexico_analitos() -> dict[str, frozenset[str]]: """clave de analito → términos con los que un texto puede referirse a él.""" lexico: dict[str, set[str]] = {} for analitos in _referencias().values(): # canino, felino for clave, info in analitos.items(): terminos = lexico.setdefault(clave, set()) terminos |= tokens_analito(clave) terminos |= tokens_analito(info.get("nombre", "")) for clave, variantes in VARIANTES.items(): lexico.setdefault(clave, set()).update(variantes) return {clave: frozenset(t) for clave, t in lexico.items()} @lru_cache def nombres_clinicos() -> dict[str, str]: """clave → nombre clínico legible ("plt" → "Plaquetas"). La clave cruda si no hay nombre.""" nombres: dict[str, str] = {} for analitos in _referencias().values(): for clave, info in analitos.items(): if nombre := info.get("nombre"): nombres.setdefault(clave, nombre) return nombres def nombre_clinico(clave: str) -> str: return nombres_clinicos().get(clave, clave) # Palabras que aparecen DENTRO del nombre clínico de algún analito pero que no lo identifican: # calificadores ("directa", "ionizado", "arterial"), unidades de medida del propio nombre # ("tiempo", "cociente", "índice") y sustantivos compartidos por varios ("proteína", "creatina"). # Sin este filtro «de» y «tiempo» —del "Tiempo de protrombina (TP)"— casan en casi cualquier # frase clínica: medido sobre las 30 predicciones del 2026-08-04, el TP salía marcado en 27. _NO_IDENTIFICAN = frozenset({ "tiempo", "exceso", "nivel", "basal", "serico", "serica", "urinario", "orina", "sangre", "total", "libre", "parcial", "activada", "protrombina", "tromboplastina", "sanguineo", "relacion", "indice", "recuento", "absoluto", "estimulacion", "supresion", "post", "pre", "arterial", "saturacion", "cociente", "antigeno", "reactiva", "directa", "ionizado", "pancreatica", "acidos", "biliares", "creatina", "kinasa", "cardiaca", "amiloide", "proteina", "dimeros", }) # Debajo de esto, un token del nombre es una abreviatura ("tp", "be", "ac", "at") que casa # dentro de cualquier palabra o como partícula suelta. Las siglas que SÍ interesan ya entran # por `VARIANTES`, que es una lista curada. _LARGO_MINIMO_TERMINO = 6 @lru_cache def terminos_especificos() -> dict[str, frozenset[str]]: """Subconjunto del léxico con la precisión que exige BUSCAR AL REVÉS. `claves_mencionadas` pregunta «¿aparece ESTE analito?» sobre un puñado de claves conocidas, y ahí un token flojo apenas molesta. La guarda de invención hace lo contrario: barre los 90 analitos contra una prosa para ver cuál NO debería estar. Con el léxico completo eso es un generador de falsos positivos, y cada falso positivo cuesta una llamada al modelo y una corrección improcedente. Aquí sólo entran los términos curados a mano (`VARIANTES`) y las palabras del nombre clínico lo bastante largas y específicas como para no casar por azar. """ especificos: dict[str, frozenset[str]] = {} for clave, terminos in lexico_analitos().items(): utiles = set(VARIANTES.get(clave, ())) utiles |= { t for t in terminos if len(t) >= _LARGO_MINIMO_TERMINO and t not in _NO_IDENTIFICAN } if utiles: especificos[clave] = frozenset(utiles) return especificos def claves_mencionadas(texto: str, claves: set[str]) -> set[str]: """Cuáles de `claves` aparecen nombradas en el texto. Se busca el término con límites de palabra para que "alt" no case dentro de "alteración". """ normalizado = sin_tildes(texto) lexico = lexico_analitos() encontradas = set() for clave in claves: terminos = lexico.get(clave) or tokens_analito(clave) if any(re.search(rf"\b{re.escape(t)}\b", normalizado) for t in terminos): encontradas.add(clave) return encontradas