File size: 6,716 Bytes
af530a3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
#!/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 <<EOF
ERROR: no se puede escribir en '$VOLUMEN'.

El volumen está montado con la propiedad ACTIVADA y su raíz es de root:wheel, así que tu
usuario no puede crear nada arriba del todo. Se arregla una vez:

    sudo mkdir -p "$BASE" && sudo chown -R "\$(id -un):staff" "$BASE"

(o Finder → Cmd-I sobre el disco → «Ignorar permisos en este volumen»)

Si en cambio el error fuera «Operation not permitted» incluso con sudo, es TCC: ejecuta este
script desde Terminal.app, no desde el terminal integrado del editor.
EOF
  exit 1
fi

# --- 2. Todas las cachés al SSD ------------------------------------------------------------
export HF_HOME="$BASE/hf-cache"
export UV_CACHE_DIR="$BASE/uv-cache"
export GRADIO_SERVER_PORT="$PUERTO"
mkdir -p "$HF_HOME" "$UV_CACHE_DIR"

# --- 3. Entorno virtual --------------------------------------------------------------------
VENV="$BASE/venv"
if [ "${1:-}" = "--recrear" ]; then
  echo "==> 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 <<EOF

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