File size: 9,384 Bytes
7dba92c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
"""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