morphos / backend /app /main.py
Jose Salazar
Añadir el stack nuevo: Vite/TypeScript + FastAPI
70e641d
Raw
History Blame Contribute Delete
5.23 kB
"""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()