File size: 7,784 Bytes
c205c6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5b69117
 
 
 
c205c6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5b69117
 
 
 
 
 
 
 
 
 
c205c6b
 
 
 
 
 
 
 
5b69117
 
 
 
 
 
 
 
c205c6b
 
 
5b69117
 
 
 
 
c205c6b
 
 
 
 
5b69117
 
 
c205c6b
5b69117
c205c6b
 
 
 
 
 
 
 
 
 
5b69117
 
 
c205c6b
5b69117
c205c6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5b69117
 
 
c205c6b
5b69117
 
 
c205c6b
 
 
 
 
5b69117
c205c6b
5b69117
 
c205c6b
 
 
 
 
 
 
 
 
 
 
 
5b69117
 
c205c6b
5b69117
c205c6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Suelo de seguridad determinista, recalculado EN EL SERVIDOR (ARCHITECTURE_REVIEW §1.1).

Por qué existe: el motor determinista del que depende toda la historia de seguridad corre en el
NAVEGADOR (`frontend/src/analisis.ts`) y el backend aceptaba su veredicto como un hecho —los
`hallazgos` llegaban como campos de la petición y `PeticionInterpretacion` lo decía por escrito:
«El backend NO recalcula»—. Con eso, un cliente que mandara `hallazgos: []` desactivaba de una
sola petición el suelo de derivación obligatoria y la detección de analitos fabricados. No hacía
falta un ataque: era el contrato documentado de la API, y bastaba un bundle cacheado de ayer con
umbrales viejos para que el servidor aplicara una seguridad distinta de la que creía aplicar.

Alcance DELIBERADAMENTE estrecho: rangos de referencia ajustados (edad, raza) y clasificación de
GRAVEDAD. Eso es lo que sostiene `_derivacion_obligatoria`. La detección de patrones —las 50 y
pico reglas de `detectarPatrones`— se queda en el cliente: enriquece el prompt, pero ninguna
decisión de seguridad cuelga de ella. Portar 1062 líneas habría creado una segunda
implementación del motor validado por veterinario, con divergencia silenciosa garantizada.

