File size: 3,445 Bytes
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a5d73ff
70e641d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6ab7946
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70e641d
 
a5d73ff
 
 
70e641d
 
 
 
 
 
 
 
 
 
a5d73ff
 
 
 
 
 
 
 
 
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
"""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