File size: 5,230 Bytes
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
"""Aplicación FastAPI de Morphos.

Sirve la API (auth, interpret, papers) y, en producción, los estáticos del frontend.
CORS bloqueado a los orígenes configurados (no '*'), rate limiting global, cabeceras de
seguridad y montaje de sólo los directorios públicos (nunca instance/ ni backend/.env).
"""

from __future__ import annotations

import logging
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles
from slowapi.errors import RateLimitExceeded

from .config import RAIZ_REPO, obtener_config
from .db import inicializar_db
from .routers import auth, interpret, lab, papers
from .security.headers import CabecerasSeguridad
from .security.rate_limit import limiter

logging.basicConfig(level=logging.INFO)


@asynccontextmanager
async def _lifespan(_app: FastAPI):
    inicializar_db()
    _recargar_resultados_lab()
    _verificar_rag()
    yield


def _verificar_rag() -> None:
    """Comprueba el RAG al arrancar y distingue las causas de fallo.

    El retriever degrada a modo sin-RAG en silencio por diseño (para que la app funcione sin el
    corpus), pero eso hace indistinguible «no hay RAG a propósito» de «el RAG está roto y las
    interpretaciones van sin fundamentar». Aquí se rompe esa ambigüedad con un log explícito.
    """
    log = logging.getLogger("morphos.rag")
    cfg = obtener_config()

    if not cfg.rag_habilitado:
        log.info("RAG deshabilitado por configuración (MORPHOS_RAG_HABILITADO=false).")
        return

    faltan_deps = False
    try:
        import lancedb  # type: ignore  # noqa: F401
        import sentence_transformers  # type: ignore  # noqa: F401
    except ImportError:
        faltan_deps = True

    from .rag.retriever import estado_rag

    estado = estado_rag()

    if faltan_deps:
        log.error(
            "RAG ACTIVADO PERO SIN DEPENDENCIAS: falta el grupo 'rag' (lancedb / "
            "sentence-transformers). Las interpretaciones saldrán SIN fundamentar. "
            "Construye la imagen con --build-arg WITH_RAG=1 o ejecuta 'uv sync --group rag'."
        )
        return

    if not estado["disponible"]:
        log.error(
            "RAG ACTIVADO PERO SIN ÍNDICE en %s. Las interpretaciones saldrán SIN fundamentar. "
            "Ejecuta 'make fetch-index' (o 'make ingest' si tienes los libros).",
            cfg.rag_index_dir,
        )
        return

    log.info(
        "RAG listo: %s fragmentos, embeddings %s, híbrido=%s, rerank=%s.",
        estado["fragmentos"],
        estado["modelo"],
        cfg.rag_hibrido,
        cfg.rag_rerank,
    )


def _recargar_resultados_lab() -> None:
    """Si la persistencia de laboratorio está activa, recarga el almacén en proceso desde SQLite
    (útil sólo con volumen persistente; en HF Spaces la BD es efímera). Degrada en silencio."""
    if not obtener_config().lab_persistir:
        return
    try:
        from . import db
        from .lab.almacen import almacen
        from .schemas_lab import ResultadoMapeado

        for payload in db.cargar_resultados_lab():
            almacen.guardar(ResultadoMapeado.model_validate_json(payload))
    except Exception:  # noqa: BLE001 — nunca bloquear el arranque por esto
        logging.getLogger("morphos").warning("no se pudieron recargar resultados de laboratorio", exc_info=True)


def crear_app() -> FastAPI:
    cfg = obtener_config()
    app = FastAPI(title="Morphos API", version="1.0.0", lifespan=_lifespan)

    app.state.limiter = limiter

    @app.exception_handler(RateLimitExceeded)
    async def _limite(_request, exc: RateLimitExceeded):  # noqa: ANN001
        return JSONResponse(
            status_code=429,
            content={"error": "Demasiadas peticiones. Inténtalo más tarde."},
            headers={"Retry-After": "60"},
        )

    # CORS bloqueado a orígenes conocidos, con credenciales (cookies de sesión).
    app.add_middleware(
        CORSMiddleware,
        allow_origins=cfg.origenes_permitidos,
        allow_credentials=True,
        allow_methods=["GET", "POST", "OPTIONS"],
        allow_headers=["Content-Type", "X-CSRF-Token"],
    )
    app.add_middleware(CabecerasSeguridad)

    app.include_router(auth.router, prefix="/api")
    app.include_router(interpret.router, prefix="/api")
    app.include_router(lab.router, prefix="/api")
    app.include_router(papers.router, prefix="/api")

    @app.get("/api/health")
    async def health() -> dict:
        from .rag.retriever import estado_rag

        return {"ok": True, "entorno": cfg.entorno, "rag": estado_rag()}

    # Estáticos: sólo directorios públicos. Los datos de referencia (data/*.json) se
    # sirven en /data; la build del frontend (dist/) en la raíz. instance/ (BD) y
    # backend/.env quedan SIEMPRE fuera de cualquier montaje.
    datos = RAIZ_REPO / "data"
    if datos.exists():
        app.mount("/data", StaticFiles(directory=str(datos)), name="data")

    dist = RAIZ_REPO / "dist"
    if dist.exists():
        app.mount("/", StaticFiles(directory=str(dist), html=True), name="static")

    return app


app = crear_app()