Las REGLAS clínicas (umbrales, cortes, factores de edad y raza) NO están aquí: viven en
`data/ajustes_clinicos.json`, que leen igual este motor y el del navegador. Aquí sólo queda la
lógica de aplicarlas, que es lo que casi nunca cambia. `tests/test_paridad_motor.py` ejecuta el
motor TS real y compara, para que una divergencia entre las dos implementaciones salte.
"""

from __future__ import annotations

import json
import math
from functools import lru_cache
from pathlib import Path

from ..config import RAIZ_REPO
from ..schemas import Direccion, Gravedad, HallazgoEntrada, PacienteEntrada

ESPECIES = ("canino", "felino")


@lru_cache
def cargar_ajustes() -> dict:
    """Reglas clínicas compartidas con el motor del navegador (`data/ajustes_clinicos.json`).

    Antes estaban duplicadas como constantes aquí y en `analisis.ts`. Lo que se desincroniza no
    es la lógica de comparar —eso no cambia casi nunca— sino los umbrales y factores, que son
    justo lo que un veterinario querría ajustar. Ahora los dos motores leen el mismo fichero.
    """
    ruta = Path(RAIZ_REPO) / "data" / "ajustes_clinicos.json"
    return json.loads(ruta.read_text(encoding="utf-8"))


@lru_cache
def cargar_referencias() -> dict:
    ruta = Path(RAIZ_REPO) / "data" / "valores_referencia.json"
    return json.loads(ruta.read_text(encoding="utf-8"))


# Orden de las categorías: la primera cuyo límite no se supera es la que aplica; si se superan
# todas, la última. Los límites vienen del JSON compartido.
_ORDEN_EDAD = {
    "canino": ("cachorro", "adulto", "senior", "geriatrico"),
    "felino": ("cachorro", "adulto", "senior"),
}


def categorizar_edad(edad_meses: float | None, especie: str) -> str:
    if edad_meses is None:
        return "adulto"
    limites = cargar_ajustes().get("limites_edad_meses", {}).get(especie, {})
    for categoria in _ORDEN_EDAD.get(especie, ())[:-1]:
        if edad_meses < limites.get(categoria, float("inf")):
            return categoria
    return _ORDEN_EDAD.get(especie, ("adulto",))[-1]


def ajustes_de_raza(raza: str | None, especie: str) -> dict[str, dict[str, float]]:
    norm = (raza or "").lower().strip()
    combinados: dict[str, dict[str, float]] = {}
    grupos = cargar_ajustes().get("ajustes_raza", {}).get(especie, [])
    for grupo in grupos:
        if not any(r in norm for r in grupo.get("razas", [])):
            continue
        for clave, factor in grupo.get("ajustes", {}).items():
            acumulado = combinados.setdefault(clave, {"inferior": 1.0, "superior": 1.0})
            acumulado["inferior"] *= factor.get("inferior", 1.0)
            acumulado["superior"] *= factor.get("superior", 1.0)
    return combinados


def ajustar_referencias(paciente: PacienteEntrada) -> dict[str, dict]:
    """Rangos de la especie con los factores de edad, raza y sexo aplicados."""
    especie = (paciente.especie or "").lower()
    refs = cargar_referencias().get(especie, {})
    ajustes = cargar_ajustes()
    categoria = categorizar_edad(paciente.edad_meses, especie)
    aj_edad = ajustes.get("ajustes_edad", {}).get(especie, {}).get(categoria, {})
    aj_raza = ajustes_de_raza(paciente.raza, especie)
    aj_sexo = ajustes.get("ajustes_sexo", {}).get(especie, {}).get(paciente.sexo or "", {})

    ajustadas: dict[str, dict] = {}
    for clave, ref in refs.items():
        f_edad, f_raza, f_sexo = (
            aj_edad.get(clave, {}), aj_raza.get(clave, {}), aj_sexo.get(clave, {})
        )
        ajustadas[clave] = {
            **ref,
            "inferior": ref["inferior"]
            * f_edad.get("inferior", 1.0) * f_raza.get("inferior", 1.0) * f_sexo.get("inferior", 1.0),
            "superior": ref["superior"]
            * f_edad.get("superior", 1.0) * f_raza.get("superior", 1.0) * f_sexo.get("superior", 1.0),
        }
    return ajustadas


def clasificar_gravedad(valor: float, ref: dict, clave: str, especie: str) -> Gravedad:
    ajustes = cargar_ajustes()

    cortes_bajo = ajustes.get("cortes_gravedad_bajo", {}).get(clave, {}).get(especie)
    if cortes_bajo and valor < ref["inferior"]:
        # `moderado_hasta: null` = ese analito nunca llega a 'grave' por este lado.
        tope = cortes_bajo.get("moderado_hasta")
        if tope is not None and valor < tope:
            return Gravedad.grave
        if valor < cortes_bajo["leve_hasta"]:
            return Gravedad.moderado
        return Gravedad.leve

    cortes_alto = ajustes.get("cortes_gravedad_alto", {}).get(clave, {}).get(especie)
    if cortes_alto and valor > ref["superior"]:
        tope = cortes_alto.get("moderado_hasta")
        if tope is not None and valor > tope:
            return Gravedad.grave
        if valor > cortes_alto["leve_hasta"]:
            return Gravedad.moderado
        return Gravedad.leve

    rango = ref["superior"] - ref["inferior"]
    if rango <= 0:
        return Gravedad.grave  # rango degenerado: no se puede medir la desviación, se es cauto
    desviacion = (
        (valor - ref["superior"]) / rango if valor > ref["superior"]
        else (ref["inferior"] - valor) / rango
    )
    umbrales = ajustes.get("umbrales_gravedad", {})
    if desviacion <= umbrales["leve"]:
        return Gravedad.leve
    if desviacion <= umbrales["moderado"]:
        return Gravedad.moderado
    return Gravedad.grave


def evaluar(valores: dict[str, float], paciente: PacienteEntrada) -> list[HallazgoEntrada]:
    """Hallazgos fuera de rango calculados AQUÍ, a partir de los valores crudos del paciente."""
    especie = (paciente.especie or "").lower()
    if especie not in ESPECIES:
        return []
    ajustadas = ajustar_referencias(paciente)

    hallazgos: list[HallazgoEntrada] = []
    for clave, ref in ajustadas.items():
        crudo = valores.get(clave)
        if crudo is None:
            continue
        try:
            valor = float(crudo)
        except (TypeError, ValueError):
            continue
        if math.isnan(valor):
            continue
        if valor > ref["superior"]:
            direccion = Direccion.alto
        elif valor < ref["inferior"]:
            direccion = Direccion.bajo
        else:
            continue
        hallazgos.append(
            HallazgoEntrada(
                clave=clave,
                nombre=ref.get("nombre", clave),
                valor=valor,
                unidad=ref.get("unidad", ""),
                direccion=direccion,
                gravedad=clasificar_gravedad(valor, ref, clave, especie),
            )
        )
    return hallazgos