#!/usr/bin/env bash # Réplica LOCAL del Space de medGemma (blackmistcode/morphos_medGemma). # # Para qué: las evals comparan modelos, pero la ruta de producción no es «el modelo» — es el # modelo DENTRO de la envoltura del Space (presupuesto de 3072 tokens compartido entre el # razonamiento y la respuesta, `extract_response` que tira el razonamiento, penalización de # repetición 1.1). Medir el GGUF en Ollama mide otra cosa. Esto levanta el `app.py` REAL del # Space en local, así que el cliente de producción (`app/ai/hf_space.py`) habla con él sin # cambiar una línea: # # MORPHOS_HF_SPACE_URL=http://127.0.0.1:7860/gradio_api # # Lo único que no se replica es el hardware (MPS en vez de ZeroGPU) y, con él, la muerte por # reserva agotada. Todo lo demás es el mismo código. # # Uso: # scripts/space_local.sh # monta si hace falta y arranca # scripts/space_local.sh --recrear # rehace el venv desde cero # # TODO vive en el SSD externo (venv, caché de HF con los ~9 GB de pesos, caché de uv): el disco # interno va justo de espacio y es la razón de que los modelos estén fuera. set -euo pipefail BASE="${MORPHOS_SPACE_LOCAL_DIR:-/Volumes/Extreme SSD/morphos-space}" SPACE_REPO="${MORPHOS_SPACE_REPO:-blackmistcode/morphos_medGemma}" PUERTO="${GRADIO_SERVER_PORT:-7860}" # El modelo es gated: hace falta el token de HF (el mismo que ya usa `hf`). Se lee de # ~/.cache/huggingface/token si no está en el entorno. if [ -z "${HF_TOKEN:-}" ] && [ -f "$HOME/.cache/huggingface/token" ]; then HF_TOKEN="$(cat "$HOME/.cache/huggingface/token")" export HF_TOKEN fi echo "==> Base: $BASE" # --- 1. El SSD tiene que estar montado Y ser escribible ----------------------------------- VOLUMEN="$(dirname "$BASE")" if [ ! -d "$VOLUMEN" ]; then echo "ERROR: no está montado '$VOLUMEN'. Conecta el disco y repite." >&2 exit 1 fi if ! mkdir -p "$BASE" 2>/dev/null; then cat >&2 < Borrando venv anterior" rm -rf "$VENV" fi if [ ! -x "$VENV/bin/python" ]; then echo "==> Creando venv en el SSD (uv)" uv venv --python 3.12 "$VENV" fi # Las dependencias se sincronizan SIEMPRE, no sólo al crear el venv. Cuando el requirements.txt # del Space crece, un venv ya existente se saltaba la instalación y la réplica se quedaba corta # en silencio: así apareció `lm-format-enforcer`, sin el cual `app.py` cae al modo prosa y # anuncia «salida estructurada NO disponible» — midiendo justo lo que el Space no hace. Con # todo satisfecho uv resuelve y sale, sin descargar nada. echo "==> Sincronizando dependencias (torch para MPS, transformers, gradio…)" # `spaces` es el paquete del decorador @spaces.GPU. Fuera de HF es un passthrough; se instala # para no tener que tocar la firma de `analyze`. # `lm-format-enforcer` es la decodificación restringida: va en el requirements.txt del Space, así # que sin él la réplica NO es la réplica. VIRTUAL_ENV="$VENV" uv pip install --python "$VENV/bin/python" \ torch transformers gradio spaces pillow safetensors "lm-format-enforcer>=0.10" # --- 4. app.py de upstream + parche mínimo ------------------------------------------------- # Se descarga cada vez a propósito: si el Space cambia, la réplica cambia con él. El parche se # aplica sobre el fichero recién traído y falla ruidosamente si upstream ya no encaja, que es # justo cuando hay que mirar en vez de seguir midiendo con una copia divergente. echo "==> Descargando app.py de $SPACE_REPO" curl -fsSL "https://huggingface.co/spaces/$SPACE_REPO/raw/main/app.py" -o "$BASE/app_upstream.py" "$VENV/bin/python" - "$BASE/app_upstream.py" "$BASE/app_local.py" <<'PYPATCH' """Aplica a app.py los cambios mínimos para correr fuera de ZeroGPU. Cada sustitución se verifica: si upstream deja de encajar, se aborta. Una réplica que se parchea «a medias» mediría algo que no es el Space y no lo diría. """ import re import sys origen, destino = sys.argv[1], sys.argv[2] codigo = open(origen, encoding="utf-8").read() deltas = [] def sustituir(patron, reemplazo, descripcion, *, obligatorio=True): global codigo # re.M porque uno de los anclajes es a principio de línea (`^MODEL_ID`), no de fichero. nuevo, n = re.subn(patron, reemplazo, codigo, count=1, flags=re.M) if n: codigo = nuevo deltas.append(descripcion) elif obligatorio: raise SystemExit(f"ERROR: no encaja el parche «{descripcion}». ¿Cambió app.py upstream?") # 1. ZeroGPU es CUDA; aquí hay Apple Silicon. Se resuelve el dispositivo en tiempo de arranque. sustituir( r'\.to\("cuda"\)', '.to(_dispositivo())', 'cuda → mps/cpu', ) # 2. transformers 5.x retiró `torch_dtype` en favor de `dtype`. sustituir( r'torch_dtype=torch\.bfloat16', 'dtype=torch.bfloat16', 'torch_dtype → dtype (transformers 5.x)', obligatorio=False, ) # 3. El resolvedor de dispositivo, justo antes de cargar el modelo. sustituir( r'^MODEL_ID = ', 'def _dispositivo():\n' ' """MPS si existe; si no, CPU. bfloat16 va en ambos, sólo cambia la velocidad."""\n' ' import torch as _t\n' ' return "mps" if _t.backends.mps.is_available() else "cpu"\n' '\n' 'MODEL_ID = ', 'resolvedor de dispositivo', ) open(destino, "w", encoding="utf-8").write(codigo) print(" parches aplicados: " + "; ".join(deltas)) PYPATCH # --- 5. Arranque --------------------------------------------------------------------------- cat < Listo. Arrancando Gradio en http://127.0.0.1:$PUERTO Pesos y caché: $HF_HOME La primera vez descarga ~9 GB del modelo (gated: usa tu token de HF). Para que las evals lo usen, en OTRA terminal: export MORPHOS_HF_SPACE_URL=http://127.0.0.1:$PUERTO/gradio_api EOF cd "$BASE" exec "$VENV/bin/python" app_local.py