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