File size: 21,733 Bytes
70e641d
 
 
 
 
 
 
 
 
e26316b
70e641d
 
 
c205c6b
bf9f7d1
 
 
 
 
 
 
 
 
c205c6b
bf9f7d1
 
 
 
 
70e641d
1e215c9
7dba92c
 
 
 
 
bf9f7d1
 
 
 
 
 
 
70e641d
 
 
 
bf9f7d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c205c6b
 
bf9f7d1
c205c6b
bf9f7d1
 
 
 
c205c6b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf9f7d1
c205c6b
bf9f7d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c205c6b
bf9f7d1
 
6ab7946
70e641d
 
 
 
 
 
6ab7946
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70e641d
 
 
 
 
 
 
 
 
7dba92c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70e641d
 
 
 
bf9f7d1
 
 
 
 
 
 
 
 
 
 
70e641d
c205c6b
 
 
 
 
 
bf9f7d1
 
 
 
 
 
 
e26316b
 
 
 
 
bf9f7d1
e26316b
 
bf9f7d1
 
 
 
e26316b
 
bf9f7d1
 
 
70e641d
bf9f7d1
 
6ab7946
 
bf9f7d1
 
 
 
 
 
 
 
 
 
 
70e641d
 
7dba92c
70e641d
7dba92c
70e641d
 
bf9f7d1
 
 
 
 
 
 
7dba92c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70e641d
 
 
a5d73ff
 
 
 
 
bf9f7d1
 
 
 
 
 
 
 
 
 
 
70e641d
 
 
 
 
1e215c9
 
 
bf9f7d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7dba92c
 
bf9f7d1
7dba92c
 
 
 
bf9f7d1
 
 
7dba92c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf9f7d1
 
 
 
c205c6b
bf9f7d1
 
1e215c9
6ab7946
 
 
 
70e641d
 
 
 
bf9f7d1
1e215c9
70e641d
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
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
"""Orquestación de la interpretación clínica.

Flujo: petición → recuperación RAG (si hay índice) → construcción de prompt endurecido
→ llamada al modelo elegido (medGemma/Claude) con salida estructurada → validación.
Un reintento ante fallo de validación; si persiste, error tipado (nunca texto crudo).
"""

from __future__ import annotations

import asyncio
import logging

from ..config import obtener_config
from ..motor.gravedad import evaluar
from ..rag.retriever import (
    Fragmento,
    construir_consulta,
    construir_consultas,
    recuperar,
    recuperar_multi,
)
from ..schemas import (
    Gravedad,
    HallazgoEntrada,
    InterpretacionClinica,
    PeticionInterpretacion,
    RespuestaInterpretacion,
)
from .alcance import motivo_fuera_de_alcance, respuesta_fuera_de_alcance
from .base import ClienteModelo, ErrorModelo
from .citas import aplicar_atribucion
from .coherencia import (
    alterados_declarados_normales,
    analitos_fabricados_en_prosa,
    descartar_fabricados,
)
from .prescripcion import detectar_prescripcion, encuadrar
from .prompt import (
    LARGO_FRAGMENTO_PROMPT,
    SISTEMA,
    SISTEMA_PROSA,
    construir_mensaje_usuario,
)

log = logging.getLogger("morphos.ia")


def recortar_a_presupuesto(fragmentos: list[Fragmento], max_chars: int) -> list[Fragmento]:
    """Los fragmentos mejor rankeados que caben en `max_chars` de literatura.

    Recortar en número de fragmentos completos, y no a media frase, mantiene cada cita
    verificable: un fragmento partido puede cambiar de sentido. Siempre se deja al menos uno
    —mejor poca literatura que ninguna— aunque exceda el presupuesto.
    """
    if max_chars <= 0:
        return fragmentos
    acumulado = 0
    cabidos: list[Fragmento] = []
    for fragmento in fragmentos:
        coste = len(fragmento.texto[:LARGO_FRAGMENTO_PROMPT])
        if cabidos and acumulado + coste > max_chars:
            break
        cabidos.append(fragmento)
        acumulado += coste
    return cabidos


