File size: 10,784 Bytes
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c205c6b
 
 
 
 
70e641d
 
 
c205c6b
 
 
 
70e641d
 
7dba92c
 
 
 
 
 
 
70e641d
 
 
6ab7946
 
 
 
70e641d
 
 
 
 
 
6ab7946
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf9f7d1
 
 
 
 
 
 
 
 
 
70e641d
 
 
 
 
 
 
 
 
 
bf9f7d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1e215c9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70e641d
 
 
 
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
"""Modelos Pydantic: petición de interpretación y salida clínica estructurada.

La salida estructurada es la corrección central del proyecto: en vez de texto libre
que había que limpiar con regex (limpiarRespuesta en ia.js), el modelo devuelve un
objeto validado. Si no valida, se reintenta o se devuelve un error tipado; nunca se
entrega texto sin parsear al cliente.
"""

from __future__ import annotations

from enum import StrEnum
from typing import Literal

from pydantic import BaseModel, Field, field_validator

# --- Entrada ---

class Direccion(StrEnum):
    alto = "alto"
    bajo = "bajo"


class Gravedad(StrEnum):
    leve = "leve"
    moderado = "moderado"
    grave = "grave"


class PacienteEntrada(BaseModel):
    especie: Literal["canino", "felino"] | None = None
    raza: str | None = None
    edad_meses: float | None = None
    sexo: str | None = None


class HallazgoEntrada(BaseModel):
    clave: str
    nombre: str
    valor: float
    unidad: str = ""
    direccion: Direccion
    gravedad: Gravedad


class PatronEntrada(BaseModel):
    nombre: str
    descripcion: str
    gravedad: Gravedad
    parametros: list[str] = Field(default_factory=list)


class PeticionInterpretacion(BaseModel):
    """Lo que el frontend envía a /api/interpret.

    `valores` son los datos crudos y son la fuente de verdad del servidor: con ellos recalcula
    los hallazgos y su gravedad. `hallazgos`/`patrones` siguen llegando del motor del navegador
    (`analisis.ts`), pero YA NO deciden por sí solos: valen como PISTA para el prompt y sólo
    pueden endurecer el suelo de seguridad, nunca relajarlo. `patrones` no tiene equivalente
    servidor —las 50 y pico reglas viven en el cliente— y por eso no gobierna nada crítico.
    """

    paciente: PacienteEntrada
    # Valores CRUDOS del panel ({clave: valor}). Es lo único a partir de lo cual el servidor
    # puede decidir por sí mismo: con ellos recalcula hallazgos y gravedad (`motor/gravedad.py`)
    # en vez de fiarse del veredicto del navegador.
    valores: dict[str, float] = Field(default_factory=dict, max_length=200)
    hallazgos: list[HallazgoEntrada] = Field(default_factory=list)
    patrones: list[PatronEntrada] = Field(default_factory=list)
    # Claves de TODOS los analitos que el usuario introdujo, alterados o no. `hallazgos` sólo
    # trae los que salieron fuera de rango, así que sin esto el modelo no puede distinguir «no
    # se midió» de «se midió y salió normal» y rellena el hueco: medido el 2026-08-04, medGemma
    # afirmó un leucograma con neutrofilia sobre un panel de calcio/fósforo/BUN/creatinina, y
    # llamó «trombocitopenia leve» a unas plaquetas de 190 que estaban en rango.
    # Vacío = cliente antiguo que no lo manda; el prompt omite el bloque y se comporta como antes.
    analitos_medidos: list[str] = Field(default_factory=list, max_length=200)
    signos_clinicos: str = Field(default="", max_length=2000)
    imagenes: list[str] = Field(default_factory=list)  # data URLs de citología
    backend: Literal["medgemma", "claude"] = "medgemma"
    # Modelo local elegido por el usuario en la UI. Sólo se acepta si está en la lista blanca
    # `MORPHOS_MODELOS_LOCALES`; si lo está, sustituye a la ruta que el servidor usaría por
    # defecto para 'medgemma' (Space u Ollama). None = decide el servidor, como siempre.
    modelo_local: str | None = Field(default=None, max_length=100)

    @field_validator("imagenes")
    @classmethod
    def _limitar_imagenes(cls, v: list[str]) -> list[str]:
        return v[:4]

    @field_validator("modelo_local")
    @classmethod
    def _validar_lista_blanca(cls, v: str | None) -> str | None:
        """Rechaza cualquier modelo fuera de la lista blanca.

        Se valida en el ESQUEMA y no en el router para que valga igual para las evals, que
        llaman a `interpretar()` sin pasar por HTTP, y para que FastAPI conteste 422 (error del
        cliente) en vez del 502 al que el router traduce los ErrorModelo.
        """
        if v is None:
            return None
        from .config import obtener_config

        permitidos = obtener_config().modelos_locales_permitidos()
        if v not in permitidos:
            disponibles = ", ".join(sorted(permitidos)) or "ninguno"
            raise ValueError(
                f"Modelo local no permitido: {v!r}. Configurados: {disponibles}."
            )
        return v


# --- Salida estructurada del modelo ---

class Diferencial(BaseModel):
    nombre: str = Field(description="Diagnóstico diferencial")
    probabilidad: Literal["alta", "media", "baja"]
    evidencia: list[str] = Field(
        default_factory=list,
        description="Hallazgos del paciente que apoyan este diferencial",
    )
    citas: list[str] = Field(
        default_factory=list,
        description="Referencias a la literatura recuperada (libro, edición, página)",
    )


class HallazgoClave(BaseModel):
    analito: str
    direccion: Direccion
    gravedad: Gravedad
    comentario: str = ""


