Spaces:
Sleeping
Sleeping
| """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) | |
| 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 | |
| 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") | |
| 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() | |