def estructura_insuficiente(
    resultado: InterpretacionClinica, pet: PeticionInterpretacion
) -> str | None:
    """Nombre del campo que el modelo dejó vacío debiendo rellenarlo, o None.

    No se puede exigir a ciegas: en un panel normal es CORRECTO no listar hallazgos ni
    diferenciales. Lo que no vale es que un caso con alteraciones detectadas por el motor se
    resuelva con prosa y los campos estructurados en blanco, que es lo que hacía qwen2.5:7b.
    """
    hallazgos = hallazgos_efectivos(pet)
    if hallazgos and not resultado.hallazgos_clave:
        return "hallazgos_clave"
    if (hallazgos or pet.patrones) and not resultado.diferenciales:
        return "diferenciales"
    return None


def con_verdad_del_servidor(pet: PeticionInterpretacion) -> PeticionInterpretacion:
    """Petición con los hallazgos y los analitos medidos RECALCULADOS de los valores crudos.

    Se normaliza una vez, al entrar, para que todo lo de después —prompt, recuperación,
    detección de analitos fabricados— trabaje sobre datos del servidor sin que cada sitio tenga
    que acordarse. Importa que el PROMPT también use estos: si el navegador pudiera declarar
    hallazgos libremente, tendría un canal directo para meter texto en el prompt y para dirigir
    la recuperación.

    `analitos_medidos` pasa a ser exactamente lo que trae `valores`: como controla qué analitos
    se consideran "no fabricados" (`coherencia.py`), una lista inflada por el cliente relajaba
    esa comprobación.

    Sin `valores` no se toca nada: cliente antiguo o eval que construye la petición a mano.
    """
    if not pet.valores:
        return pet
    return pet.model_copy(
        update={
            "hallazgos": evaluar(pet.valores, pet.paciente),
            "analitos_medidos": sorted(pet.valores),
        }
    )


def hallazgos_efectivos(pet: PeticionInterpretacion) -> list[HallazgoEntrada]:
    """Hallazgos sobre los que trabaja el servicio: los del SERVIDOR si hay valores crudos.

    Con `valores` presentes, el veredicto del navegador se ignora para esto: es el punto entero
    de §1.1. Sin ellos (cliente antiguo, o una eval que construye la petición a mano) se cae a
    los del cliente, que es el comportamiento anterior.
    """
    if not pet.valores:
        return list(pet.hallazgos)
    return evaluar(pet.valores, pet.paciente)


def _derivacion_obligatoria(
    pet: PeticionInterpretacion, original: PeticionInterpretacion | None = None
) -> bool:
    """True si algo es grave, sea cual sea la opinión del modelo.

    Se toma la UNIÓN de lo que ve el servidor y lo que afirma el cliente: así una pista del
    navegador sólo puede ENDURECER el suelo, nunca relajarlo. Omitir `gravedad: grave` en la
    petición —que antes desactivaba la derivación obligatoria— ya no cambia nada, porque el
    servidor la recalcula de los valores crudos; y si el cliente marca grave algo que el
    servidor no ve (un patrón, que no tiene equivalente servidor), se deriva igualmente.
    """
    afirmado = original or pet
    del_servidor = any(h.gravedad == Gravedad.grave for h in hallazgos_efectivos(pet))
    del_cliente = any(h.gravedad == Gravedad.grave for h in afirmado.hallazgos) or any(
        p.gravedad == Gravedad.grave for p in afirmado.patrones
    )
    return del_servidor or del_cliente


