morphos / backend /app /motor /gravedad.py
Jose Salazar
Las reglas clínicas del suelo de seguridad pasan a data/ajustes_clinicos.json
5b69117
Raw
History Blame Contribute Delete
7.78 kB
"""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