class InterpretacionClinica(BaseModel):
    """Salida validada que se entrega al cliente. Reemplaza el texto libre + limpieza."""

    interpretacion: str = Field(description="Resumen clínico integrado, en español")
    hallazgos_clave: list[HallazgoClave] = Field(default_factory=list)
    diferenciales: list[Diferencial] = Field(default_factory=list)
    siguientes_pruebas: list[str] = Field(default_factory=list)
    confianza: Literal["alta", "media", "baja"] = "media"
    requiere_derivacion: bool = Field(
        default=True,
        description="Marca de seguridad: el caso requiere valoración presencial del veterinario",
    )
    # La rellena la guarda determinista de `ai/alcance.py` ANTES de llamar al modelo; el modelo
    # también puede marcarla, pero no es de quien depende: medido el 2026-07-28, ninguno de los
    # tres modelos evaluados detectó un paciente humano ni declinó.
    fuera_de_alcance: bool = Field(
        default=False,
        description=(
            "El caso queda fuera del dominio de la herramienta (paciente no canino ni felino, "
            "o petición ajena a la interpretación de laboratorio veterinario)"
        ),
    )
    idioma: Literal["es"] = "es"

    @field_validator("interpretacion")
    @classmethod
    def _no_vacia(cls, v: str) -> str:
        if not v or not v.strip():
            raise ValueError("interpretacion vacía")
        return v.strip()


def esquema_estructurado() -> dict:
    """JSON Schema endurecido para los backends que SÍ pueden emitir salida estructurada.

    El esquema por defecto deja `hallazgos_clave`, `diferenciales` y `siguientes_pruebas` con
    lista vacía por defecto, así que un modelo puede devolver `{"interpretacion": "..."}` y
    validar sin problema. Medido: qwen2.5:7b hacía exactamente eso —JSON válido con los tres
    campos vacíos— y la respuesta pasaba como buena, dejando al veterinario sin diferenciales
    ni siguientes pasos.

    `minItems` se lo pide al modelo (Ollama lo aplica en la decodificación restringida, Claude
    lo lee como parte del contrato de la herramienta). La comprobación dura vive en el
    servicio, que es quien sabe si el caso admite listas vacías: un panel normal SÍ puede no
    tener hallazgos.
    """
    esquema = InterpretacionClinica.model_json_schema()
    for campo in ("hallazgos_clave", "diferenciales", "siguientes_pruebas"):
        esquema["properties"][campo]["minItems"] = 1
    esquema["required"] = sorted(
        set(esquema.get("required", []))
        | {"interpretacion", "hallazgos_clave", "diferenciales", "siguientes_pruebas"}
    )
    _acotar_longitudes(esquema)
    return esquema


# Techos de longitud del esquema. No son cosmética: en decodificación restringida el esquema es
# lo ÚNICO que limita cuánto escribe el modelo, y sin techos medGemma produjo entradas como una
# sola «siguiente prueba» de varias líneas con paréntesis sin cerrar, hasta agotar el presupuesto
# de tokens y devolver un JSON truncado a media cadena (medido el 2026-08-01). Acotar por campo
# hace la salida más compacta, más rápida de generar —la restricción cuesta por token— y de paso
# más legible en la tarjeta clínica.
_TECHOS: dict[str, int] = {
    "interpretacion": 1400,
    "analito": 60,
    "comentario": 200,
    "nombre": 90,
    "evidencia": 140,
    "citas": 200,
    "siguientes_pruebas": 130,
}
_MAX_ITEMS: dict[str, int] = {
    "hallazgos_clave": 8,
    "diferenciales": 6,
    "siguientes_pruebas": 6,
    "evidencia": 4,
    "citas": 4,
}


def _acotar_longitudes(nodo: object, clave: str = "") -> None:
    """Recorre el esquema (incluidos los `$defs`) aplicando techos de longitud y de nº de ítems."""
    if not isinstance(nodo, dict):
        return
    if nodo.get("type") == "string" and clave in _TECHOS:
        nodo["maxLength"] = _TECHOS[clave]
    if nodo.get("type") == "array":
        if clave in _MAX_ITEMS:
            nodo["maxItems"] = _MAX_ITEMS[clave]
        # El techo de cadena de una lista se aplica a sus elementos, no a la lista.
        if isinstance(nodo.get("items"), dict) and clave in _TECHOS:
            if nodo["items"].get("type") == "string":
                nodo["items"]["maxLength"] = _TECHOS[clave]
    for subclave, valor in nodo.items():
        if subclave in ("properties", "$defs"):
            for nombre, sub in (valor or {}).items():
                _acotar_longitudes(sub, nombre)
        elif isinstance(valor, dict):
            _acotar_longitudes(valor, clave)


class Fuente(BaseModel):
    """Un fragmento de literatura realmente recuperado, con su numeración del prompt.

    NO forma parte del esquema que ve el modelo: lo rellena el servidor desde la salida de
    la recuperación. Así la atribución es verificable en las tres rutas —incluida la del HF
    Space, que sólo devuelve prosa y no puede rellenar `Diferencial.citas`— y el modelo no
    puede inventarse una fuente que no se le dio.
    """

    indice: int = Field(description="Número con el que se presentó al modelo, base 1")
    libro: str
    edicion: str = ""
    capitulo: str = ""
    pagina: str = ""
    cita: str = Field(description="Cita formateada lista para mostrar")
    citada: bool = Field(
        default=False,
        description="El modelo se apoyó explícitamente en esta fuente ([n] o cita resuelta)",
    )


class RespuestaInterpretacion(BaseModel):
    resultado: InterpretacionClinica
    modelo: str
    fuentes_rag: int = 0
    fuentes: list[Fuente] = Field(
        default_factory=list,
        description="Literatura recuperada para esta respuesta, marcando cuál se citó",
    )


class ErrorRespuesta(BaseModel):
    error: str
    detalle: str | None = None