morphos / backend /app /routers /interpret.py
Jose Salazar
Allow list para modelos locales configurables por el usuario
6ab7946
Raw
History Blame Contribute Delete
3.45 kB
"""POST /api/interpret — interpretación clínica con IA.
Protegido con sesión, CSRF y rate limiting (cierra el agujero de hf_proxy.php, que era
anónimo y con CORS abierto). Valida las imágenes del lado servidor (número, tamaño, mime).
"""
from __future__ import annotations
import base64
import binascii
import re
from fastapi import APIRouter, Depends, HTTPException, Request, status
from ..ai.base import ErrorModelo
from ..ai.service import interpretar
from ..config import obtener_config
from ..schemas import PeticionInterpretacion, RespuestaInterpretacion
from ..security.authz import usuario_actual, verificar_csrf
from ..security.rate_limit import clave_usuario, limiter
router = APIRouter()
_DATA_URL = re.compile(r"^data:image/(jpeg|png|gif|webp);base64,(.+)$", re.DOTALL)
def _validar_imagenes(imagenes: list[str]) -> None:
cfg = obtener_config()
if len(imagenes) > cfg.max_imagenes:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "Demasiadas imágenes.")
for img in imagenes:
m = _DATA_URL.match(img)
if not m:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "Formato de imagen no permitido.")
try:
crudo = base64.b64decode(m.group(2), validate=True)
except (binascii.Error, ValueError) as exc:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY, "Imagen base64 inválida."
) from exc
if len(crudo) > cfg.max_bytes_imagen:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, "Imagen demasiado grande.")
@router.get("/modelos")
async def get_modelos(_sesion: dict = Depends(usuario_actual)) -> dict:
"""Modelos locales que el usuario puede elegir, para poblar el selector de la UI.
Devuelve sólo NOMBRES: la URL de Ollama no sale del servidor ni entra desde el cliente.
Lista vacía = el selector no se muestra y la ruta la sigue decidiendo el servidor, que es
el caso de la instancia pública (allí no hay Ollama que valga).
"""
cfg = obtener_config()
return {
"locales": sorted(cfg.modelos_locales_permitidos()),
"defecto": cfg.medgemma_model,
}
@router.post("/interpret", response_model=RespuestaInterpretacion)
@limiter.limit(obtener_config().limite_interpret)
# Segundo límite, con clave por usuario: el de arriba (por IP) frena ráfagas puntuales, éste
# impide que una sola cuenta consuma la cuota de GPU compartida a lo largo del día.
@limiter.limit(obtener_config().limite_interpret_usuario, key_func=clave_usuario)
async def post_interpret(
request: Request,
pet: PeticionInterpretacion,
_sesion: dict = Depends(usuario_actual),
_csrf: None = Depends(verificar_csrf),
) -> RespuestaInterpretacion:
_validar_imagenes(pet.imagenes)
try:
return await interpretar(pet)
except ErrorModelo as exc:
if exc.saturado:
# 503 + Retry-After y no 502: al cliente le sirve saber que es transitorio y cuándo
# reintentar. Un 502 genérico invita a recargar en bucle, que es lo peor que puede
# hacerse contra una cuota agotada.
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
str(exc),
headers={"Retry-After": "300"},
) from exc
raise HTTPException(status.HTTP_502_BAD_GATEWAY, f"Error del modelo: {exc}") from exc