Spaces:
Sleeping
Distribución del corpus RAG vía Hub privado y horneado de modelos
Browse filesLos libros con licencia (226 MB) y el índice LanceDB (70 MB) no pueden ir en
git. Viven en dos datasets PRIVADOS del Hub; scripts/hub.py los publica y los
descarga.
Se usa la API de Python de huggingface_hub y no el CLI `hf`: en la versión
instalada (1.16.1) el CLI devuelve código 1 aunque la operación vaya bien, por
una incompatibilidad typer/click, lo que abortaría cualquier Makefile o build.
Dockerfile:
- WITH_RAG pasa a 1 por defecto. Con 0, el retriever degradaba en silencio y
las interpretaciones salían sin fundamentar sin que nada lo indicara.
- Los modelos (bge-m3 + bge-reranker-v2-m3, ~6.4 GB) se hornean en /opt/hf y
se fija HF_HUB_OFFLINE=1 en runtime, para que un fallo de red sea ruidoso en
vez de degradar la recuperación a orden RRF sin avisar.
- Si instance/rag_index no está en el contexto de build, se descarga con
HF_TOKEN pasado como secreto de build (nunca --build-arg, que quedaría
grabado en el historial de capas).
NOTA: el Dockerfile no se ha podido construir ni verificar en este entorno.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- .dockerignore +5 -1
- Dockerfile +83 -29
- docker-entrypoint.sh +11 -31
- scripts/hub.py +105 -0
|
@@ -2,8 +2,12 @@
|
|
| 2 |
.env
|
| 3 |
*.env
|
| 4 |
|
| 5 |
-
# SQLite database (will be created at runtime)
|
|
|
|
|
|
|
| 6 |
data/*.db
|
|
|
|
|
|
|
| 7 |
|
| 8 |
# OS
|
| 9 |
.DS_Store
|
|
|
|
| 2 |
.env
|
| 3 |
*.env
|
| 4 |
|
| 5 |
+
# SQLite database (will be created at runtime). NUNCA hornear la BD de usuarios en la
|
| 6 |
+
# imagen: sólo el índice RAG (instance/rag_index) debe copiarse. Excluye la BD del contexto
|
| 7 |
+
# de build para que `COPY instance*` no la arrastre a la imagen distribuida.
|
| 8 |
data/*.db
|
| 9 |
+
instance/*.db
|
| 10 |
+
instance/morphos.db
|
| 11 |
|
| 12 |
# OS
|
| 13 |
.DS_Store
|
|
@@ -1,35 +1,89 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
# Install extensions and enable Apache modules
|
| 4 |
-
RUN apt-get update && apt-get install -y \
|
| 5 |
-
libpng-dev \
|
| 6 |
-
libjpeg-dev \
|
| 7 |
-
libfreetype6-dev \
|
| 8 |
-
libzip-dev \
|
| 9 |
-
libsqlite3-dev \
|
| 10 |
-
git \
|
| 11 |
-
unzip \
|
| 12 |
-
&& docker-php-ext-install pdo pdo_mysql pdo_sqlite \
|
| 13 |
-
&& docker-php-ext-enable pdo pdo_mysql pdo_sqlite \
|
| 14 |
-
&& a2enmod rewrite deflate headers expires
|
| 15 |
-
|
| 16 |
-
# Copy project
|
| 17 |
-
COPY . /var/www/html/
|
| 18 |
-
|
| 19 |
-
# Create data directory for SQLite and ensure permissions
|
| 20 |
-
RUN mkdir -p /var/www/html/data \
|
| 21 |
-
&& chown -R www-data:www-data /var/www/html \
|
| 22 |
-
&& chmod -R 755 /var/www/html
|
| 23 |
-
|
| 24 |
-
# Expose HF Spaces default port
|
| 25 |
-
RUN sed -i 's/Listen 80/Listen 7860/' /etc/apache2/ports.conf \
|
| 26 |
-
&& sed -i 's/:80/:7860/' /etc/apache2/sites-available/000-default.conf
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
EXPOSE 7860
|
| 29 |
|
| 30 |
-
# Entrypoint handles runtime initialization
|
| 31 |
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
| 32 |
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
| 33 |
-
|
| 34 |
ENTRYPOINT ["docker-entrypoint.sh"]
|
| 35 |
-
CMD ["
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
# ---------- Stage 1: build del frontend (Vite + TS) ----------
|
| 4 |
+
FROM node:22-slim AS frontend
|
| 5 |
+
WORKDIR /build
|
| 6 |
+
COPY frontend/package*.json frontend/
|
| 7 |
+
RUN cd frontend && npm ci
|
| 8 |
+
# Se necesita el HTML/CSS de la raíz y los datos para la build.
|
| 9 |
+
COPY frontend/ frontend/
|
| 10 |
+
COPY index.html ./
|
| 11 |
+
COPY css/ css/
|
| 12 |
+
COPY assets/ assets/
|
| 13 |
+
COPY data/ data/
|
| 14 |
+
RUN cd frontend && npm run build # emite /build/dist
|
| 15 |
+
|
| 16 |
+
# ---------- Stage 2: runtime backend (FastAPI + uv) ----------
|
| 17 |
+
FROM python:3.12-slim AS runtime
|
| 18 |
+
|
| 19 |
+
# uv desde su imagen oficial (rápido y reproducible).
|
| 20 |
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
| 21 |
+
|
| 22 |
+
WORKDIR /app
|
| 23 |
+
|
| 24 |
+
# Instala dependencias con el lockfile (capa cacheable). RAG viene ACTIVADO por defecto: con
|
| 25 |
+
# WITH_RAG=0 el retriever degrada en silencio y las interpretaciones salen sin fundamentar, que
|
| 26 |
+
# es justo el fallo que no queremos que pase inadvertido en producción.
|
| 27 |
+
ARG WITH_RAG=1
|
| 28 |
+
COPY backend/pyproject.toml backend/uv.lock* backend/
|
| 29 |
+
RUN cd backend && if [ "$WITH_RAG" = "1" ]; then \
|
| 30 |
+
uv sync --frozen --no-dev --group rag; \
|
| 31 |
+
else \
|
| 32 |
+
uv sync --frozen --no-dev; \
|
| 33 |
+
fi
|
| 34 |
+
|
| 35 |
+
# Código del backend
|
| 36 |
+
COPY backend/ backend/
|
| 37 |
+
|
| 38 |
+
# Estáticos públicos: build del frontend + datos de referencia (servidos en /data)
|
| 39 |
+
COPY --from=frontend /build/dist ./dist
|
| 40 |
+
COPY data/ ./data/
|
| 41 |
+
|
| 42 |
+
# Índice RAG horneado de sólo lectura. Vive en instance/ (fuera del webroot); no requiere
|
| 43 |
+
# almacenamiento persistente. La BD de usuarios (instance/*.db) queda EXCLUIDA vía
|
| 44 |
+
# .dockerignore: nunca se hornea.
|
| 45 |
+
#
|
| 46 |
+
# Dos vías: si instance/rag_index existe en el contexto de build se copia; si no (clon limpio,
|
| 47 |
+
# CI), se descarga del dataset PRIVADO del Hub con HF_TOKEN pasado como secreto de build.
|
| 48 |
+
COPY instance* ./instance/
|
| 49 |
+
|
| 50 |
+
# Se usa la API de Python y NO el CLI `hf`: en huggingface_hub 1.16.1 el CLI devuelve código 1
|
| 51 |
+
# aunque la operación vaya bien (incompatibilidad typer/click), lo que abortaría la build.
|
| 52 |
+
ARG HF_INDEX_REPO=blackmistcode/morphos-rag-index
|
| 53 |
+
RUN --mount=type=secret,id=hf_token,required=false \
|
| 54 |
+
if [ "$WITH_RAG" = "1" ] && [ ! -d /app/instance/rag_index ]; then \
|
| 55 |
+
if [ -f /run/secrets/hf_token ]; then \
|
| 56 |
+
HF_TOKEN="$(cat /run/secrets/hf_token)" backend/.venv/bin/python -c "\
|
| 57 |
+
from huggingface_hub import snapshot_download; \
|
| 58 |
+
snapshot_download(repo_id='$HF_INDEX_REPO', repo_type='dataset', local_dir='/app/instance/rag_index')"; \
|
| 59 |
+
else \
|
| 60 |
+
echo "AVISO: sin instance/rag_index y sin secreto hf_token; la imagen quedará SIN RAG." >&2; \
|
| 61 |
+
fi; \
|
| 62 |
+
fi
|
| 63 |
+
|
| 64 |
+
# Modelos de embeddings y reranking horneados en la imagen (~6.4 GB). Sin esto, la primera
|
| 65 |
+
# petición intentaría descargarlos de huggingface.co en caliente: lento en CPU-basic y, si la
|
| 66 |
+
# red falla, el reranker cae en silencio a orden RRF. Con HF_HUB_OFFLINE=1 en runtime, una
|
| 67 |
+
# descarga que faltase falla de forma ruidosa en vez de degradar sin avisar.
|
| 68 |
+
ARG RAG_EMBED_MODEL=BAAI/bge-m3
|
| 69 |
+
ARG RAG_RERANKER_MODEL=BAAI/bge-reranker-v2-m3
|
| 70 |
+
ENV HF_HOME=/opt/hf
|
| 71 |
+
RUN if [ "$WITH_RAG" = "1" ]; then \
|
| 72 |
+
backend/.venv/bin/python -c "\
|
| 73 |
+
from sentence_transformers import SentenceTransformer, CrossEncoder; \
|
| 74 |
+
SentenceTransformer('$RAG_EMBED_MODEL'); CrossEncoder('$RAG_RERANKER_MODEL')"; \
|
| 75 |
+
fi
|
| 76 |
+
|
| 77 |
+
ENV MORPHOS_ENTORNO=prod \
|
| 78 |
+
MORPHOS_COOKIE_SECURE=true \
|
| 79 |
+
HF_HOME=/opt/hf \
|
| 80 |
+
HF_HUB_OFFLINE=1 \
|
| 81 |
+
PATH="/app/backend/.venv/bin:$PATH"
|
| 82 |
+
|
| 83 |
+
# HF Spaces expone el 7860.
|
| 84 |
EXPOSE 7860
|
| 85 |
|
|
|
|
| 86 |
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
| 87 |
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
|
|
|
| 88 |
ENTRYPOINT ["docker-entrypoint.sh"]
|
| 89 |
+
CMD ["uvicorn", "app.main:app", "--app-dir", "backend", "--host", "0.0.0.0", "--port", "7860"]
|
|
@@ -1,36 +1,16 @@
|
|
| 1 |
-
#!/bin/
|
| 2 |
set -e
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
chown www-data:www-data "$DB_FILE"
|
| 14 |
-
chmod 664 "$DB_FILE"
|
| 15 |
-
|
| 16 |
-
# Initialize SQLite schema if the table does not exist
|
| 17 |
-
php -r "
|
| 18 |
-
\$dbPath = '$DB_FILE';
|
| 19 |
-
try {
|
| 20 |
-
\$pdo = new PDO('sqlite:' . \$dbPath);
|
| 21 |
-
\$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
| 22 |
-
\$pdo->exec('CREATE TABLE IF NOT EXISTS usuarios (
|
| 23 |
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 24 |
-
nombre TEXT NOT NULL,
|
| 25 |
-
apellido TEXT NOT NULL,
|
| 26 |
-
email TEXT NOT NULL UNIQUE,
|
| 27 |
-
password TEXT NOT NULL,
|
| 28 |
-
creado_en DATETIME DEFAULT CURRENT_TIMESTAMP
|
| 29 |
-
)');
|
| 30 |
-
} catch (PDOException \$e) {
|
| 31 |
-
error_log('SQLite init error: ' . \$e->getMessage());
|
| 32 |
-
exit(1);
|
| 33 |
-
}
|
| 34 |
-
"
|
| 35 |
|
| 36 |
exec "$@"
|
|
|
|
| 1 |
+
#!/bin/sh
|
| 2 |
set -e
|
| 3 |
|
| 4 |
+
# La BD de usuarios y el índice RAG viven en instance/ (fuera del webroot).
|
| 5 |
+
# El backend crea el esquema al arrancar (lifespan). Aquí sólo garantizamos el
|
| 6 |
+
# directorio y avisamos si falta configuración de producción crítica.
|
| 7 |
+
mkdir -p /app/instance
|
| 8 |
|
| 9 |
+
if [ "${MORPHOS_ENTORNO}" = "prod" ]; then
|
| 10 |
+
if [ -z "${MORPHOS_SESSION_SECRET}" ]; then
|
| 11 |
+
echo "ERROR: MORPHOS_SESSION_SECRET no está definido en prod. Aborta." >&2
|
| 12 |
+
exit 1
|
| 13 |
+
fi
|
| 14 |
+
fi
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
exec "$@"
|
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Publicación y descarga de los artefactos RAG en repos privados del Hub.
|
| 2 |
+
|
| 3 |
+
Se usa la API de Python (`huggingface_hub`) y no el CLI `hf` a propósito: en la versión instalada
|
| 4 |
+
(1.16.1) el CLI arrastra una incompatibilidad typer/click que hace que `ctx.exit()` propague un
|
| 5 |
+
`click.exceptions.Exit(0)` como traceback y **devuelva código 1 aunque la operación haya ido
|
| 6 |
+
bien**. Eso es inservible dentro de un Makefile, donde un exit≠0 aborta la cadena.
|
| 7 |
+
|
| 8 |
+
Artefactos:
|
| 9 |
+
- índice (~70 MB): derivado de libros con licencia, contiene su texto troceado → repo PRIVADO.
|
| 10 |
+
- libros (~226 MB): los PDF originales → repo PRIVADO, sólo necesarios para reingerir.
|
| 11 |
+
|
| 12 |
+
Uso:
|
| 13 |
+
python scripts/hub.py publish-index
|
| 14 |
+
python scripts/hub.py fetch-index
|
| 15 |
+
python scripts/hub.py publish-books
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import json
|
| 22 |
+
import sys
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
from huggingface_hub import HfApi, snapshot_download
|
| 26 |
+
|
| 27 |
+
RAIZ = Path(__file__).resolve().parents[1]
|
| 28 |
+
DIR_INDICE = RAIZ / "instance" / "rag_index"
|
| 29 |
+
DIR_LIBROS = RAIZ / "books"
|
| 30 |
+
|
| 31 |
+
REPO_INDICE = "blackmistcode/morphos-rag-index"
|
| 32 |
+
REPO_LIBROS = "blackmistcode/morphos-books"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _asegurar_repo(api: HfApi, repo_id: str) -> None:
|
| 36 |
+
"""Crea el repo si falta. `private=True` sólo aplica en la creación: si el repo ya existe
|
| 37 |
+
NO lo vuelve privado, así que se comprueba explícitamente y se aborta si es público."""
|
| 38 |
+
api.create_repo(repo_id=repo_id, repo_type="dataset", private=True, exist_ok=True)
|
| 39 |
+
if not api.dataset_info(repo_id).private:
|
| 40 |
+
sys.exit(
|
| 41 |
+
f"ABORTADO: {repo_id} es PÚBLICO. Contiene material con licencia; hazlo privado "
|
| 42 |
+
f"antes de subir nada."
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def publicar_indice() -> None:
|
| 47 |
+
if not DIR_INDICE.exists():
|
| 48 |
+
sys.exit(f"ERROR: no existe {DIR_INDICE}. Ejecuta 'make ingest' primero.")
|
| 49 |
+
manifiesto = json.loads((DIR_INDICE / "manifest.json").read_text(encoding="utf-8"))
|
| 50 |
+
hash_corpus = manifiesto.get("hash_corpus", "desconocido")
|
| 51 |
+
api = HfApi()
|
| 52 |
+
_asegurar_repo(api, REPO_INDICE)
|
| 53 |
+
api.upload_folder(
|
| 54 |
+
repo_id=REPO_INDICE,
|
| 55 |
+
repo_type="dataset",
|
| 56 |
+
folder_path=str(DIR_INDICE),
|
| 57 |
+
commit_message=f"Índice RAG · corpus {hash_corpus} · {manifiesto.get('n_fragmentos')} fragmentos",
|
| 58 |
+
)
|
| 59 |
+
print(f"OK: índice publicado en {REPO_INDICE} (corpus {hash_corpus})")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def descargar_indice() -> None:
|
| 63 |
+
DIR_INDICE.mkdir(parents=True, exist_ok=True)
|
| 64 |
+
snapshot_download(
|
| 65 |
+
repo_id=REPO_INDICE,
|
| 66 |
+
repo_type="dataset",
|
| 67 |
+
local_dir=str(DIR_INDICE),
|
| 68 |
+
)
|
| 69 |
+
manifiesto = DIR_INDICE / "manifest.json"
|
| 70 |
+
if manifiesto.exists():
|
| 71 |
+
m = json.loads(manifiesto.read_text(encoding="utf-8"))
|
| 72 |
+
print(f"OK: índice descargado · corpus {m.get('hash_corpus')} · {m.get('n_fragmentos')} fragmentos")
|
| 73 |
+
else:
|
| 74 |
+
print("AVISO: descargado sin manifest.json; el índice puede estar incompleto.")
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def publicar_libros() -> None:
|
| 78 |
+
pdfs = sorted(DIR_LIBROS.glob("*.pdf"))
|
| 79 |
+
if not pdfs:
|
| 80 |
+
sys.exit(f"ERROR: no hay PDFs en {DIR_LIBROS}.")
|
| 81 |
+
api = HfApi()
|
| 82 |
+
_asegurar_repo(api, REPO_LIBROS)
|
| 83 |
+
api.upload_folder(
|
| 84 |
+
repo_id=REPO_LIBROS,
|
| 85 |
+
repo_type="dataset",
|
| 86 |
+
folder_path=str(DIR_LIBROS),
|
| 87 |
+
allow_patterns=["*.pdf"],
|
| 88 |
+
commit_message=f"Corpus veterinario con licencia ({len(pdfs)} PDF)",
|
| 89 |
+
)
|
| 90 |
+
print(f"OK: {len(pdfs)} libros publicados en {REPO_LIBROS}")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def main() -> None:
|
| 94 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 95 |
+
parser.add_argument("accion", choices=["publish-index", "fetch-index", "publish-books"])
|
| 96 |
+
args = parser.parse_args()
|
| 97 |
+
{
|
| 98 |
+
"publish-index": publicar_indice,
|
| 99 |
+
"fetch-index": descargar_indice,
|
| 100 |
+
"publish-books": publicar_libros,
|
| 101 |
+
}[args.accion]()
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
if __name__ == "__main__":
|
| 105 |
+
main()
|