def _derivacion_en_ruta_de_prosa(pet: PeticionInterpretacion) -> bool:
    """Valor de `requiere_derivacion` para los backends que sólo devuelven prosa.

    Ahí el campo no lo escribe el modelo —el cliente construye el objeto con el default del
    esquema—, así que era CONSTANTE a true. Medido el 2026-07-31: en `normal-canino` eso
    contradecía al propio texto («los valores se encuentran dentro de los límites de
    referencia») y el juez lo penalizó como incoherencia con riesgo de alarma injustificada
    (seguridad 0.50). Peor aún, `acierto_derivacion` estaba midiendo este default.

    Criterio: si el motor determinista no marcó NADA —ni un hallazgo fuera de rango ni un
    patrón—, no hay nada que derivar. En cuanto hay algo, se deriva: en una herramienta de
    apoyo diagnóstico, el lado conservador es el que pide ojos de veterinario. El suelo de
    `_derivacion_obligatoria` sigue por encima para los casos graves.
    """
    return bool(hallazgos_efectivos(pet) or pet.patrones)


def _crear_cliente(backend: str, modelo_local: str | None = None) -> ClienteModelo:
    if backend == "claude":
        from .claude import ClaudeClient

        return ClaudeClient()

    cfg = obtener_config()

    # Modelo local elegido por el usuario. El nombre ya viene validado contra la lista blanca
    # por `PeticionInterpretacion`; aquí se revalida porque este camino también lo usan las
    # evals, que pueden construir la petición de otras formas. Tiene prioridad sobre el Space:
    # elegir un modelo en la UI y que el servidor llamara igualmente al Space sería mentirle al
    # usuario. Todo lo demás —RAG, prompt endurecido, atribución y suelos de seguridad— es
    # idéntico, porque vive en este servicio y no en el cliente.
    if modelo_local:
        permitidos = cfg.modelos_locales_permitidos()
        if modelo_local not in permitidos:
            raise ErrorModelo(
                f"Modelo local no permitido: {modelo_local!r}.", reintentable=False
            )
        from .medgemma import MedGemmaClient

        return MedGemmaClient(modelo_local, prosa=permitidos[modelo_local])

    # Ruta 'medgemma' por defecto: el HF Space (donde vive medGemma); si no hay Space
    # configurado, cae a Ollama local.
    if cfg.hf_space_url:
        from .hf_space import HFSpaceClient

        return HFSpaceClient()
    from .medgemma import MedGemmaClient

    return MedGemmaClient()


def detectar_infracciones(
    resultado: InterpretacionClinica, pet: PeticionInterpretacion
) -> tuple[list[str], list[str], list[str]]:
    """(frases prescriptivas, analitos inventados, alterados dados por normales) de la prosa.

    Las tres listas vacías = salida limpia.
    """
    return (
        detectar_prescripcion(resultado.interpretacion),
        analitos_fabricados_en_prosa(resultado.interpretacion, pet),
        alterados_declarados_normales(resultado.interpretacion, pet),
    )


def instruccion_correctiva(
    prescripciones: list[str], fabricados: list[str], normalizados: list[str]
) -> str:
    """Addendum al mensaje del segundo intento, nombrando lo que hay que quitar.

    Nombrar la infracción concreta no es cosmético: la generación del Space es voraz (greedy),
    así que repetir el MISMO prompt devuelve la MISMA respuesta y gasta otra reserva de GPU
    para nada. Cambiar la entrada es lo único que cambia la salida — mismo razonamiento que la
    rama de `exc.truncado`, que recorta la literatura por este motivo.
    """
    lineas = ["\n\nCORRECCIÓN OBLIGATORIA de tu respuesta anterior:"]
    if prescripciones:
        lineas.append(
            "- Indicaste tratamiento, y no debes: "
            + "; ".join(f"«{f}»" for f in prescripciones[:3])
            + ". Reescribe esa parte como interpretación y, si procede, como prueba "
            "diagnóstica a solicitar. El plan terapéutico es del veterinario presencial."
        )
    if fabricados:
        lineas.append(
            "- Afirmaste hallazgos sobre analitos que NO se han medido en este paciente: "
            + ", ".join(fabricados[:5])
            + ". Elimina esas afirmaciones. Puedes pedir esas pruebas, pero no dar por hecho "
            "su resultado."
        )
    if normalizados:
        lineas.append(
            "- Diste por normales analitos que SÍ están fuera de rango en este paciente: "
            + ", ".join(normalizados[:5])
            + ". Corrige esa lectura: sus valores están en los hallazgos que se te entregaron."
        )
    return "\n".join(lineas)


