Spaces:
Sleeping
Sleeping
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
|