Jose Salazar Claude Opus 5 commited on
Commit
aa0d853
·
1 Parent(s): 70e641d

Distribución del corpus RAG vía Hub privado y horneado de modelos

Browse files

Los 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>

Files changed (4) hide show
  1. .dockerignore +5 -1
  2. Dockerfile +83 -29
  3. docker-entrypoint.sh +11 -31
  4. scripts/hub.py +105 -0
.dockerignore CHANGED
@@ -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
Dockerfile CHANGED
@@ -1,35 +1,89 @@
1
- FROM php:8.2-apache
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 ["apache2-foreground"]
 
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"]
docker-entrypoint.sh CHANGED
@@ -1,36 +1,16 @@
1
- #!/bin/bash
2
  set -e
3
 
4
- DATA_DIR="/var/www/html/data"
5
- DB_FILE="$DATA_DIR/morphos.db"
 
 
6
 
7
- # Ensure data directory exists with correct ownership
8
- mkdir -p "$DATA_DIR"
9
- chown www-data:www-data "$DATA_DIR"
10
-
11
- # Ensure SQLite database file exists so PDO can connect
12
- touch "$DB_FILE"
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 "$@"
scripts/hub.py ADDED
@@ -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()