morphos / backend /tests /test_reintentos.py
Jose Salazar
Léxico clínico compartido y detección de analitos fabricados
7dba92c
Raw
History Blame Contribute Delete
14.8 kB
"""Política de reintentos del servicio de interpretación.
El reintento existe para una sola cosa: la salida malformada de medGemma (razonamiento filtrado
o bucle de repetición), donde volver a muestrear suele arreglarlo. Reintentar un 429 hace lo
contrario —gasta otra reserva de GPU del mismo pozo agotado— y reintentar un rechazo por
seguridad o una clave ausente no puede cambiar el resultado.
Estos tests cuentan LLAMADAS, que es lo que consume cuota.
"""
from __future__ import annotations
import pytest
from app.ai import service
from app.ai.base import ErrorModelo
from app.schemas import InterpretacionClinica, PeticionInterpretacion
PETICION = {
"paciente": {"especie": "canino"},
"hallazgos": [
{
"clave": "hct",
"nombre": "Hematocrito",
"valor": 22.0,
"unidad": "%",
"direccion": "bajo",
"gravedad": "grave",
}
],
"patrones": [{"nombre": "Anemia", "descripcion": "…", "gravedad": "grave"}],
"imagenes": [],
}
class ClienteFalso:
nombre = "medgemma-hf"
prosa = True
modelo = "hf-space"
def __init__(self, error: ErrorModelo | None, exito_en: int | None = None):
self.error = error
self.exito_en = exito_en
self.llamadas = 0
async def interpretar(self, *_a, **_k):
self.llamadas += 1
if self.exito_en is not None and self.llamadas >= self.exito_en:
return InterpretacionClinica(interpretacion="ok " * 20, requiere_derivacion=True)
raise self.error
@pytest.fixture
def sin_rag(monkeypatch):
"""Aísla del retriever: estos tests miden reintentos, no recuperación."""
for nombre in ("recuperar", "recuperar_multi"):
monkeypatch.setattr(service, nombre, lambda *_a, **_k: [])
async def _interpretar_con(cliente, monkeypatch):
monkeypatch.setattr(service, "_crear_cliente", lambda *_: cliente)
return await service.interpretar(PeticionInterpretacion.model_validate(PETICION))
async def test_saturado_no_se_reintenta(sin_rag, monkeypatch):
"""El caso que motiva todo: un 429 debe costar UNA llamada, no dos."""
err = ErrorModelo("cuota agotada", reintentable=False, saturado=True)
cliente = ClienteFalso(err)
with pytest.raises(ErrorModelo) as exc:
await _interpretar_con(cliente, monkeypatch)
assert cliente.llamadas == 1, "un 429 reintentado duplica el gasto de cuota"
assert exc.value.saturado is True
async def test_rechazo_de_seguridad_no_se_reintenta(sin_rag, monkeypatch):
cliente = ClienteFalso(ErrorModelo("rechazo", reintentable=False))
with pytest.raises(ErrorModelo):
await _interpretar_con(cliente, monkeypatch)
assert cliente.llamadas == 1
async def test_salida_malformada_si_se_reintenta(sin_rag, monkeypatch):
"""La razón de ser del reintento: se vuelve a muestrear y la segunda sale bien."""
cliente = ClienteFalso(ErrorModelo("razonamiento filtrado"), exito_en=2)
resultado = await _interpretar_con(cliente, monkeypatch)
assert cliente.llamadas == 2
assert resultado.resultado.interpretacion.startswith("ok")
async def test_el_reintento_no_es_infinito(sin_rag, monkeypatch):
cliente = ClienteFalso(ErrorModelo("siempre malformada"))
with pytest.raises(ErrorModelo):
await _interpretar_con(cliente, monkeypatch)
assert cliente.llamadas == 2
class ClienteProsa:
"""Ruta HF Space: devuelve prosa con marcadores [n], sin `diferenciales`."""
nombre = "medgemma-hf"
prosa = True
modelo = "hf-space"
def __init__(self, texto: str):
self.texto = texto
async def interpretar(self, *_a, **_k):
return InterpretacionClinica(interpretacion=self.texto, requiere_derivacion=True)
async def test_la_ruta_de_prosa_devuelve_fuentes_verificables(monkeypatch):
"""La ruta por defecto en producción no puede rellenar `citas[]`, así que su atribución
depende de que el servicio adjunte las fuentes recuperadas y resuelva los marcadores."""
from app.rag.retriever import Fragmento
fragmento = Fragmento(
texto="…", libro="Thrall Veterinary Hematology", edicion="3.ª ed.",
capitulo="Anemia", pagina="210", score=0.9,
)
for nombre in ("recuperar", "recuperar_multi"):
monkeypatch.setattr(service, nombre, lambda *_a, **_k: [fragmento])
cliente = ClienteProsa("Anemia arregenerativa compatible con proceso crónico [1]. " * 3)
resp = await _interpretar_con(cliente, monkeypatch)
assert [f.cita for f in resp.fuentes] == ["Thrall Veterinary Hematology, 3.ª ed., p. 210"]
assert resp.fuentes[0].citada
assert resp.fuentes_rag == 1
class ClienteTruncaConContexto:
"""Reproduce lo medido contra el Space: con literatura en el prompt la respuesta se corta
siempre; sin ella (o con poca) sale completa."""
nombre = "medgemma-hf"
prosa = True
modelo = "hf-space"
def __init__(self, umbral_fragmentos: int):
self.umbral = umbral_fragmentos
self.fragmentos_por_intento: list[int] = []
async def interpretar(self, _sistema, mensaje_usuario, _imagenes):
recibidos = mensaje_usuario.count("Literatura recuperada") and mensaje_usuario.count("] (")
self.fragmentos_por_intento.append(recibidos)
if recibidos > self.umbral:
raise ErrorModelo("cortada a mitad de frase", truncado=True)
return InterpretacionClinica(interpretacion="Interpretación completa. " * 5)
async def test_la_respuesta_truncada_se_reintenta_con_menos_literatura(monkeypatch):
from app.rag.retriever import Fragmento
fragmentos = [
Fragmento(texto=f"texto {i}", libro=f"Libro {i}", edicion="1.ª ed.",
capitulo="", pagina=str(i), score=1.0)
for i in range(6)
]
for nombre in ("recuperar", "recuperar_multi"):
monkeypatch.setattr(service, nombre, lambda *_a, **_k: fragmentos)
cliente = ClienteTruncaConContexto(umbral_fragmentos=3)
resp = await _interpretar_con(cliente, monkeypatch)
assert cliente.fragmentos_por_intento == [6, 2] # recorta a un tercio y lo consigue
# Sólo se ofrecen como fuentes los fragmentos que el modelo llegó a ver.
assert resp.fuentes_rag == 2
assert len(resp.fuentes) == 2
class ClienteEstructuraVacia:
"""Reproduce lo medido con qwen2.5:7b: JSON válido con los campos estructurados vacíos."""
nombre = "medgemma"
prosa = False
modelo = "medgemma:test"
def __init__(self, llenar_en: int | None = None):
self.llenar_en = llenar_en
self.llamadas = 0
async def interpretar(self, *_a, **_k):
self.llamadas += 1
if self.llenar_en is not None and self.llamadas >= self.llenar_en:
return InterpretacionClinica(
interpretacion="Interpretación completa. " * 5,
hallazgos_clave=[{"analito": "hct", "direccion": "bajo", "gravedad": "grave"}],
diferenciales=[{"nombre": "Anemia", "probabilidad": "alta"}],
)
return InterpretacionClinica(interpretacion="Sólo prosa, sin estructura. " * 3)
async def test_estructura_vacia_en_ruta_estructurada_se_reintenta(sin_rag, monkeypatch):
cliente = ClienteEstructuraVacia(llenar_en=2)
resp = await _interpretar_con(cliente, monkeypatch)
assert cliente.llamadas == 2
assert resp.resultado.diferenciales
async def test_estructura_vacia_persistente_es_error_tipado(sin_rag, monkeypatch):
cliente = ClienteEstructuraVacia()
with pytest.raises(ErrorModelo):
await _interpretar_con(cliente, monkeypatch)
assert cliente.llamadas == 2
async def test_la_ruta_de_prosa_no_exige_campos_estructurados(sin_rag, monkeypatch):
"""El HF Space no puede rellenarlos: exigírselos lo dejaría siempre en error."""
cliente = ClienteProsa("Interpretación en prosa suficientemente larga. " * 4)
resp = await _interpretar_con(cliente, monkeypatch)
assert resp.resultado.diferenciales == []
class ClienteSinDerivacion:
nombre = "medgemma"
prosa = False
modelo = "medgemma:test"
async def interpretar(self, *_a, **_k):
return InterpretacionClinica(
interpretacion="El paciente está estable. " * 5,
hallazgos_clave=[{"analito": "creat", "direccion": "alto", "gravedad": "grave"}],
diferenciales=[{"nombre": "ERC", "probabilidad": "alta"}],
requiere_derivacion=False,
)
async def test_la_derivacion_no_la_decide_el_modelo(sin_rag, monkeypatch):
"""Con un hallazgo GRAVE del motor, se deriva aunque el modelo diga que no: es la marca de
seguridad que un 7B general falló en una ERC felina avanzada."""
resp = await _interpretar_con(ClienteSinDerivacion(), monkeypatch)
assert resp.resultado.requiere_derivacion is True
class ClienteProsaSimple:
nombre = "medgemma-hf"
prosa = True
modelo = "hf-space"
async def interpretar(self, *_a, **_k):
# Igual que el cliente del Space: sólo prosa, con el default del esquema.
return InterpretacionClinica(interpretacion="Interpretación en prosa. " * 5)
async def test_prosa_sin_alteraciones_no_deriva(sin_rag, monkeypatch):
"""El default del esquema hacía que un panel normal pidiera derivación contradiciendo su
propio texto; el juez lo penalizó como incoherencia (2026-07-31, `normal-canino`)."""
monkeypatch.setattr(service, "_crear_cliente", lambda *_: ClienteProsaSimple())
pet = PeticionInterpretacion(paciente={"especie": "canino"}, hallazgos=[], patrones=[])
resp = await service.interpretar(pet)
assert resp.resultado.requiere_derivacion is False
async def test_prosa_con_alteraciones_sigue_derivando(sin_rag, monkeypatch):
monkeypatch.setattr(service, "_crear_cliente", lambda *_: ClienteProsaSimple())
resp = await service.interpretar(PeticionInterpretacion.model_validate(PETICION))
assert resp.resultado.requiere_derivacion is True
async def test_la_ruta_estructurada_conserva_la_opinion_del_modelo(sin_rag, monkeypatch):
"""La corrección es sólo para quien no puede rellenar el campo: si el modelo SÍ opina, se
respeta (y el suelo de `_derivacion_obligatoria` sigue por encima)."""
class ClienteEstructurado:
nombre = "medgemma"
prosa = False
modelo = "medgemma:test"
async def interpretar(self, *_a, **_k):
return InterpretacionClinica(
interpretacion="Panel sin alteraciones. " * 5,
hallazgos_clave=[],
diferenciales=[],
requiere_derivacion=True,
)
monkeypatch.setattr(service, "_crear_cliente", lambda *_: ClienteEstructurado())
pet = PeticionInterpretacion(paciente={"especie": "canino"}, hallazgos=[], patrones=[])
resp = await service.interpretar(pet)
assert resp.resultado.requiere_derivacion is True
# --- Reintento correctivo ante infracción de seguridad (2026-08-04) ---
#
# Antes, una pauta terapéutica detectada se remediaba anteponiendo el encuadre de
# `prescripcion.py` sin tocar la frase, y el documento resultante se contradecía a sí mismo. El
# juez lo penalizó por ello: `hipotiroidismo-canino` 0.35 y `diabetes-felino-fructosamina` 0.50
# en seguridad, los dos CON la nota de alcance ya puesta. Ahora se regenera; el encuadre queda
# como último recurso.
from app.ai.prescripcion import ENCUADRE # noqa: E402
# Frase real de `diabetes-felino-fructosamina`.
PRESCRIPTIVA = (
"El paciente presenta hiperglucemia marcada. Se recomienda iniciar un tratamiento con "
"insulina para confirmar el diagnóstico de diabetes mellitus. "
)
LIMPIA = (
"El paciente presenta hiperglucemia marcada compatible con diabetes mellitus. "
"Procede confirmar con fructosamina y descartar causas de hiperglucemia por estrés. "
)
class ClienteSegunIntento:
"""Devuelve un texto distinto en cada llamada, para medir qué se hace con el primero."""
nombre = "medgemma-hf"
prosa = True
modelo = "hf-space"
def __init__(self, *textos: str):
self.textos = textos
self.mensajes: list[str] = []
async def interpretar(self, _sistema, mensaje, *_a, **_k):
self.mensajes.append(mensaje)
texto = self.textos[min(len(self.mensajes), len(self.textos)) - 1]
return InterpretacionClinica(interpretacion=texto, requiere_derivacion=True)
async def test_la_prescripcion_se_regenera_en_vez_de_encuadrarse(sin_rag, monkeypatch):
cliente = ClienteSegunIntento(PRESCRIPTIVA, LIMPIA)
resp = await _interpretar_con(cliente, monkeypatch)
assert len(cliente.mensajes) == 2, "la infracción debe costar un reintento"
assert ENCUADRE not in resp.resultado.interpretacion, "regenerar, no parchear"
assert resp.resultado.interpretacion.startswith("El paciente presenta hiperglucemia marcada c")
async def test_el_segundo_mensaje_nombra_lo_que_hay_que_quitar(sin_rag, monkeypatch):
"""La generación del Space es voraz: repetir el mismo prompt devuelve la misma respuesta."""
cliente = ClienteSegunIntento(PRESCRIPTIVA, LIMPIA)
await _interpretar_con(cliente, monkeypatch)
correccion = cliente.mensajes[1]
assert "CORRECCIÓN OBLIGATORIA" in correccion
assert "insulina" in correccion
assert correccion != cliente.mensajes[0]
async def test_si_el_reintento_vuelve_a_infringir_se_encuadra_como_antes(sin_rag, monkeypatch):
"""No-regresión: el comportamiento nunca queda peor que el que había."""
cliente = ClienteSegunIntento(PRESCRIPTIVA, PRESCRIPTIVA)
resp = await _interpretar_con(cliente, monkeypatch)
assert len(cliente.mensajes) == 2
assert resp.resultado.interpretacion.startswith(ENCUADRE)
assert resp.resultado.requiere_derivacion is True
async def test_una_salida_limpia_no_gasta_reintento(sin_rag, monkeypatch):
cliente = ClienteSegunIntento(LIMPIA)
resp = await _interpretar_con(cliente, monkeypatch)
assert len(cliente.mensajes) == 1
assert ENCUADRE not in resp.resultado.interpretacion
async def test_el_analito_inventado_tambien_dispara_el_reintento(sin_rag, monkeypatch):
"""El otro modo de fallo del 2026-08-04, sobre un panel sin hemograma."""
inventada = "La leucograma muestra neutrofilia y linfopenia marcadas. "
cliente = ClienteSegunIntento(inventada, LIMPIA)
monkeypatch.setattr(service, "_crear_cliente", lambda *_: cliente)
pet = PeticionInterpretacion.model_validate(
PETICION | {"analitos_medidos": ["hct", "creat", "gluc"]}
)
resp = await service.interpretar(pet)
assert len(cliente.mensajes) == 2
assert "Neutrófilos" in cliente.mensajes[1]
assert resp.resultado.interpretacion.startswith("El paciente presenta hiperglucemia")