File size: 5,610 Bytes
70e641d
 
 
 
 
 
 
 
 
bf9f7d1
 
70e641d
 
 
bf9f7d1
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf9f7d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Regresi贸n de la limpieza y detecci贸n de salida defectuosa del HF Space (medGemma).

medGemma es un modelo con "pensamiento" que de forma intermitente filtra su cadena de
razonamiento en ingl茅s y/o entra en un bucle de repetici贸n sin llegar a la respuesta. Estas
pruebas fijan que esa salida se detecte (para forzar un reintento) y no llegue al usuario.
"""

from __future__ import annotations

import pytest

from app.ai.hf_space import (
    _cortar_bucle_lineas,
    interpretacion_defectuosa,
    interpretacion_truncada,
    limpiar_respuesta,
)

_SALIDA_CON_RAZONAMIENTO = """thought
Here's a thinking process to arrive at the clinical interpretation:
1. Understand the Goal: interpret canine labs.
Highly Recommended:
* Serum Chemistry (repeat): To monitor liver enzymes and bilirubin.
* Serum Chemistry (repeat): To monitor liver enzymes and bilirubin.
* Serum Chemistry (repeat): To monitor liver enzymes and bilirubin.
"""

_SALIDA_VALIDA = (
    "Los hallazgos muestran neutrofilia moderada y linfopenia leve, con enzimas hep谩ticas "
    "elevadas y bilirrubina alta que sugieren un patr贸n colest谩sico. Los diferenciales "
    "principales son hiperadrenocorticismo y hepatopat铆a. Se recomienda urian谩lisis, perfil "
    "bioqu铆mico y ecograf铆a abdominal para confirmar."
)


def test_detecta_razonamiento_filtrado():
    assert interpretacion_defectuosa(limpiar_respuesta(_SALIDA_CON_RAZONAMIENTO)) is True


def test_detecta_bucle_de_repeticion():
    bucle = "Introducci贸n v谩lida del caso.\n" + "\n".join(
        ["* Repetir esta recomendaci贸n diagn贸stica concreta."] * 5
    )
    assert interpretacion_defectuosa(bucle) is True


def test_detecta_salida_trivial():
    assert interpretacion_defectuosa("ok") is True


def test_interpretacion_valida_no_se_marca():
    assert interpretacion_defectuosa(limpiar_respuesta(_SALIDA_VALIDA)) is False


def test_corte_de_bucle_a_nivel_de_linea():
    texto = "Intro.\n" + "\n".join(["* Item largo repetido de prueba cl铆nica."] * 6) + "\nfinal"
    cortado = _cortar_bucle_lineas(texto)
    assert cortado.count("Item largo repetido") < 6


def test_limpieza_conserva_respuesta_valida():
    # La limpieza no debe destruir una respuesta correcta.
    assert "colest谩sico" in limpiar_respuesta(_SALIDA_VALIDA)


# El corte a mitad de frase es el 煤nico defecto que se ve completo: texto largo, sin
# repeticiones y sin razonamiento filtrado. Visto en producci贸n (colestasis-felino).
_SALIDA_TRUNCADA = (
    "El paciente presenta elevaci贸n de ALT, ALP y bilirrubina, compatible con un patr贸n "
    "mixto de da帽o hepatocelular y colestasis. Las principales diferencias diagn贸sticas "
    "a considerar incluyen la lip"
)


def test_detecta_respuesta_cortada_a_mitad_de_frase():
    assert interpretacion_truncada(_SALIDA_TRUNCADA) is True
    assert interpretacion_defectuosa(_SALIDA_TRUNCADA) is True


def test_respuesta_completa_no_se_marca_como_truncada():
    assert interpretacion_truncada(_SALIDA_VALIDA) is False


def test_cierre_con_adornos_markdown_no_es_truncamiento():
    assert interpretacion_truncada(_SALIDA_VALIDA + "**") is False
    assert interpretacion_truncada(_SALIDA_VALIDA[:-1] + '.")') is False


def test_lista_final_corta_sin_punto_no_es_truncamiento():
    """Falso positivo a evitar: una recomendaci贸n en vi帽eta no lleva punto y es v谩lida."""
    texto = _SALIDA_VALIDA + "\n- Ecograf铆a abdominal\n- Perfil de coagulaci贸n"
    assert interpretacion_truncada(texto) is False


# --- Modo estructurado (hf_space_estructurado) ---

def _cliente(monkeypatch, estructurado: bool):
    """Instancia el cliente con el flag puesto, invalidando la config cacheada."""
    from app.ai.hf_space import HFSpaceClient
    from app.config import obtener_config

    monkeypatch.setenv("MORPHOS_HF_SPACE_ESTRUCTURADO", "1" if estructurado else "0")
    obtener_config.cache_clear()
    try:
        return HFSpaceClient()
    finally:
        monkeypatch.undo()
        obtener_config.cache_clear()


def test_el_nombre_distingue_el_modo(monkeypatch):
    # El servicio decide por el nombre si aplica el prompt de prosa y si exige estructura.
    assert _cliente(monkeypatch, False).nombre == "medgemma-hf"
    assert _cliente(monkeypatch, True).nombre == "medgemma-hf-json"


def test_parsea_json_del_space(monkeypatch):
    import json as _json

    from app.ai.hf_space import HFSpaceClient

    payload = _json.dumps({
        "interpretacion": "Anemia microc铆tica hipocr贸mica compatible con ferropenia.",
        "hallazgos_clave": [{"analito": "hct", "direccion": "bajo", "gravedad": "moderado", "comentario": ""}],
        "diferenciales": [{"nombre": "ferropenia", "probabilidad": "alta", "evidencia": [], "citas": []}],
        "siguientes_pruebas": ["sangre oculta en heces"],
        "confianza": "media",
        "requiere_derivacion": True,
    })
    r = HFSpaceClient._parsear_estructurado(payload)
    assert r.diferenciales[0].nombre == "ferropenia"
    assert r.hallazgos_clave[0].analito == "hct"


def test_json_invalido_es_error_reintentable():
    from app.ai.base import ErrorModelo
    from app.ai.hf_space import HFSpaceClient

    with pytest.raises(ErrorModelo) as exc:
        HFSpaceClient._parsear_estructurado("{no es json")
    assert exc.value.reintentable


def test_json_fuera_de_esquema_es_error_reintentable():
    from app.ai.base import ErrorModelo
    from app.ai.hf_space import HFSpaceClient

    # `interpretacion` vac铆a viola el validador del esquema.
    with pytest.raises(ErrorModelo):
        HFSpaceClient._parsear_estructurado('{"interpretacion": ""}')