async def interpretar(pet: PeticionInterpretacion) -> RespuestaInterpretacion:
    cfg = obtener_config()
    backend = pet.backend or cfg.ia_backend_defecto

    # 0) Guarda de alcance: determinista y ANTES de gastar una llamada al modelo. Mismo patrón
    # que `_derivacion_obligatoria`, por el mismo motivo: los tres modelos evaluados el
    # 2026-07-28 fallaron en abierto ante un paciente humano y fabricaron clínica sobre él.
    if (motivo := motivo_fuera_de_alcance(pet)) is not None:
        log.warning("Petición fuera de alcance (%s); no se llama al modelo.", motivo)
        return RespuestaInterpretacion(
            resultado=respuesta_fuera_de_alcance(motivo),
            modelo="guarda:alcance",
            fuentes_rag=0,
            fuentes=[],
        )

    # 0.5) A partir de aquí el servicio trabaja con SUS hallazgos, no con los del navegador.
    # Se guarda la petición original: lo que afirmó el cliente sólo sirve para ENDURECER el
    # suelo de derivación, nunca para relajarlo.
    peticion_del_cliente = pet
    pet = con_verdad_del_servidor(pet)

    # 1) Recuperación RAG basada en los patrones/hallazgos del paciente (degrada a []).
    # Con `rag_multiconsulta`, una consulta por patrón fusionadas con RRF en vez de una sola
    # cadena concatenada; OFF por defecto hasta que un juez LLM confirme la mejora (ver
    # config.py: el juez heurístico disponible la puntúa peor y está sesgado a favor de la
    # consulta concatenada).
    nombres_patrones = [p.nombre for p in pet.patrones]
    nombres_hallazgos = [h.nombre for h in pet.hallazgos]
    # `asyncio.to_thread` y no llamada directa: la recuperación es SÍNCRONA y cara —embedding
    # bge-m3, búsqueda en LanceDB y cross-encoder bge-reranker-v2-m3 sobre hasta
    # `rag_candidatos` filas—, del orden de segundos en cpu-basic. Ejecutada en el bucle de
    # eventos dejaba el proceso entero sordo mientras durase: ni health check, ni el login de
    # otro veterinario, ni una ingesta del puente. La concurrencia efectiva era 1.
    if cfg.rag_multiconsulta:
        fragmentos = await asyncio.to_thread(
            recuperar_multi,
            construir_consultas(nombres_patrones, nombres_hallazgos),
            especie=pet.paciente.especie,
        )
    else:
        fragmentos = await asyncio.to_thread(
            recuperar,
            construir_consulta(nombres_patrones, nombres_hallazgos),
            especie=pet.paciente.especie,
        )

    # 2-3) Prompt endurecido con contexto recuperado y llamada al modelo, con un reintento
    # ante salida malformada. El HF Space devuelve texto libre → system prompt de prosa.
    cliente = _crear_cliente(backend, pet.modelo_local)
    es_prosa = cliente.prosa
    sistema = SISTEMA_PROSA if es_prosa else SISTEMA
    # Los fragmentos ENVIADOS pueden ser menos que los recuperados —por presupuesto de prompt
    # aquí, o por el recorte del reintento más abajo— y son ésos, no los recuperados, los que
    # el modelo puede citar. Sólo la ruta de prosa paga el razonamiento descartado del modelo
    # con el mismo presupuesto que la respuesta; ver rag_max_chars_prompt en config.py.
    enviados = recortar_a_presupuesto(fragmentos, cfg.rag_max_chars_prompt) if es_prosa else fragmentos
    if len(enviados) < len(fragmentos):
        log.info(
            "Literatura recortada a %d de %d fragmentos por presupuesto de prompt.",
            len(enviados), len(fragmentos),
        )
    resultado: InterpretacionClinica | None = None
    ultimo_error: ErrorModelo | None = None
    correccion = ""
    for intento in range(2):
        mensaje = construir_mensaje_usuario(pet, enviados) + correccion
        try:
            resultado = await cliente.interpretar(sistema, mensaje, pet.imagenes)
            # La ruta de prosa no puede rellenar los campos estructurados; el resto sí, y un
            # JSON válido con los diferenciales vacíos deja al veterinario sin lo que vino a
            # buscar. Se trata como salida malformada: se vuelve a muestrear.
            vacio = None if es_prosa else estructura_insuficiente(resultado, pet)
            if vacio:
                resultado = None
                raise ErrorModelo(f"El modelo dejó '{vacio}' vacío pese a haber hallazgos.")
            # Infracciones de seguridad en la PROSA: se regenera en vez de parchear. Medido el
            # 2026-08-04, la remediación anterior —anteponer el encuadre de `prescripcion.py`
            # sin tocar la frase— dejaba un documento que se contradice a sí mismo, y 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. Los pasos 4.6/4.7 de
            # abajo siguen actuando como último recurso si el reintento vuelve a infringir, así
            # que esto nunca deja la salida peor que antes.
            if intento == 0:
                prescripciones, fabricados, normalizados = detectar_infracciones(resultado, pet)
                if prescripciones or fabricados or normalizados:
                    log.warning(
                        "Infracciones en la salida (prescripción: %s; inventados: %s; "
                        "dados por normales: %s); se regenera con corrección.",
                        "; ".join(prescripciones[:3]) or "ninguna",
                        ", ".join(fabricados[:5]) or "ninguno",
                        ", ".join(normalizados[:5]) or "ninguno",
                    )
                    correccion = instruccion_correctiva(prescripciones, fabricados, normalizados)
                    resultado = None
                    raise ErrorModelo("Salida con infracciones de seguridad; se vuelve a muestrear.")
            break
        except ErrorModelo as exc:
            ultimo_error = exc
            if not exc.reintentable:
                # 429/cuota, rechazo por seguridad o configuración ausente: reintentar no puede
                # ayudar y, en el caso de la cuota, gasta otra reserva de GPU del pozo agotado.
                log.warning("Interpretación fallida sin reintento: %s", exc)
                break
            if exc.truncado and enviados:
                # El Space reparte un único presupuesto de 2048 tokens entre el razonamiento
                # que descarta y la respuesta. La generación es voraz (greedy), así que repetir
                # el mismo prompt devuelve el mismo recorte y gasta otra reserva de GPU para
                # nada: hay que cambiar la entrada. Menos literatura acorta el razonamiento y
                # deja más presupuesto para la respuesta.
                enviados = enviados[: max(1, len(enviados) // 3)]
                log.warning(
                    "Respuesta truncada; se reintenta con %d fragmento(s) de literatura.",
                    len(enviados),
                )
            log.warning("Interpretación fallida (intento %d): %s", intento + 1, exc)

    if resultado is None:
        raise ultimo_error or ErrorModelo("Fallo desconocido de interpretación.")

    # 4) Atribución: las fuentes salen de la recuperación, no del modelo, y las citas que no
    # se resuelven contra un fragmento real se descartan. Es lo que da citas verificables
    # también en la ruta de prosa del HF Space, que no puede rellenar `citas[]`.
    resultado, fuentes = aplicar_atribucion(resultado, enviados)

    # 4.5) En la ruta de prosa el modelo no ha podido opinar sobre `requiere_derivacion`: lo
    # decide el motor determinista en su lugar. Antes quedaba el default del esquema (true).
    # El suelo es de TODAS las rutas, no sólo de la prosa. La ruta estructurada devolvía la
    # decisión al modelo y medGemma la usó mal: el 2026-08-01, con salida estructurada,
    # `acierto_derivacion` cayó de 1.00 a 0.75 y aparecieron 4 violaciones de seguridad del
    # juez, todas en casos donde el modelo puso `false` sin haber ningún hallazgo `grave` que
    # disparara `_derivacion_obligatoria` — anemia moderada con melena, hipertiroidismo felino
    # geriátrico y panhipoproteinemia con ascitis. Si el motor determinista vio algo, se deriva.
    if _derivacion_en_ruta_de_prosa(pet):
        resultado.requiere_derivacion = True
    elif es_prosa:
        # Panel sin alteraciones: en prosa el modelo no puede opinar, así que se dice que no.
        resultado.requiere_derivacion = False

    # 4.6) Coherencia: un hallazgo estructurado sobre un analito que nadie envió es una
    # invención con formato de dato. Se descarta antes de que llegue a la tarjeta clínica.
    resultado = descartar_fabricados(resultado, pet)

    # 4.7) Guarda de prescripción. El modelo no debe indicar tratamientos; si aun así lo hace,
    # no se le borra el texto (mutilar prosa clínica es peor) sino que se antepone el encuadre
    # que faltaba y se fuerza la derivación. Medido el 2026-07-31: sin esto, una recomendación
    # de insulina en un paciente hipopotasémico pasó como buena.
    # Sólo se llega aquí con lenguaje prescriptivo si el reintento correctivo de arriba tampoco
    # lo resolvió (o si no lo hubo, porque la infracción apareció en el segundo intento).
    if (frases := detectar_prescripcion(resultado.interpretacion)):
        log.warning(
            "Lenguaje prescriptivo que sobrevive al reintento (%s); se encuadra.",
            "; ".join(frases[:3]),
        )
        resultado.interpretacion = encuadrar(resultado.interpretacion)
        resultado.requiere_derivacion = True

    # Mismo caso para los analitos inventados en prosa: aquí no se puede borrar sin mutilar la
    # frase, así que sólo queda dejar constancia y derivar. Se registra para poder medir en
    # producción cuántas infracciones sobreviven al reintento; si son muchas, el problema es el
    # modelo y no la guarda.
    if correccion and (restantes := analitos_fabricados_en_prosa(resultado.interpretacion, pet)):
        log.warning(
            "Analitos inventados que sobreviven al reintento: %s; se fuerza derivación.",
            ", ".join(restantes[:5]),
        )
        resultado.requiere_derivacion = True

    # Y para lo simétrico: dar por normal un valor patológico invita a no actuar, así que si
    # sobrevive al reintento se deriva. Mismo motivo que arriba para no tocar el texto.
    if correccion and (
        normalizados := alterados_declarados_normales(resultado.interpretacion, pet)
    ):
        log.warning(
            "Analitos alterados dados por normales pese al reintento: %s; se fuerza derivación.",
            ", ".join(normalizados[:5]),
        )
        resultado.requiere_derivacion = True

    # 5) Suelo de seguridad. `requiere_derivacion` es una marca clínica, no una opinión: si el
    # motor determinista ve algo grave, se deriva aunque el modelo diga que no. Medido: un 7B
    # general marcó `false` en una ERC felina avanzada (creat 4.8, BUN 68, isostenuria). Con
    # esto ese fallo es imposible por construcción, venga el modelo que venga.
    if _derivacion_obligatoria(pet, peticion_del_cliente) and not resultado.requiere_derivacion:
        log.warning("El modelo no marcó derivación con hallazgos graves; se fuerza.")
        resultado.requiere_derivacion = True

    # La etiqueta sale del propio cliente y no de la configuración: con un modelo elegido en la
    # UI, `cfg.medgemma_model` ya no es el que respondió, y la línea "Modelo:" de la tarjeta
    # clínica es lo único que le dice al veterinario con qué se generó lo que está leyendo.
    etiqueta = cliente.modelo

    return RespuestaInterpretacion(
        resultado=resultado,
        modelo=f"{cliente.nombre}:{etiqueta}",
        fuentes_rag=len(enviados),
        fuentes=fuentes,
    )